Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -55,16 +56,26 @@ 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
# → starts logging to ~/retri-logs/hostname_YYYYMMDD_HHmmss.log
# → 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):
Expand Down
7 changes: 6 additions & 1 deletion docs/cli-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

```
Usage:
retri [OPTIONS]
retri Start local work session recording
retri <hostname> SSH to host and record the session
retri [OPTIONS] Execute commands and collect logs

Note: <hostname> is ignored when -H, -g, --command, or -f is specified.


Application Options:
-c, --config= Config file path (default: ~/.config/retri/config.yaml)
Expand Down
59 changes: 56 additions & 3 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,14 @@ 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 <hostname> SSH to host and record the session\n" +
" retri [OPTIONS] Execute commands and collect logs\n\n" +
" Note: <hostname> is ignored when -H, -g, --command, or -f is specified."

if _, err := parser.Parse(); err != nil {
remaining, err := parser.Parse()
if err != nil {
if flags.WroteHelp(err) {
os.Exit(0)
}
Expand Down Expand Up @@ -124,7 +129,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 <hostname> → 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
}

Expand Down Expand Up @@ -210,6 +221,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()
Expand Down
57 changes: 57 additions & 0 deletions internal/executor/record.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down