Skip to content
Open
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
5 changes: 4 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
}
Expand Down
26 changes: 26 additions & 0 deletions cmd/shell-now/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions pkg/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"log/slog"
"math/rand"
"net"
"os"
"path/filepath"
"sync"
"time"
)
Expand All @@ -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 {
Expand Down Expand Up @@ -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"))
}
7 changes: 7 additions & 0 deletions pkg/prepare.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
12 changes: 12 additions & 0 deletions pkg/prepare_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package pkg
import (
"context"
"fmt"
"log/slog"
"os/exec"
)

Expand All @@ -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
}
12 changes: 12 additions & 0 deletions pkg/prepare_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
121 changes: 121 additions & 0 deletions pkg/replay.go
Original file line number Diff line number Diff line change
@@ -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 <filename>' to replay a specific recording")
fmt.Println("Use 'shell-now replay' to replay the latest recording")

return nil
}
47 changes: 44 additions & 3 deletions pkg/ttyd.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
)

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

// execute ttyd <options> <startupCommand>
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 <options> <command> [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
Expand All @@ -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 {
Expand All @@ -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
}