From 82953e2e0f02f4f4c6f2b7b9d429e02d87da3b26 Mon Sep 17 00:00:00 2001 From: cotta-dev Date: Thu, 2 Apr 2026 23:43:33 +0900 Subject: [PATCH 1/3] feat: add SSH session recording via positional argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retri — SSH to host and record the interactive session. Log filename uses the SSH hostname. Session ends when SSH exits. Co-Authored-By: Claude Sonnet 4.6 --- internal/cli/cli.go | 53 ++++++++++++++++++++++++++++++++-- internal/executor/record.go | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0817604..4df7ad4 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -63,7 +63,8 @@ func Run(version string, defaultConfigContent []byte, helpContent string) { parser.Name = config.AppName parser.Usage = "[OPTIONS]" - if _, err := parser.Parse(); err != nil { + remaining, err := parser.Parse() + if err != nil { if flags.WroteHelp(err) { os.Exit(0) } @@ -124,7 +125,13 @@ func Run(version string, defaultConfigContent []byte, helpContent string) { // 6. Record mode: no target or command specified → start session recording if opts.Host == "" && opts.Group == "" && opts.Command == "" && opts.CommandFile == "" { - runRecordMode(opts, cfg.Defaults) + if len(remaining) == 1 { + // retri → SSH to host and record session + runSSHRecordMode(opts, remaining[0], cfg.Defaults) + } else { + // retri (no args) → record local shell session + runRecordMode(opts, cfg.Defaults) + } return } @@ -210,6 +217,48 @@ func promptMissingCredentials(targets []config.ResolvedHost, defaults config.Glo return } +// runSSHRecordMode SSHes to host and records the interactive session to a log file. +func runSSHRecordMode(opts Options, host string, defaults config.GlobalOptions) { + logDir := opts.LogDir + if logDir == "" && defaults.LogDir != "" { + logDir = defaults.LogDir + } + suffix := opts.Suffix + if suffix == "" { + suffix = defaults.Suffix + } + fileFmt := opts.FilenameFormat + if fileFmt == "" { + fileFmt = defaults.FilenameFormat + } + tsFmt := opts.TimestampFormat + if tsFmt == "" { + tsFmt = defaults.TimestampFormat + } + + lg, logFile, logPath, err := logger.SetupLogger(host, logDir, fileFmt, tsFmt, suffix, opts.NoTimestamp, defaults.Timestamp) + if err != nil { + log.Fatalf("[ERROR] Failed to setup logger: %v", err) + } + defer func() { _ = logFile.Close() }() + + header := fmt.Sprintf("%s\n SESSION LOG : %s\n START TIME : %s\n%s\n", + strings.Repeat("=", 60), host, time.Now().Format("2006-01-02 15:04:05"), strings.Repeat("=", 60)) + lg.WriteRaw(header) + + log.Printf("SSH to %s — recording session to: %s", host, logPath) + + if err := executor.RunSSHRecordSession(host, "", lg, opts.Debug); err != nil { + log.Printf("[ERROR] SSH session error: %v", err) + } + + footer := fmt.Sprintf("\n%s\n LOG END : %s\n%s\n", + strings.Repeat("=", 60), time.Now().Format("2006-01-02 15:04:05"), strings.Repeat("=", 60)) + lg.WriteRaw(footer) + + log.Printf("Session log saved: %s", logPath) +} + // runRecordMode starts a local shell session recording. func runRecordMode(opts Options, defaults config.GlobalOptions) { hostname, err := os.Hostname() diff --git a/internal/executor/record.go b/internal/executor/record.go index 3615bf4..70ce718 100644 --- a/internal/executor/record.go +++ b/internal/executor/record.go @@ -14,6 +14,63 @@ import ( "golang.org/x/term" ) +// RunSSHRecordSession opens an interactive SSH session to host (as user, empty = current OS user) +// in a PTY and records all I/O to the logger. Returns when the SSH session exits. +func RunSSHRecordSession(host, user string, lg *logger.LineLogger, debug bool) error { + args := []string{"-t"} + if user != "" { + args = append(args, "-l", user) + } + args = append(args, host) + + c := exec.Command("ssh", args...) + c.Env = os.Environ() + + ptmx, err := pty.Start(c) + if err != nil { + return err + } + defer func() { _ = ptmx.Close() }() + + _ = pty.InheritSize(os.Stdin, ptmx) + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGWINCH) + go func() { + for range sigCh { + _ = pty.InheritSize(os.Stdin, ptmx) + } + }() + defer signal.Stop(sigCh) + + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + return err + } + defer func() { _ = term.Restore(int(os.Stdin.Fd()), oldState) }() + + go func() { + _, _ = io.Copy(ptmx, os.Stdin) + }() + + buf := make([]byte, config.ReadBufferSize) + for { + n, err := ptmx.Read(buf) + if n > 0 { + _, _ = os.Stdout.Write(buf[:n]) + _, _ = lg.Write(buf[:n]) + } + if err != nil { + break + } + } + + _ = c.Wait() + lg.Flush() + + return nil +} + // RunRecordSession starts the user's shell in a PTY and records all output to the logger. // It relays stdin/stdout so the user interacts normally while all I/O is captured. func RunRecordSession(lg *logger.LineLogger, debug bool) error { From dfa02d6c282c29908ffcec760ed5060d7e4f383e Mon Sep 17 00:00:00 2001 From: cotta-dev Date: Fri, 3 Apr 2026 00:00:36 +0900 Subject: [PATCH 2/3] docs: document local/SSH record modes in help, README, and cli-options Co-Authored-By: Claude Sonnet 4.6 --- README.md | 17 ++++++++++++++--- docs/cli-options.md | 5 ++++- internal/cli/cli.go | 5 ++++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f9a51a8..a82718b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ It was born from a desire to replicate the **TeraTerm log + macro workflow** in ## Key Features -* **Session Recording**: Run without any options to record your current shell session to a log file — just like TeraTerm's log function. +* **Local Session Recording**: Run without any arguments to record your current shell session to a log file — just like TeraTerm's log function. +* **SSH Session Recording**: Pass a hostname as an argument to SSH into a remote host and record the entire session automatically. * **Automated Command Execution**: Execute commands across multiple hosts and save timestamped logs — equivalent to a TeraTerm macro. * **Agentless**: Works with standard SSH. No software required on remote hosts. * **Dependency Free**: Single binary (statically linked). Download and run. @@ -55,9 +56,9 @@ CGO_ENABLED=0 go install github.com/cotta-dev/retri@latest ## Usage -### Record a Work Session (no options) +### Record a Local Work Session (no arguments) -Running `retri` without any arguments starts recording your current shell session to a log file — equivalent to TeraTerm's log function. +Running `retri` without any arguments starts recording your current shell session to a log file. ```bash retri @@ -65,6 +66,16 @@ retri # → type 'exit' or press Ctrl-D to stop recording ``` +### SSH + Record Session (hostname as argument) + +Pass a hostname to SSH into the remote host and record the entire interactive session. + +```bash +retri myserver +# → SSHes to myserver and records the session to ~/retri-logs/myserver_YYYYMMDD_HHmmss.log +# → type 'exit' to disconnect and stop recording +``` + ### Automate Commands and Collect Logs Run a command on a single host (using `~/.ssh/config` alias): diff --git a/docs/cli-options.md b/docs/cli-options.md index 1ed3062..f0a108a 100644 --- a/docs/cli-options.md +++ b/docs/cli-options.md @@ -2,7 +2,10 @@ ``` Usage: - retri [OPTIONS] + retri Start local work session recording + retri SSH to host and record the session + retri [OPTIONS] Execute commands and collect logs + Application Options: -c, --config= Config file path (default: ~/.config/retri/config.yaml) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 4df7ad4..3887ff3 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -61,7 +61,10 @@ func Run(version string, defaultConfigContent []byte, helpContent string) { var opts Options parser := flags.NewParser(&opts, flags.Default) parser.Name = config.AppName - parser.Usage = "[OPTIONS]" + parser.Usage = "[OPTIONS] [hostname]\n\n" + + " retri Start local work session recording\n" + + " retri SSH to host and record the session\n" + + " retri [OPTIONS] Execute commands and collect logs" remaining, err := parser.Parse() if err != nil { From ab5fae267974fe24db442a3c7d90473fb92a5056 Mon Sep 17 00:00:00 2001 From: cotta-dev Date: Fri, 3 Apr 2026 00:04:26 +0900 Subject: [PATCH 3/3] docs: note that hostname arg is ignored when collection options are set --- docs/cli-options.md | 2 ++ internal/cli/cli.go | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/cli-options.md b/docs/cli-options.md index f0a108a..56f1907 100644 --- a/docs/cli-options.md +++ b/docs/cli-options.md @@ -6,6 +6,8 @@ Usage: retri SSH to host and record the session retri [OPTIONS] Execute commands and collect logs + Note: is ignored when -H, -g, --command, or -f is specified. + Application Options: -c, --config= Config file path (default: ~/.config/retri/config.yaml) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3887ff3..0f7b6f9 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -64,7 +64,8 @@ func Run(version string, defaultConfigContent []byte, helpContent string) { parser.Usage = "[OPTIONS] [hostname]\n\n" + " retri Start local work session recording\n" + " retri SSH to host and record the session\n" + - " retri [OPTIONS] Execute commands and collect logs" + " retri [OPTIONS] Execute commands and collect logs\n\n" + + " Note: is ignored when -H, -g, --command, or -f is specified." remaining, err := parser.Parse() if err != nil {