Skip to content

Commit b09185e

Browse files
committed
feat: asciinema recording
1 parent 54a4876 commit b09185e

File tree

8 files changed

+250
-4
lines changed

8 files changed

+250
-4
lines changed

.claude/settings.local.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
"WebFetch(domain:pkg.go.dev)",
55
"Bash(make:*)",
66
"Bash(./out/shell-now:*)",
7-
"WebFetch(domain:github.com)"
7+
"WebFetch(domain:github.com)",
8+
"Bash(find:*)",
9+
"Bash(true)",
10+
"Bash(ls:*)"
811
],
912
"deny": []
1013
}

cmd/shell-now/main.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,32 @@ PowerShell:
113113
}
114114
rootCmd.AddCommand(versionCmd)
115115

116+
// Add replay command
117+
replayCmd := &cobra.Command{
118+
Use: "replay [filename]",
119+
Short: "Replay recorded shell sessions",
120+
Long: `Replay recorded shell sessions using asciinema.
121+
122+
Without arguments, replays the most recent recording.
123+
With filename argument, replays the specific recording.
124+
Use 'shell-now replay list' to see all available recordings.`,
125+
RunE: func(cmd *cobra.Command, args []string) error {
126+
if len(args) == 0 {
127+
// Replay latest recording
128+
return pkg.ReplayLatestRecording(ctx)
129+
}
130+
131+
if args[0] == "list" {
132+
// List all recordings
133+
return pkg.PrintRecordingsList()
134+
}
135+
136+
// Replay specific recording
137+
return pkg.ReplayRecording(ctx, args[0])
138+
},
139+
}
140+
rootCmd.AddCommand(replayCmd)
141+
116142
// Execute with context
117143
if err := rootCmd.ExecuteContext(ctx); err != nil {
118144
os.Exit(1)

pkg/bootstrap.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"log/slog"
77
"math/rand"
88
"net"
9+
"os"
10+
"path/filepath"
911
"sync"
1012
"time"
1113
)
@@ -19,6 +21,9 @@ func Bootstrap(ctx context.Context) error {
1921
if err := prepareTtyd(ctx); err != nil {
2022
return err
2123
}
24+
if err := prepareAsciinema(ctx); err != nil {
25+
return err
26+
}
2227

2328
ttydListenPort, err := getAvailablePort()
2429
if err != nil {
@@ -94,3 +99,22 @@ func randomDigitalString(length int) string {
9499
}
95100
return string(b)
96101
}
102+
103+
func ensureRecordingsDirectory() (string, error) {
104+
home, err := os.UserHomeDir()
105+
if err != nil {
106+
return "", err
107+
}
108+
109+
recordingsDir := filepath.Join(home, ".local", "share", "shell-now", "recordings")
110+
err = os.MkdirAll(recordingsDir, 0755)
111+
if err != nil {
112+
return "", err
113+
}
114+
115+
return recordingsDir, nil
116+
}
117+
118+
func generateRecordingFilename() string {
119+
return fmt.Sprintf("shell-now-%s.cast", time.Now().Format("2006-01-02-15-04-05"))
120+
}

pkg/prepare.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,10 @@ func prepareCloudflared(ctx context.Context) error {
2020
slog.Info("can not automatically prepare [cloudflared] on this platform, please install it manually", "platform", getPlatform())
2121
return nil
2222
}
23+
24+
// prepareAsciinema will check if asciinema is existed in PATH
25+
// if not, it will print an error message
26+
func prepareAsciinema(ctx context.Context) error {
27+
slog.Warn("asciinema not available on this platform, session recording will be disabled", "platform", getPlatform())
28+
return nil
29+
}

pkg/prepare_darwin.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package pkg
33
import (
44
"context"
55
"fmt"
6+
"log/slog"
67
"os/exec"
78
)
89

@@ -25,3 +26,14 @@ func prepareCloudflared(ctx context.Context) error {
2526
}
2627
return nil
2728
}
29+
30+
// prepareAsciinema will check if asciinema is existed in PATH
31+
// if not, it will print an error message
32+
func prepareAsciinema(ctx context.Context) error {
33+
// check if asciinema is existed in PATH
34+
if _, err := exec.LookPath("asciinema"); err != nil {
35+
slog.Warn("asciinema not found in PATH, session recording will be disabled. Execute `brew install asciinema` to enable recording.")
36+
return nil
37+
}
38+
return nil
39+
}

pkg/prepare_linux.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,15 @@ func download(ctx context.Context, url string, output string) error {
9999
}
100100
return nil
101101
}
102+
103+
func prepareAsciinema(ctx context.Context) error {
104+
// lookup command asciinema
105+
_, err := lookupBinary(ctx, "asciinema")
106+
if err == nil {
107+
// asciinema is available
108+
return nil
109+
}
110+
111+
slog.Warn("asciinema not found in PATH, session recording will be disabled. Install asciinema to enable recording.")
112+
return nil
113+
}

pkg/replay.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package pkg
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"sort"
11+
"strings"
12+
)
13+
14+
func ListRecordings() ([]string, error) {
15+
recordingsDir, err := ensureRecordingsDirectory()
16+
if err != nil {
17+
return nil, fmt.Errorf("ensure recordings directory: %w", err)
18+
}
19+
20+
// List all .cast files in the recordings directory
21+
files, err := filepath.Glob(filepath.Join(recordingsDir, "*.cast"))
22+
if err != nil {
23+
return nil, fmt.Errorf("list recording files: %w", err)
24+
}
25+
26+
// Sort files by modification time (newest first)
27+
sort.Slice(files, func(i, j int) bool {
28+
infoI, errI := os.Stat(files[i])
29+
infoJ, errJ := os.Stat(files[j])
30+
if errI != nil || errJ != nil {
31+
return false
32+
}
33+
return infoI.ModTime().After(infoJ.ModTime())
34+
})
35+
36+
// Return just the filenames without the full path
37+
var recordings []string
38+
for _, file := range files {
39+
recordings = append(recordings, filepath.Base(file))
40+
}
41+
42+
return recordings, nil
43+
}
44+
45+
func ReplayRecording(ctx context.Context, filename string) error {
46+
// Lookup asciinema binary
47+
asciinema, err := lookupBinary(ctx, "asciinema")
48+
if err != nil {
49+
return fmt.Errorf("lookup asciinema binary: %w", err)
50+
}
51+
52+
recordingsDir, err := ensureRecordingsDirectory()
53+
if err != nil {
54+
return fmt.Errorf("ensure recordings directory: %w", err)
55+
}
56+
57+
// Construct full path to recording file
58+
recordingPath := filepath.Join(recordingsDir, filename)
59+
60+
// Check if file exists
61+
if _, err := os.Stat(recordingPath); os.IsNotExist(err) {
62+
return fmt.Errorf("recording file not found: %s", filename)
63+
}
64+
65+
slog.Info("replaying session", "file", recordingPath)
66+
67+
// Execute asciinema play command
68+
cmd := exec.CommandContext(ctx, asciinema, "play", recordingPath)
69+
cmd.Stdout = os.Stdout
70+
cmd.Stderr = os.Stderr
71+
cmd.Stdin = os.Stdin
72+
73+
return cmd.Run()
74+
}
75+
76+
func ReplayLatestRecording(ctx context.Context) error {
77+
recordings, err := ListRecordings()
78+
if err != nil {
79+
return fmt.Errorf("list recordings: %w", err)
80+
}
81+
82+
if len(recordings) == 0 {
83+
return fmt.Errorf("no recordings found")
84+
}
85+
86+
// Replay the most recent recording (first in sorted list)
87+
return ReplayRecording(ctx, recordings[0])
88+
}
89+
90+
func PrintRecordingsList() error {
91+
recordings, err := ListRecordings()
92+
if err != nil {
93+
return fmt.Errorf("list recordings: %w", err)
94+
}
95+
96+
if len(recordings) == 0 {
97+
fmt.Println("No recordings found.")
98+
return nil
99+
}
100+
101+
fmt.Printf("Available recordings (%d):\n", len(recordings))
102+
fmt.Println("----------------------------------")
103+
104+
for i, recording := range recordings {
105+
// Extract timestamp from filename for better display
106+
displayName := recording
107+
if strings.HasPrefix(recording, "shell-now-") && strings.HasSuffix(recording, ".cast") {
108+
timestamp := strings.TrimPrefix(recording, "shell-now-")
109+
timestamp = strings.TrimSuffix(timestamp, ".cast")
110+
displayName = fmt.Sprintf("Session %s", timestamp)
111+
}
112+
113+
fmt.Printf("%2d. %s\n", i+1, displayName)
114+
}
115+
116+
fmt.Println("----------------------------------")
117+
fmt.Println("Use 'shell-now replay <filename>' to replay a specific recording")
118+
fmt.Println("Use 'shell-now replay' to replay the latest recording")
119+
120+
return nil
121+
}

pkg/ttyd.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"log/slog"
77
"os"
88
"os/exec"
9+
"path/filepath"
10+
"strings"
911
)
1012

1113
func startTtyd(ctx context.Context,
@@ -26,8 +28,21 @@ func startTtyd(ctx context.Context,
2628
return fmt.Errorf("fetch available startup command: %w", err)
2729
}
2830

29-
// execute ttyd <options> <startupCommand>
30-
cmd := exec.CommandContext(ctx, ttydBinary, "--writable", "--port", fmt.Sprintf("%d", listenPort), "--credential", fmt.Sprintf("%s:%s", username, password), startupCommand)
31+
// Get asciinema binary and setup recording (best-effort)
32+
command, args, _ := prepareAsciinemaCommand(ctx, startupCommand)
33+
34+
// execute ttyd <options> <command> [args]
35+
var cmd *exec.Cmd
36+
if args == "" {
37+
// No recording, just use the original command
38+
cmd = exec.CommandContext(ctx, ttydBinary, "--writable", "--port", fmt.Sprintf("%d", listenPort), "--credential", fmt.Sprintf("%s:%s", username, password), command)
39+
} else {
40+
// Recording with asciinema - split args properly
41+
argsList := strings.Fields(args)
42+
ttydArgs := []string{"--writable", "--port", fmt.Sprintf("%d", listenPort), "--credential", fmt.Sprintf("%s:%s", username, password), command}
43+
ttydArgs = append(ttydArgs, argsList...)
44+
cmd = exec.CommandContext(ctx, ttydBinary, ttydArgs...)
45+
}
3146

3247
if os.Getenv("DEBUG") != "" {
3348
cmd.Stdout = os.Stdout
@@ -39,7 +54,7 @@ func startTtyd(ctx context.Context,
3954

4055
func fetchAvailableStartupCommand(ctx context.Context) (string, error) {
4156
// test commands in PATH,
42-
// zsh, fish, bash, sh, login
57+
// zsh, fish, bash, sh, login (login as lowest choice)
4358
commands := []string{"zsh", "fish", "bash", "sh", "login"}
4459
for _, command := range commands {
4560
if _, err := exec.LookPath(command); err == nil {
@@ -48,3 +63,29 @@ func fetchAvailableStartupCommand(ctx context.Context) (string, error) {
4863
}
4964
return "", fmt.Errorf("no available startup command found, auto detect failed with zsh, fish, bash, sh, login")
5065
}
66+
67+
func prepareAsciinemaCommand(ctx context.Context, originalCommand string) (string, string, error) {
68+
// Lookup asciinema binary
69+
asciinema, err := lookupBinary(ctx, "asciinema")
70+
if err != nil {
71+
// Best-effort: if asciinema is not available, just use the original command
72+
slog.Debug("asciinema not available, proceeding without recording", "error", err)
73+
return originalCommand, "", nil
74+
}
75+
76+
// Ensure recordings directory exists
77+
recordingsDir, err := ensureRecordingsDirectory()
78+
if err != nil {
79+
slog.Warn("failed to create recordings directory, proceeding without recording", "error", err)
80+
return originalCommand, "", nil
81+
}
82+
83+
// Generate recording filename
84+
recordingFile := filepath.Join(recordingsDir, generateRecordingFilename())
85+
86+
slog.Info("recording session", "file", recordingFile)
87+
88+
// Use asciinema as the main command with -c flag to specify shell to record
89+
// Format: asciinema rec filename.cast -c shell_command
90+
return asciinema, fmt.Sprintf("rec %s -c %s", recordingFile, originalCommand), nil
91+
}

0 commit comments

Comments
 (0)