diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a8fbbe8..5350b29 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,10 @@ "WebFetch(domain:pkg.go.dev)", "Bash(make:*)", "Bash(./out/shell-now:*)", - "WebFetch(domain:github.com)" + "WebFetch(domain:github.com)", + "Bash(find:*)", + "Bash(true)", + "Bash(ls:*)" ], "deny": [] } diff --git a/cmd/shell-now/main.go b/cmd/shell-now/main.go index 0739bdc..25f0826 100644 --- a/cmd/shell-now/main.go +++ b/cmd/shell-now/main.go @@ -113,6 +113,32 @@ PowerShell: } rootCmd.AddCommand(versionCmd) + // Add replay command + replayCmd := &cobra.Command{ + Use: "replay [filename]", + Short: "Replay recorded shell sessions", + Long: `Replay recorded shell sessions using asciinema. + +Without arguments, replays the most recent recording. +With filename argument, replays the specific recording. +Use 'shell-now replay list' to see all available recordings.`, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + // Replay latest recording + return pkg.ReplayLatestRecording(ctx) + } + + if args[0] == "list" { + // List all recordings + return pkg.PrintRecordingsList() + } + + // Replay specific recording + return pkg.ReplayRecording(ctx, args[0]) + }, + } + rootCmd.AddCommand(replayCmd) + // Execute with context if err := rootCmd.ExecuteContext(ctx); err != nil { os.Exit(1) diff --git a/pkg/bootstrap.go b/pkg/bootstrap.go index 7ea05d8..1276fcd 100644 --- a/pkg/bootstrap.go +++ b/pkg/bootstrap.go @@ -6,6 +6,8 @@ import ( "log/slog" "math/rand" "net" + "os" + "path/filepath" "sync" "time" ) @@ -19,6 +21,9 @@ func Bootstrap(ctx context.Context) error { if err := prepareTtyd(ctx); err != nil { return err } + if err := prepareAsciinema(ctx); err != nil { + return err + } ttydListenPort, err := getAvailablePort() if err != nil { @@ -94,3 +99,22 @@ func randomDigitalString(length int) string { } return string(b) } + +func ensureRecordingsDirectory() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + recordingsDir := filepath.Join(home, ".local", "share", "shell-now", "recordings") + err = os.MkdirAll(recordingsDir, 0755) + if err != nil { + return "", err + } + + return recordingsDir, nil +} + +func generateRecordingFilename() string { + return fmt.Sprintf("shell-now-%s.cast", time.Now().Format("2006-01-02-15-04-05")) +} diff --git a/pkg/prepare.go b/pkg/prepare.go index 74103d2..fbae825 100644 --- a/pkg/prepare.go +++ b/pkg/prepare.go @@ -20,3 +20,10 @@ func prepareCloudflared(ctx context.Context) error { slog.Info("can not automatically prepare [cloudflared] on this platform, please install it manually", "platform", getPlatform()) return nil } + +// prepareAsciinema will check if asciinema is existed in PATH +// if not, it will print an error message +func prepareAsciinema(ctx context.Context) error { + slog.Warn("asciinema not available on this platform, session recording will be disabled", "platform", getPlatform()) + return nil +} diff --git a/pkg/prepare_darwin.go b/pkg/prepare_darwin.go index a78fd48..77a1db0 100644 --- a/pkg/prepare_darwin.go +++ b/pkg/prepare_darwin.go @@ -3,6 +3,7 @@ package pkg import ( "context" "fmt" + "log/slog" "os/exec" ) @@ -25,3 +26,14 @@ func prepareCloudflared(ctx context.Context) error { } return nil } + +// prepareAsciinema will check if asciinema is existed in PATH +// if not, it will print an error message +func prepareAsciinema(ctx context.Context) error { + // check if asciinema is existed in PATH + if _, err := exec.LookPath("asciinema"); err != nil { + slog.Warn("asciinema not found in PATH, session recording will be disabled. Execute `brew install asciinema` to enable recording.") + return nil + } + return nil +} diff --git a/pkg/prepare_linux.go b/pkg/prepare_linux.go index 6062cba..552edf6 100644 --- a/pkg/prepare_linux.go +++ b/pkg/prepare_linux.go @@ -99,3 +99,15 @@ func download(ctx context.Context, url string, output string) error { } return nil } + +func prepareAsciinema(ctx context.Context) error { + // lookup command asciinema + _, err := lookupBinary(ctx, "asciinema") + if err == nil { + // asciinema is available + return nil + } + + slog.Warn("asciinema not found in PATH, session recording will be disabled. Install asciinema to enable recording.") + return nil +} diff --git a/pkg/replay.go b/pkg/replay.go new file mode 100644 index 0000000..0360e88 --- /dev/null +++ b/pkg/replay.go @@ -0,0 +1,121 @@ +package pkg + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" +) + +func ListRecordings() ([]string, error) { + recordingsDir, err := ensureRecordingsDirectory() + if err != nil { + return nil, fmt.Errorf("ensure recordings directory: %w", err) + } + + // List all .cast files in the recordings directory + files, err := filepath.Glob(filepath.Join(recordingsDir, "*.cast")) + if err != nil { + return nil, fmt.Errorf("list recording files: %w", err) + } + + // Sort files by modification time (newest first) + sort.Slice(files, func(i, j int) bool { + infoI, errI := os.Stat(files[i]) + infoJ, errJ := os.Stat(files[j]) + if errI != nil || errJ != nil { + return false + } + return infoI.ModTime().After(infoJ.ModTime()) + }) + + // Return just the filenames without the full path + var recordings []string + for _, file := range files { + recordings = append(recordings, filepath.Base(file)) + } + + return recordings, nil +} + +func ReplayRecording(ctx context.Context, filename string) error { + // Lookup asciinema binary + asciinema, err := lookupBinary(ctx, "asciinema") + if err != nil { + return fmt.Errorf("lookup asciinema binary: %w", err) + } + + recordingsDir, err := ensureRecordingsDirectory() + if err != nil { + return fmt.Errorf("ensure recordings directory: %w", err) + } + + // Construct full path to recording file + recordingPath := filepath.Join(recordingsDir, filename) + + // Check if file exists + if _, err := os.Stat(recordingPath); os.IsNotExist(err) { + return fmt.Errorf("recording file not found: %s", filename) + } + + slog.Info("replaying session", "file", recordingPath) + + // Execute asciinema play command + cmd := exec.CommandContext(ctx, asciinema, "play", recordingPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + return cmd.Run() +} + +func ReplayLatestRecording(ctx context.Context) error { + recordings, err := ListRecordings() + if err != nil { + return fmt.Errorf("list recordings: %w", err) + } + + if len(recordings) == 0 { + return fmt.Errorf("no recordings found") + } + + // Replay the most recent recording (first in sorted list) + return ReplayRecording(ctx, recordings[0]) +} + +func PrintRecordingsList() error { + recordings, err := ListRecordings() + if err != nil { + return fmt.Errorf("list recordings: %w", err) + } + + if len(recordings) == 0 { + fmt.Println("No recordings found.") + return nil + } + + fmt.Printf("Available recordings (%d):\n", len(recordings)) + fmt.Println("----------------------------------") + + for i, recording := range recordings { + // Extract timestamp from filename for better display + displayName := recording + if strings.HasPrefix(recording, "shell-now-") && strings.HasSuffix(recording, ".cast") { + timestamp := strings.TrimPrefix(recording, "shell-now-") + timestamp = strings.TrimSuffix(timestamp, ".cast") + displayName = fmt.Sprintf("Session %s", timestamp) + } + + fmt.Printf("%2d. %s\n", i+1, displayName) + } + + fmt.Println("----------------------------------") + fmt.Println("Use 'shell-now replay ' to replay a specific recording") + fmt.Println("Use 'shell-now replay' to replay the latest recording") + + return nil +} \ No newline at end of file diff --git a/pkg/ttyd.go b/pkg/ttyd.go index 5462252..8f35542 100644 --- a/pkg/ttyd.go +++ b/pkg/ttyd.go @@ -6,6 +6,8 @@ import ( "log/slog" "os" "os/exec" + "path/filepath" + "strings" ) func startTtyd(ctx context.Context, @@ -26,8 +28,21 @@ func startTtyd(ctx context.Context, return fmt.Errorf("fetch available startup command: %w", err) } - // execute ttyd - cmd := exec.CommandContext(ctx, ttydBinary, "--writable", "--port", fmt.Sprintf("%d", listenPort), "--credential", fmt.Sprintf("%s:%s", username, password), startupCommand) + // Get asciinema binary and setup recording (best-effort) + command, args, _ := prepareAsciinemaCommand(ctx, startupCommand) + + // execute ttyd [args] + var cmd *exec.Cmd + if args == "" { + // No recording, just use the original command + cmd = exec.CommandContext(ctx, ttydBinary, "--writable", "--port", fmt.Sprintf("%d", listenPort), "--credential", fmt.Sprintf("%s:%s", username, password), command) + } else { + // Recording with asciinema - split args properly + argsList := strings.Fields(args) + ttydArgs := []string{"--writable", "--port", fmt.Sprintf("%d", listenPort), "--credential", fmt.Sprintf("%s:%s", username, password), command} + ttydArgs = append(ttydArgs, argsList...) + cmd = exec.CommandContext(ctx, ttydBinary, ttydArgs...) + } if os.Getenv("DEBUG") != "" { cmd.Stdout = os.Stdout @@ -39,7 +54,7 @@ func startTtyd(ctx context.Context, func fetchAvailableStartupCommand(ctx context.Context) (string, error) { // test commands in PATH, - // zsh, fish, bash, sh, login + // zsh, fish, bash, sh, login (login as lowest choice) commands := []string{"zsh", "fish", "bash", "sh", "login"} for _, command := range commands { if _, err := exec.LookPath(command); err == nil { @@ -48,3 +63,29 @@ func fetchAvailableStartupCommand(ctx context.Context) (string, error) { } return "", fmt.Errorf("no available startup command found, auto detect failed with zsh, fish, bash, sh, login") } + +func prepareAsciinemaCommand(ctx context.Context, originalCommand string) (string, string, error) { + // Lookup asciinema binary + asciinema, err := lookupBinary(ctx, "asciinema") + if err != nil { + // Best-effort: if asciinema is not available, just use the original command + slog.Debug("asciinema not available, proceeding without recording", "error", err) + return originalCommand, "", nil + } + + // Ensure recordings directory exists + recordingsDir, err := ensureRecordingsDirectory() + if err != nil { + slog.Warn("failed to create recordings directory, proceeding without recording", "error", err) + return originalCommand, "", nil + } + + // Generate recording filename + recordingFile := filepath.Join(recordingsDir, generateRecordingFilename()) + + slog.Info("recording session", "file", recordingFile) + + // Use asciinema as the main command with -c flag to specify shell to record + // Format: asciinema rec filename.cast -c shell_command + return asciinema, fmt.Sprintf("rec %s -c %s", recordingFile, originalCommand), nil +}