From 4732c3f901887caf5c32ef6a0a7f5900f3921308 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Wed, 20 May 2026 18:56:56 +0900 Subject: [PATCH] CPLAT-9497: improve remote worker session handoff --- internal/cli/cli.go | 40 +- internal/remote/config.go | 74 ++- internal/remote/pod.go | 15 +- internal/remote/session.go | 188 ++++++-- internal/remote/snapshot.go | 359 +++++++++++++++ internal/remote/stream.go | 6 +- internal/tui/app.go | 28 +- internal/tui/cmdmode.go | 84 +++- internal/tui/remote.go | 869 ++++++++++++++++++++++++++++++++---- main.go | 152 ++++++- 10 files changed, 1656 insertions(+), 159 deletions(-) create mode 100644 internal/remote/snapshot.go diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 92ebcaf..938de49 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -23,7 +23,9 @@ var Commands = []struct { {"changes", "List file changes made by the session (interactive on TTY)"}, {"images", "List image paths from the session (interactive on TTY)"}, {"conversation", "List conversation turns from the Claude session (interactive on TTY)"}, + {"info", "Show the matched Claude session metadata"}, {"sessions", "List session IDs with metadata (use --pick for TUI JSON picker)"}, + {"config", "View/edit ccx config and get/set dot-path values"}, {"help", "Show available commands and usage"}, } @@ -45,6 +47,9 @@ func Run(command, claudeDir string, plain bool) (*RunResult, error) { if command == "sessions" { return nil, RunSessions(claudeDir, false) } + if command == "info" { + return nil, RunInfo(claudeDir) + } filePath, sessID, err := findSessionFile(claudeDir) if err != nil { @@ -125,7 +130,8 @@ func printHelp() { fmt.Fprintf(os.Stderr, "ccx — Claude Code Explorer\n\n") fmt.Fprintf(os.Stderr, "Usage:\n") fmt.Fprintf(os.Stderr, " ccx Launch the TUI\n") - fmt.Fprintf(os.Stderr, " ccx Run a subcommand\n\n") + fmt.Fprintf(os.Stderr, " ccx Run a subcommand\n") + fmt.Fprintf(os.Stderr, " ccx config ...\n\n") fmt.Fprintf(os.Stderr, "Commands:\n") for _, c := range Commands { fmt.Fprintf(os.Stderr, " %-10s %s\n", c.Name, c.Desc) @@ -140,7 +146,11 @@ func printHelp() { fmt.Fprintf(os.Stderr, " ccx files Interactive file picker\n") fmt.Fprintf(os.Stderr, " ccx changes Interactive changed-files picker\n") fmt.Fprintf(os.Stderr, " ccx images Interactive image picker\n") - fmt.Fprintf(os.Stderr, " ccx conversation Interactive conversation picker\n\n") + fmt.Fprintf(os.Stderr, " ccx conversation Interactive conversation picker\n") + fmt.Fprintf(os.Stderr, " ccx info Show current matched session metadata\n") + fmt.Fprintf(os.Stderr, " ccx config view Print ~/.config/ccx/config.yaml\n") + fmt.Fprintf(os.Stderr, " ccx config edit Open config in $EDITOR\n") + fmt.Fprintf(os.Stderr, " ccx config set remote.pod_name ccx-worker\n\n") fmt.Fprintf(os.Stderr, "Picker keys:\n") fmt.Fprintf(os.Stderr, " ↵ enter Jump to message in full ccx TUI\n") fmt.Fprintf(os.Stderr, " o Open URL in browser\n") @@ -311,6 +321,32 @@ func RunSessions(claudeDir string, all bool) error { return nil } +func RunInfo(claudeDir string) error { + _, sessID, err := findSessionFile(claudeDir) + if err != nil { + return err + } + sess, ok := session.FindSessionByID(claudeDir, sessID) + if !ok { + return fmt.Errorf("session %s not found", sessID) + } + fmt.Fprintf(os.Stdout, "id\t%s\n", sess.ID) + fmt.Fprintf(os.Stdout, "short_id\t%s\n", sess.ShortID) + fmt.Fprintf(os.Stdout, "project\t%s\n", sess.ProjectName) + fmt.Fprintf(os.Stdout, "project_path\t%s\n", sess.ProjectPath) + fmt.Fprintf(os.Stdout, "transcript\t%s\n", sess.FilePath) + fmt.Fprintf(os.Stdout, "modified\t%s\n", sess.ModTime.Format("2006-01-02 15:04:05")) + fmt.Fprintf(os.Stdout, "messages\t%d\n", sess.MsgCount) + if sess.GitBranch != "" { + fmt.Fprintf(os.Stdout, "git_branch\t%s\n", sess.GitBranch) + } + if sess.FirstPrompt != "" { + prompt := strings.ReplaceAll(sess.FirstPrompt, "\n", " ") + fmt.Fprintf(os.Stdout, "first_prompt\t%s\n", prompt) + } + return nil +} + // findSessionFile detects Claude sessions in the same tmux window. // If multiple sessions are found, prompts the user to choose one. func findSessionFile(claudeDir string) (string, string, error) { diff --git a/internal/remote/config.go b/internal/remote/config.go index 1f41187..ee32dda 100644 --- a/internal/remote/config.go +++ b/internal/remote/config.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "fmt" "os/exec" + "runtime" "strings" ) @@ -18,24 +19,34 @@ func CurrentContext() (string, error) { // Config holds settings for a remote Claude execution. type Config struct { - Context string `yaml:"context"` // kubectl --context (required) - Namespace string `yaml:"namespace"` // target namespace - Image string `yaml:"image"` // container image - LocalDir string `yaml:"local_dir"` // local workdir to sync - GitRepo string `yaml:"git_repo"` // repo URL to clone (fallback if no local_dir) - GitBranch string `yaml:"git_branch"` // branch to checkout - WorkDir string `yaml:"work_dir"` // remote working directory - Prompt string `yaml:"-"` // initial prompt (not persisted) - CPULimit string `yaml:"cpu_limit"` // e.g. "2" - MemoryLimit string `yaml:"memory_limit"` // e.g. "4Gi" - Arch string `yaml:"arch"` // "amd64" or "arm64" - EnvVars map[string]string `yaml:"env_vars"` // extra env vars to inject into pod - MirrorEnv []string `yaml:"mirror_env"` // local env var names to mirror to pod - Labels map[string]string `yaml:"labels"` // extra pod labels - Tolerations []string `yaml:"tolerations"` // toleration keys - ClaudeArgs []string `yaml:"claude_args"` // extra args for claude CLI (e.g. --model, --allowedTools) - SessionID string `yaml:"-"` // session ID to resume - SessionFile string `yaml:"-"` // local path to session JSONL + Context string `yaml:"context"` // kubectl --context (required) + Namespace string `yaml:"namespace"` // target namespace + PodName string `yaml:"pod_name"` // fixed pod name to reuse (optional) + Container string `yaml:"container"` // target container name (optional) + RemoteUser string `yaml:"remote_user"` // user to run Claude as + RemoteHome string `yaml:"remote_home"` // remote user's home directory + Image string `yaml:"image"` // container image + LocalDir string `yaml:"local_dir"` // local workdir to sync + RemoteProjectPath string `yaml:"remote_project_path"` // project path key to use for Claude session JSONL + GitRepo string `yaml:"git_repo"` // repo URL to clone (fallback if no local_dir) + GitBranch string `yaml:"git_branch"` // branch to checkout + WorkDir string `yaml:"work_dir"` // remote working directory + WorkDirTemplate string `yaml:"work_dir_template"` // optional template for per-session workdirs + Prompt string `yaml:"-"` // initial prompt (not persisted) + CPULimit string `yaml:"cpu_limit"` // e.g. "2" + MemoryLimit string `yaml:"memory_limit"` // e.g. "4Gi" + Arch string `yaml:"arch"` // "amd64" or "arm64" + EnvVars map[string]string `yaml:"env_vars"` // extra env vars to inject into pod + MirrorEnv []string `yaml:"mirror_env"` // local env var names to mirror to pod + Labels map[string]string `yaml:"labels"` // extra pod labels + Tolerations []string `yaml:"tolerations"` // toleration keys + ClaudeArgs []string `yaml:"claude_args"` // extra args for claude CLI (e.g. --model, --allowedTools) + SessionID string `yaml:"-"` // session ID to resume + SessionFile string `yaml:"-"` // local path to session JSONL + // WorkdirTarball, when non-nil, is uploaded verbatim into WorkDir on the pod + // instead of re-tarring LocalDir. Used by snapshot restore / fork to avoid + // host-side changes leaking into the resumed pod. + WorkdirTarball []byte `yaml:"-"` } // Defaults returns a Config with sensible defaults filled in. @@ -49,6 +60,15 @@ func (c Config) Defaults() Config { if c.Image == "" { c.Image = "ubuntu:24.04" } + if c.Container == "" { + c.Container = "main" + } + if c.RemoteUser == "" { + c.RemoteUser = "claude" + } + if c.RemoteHome == "" { + c.RemoteHome = "/home/" + c.RemoteUser + } if c.GitBranch == "" { c.GitBranch = "main" } @@ -61,9 +81,6 @@ func (c Config) Defaults() Config { if c.MemoryLimit == "" { c.MemoryLimit = "4Gi" } - if c.Arch == "" { - c.Arch = "amd64" - } return c } @@ -81,3 +98,18 @@ func GeneratePodName() string { rand.Read(b) return fmt.Sprintf("ccx-remote-%x", b) } + +// HostArch returns the local machine's GOARCH normalized to the k8s +// kubernetes.io/arch convention (amd64, arm64). +func HostArch() string { + return runtime.GOARCH +} + +// ArchMismatch reports whether cfg.Arch is set and differs from the host arch. +// Comparison is case-insensitive; "" never mismatches. +func (c Config) ArchMismatch() bool { + if c.Arch == "" { + return false + } + return !strings.EqualFold(c.Arch, HostArch()) +} diff --git a/internal/remote/pod.go b/internal/remote/pod.go index d13221e..beafcd8 100644 --- a/internal/remote/pod.go +++ b/internal/remote/pod.go @@ -70,6 +70,9 @@ func podSpec(cfg Config, podName, oauthToken string) ([]byte, error) { }, }, } + if cfg.Arch != "" { + podSpecMap["nodeSelector"] = map[string]string{"kubernetes.io/arch": cfg.Arch} + } if len(tolerations) > 0 { podSpecMap["tolerations"] = tolerations } @@ -127,8 +130,12 @@ func ExecInPod(ctx context.Context, cfg Config, podName string, cmd ...string) ( args := []string{ "--context", cfg.Context, "-n", cfg.Namespace, - "exec", podName, "--", + "exec", podName, + } + if cfg.Container != "" { + args = append(args, "-c", cfg.Container) } + args = append(args, "--") args = append(args, cmd...) c := exec.CommandContext(ctx, "kubectl", args...) return c.CombinedOutput() @@ -154,8 +161,12 @@ func ExecInteractive(cfg Config, podName string, cmd ...string) *exec.Cmd { args := []string{ "--context", cfg.Context, "-n", cfg.Namespace, - "exec", "-it", podName, "--", + "exec", "-it", podName, + } + if cfg.Container != "" { + args = append(args, "-c", cfg.Container) } + args = append(args, "--") args = append(args, cmd...) return exec.Command("kubectl", args...) } diff --git a/internal/remote/session.go b/internal/remote/session.go index e65de2e..11673a2 100644 --- a/internal/remote/session.go +++ b/internal/remote/session.go @@ -3,6 +3,7 @@ package remote import ( "context" "fmt" + "os" "os/exec" "strings" "time" @@ -15,7 +16,7 @@ type Session struct { Config Config PodName string Stream <-chan StreamLine // live output stream (nil until Claude starts) - Status string // current status for display + Status string // current status for display ctx context.Context cancel context.CancelFunc } @@ -34,9 +35,13 @@ func Start(cfg Config, claudeDir, projectPath string) (*Session, <-chan SetupSte steps := make(chan SetupStep, 16) ctx, cancel := context.WithCancel(context.Background()) + podName := cfg.PodName + if podName == "" { + podName = GeneratePodName() + } sess := &Session{ Config: cfg, - PodName: GeneratePodName(), + PodName: podName, Status: "starting", ctx: ctx, cancel: cancel, @@ -57,6 +62,25 @@ func Start(cfg Config, claudeDir, projectPath string) (*Session, <-chan SetupSte return sess, steps } +// Adopt attaches ccx state to an already-running remote pod. It does not sync +// config or workdir; callers should only use it for pods previously created by +// ccx with matching metadata. +func Adopt(cfg Config, podName string) (*Session, <-chan SetupStep) { + cfg = cfg.Defaults() + steps := make(chan SetupStep, 1) + ctx, cancel := context.WithCancel(context.Background()) + sess := &Session{ + Config: cfg, + PodName: podName, + Status: "running", + ctx: ctx, + cancel: cancel, + } + steps <- SetupStep{Done: true, Message: "Reusing existing pod"} + close(steps) + return sess, steps +} + func (s *Session) setup(cfg Config, claudeDir, projectPath string, steps chan<- SetupStep) error { ctx := s.ctx @@ -72,56 +96,76 @@ func (s *Session) setup(cfg Config, claudeDir, projectPath string, steps chan<- return fmt.Errorf("auth: %w", err) } - // Create pod - steps <- SetupStep{Message: fmt.Sprintf("Creating pod %s...", s.PodName)} - if err := CreatePod(ctx, cfg, s.PodName, token); err != nil { - return fmt.Errorf("create pod: %w", err) - } - - // Wait ready - steps <- SetupStep{Message: "Waiting for pod ready..."} - if err := WaitForPod(ctx, cfg, s.PodName, 3*time.Minute); err != nil { - DeletePod(context.Background(), cfg, s.PodName) - return fmt.Errorf("pod not ready: %w", err) - } - - // Create non-root user (--dangerously-skip-permissions blocks root) - steps <- SetupStep{Message: "Creating user..."} - ExecInPod(ctx, cfg, s.PodName, "sh", "-c", - "useradd -m -s /bin/bash claude 2>/dev/null; "+ - "mkdir -p /home/claude/.claude "+cfg.WorkDir+" && "+ - "chown -R claude:claude /home/claude "+cfg.WorkDir+" && "+ - // Write token to file so claude user can source it - "echo \"export CLAUDE_CODE_OAUTH_TOKEN=$CLAUDE_CODE_OAUTH_TOKEN\" > /home/claude/.claude_env && "+ - "chown claude:claude /home/claude/.claude_env && chmod 600 /home/claude/.claude_env") - - // Install prerequisites + Claude Code CLI (as root) - steps <- SetupStep{Message: "Installing Node.js and Claude Code CLI..."} - installCmd := "apt-get update -qq && apt-get install -y -qq curl git > /dev/null 2>&1 && " + - "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null 2>&1 && " + - "apt-get install -y -qq nodejs > /dev/null 2>&1 && " + - "npm install -g @anthropic-ai/claude-code 2>&1 | tail -3" - out, err := ExecInPod(ctx, cfg, s.PodName, "sh", "-c", installCmd) - if err != nil { - steps <- SetupStep{Message: fmt.Sprintf("Install issue: %s", string(out))} + // Create pod or reuse fixed existing pod. + if phase, err := PodPhase(ctx, cfg, s.PodName); err == nil && (phase == "Running" || phase == "Pending") { + steps <- SetupStep{Message: fmt.Sprintf("Reusing pod %s (%s)...", s.PodName, phase)} + } else { + steps <- SetupStep{Message: fmt.Sprintf("Creating pod %s...", s.PodName)} + if err := CreatePod(ctx, cfg, s.PodName, token); err != nil { + return fmt.Errorf("create pod: %w", err) + } + + // Wait ready + steps <- SetupStep{Message: "Waiting for pod ready..."} + if err := WaitForPod(ctx, cfg, s.PodName, 3*time.Minute); err != nil { + DeletePod(context.Background(), cfg, s.PodName) + return fmt.Errorf("pod not ready: %w", err) + } } - // Sync config to claude user's home + // Create/update remote user and auth env. + steps <- SetupStep{Message: "Preparing remote user..."} + tokenExport := "export CLAUDE_CODE_OAUTH_TOKEN=" + token + "\n" + setupUserCmd := fmt.Sprintf( + "id -u %s >/dev/null 2>&1 || useradd -m -s /bin/bash %s 2>/dev/null; "+ + "mkdir -p %s/.claude %s && "+ + "chown -R %s:%s %s %s 2>/dev/null || true && "+ + "printf %%s %s > %s/.claude_env && "+ + "chown %s:%s %s/.claude_env 2>/dev/null || true && chmod 600 %s/.claude_env", + cfg.RemoteUser, cfg.RemoteUser, + cfg.RemoteHome, cfg.WorkDir, + cfg.RemoteUser, cfg.RemoteUser, cfg.RemoteHome, cfg.WorkDir, + shellQuote(tokenExport), cfg.RemoteHome, + cfg.RemoteUser, cfg.RemoteUser, cfg.RemoteHome, cfg.RemoteHome) + ExecInPod(ctx, cfg, s.PodName, "sh", "-c", setupUserCmd) + + // Install prerequisites + Claude Code CLI (as root), but skip on warm worker pods. + steps <- SetupStep{Message: "Checking Claude Code CLI..."} + if _, err := ExecInPod(ctx, cfg, s.PodName, "sh", "-c", "command -v claude >/dev/null 2>&1"); err != nil { + steps <- SetupStep{Message: "Installing Node.js and Claude Code CLI..."} + installCmd := "apt-get update -qq && apt-get install -y -qq curl git > /dev/null 2>&1 && " + + "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - > /dev/null 2>&1 && " + + "apt-get install -y -qq nodejs > /dev/null 2>&1 && " + + "npm install -g @anthropic-ai/claude-code 2>&1 | tail -3" + out, err := ExecInPod(ctx, cfg, s.PodName, "sh", "-c", installCmd) + if err != nil { + steps <- SetupStep{Message: fmt.Sprintf("Install issue: %s", string(out))} + } + } + + // Sync config to remote user's home. steps <- SetupStep{Message: "Syncing config..."} - configTar, err := CreateConfigTarball(claudeDir, projectPath, cfg.WorkDir, cfg.SessionFile) + remoteProjectPath := cfg.RemoteProjectPath + if remoteProjectPath == "" { + remoteProjectPath = cfg.WorkDir + } + configTar, err := CreateConfigTarball(claudeDir, projectPath, remoteProjectPath, cfg.SessionFile) if err == nil && len(configTar) > 0 { - UploadTarball(ctx, cfg, s.PodName, "main", "/home/claude", configTar) - // Fix ownership - ExecInPod(ctx, cfg, s.PodName, "chown", "-R", "claude:claude", "/home/claude/.claude", "/home/claude/.claude.json") + UploadTarball(ctx, cfg, s.PodName, cfg.Container, cfg.RemoteHome, configTar) + ExecInPod(ctx, cfg, s.PodName, "sh", "-c", fmt.Sprintf("chown -R %s:%s %s/.claude %s/.claude.json 2>/dev/null || true", cfg.RemoteUser, cfg.RemoteUser, cfg.RemoteHome, cfg.RemoteHome)) } - // Sync workdir - if cfg.LocalDir != "" { + // Sync workdir — prefer prebuilt tarball (snapshot/fork), else tar LocalDir. + if len(cfg.WorkdirTarball) > 0 { + steps <- SetupStep{Message: "Restoring workdir from snapshot..."} + UploadTarball(ctx, cfg, s.PodName, cfg.Container, cfg.WorkDir, cfg.WorkdirTarball) + ExecInPod(ctx, cfg, s.PodName, "sh", "-c", fmt.Sprintf("chown -R %s:%s %s 2>/dev/null || true", cfg.RemoteUser, cfg.RemoteUser, cfg.WorkDir)) + } else if cfg.LocalDir != "" { steps <- SetupStep{Message: "Syncing workdir..."} workdirTar, err := CreateWorkdirTarball(cfg.LocalDir) if err == nil && len(workdirTar) > 0 { - UploadTarball(ctx, cfg, s.PodName, "main", cfg.WorkDir, workdirTar) - ExecInPod(ctx, cfg, s.PodName, "chown", "-R", "claude:claude", cfg.WorkDir) + UploadTarball(ctx, cfg, s.PodName, cfg.Container, cfg.WorkDir, workdirTar) + ExecInPod(ctx, cfg, s.PodName, "sh", "-c", fmt.Sprintf("chown -R %s:%s %s 2>/dev/null || true", cfg.RemoteUser, cfg.RemoteUser, cfg.WorkDir)) } } @@ -140,8 +184,8 @@ func (s *Session) AttachCmd() *exec.Cmd { func BuildAttachCmd(cfg Config, podName string) *exec.Cmd { claudeCmd := BuildClaudeCmd(cfg, false) shellCmd := fmt.Sprintf( - "su - claude -c 'export PATH=/usr/local/bin:/usr/bin:/bin:$PATH; . ~/.claude_env; cd %s 2>/dev/null; %s'", - cfg.WorkDir, claudeCmd) + "su - %s -c 'export PATH=$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH; . ~/.claude_env; cd %s 2>/dev/null; %s'", + cfg.RemoteUser, cfg.WorkDir, claudeCmd) return ExecInteractive(cfg, podName, "sh", "-c", shellCmd) } @@ -163,9 +207,13 @@ func BuildClaudeCmd(cfg Config, streaming bool) string { // FetchSessionJSONL downloads the latest session JSONL from the pod. // It finds the most recent .jsonl file under the remote workdir's project path. func FetchSessionJSONL(cfg Config, podName string) ([]byte, error) { - encoded := encodeProjectPath(cfg.WorkDir) + projectPath := cfg.RemoteProjectPath + if projectPath == "" { + projectPath = cfg.WorkDir + } + encoded := encodeProjectPath(projectPath) // Find the latest .jsonl file - findCmd := fmt.Sprintf("ls -t /home/claude/.claude/projects/%s/*.jsonl 2>/dev/null | head -1", encoded) + findCmd := fmt.Sprintf("ls -t %s/.claude/projects/%s/*.jsonl 2>/dev/null | head -1", cfg.RemoteHome, encoded) out, err := ExecInPod(context.Background(), cfg, podName, "sh", "-c", findCmd) if err != nil || len(out) == 0 { return nil, fmt.Errorf("no session file found on pod") @@ -192,3 +240,49 @@ func (s *Session) Stop() error { func (s *Session) IsRunning() bool { return s.ctx.Err() == nil } + +// StartFromSnapshot resolves a snapshot by name and starts a new pod whose +// workdir and (optionally) session file come from the snapshot rather than +// from the local host. +// +// overrides lets the caller change the target Context/Namespace/Image without +// editing the snapshot meta on disk — anything zero in overrides falls back to +// the snapshot's recorded value. +func StartFromSnapshot(name string, overrides Config, claudeDir string) (*Session, <-chan SetupStep, error) { + meta, sessionPath, workdirPath, err := LoadSnapshot(name) + if err != nil { + return nil, nil, fmt.Errorf("load snapshot %q: %w", name, err) + } + + cfg := overrides + if cfg.Context == "" { + cfg.Context = meta.Context + } + if cfg.Namespace == "" { + cfg.Namespace = meta.Namespace + } + if cfg.Image == "" { + cfg.Image = meta.Image + } + if cfg.WorkDir == "" { + cfg.WorkDir = meta.WorkDir + } + if cfg.LocalDir == "" { + cfg.LocalDir = meta.LocalDir + } + if cfg.SessionID == "" { + cfg.SessionID = meta.SessionID + } + if cfg.SessionFile == "" && sessionPath != "" { + cfg.SessionFile = sessionPath + } + if workdirPath != "" { + data, rerr := os.ReadFile(workdirPath) + if rerr == nil { + cfg.WorkdirTarball = data + } + } + + sess, steps := Start(cfg, claudeDir, cfg.LocalDir) + return sess, steps, nil +} diff --git a/internal/remote/snapshot.go b/internal/remote/snapshot.go new file mode 100644 index 0000000..e9d3eba --- /dev/null +++ b/internal/remote/snapshot.go @@ -0,0 +1,359 @@ +package remote + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// SnapshotMeta describes a persisted remote session snapshot. The matching +// payload files (session.jsonl, workdir.tgz) live next to meta.yaml under +// snapshotDir//. +type SnapshotMeta struct { + Name string `yaml:"name"` + CreatedAt time.Time `yaml:"created_at"` + SourcePod string `yaml:"source_pod"` + Context string `yaml:"context"` + Namespace string `yaml:"namespace"` + Image string `yaml:"image"` + WorkDir string `yaml:"work_dir"` + LocalDir string `yaml:"local_dir,omitempty"` + SessionID string `yaml:"session_id,omitempty"` + HasSession bool `yaml:"has_session"` + HasWorkdir bool `yaml:"has_workdir"` + WorkdirSize int64 `yaml:"workdir_size,omitempty"` +} + +func snapshotsRoot() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "ccx", "snapshots") +} + +func snapshotDir(name string) string { + return filepath.Join(snapshotsRoot(), name) +} + +// ListSnapshots returns metadata for every snapshot on disk, newest first. +func ListSnapshots() []SnapshotMeta { + root := snapshotsRoot() + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + var out []SnapshotMeta + for _, e := range entries { + if !e.IsDir() { + continue + } + meta, err := loadMeta(snapshotDir(e.Name())) + if err != nil { + continue + } + out = append(out, meta) + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + return out +} + +// LoadSnapshot returns the metadata and on-disk payload paths for a named snapshot. +func LoadSnapshot(name string) (SnapshotMeta, string, string, error) { + dir := snapshotDir(name) + meta, err := loadMeta(dir) + if err != nil { + return SnapshotMeta{}, "", "", err + } + sessionPath := "" + if meta.HasSession { + sessionPath = filepath.Join(dir, "session.jsonl") + } + workdirPath := "" + if meta.HasWorkdir { + workdirPath = filepath.Join(dir, "workdir.tgz") + } + return meta, sessionPath, workdirPath, nil +} + +// DeleteSnapshot removes a snapshot directory. +func DeleteSnapshot(name string) error { + if name == "" || strings.ContainsAny(name, "/\\") { + return fmt.Errorf("invalid snapshot name") + } + return os.RemoveAll(snapshotDir(name)) +} + +// ExportSnapshot writes a portable tar.gz bundle for a snapshot directory. +// The archive contains files under a single top-level / prefix. +func ExportSnapshot(name, outputPath string) error { + if name == "" || strings.ContainsAny(name, "/\\") { + return fmt.Errorf("invalid snapshot name") + } + if outputPath == "" { + return fmt.Errorf("output path required") + } + dir := snapshotDir(name) + if _, err := loadMeta(dir); err != nil { + return fmt.Errorf("load snapshot: %w", err) + } + + out, err := os.Create(outputPath) + if err != nil { + return fmt.Errorf("create export: %w", err) + } + defer out.Close() + gw := gzip.NewWriter(out) + defer gw.Close() + tw := tar.NewWriter(gw) + defer tw.Close() + + return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + rel, err := filepath.Rel(dir, path) + if err != nil { + return err + } + return addFileToTar(tw, path, filepath.Join(name, rel)) + }) +} + +// ImportSnapshot extracts an exported tar.gz bundle into the snapshots root. +// If overrideName is non-empty, the imported directory and meta name are renamed. +func ImportSnapshot(inputPath, overrideName string) (SnapshotMeta, error) { + if inputPath == "" { + return SnapshotMeta{}, fmt.Errorf("input path required") + } + f, err := os.Open(inputPath) + if err != nil { + return SnapshotMeta{}, fmt.Errorf("open import: %w", err) + } + defer f.Close() + gr, err := gzip.NewReader(f) + if err != nil { + return SnapshotMeta{}, fmt.Errorf("read gzip: %w", err) + } + defer gr.Close() + + if err := os.MkdirAll(snapshotsRoot(), 0755); err != nil { + return SnapshotMeta{}, fmt.Errorf("mkdir snapshots root: %w", err) + } + tmp, err := os.MkdirTemp(snapshotsRoot(), ".import-*") + if err != nil { + return SnapshotMeta{}, fmt.Errorf("mktemp import: %w", err) + } + defer os.RemoveAll(tmp) + + tr := tar.NewReader(gr) + top := "" + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return SnapshotMeta{}, fmt.Errorf("read tar: %w", err) + } + if header.Typeflag != tar.TypeReg { + continue + } + parts := strings.Split(filepath.Clean(header.Name), string(os.PathSeparator)) + if len(parts) < 2 || parts[0] == "." || parts[0] == ".." { + return SnapshotMeta{}, fmt.Errorf("invalid archive path: %s", header.Name) + } + if top == "" { + top = parts[0] + } else if top != parts[0] { + return SnapshotMeta{}, fmt.Errorf("archive has multiple top-level dirs") + } + rel := filepath.Join(parts[1:]...) + if strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) { + return SnapshotMeta{}, fmt.Errorf("unsafe archive path: %s", header.Name) + } + dest := filepath.Join(tmp, rel) + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return SnapshotMeta{}, err + } + out, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode)) + if err != nil { + return SnapshotMeta{}, err + } + _, copyErr := io.Copy(out, tr) + closeErr := out.Close() + if copyErr != nil { + return SnapshotMeta{}, copyErr + } + if closeErr != nil { + return SnapshotMeta{}, closeErr + } + } + + meta, err := loadMeta(tmp) + if err != nil { + return SnapshotMeta{}, fmt.Errorf("import meta: %w", err) + } + name := meta.Name + if overrideName != "" { + if strings.ContainsAny(overrideName, "/\\") { + return SnapshotMeta{}, fmt.Errorf("invalid snapshot name") + } + name = overrideName + meta.Name = overrideName + } + if name == "" || strings.ContainsAny(name, "/\\") { + return SnapshotMeta{}, fmt.Errorf("invalid snapshot name") + } + + dest := snapshotDir(name) + if err := os.RemoveAll(dest); err != nil { + return SnapshotMeta{}, err + } + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return SnapshotMeta{}, err + } + if err := os.Rename(tmp, dest); err != nil { + return SnapshotMeta{}, err + } + if overrideName != "" { + if err := writeMeta(dest, meta); err != nil { + return SnapshotMeta{}, err + } + } + return meta, nil +} + +// SaveSnapshot captures the JSONL transcript and workdir tarball from a live +// remote pod into snapshotsRoot//. Returns the resolved metadata. +func SaveSnapshot(ctx context.Context, cfg Config, podName, name string, src SavedSession) (SnapshotMeta, error) { + if name == "" { + name = fmt.Sprintf("%s-%s", podName, time.Now().Format("20060102-150405")) + } + if strings.ContainsAny(name, "/\\") { + return SnapshotMeta{}, fmt.Errorf("invalid snapshot name: %q", name) + } + + dir := snapshotDir(name) + if err := os.MkdirAll(dir, 0755); err != nil { + return SnapshotMeta{}, fmt.Errorf("mkdir snapshot: %w", err) + } + + meta := SnapshotMeta{ + Name: name, + CreatedAt: time.Now(), + SourcePod: podName, + Context: cfg.Context, + Namespace: cfg.Namespace, + Image: cfg.Image, + WorkDir: cfg.WorkDir, + LocalDir: src.LocalDir, + SessionID: src.SessionID, + } + + // Session JSONL — best effort, missing pod state shouldn't fail the snapshot. + if data, err := FetchSessionJSONL(cfg, podName); err == nil && len(data) > 0 { + if err := os.WriteFile(filepath.Join(dir, "session.jsonl"), data, 0644); err == nil { + meta.HasSession = true + } + } + + // Workdir tarball. + if tarball, err := fetchRemoteWorkdir(ctx, cfg, podName); err == nil && len(tarball) > 0 { + if err := os.WriteFile(filepath.Join(dir, "workdir.tgz"), tarball, 0644); err == nil { + meta.HasWorkdir = true + meta.WorkdirSize = int64(len(tarball)) + } + } + + if !meta.HasSession && !meta.HasWorkdir { + os.RemoveAll(dir) + return SnapshotMeta{}, fmt.Errorf("snapshot %q is empty (no session, no workdir)", name) + } + + if err := writeMeta(dir, meta); err != nil { + return SnapshotMeta{}, fmt.Errorf("write meta: %w", err) + } + return meta, nil +} + +// FetchWorkdirToDir extracts the pod's workdir tarball into destDir on disk. +// Used by `remote:pull` to bring guest changes back to the host LocalDir. +func FetchWorkdirToDir(ctx context.Context, cfg Config, podName, destDir string) error { + if destDir == "" { + return fmt.Errorf("destination directory required") + } + if err := ValidateWorkdir(destDir); err != nil { + return err + } + tarball, err := fetchRemoteWorkdir(ctx, cfg, podName) + if err != nil { + return err + } + if len(tarball) == 0 { + return fmt.Errorf("empty workdir tarball") + } + cmd := exec.CommandContext(ctx, "tar", "xzf", "-", "-C", destDir) + cmd.Stdin = bytes.NewReader(tarball) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("tar extract: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} + +// fetchRemoteWorkdir tars+gzips cfg.WorkDir on the pod and streams it back. +func fetchRemoteWorkdir(ctx context.Context, cfg Config, podName string) ([]byte, error) { + if cfg.WorkDir == "" { + return nil, fmt.Errorf("work_dir unset") + } + // Tar the workdir contents (not the parent), excluding noisy dirs. + script := fmt.Sprintf( + "cd %s 2>/dev/null && tar czf - --exclude=node_modules --exclude=.git --exclude=vendor --exclude=tmp --exclude=__pycache__ --exclude=dist --exclude=build . 2>/dev/null", + shellQuote(cfg.WorkDir)) + args := []string{ + "--context", cfg.Context, + "-n", cfg.Namespace, + "exec", podName, + } + if cfg.Container != "" { + args = append(args, "-c", cfg.Container) + } + args = append(args, "--", "sh", "-c", script) + cmd := exec.CommandContext(ctx, "kubectl", args...) + var stdout bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("fetch workdir: %w", err) + } + return stdout.Bytes(), nil +} + +func loadMeta(dir string) (SnapshotMeta, error) { + data, err := os.ReadFile(filepath.Join(dir, "meta.yaml")) + if err != nil { + return SnapshotMeta{}, err + } + var meta SnapshotMeta + if err := yaml.Unmarshal(data, &meta); err != nil { + return SnapshotMeta{}, err + } + return meta, nil +} + +func writeMeta(dir string, meta SnapshotMeta) error { + data, err := yaml.Marshal(meta) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "meta.yaml"), data, 0644) +} diff --git a/internal/remote/stream.go b/internal/remote/stream.go index 811b1a2..670df99 100644 --- a/internal/remote/stream.go +++ b/internal/remote/stream.go @@ -18,8 +18,12 @@ func StreamExec(ctx context.Context, cfg Config, podName string, cmd ...string) args := []string{ "--context", cfg.Context, "-n", cfg.Namespace, - "exec", podName, "--", + "exec", podName, } + if cfg.Container != "" { + args = append(args, "-c", cfg.Container) + } + args = append(args, "--") args = append(args, cmd...) c := exec.CommandContext(ctx, "kubectl", args...) diff --git a/internal/tui/app.go b/internal/tui/app.go index b2f8c06..b2bdfa8 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -386,6 +386,7 @@ type App struct { remoteDefaults remote.Config // defaults from config.yaml remoteJSONLFile *os.File // temp file accumulating streamed JSONL remoteStreaming bool // true once Claude output is streaming + remoteLastPoll time.Time // last time saved-pod phases were polled // Generic confirm modal confirmMsg string // message to show (empty = no modal) @@ -765,6 +766,21 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case remoteExecDoneMsg: return a.handleRemoteExecDone(msg) + case remotePhaseMsg: + return a.handleRemotePhase(msg) + + case remoteExecOutputMsg: + return a.handleRemoteExecOutput(msg) + + case remoteSnapshotMsg: + return a.handleRemoteSnapshot(msg) + + case remotePullMsg: + return a.handleRemotePull(msg) + + case remoteForkReadyMsg: + return a.handleRemoteForkReady(msg) + case delayedRefreshMsg: // Auto-refresh after spawning a new session; retry if session not found yet oldCount := len(a.sessions) @@ -3841,8 +3857,18 @@ func (a *App) handleTick() tea.Cmd { a.refreshRespondingState() } + // Poll remote pod phases at most every 30s. Off the main goroutine. + var pollCmd tea.Cmd + if time.Since(a.remoteLastPoll) >= 30*time.Second { + a.remoteLastPoll = time.Now() + pollCmd = pollRemotePhasesCmd() + } + if !a.liveUpdate { - return nil + return pollCmd + } + if pollCmd != nil { + return tea.Batch(a.doRefresh(), pollCmd) } return a.doRefresh() } diff --git a/internal/tui/cmdmode.go b/internal/tui/cmdmode.go index 614cff3..d529543 100644 --- a/internal/tui/cmdmode.go +++ b/internal/tui/cmdmode.go @@ -332,10 +332,51 @@ func buildCmdRegistry() []cmdEntry { // Remote execution {name: "remote:start", aliases: []string{"r:start"}, desc: "resume session remotely", action: func(a *App) (tea.Model, tea.Cmd) { return a.executeCmdRemoteStart("remote:start") }}, - {name: "remote:stop", aliases: []string{"r:stop"}, desc: "stop remote + delete pod", + {name: "remote:stop", aliases: []string{"r:stop", "remote:rm"}, desc: "stop remote + delete pod", action: func(a *App) (tea.Model, tea.Cmd) { return a.stopRemoteSession() }}, + {name: "remote:stop-pull", aliases: []string{"r:stop-pull"}, desc: "pull workdir then stop pod", + action: func(a *App) (tea.Model, tea.Cmd) { return a.stopRemoteSessionWithPull() }}, {name: "remote:attach", aliases: []string{"r:attach"}, desc: "reattach to remote Claude", action: func(a *App) (tea.Model, tea.Cmd) { return a.reconnectRemoteSession() }}, + {name: "remote:ls", aliases: []string{"r:ls"}, desc: "jump to first remote session", + action: func(a *App) (tea.Model, tea.Cmd) { return a.executeCmdRemoteLs() }}, + {name: "remote:phase", aliases: []string{"r:phase"}, desc: "show pod phase for selected remote", + action: func(a *App) (tea.Model, tea.Cmd) { return a.executeCmdRemotePhase() }}, + {name: "remote:exec", aliases: []string{"r:exec"}, desc: "kubectl exec in selected remote pod", + action: func(a *App) (tea.Model, tea.Cmd) { + a.copiedMsg = "Usage: remote:exec " + return a, nil + }}, + {name: "remote:snapshot", aliases: []string{"r:snap"}, desc: "save remote workdir + session", + action: func(a *App) (tea.Model, tea.Cmd) { return a.executeCmdRemoteSnapshot("remote:snapshot") }}, + {name: "remote:snapshots", aliases: []string{"r:snaps"}, desc: "list saved snapshots", + action: func(a *App) (tea.Model, tea.Cmd) { return a.executeCmdRemoteSnapshots() }}, + {name: "remote:restore", aliases: []string{"r:restore"}, desc: "boot new pod from snapshot", + action: func(a *App) (tea.Model, tea.Cmd) { + a.copiedMsg = "Usage: remote:restore [context] [namespace]" + return a, nil + }}, + {name: "remote:fork", aliases: []string{"r:fork"}, desc: "clone selected pod into a fresh one", + action: func(a *App) (tea.Model, tea.Cmd) { return a.executeCmdRemoteFork("") }}, + {name: "remote:pull", aliases: []string{"r:pull", "remote:sync-down", "r:sync-down"}, desc: "fetch pod workdir back to host", + action: func(a *App) (tea.Model, tea.Cmd) { return a.executeCmdRemotePull("remote:pull") }}, + {name: "remote:sync-up", aliases: []string{"r:sync-up"}, desc: "sync local session/workdir to remote pod", + action: func(a *App) (tea.Model, tea.Cmd) { return a.executeCmdRemoteStart("remote:sync-up") }}, + {name: "remote:rm-snap", aliases: []string{"r:rm-snap"}, desc: "delete a saved snapshot", + action: func(a *App) (tea.Model, tea.Cmd) { + a.copiedMsg = "Usage: remote:rm-snap " + return a, nil + }}, + {name: "remote:export-snap", aliases: []string{"r:export-snap"}, desc: "export snapshot as tar.gz", + action: func(a *App) (tea.Model, tea.Cmd) { + a.copiedMsg = "Usage: remote:export-snap " + return a, nil + }}, + {name: "remote:import-snap", aliases: []string{"r:import-snap"}, desc: "import snapshot tar.gz", + action: func(a *App) (tea.Model, tea.Cmd) { + a.copiedMsg = "Usage: remote:import-snap [snapshot-name]" + return a, nil + }}, } } @@ -584,11 +625,48 @@ func (a *App) executeCommand(input string) (tea.Model, tea.Cmd) { return a.executeCmdWorktreeNew(input) } - // Check remote:start [branch] [prompt] - if strings.HasPrefix(lower, "remote:start") || strings.HasPrefix(lower, "r:start") { + // Check remote:start / remote:sync-up [key=value...] [prompt...] + if strings.HasPrefix(lower, "remote:start") || strings.HasPrefix(lower, "r:start") || + strings.HasPrefix(lower, "remote:sync-up") || strings.HasPrefix(lower, "r:sync-up") { return a.executeCmdRemoteStart(input) } + // Check remote:exec + if strings.HasPrefix(lower, "remote:exec") || strings.HasPrefix(lower, "r:exec") { + return a.executeCmdRemoteExec(input) + } + + // Check remote:snapshot [name] + if strings.HasPrefix(lower, "remote:snapshot") || strings.HasPrefix(lower, "r:snap") { + return a.executeCmdRemoteSnapshot(input) + } + + // Check remote:restore + if strings.HasPrefix(lower, "remote:restore") || strings.HasPrefix(lower, "r:restore") { + return a.executeCmdRemoteRestore(input) + } + + // Check remote:pull / remote:sync-down [target-dir] + if strings.HasPrefix(lower, "remote:pull") || strings.HasPrefix(lower, "r:pull") || + strings.HasPrefix(lower, "remote:sync-down") || strings.HasPrefix(lower, "r:sync-down") { + return a.executeCmdRemotePull(input) + } + + // Check remote:rm-snap + if strings.HasPrefix(lower, "remote:rm-snap") || strings.HasPrefix(lower, "r:rm-snap") { + return a.executeCmdRemoteRmSnap(input) + } + + // Check remote:export-snap + if strings.HasPrefix(lower, "remote:export-snap") || strings.HasPrefix(lower, "r:export-snap") { + return a.executeCmdRemoteExportSnap(input) + } + + // Check remote:import-snap [name] + if strings.HasPrefix(lower, "remote:import-snap") || strings.HasPrefix(lower, "r:import-snap") { + return a.executeCmdRemoteImportSnap(input) + } + // Split into parts for multi-command support parts := strings.Fields(lower) var cmds []tea.Cmd diff --git a/internal/tui/remote.go b/internal/tui/remote.go index e38c225..246e9db 100644 --- a/internal/tui/remote.go +++ b/internal/tui/remote.go @@ -14,6 +14,10 @@ import ( "github.com/sendbird/ccx/internal/tmux" ) +func shellArg(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} + // injectRemoteSessions prepends virtual remote sessions into a session list. func (a *App) injectRemoteSessions(sessions []session.Session) []session.Session { remoteMap := make(map[string]session.Session) @@ -89,7 +93,10 @@ func (a *App) buildRemoteProgressView(sess *remote.Session, currentStep string) sb.WriteString(labelStyle.Render(" Pod: ") + valStyle.Render(sess.PodName) + "\n") sb.WriteString(labelStyle.Render(" Image: ") + valStyle.Render(sess.Config.Image) + "\n") if sess.Config.LocalDir != "" { - sb.WriteString(labelStyle.Render(" Workdir: ") + valStyle.Render(sess.Config.LocalDir) + "\n") + sb.WriteString(labelStyle.Render(" Local dir: ") + valStyle.Render(sess.Config.LocalDir) + "\n") + } + if sess.Config.WorkDir != "" { + sb.WriteString(labelStyle.Render(" Remote dir:") + " " + valStyle.Render(sess.Config.WorkDir) + "\n") } if sess.Config.SessionID != "" { sid := sess.Config.SessionID @@ -120,32 +127,163 @@ type remoteExecDoneMsg struct { err error } +// remotePhaseMsg carries refreshed pod phases for saved remote sessions. +type remotePhaseMsg struct { + phases map[string]string // pod name -> phase (Running, Pending, Failed, NotFound) +} + +// remoteExecOutputMsg carries combined output of an ad-hoc kubectl exec. +type remoteExecOutputMsg struct { + podName string + out []byte + err error +} + +// remoteSnapshotMsg signals snapshot completion. +type remoteSnapshotMsg struct { + name string + meta remote.SnapshotMeta + err error +} + +// remotePullMsg signals workdir write-back completion. +type remotePullMsg struct { + podName string + dest string + err error +} + // mergeRemoteConfig applies defaults from config.yaml onto a runtime config. // Runtime values take precedence over defaults. func mergeRemoteConfig(defaults, cfg remote.Config) remote.Config { - if cfg.Context == "" { cfg.Context = defaults.Context } - if cfg.Namespace == "" { cfg.Namespace = defaults.Namespace } - if cfg.Image == "" { cfg.Image = defaults.Image } - if cfg.WorkDir == "" { cfg.WorkDir = defaults.WorkDir } - if cfg.CPULimit == "" { cfg.CPULimit = defaults.CPULimit } - if cfg.MemoryLimit == "" { cfg.MemoryLimit = defaults.MemoryLimit } - if len(cfg.EnvVars) == 0 { cfg.EnvVars = defaults.EnvVars } - if len(cfg.MirrorEnv) == 0 { cfg.MirrorEnv = defaults.MirrorEnv } - if len(cfg.Labels) == 0 { cfg.Labels = defaults.Labels } - if len(cfg.Tolerations) == 0 { cfg.Tolerations = defaults.Tolerations } - if len(cfg.ClaudeArgs) == 0 { cfg.ClaudeArgs = defaults.ClaudeArgs } + if cfg.Context == "" { + cfg.Context = defaults.Context + } + if cfg.Namespace == "" { + cfg.Namespace = defaults.Namespace + } + if cfg.PodName == "" { + cfg.PodName = defaults.PodName + } + if cfg.Container == "" { + cfg.Container = defaults.Container + } + if cfg.RemoteUser == "" { + cfg.RemoteUser = defaults.RemoteUser + } + if cfg.RemoteHome == "" { + cfg.RemoteHome = defaults.RemoteHome + } + if cfg.RemoteProjectPath == "" { + cfg.RemoteProjectPath = defaults.RemoteProjectPath + } + if cfg.Image == "" { + cfg.Image = defaults.Image + } + if cfg.WorkDir == "" { + cfg.WorkDir = defaults.WorkDir + } + if cfg.WorkDirTemplate == "" { + cfg.WorkDirTemplate = defaults.WorkDirTemplate + } + if cfg.CPULimit == "" { + cfg.CPULimit = defaults.CPULimit + } + if cfg.MemoryLimit == "" { + cfg.MemoryLimit = defaults.MemoryLimit + } + if cfg.Arch == "" { + cfg.Arch = defaults.Arch + } + if len(cfg.EnvVars) == 0 { + cfg.EnvVars = defaults.EnvVars + } + if len(cfg.MirrorEnv) == 0 { + cfg.MirrorEnv = defaults.MirrorEnv + } + if len(cfg.Labels) == 0 { + cfg.Labels = defaults.Labels + } + if len(cfg.Tolerations) == 0 { + cfg.Tolerations = defaults.Tolerations + } + if len(cfg.ClaudeArgs) == 0 { + cfg.ClaudeArgs = defaults.ClaudeArgs + } return cfg } +func expandRemoteWorkDirTemplate(tmpl string, sess session.Session, remoteHome string) string { + if tmpl == "" { + return "" + } + project := sess.ProjectName + if project == "" { + project = "project" + } + shortSession := sess.ShortID + if shortSession == "" { + shortSession = sess.ID + if len(shortSession) > 12 { + shortSession = shortSession[:12] + } + } + repls := map[string]string{ + "{{project}}": safePathPart(project), + "{{session}}": safePathPart(sess.ID), + "{{short_session}}": safePathPart(shortSession), + "{{home_rel}}": safeHomeRel(sess.ProjectPath), + "{{remote_home}}": strings.TrimRight(remoteHome, "/"), + } + out := tmpl + for k, v := range repls { + out = strings.ReplaceAll(out, k, v) + } + return out +} + +func safePathPart(s string) string { + if s == "" { + return "unknown" + } + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + trimmed := strings.Trim(b.String(), "-.") + if trimmed == "" { + return "unknown" + } + return trimmed +} + +func safeHomeRel(path string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return safePathPart(path) + } + if path == home { + return "" + } + prefix := strings.TrimRight(home, "/") + "/" + if strings.HasPrefix(path, prefix) { + parts := strings.Split(strings.TrimPrefix(path, prefix), "/") + for i, part := range parts { + parts[i] = safePathPart(part) + } + return strings.Join(parts, "/") + } + return safePathPart(path) +} + // --- Actions --- // startRemoteSession shows confirmation with context info. func (a *App) startRemoteSession(cfg remote.Config) (tea.Model, tea.Cmd) { - if a.remoteSession != nil { - a.copiedMsg = "Remote session already active — :remote:stop first" - return a, nil - } - // Merge config.yaml remote defaults into the config cfg = mergeRemoteConfig(a.remoteDefaults, cfg) cfg = cfg.Defaults() @@ -159,10 +297,26 @@ func (a *App) startRemoteSession(cfg remote.Config) (tea.Model, tea.Cmd) { cfg.SessionID = sess.ID cfg.SessionFile = sess.FilePath } + if cfg.WorkDirTemplate != "" { + cfg.WorkDir = expandRemoteWorkDirTemplate(cfg.WorkDirTemplate, sess, cfg.RemoteHome) + } + } + + if a.remoteSession != nil && cfg.PodName == "" { + a.copiedMsg = "Remote session already active — use remote.pod_name to reuse a fixed worker pod" + return a, nil } cfgCopy := cfg - a.confirmMsg = fmt.Sprintf("Start remote on %s/%s?", cfg.Context, cfg.Namespace) + prompt := fmt.Sprintf("Start remote on %s/%s?", cfg.Context, cfg.Namespace) + if cfg.PodName != "" { + prompt = fmt.Sprintf("Sync session to remote pod %s/%s/%s?", cfg.Context, cfg.Namespace, cfg.PodName) + } + if cfg.ArchMismatch() { + prompt = fmt.Sprintf("Start remote on %s/%s? [arch %s ≠ host %s]", + cfg.Context, cfg.Namespace, cfg.Arch, remote.HostArch()) + } + a.confirmMsg = prompt a.confirmAction = func() (tea.Model, tea.Cmd) { a.remoteConfirmCfg = &cfgCopy return a.confirmRemoteStart() @@ -179,61 +333,7 @@ func (a *App) confirmRemoteStart() (tea.Model, tea.Cmd) { projectPath := cfg.LocalDir sess, steps := remote.Start(cfg, claudeDir, projectPath) - a.remoteSession = sess - a.remoteSetupSteps = steps - - // Persist to disk - remote.AddSavedSession(remote.SavedSession{ - PodName: sess.PodName, - Context: sess.Config.Context, - Namespace: sess.Config.Namespace, - Image: sess.Config.Image, - LocalDir: cfg.LocalDir, - SessionID: cfg.SessionID, - WorkDir: sess.Config.WorkDir, - Status: "starting", - }) - - // Insert virtual session - virtualID := "remote-" + sess.PodName - virtualSess := session.Session{ - ID: virtualID, - ShortID: sess.PodName, - ProjectPath: cfg.LocalDir, - ProjectName: "remote:" + sess.PodName, - ModTime: time.Now(), - IsRemote: true, - RemotePodName: sess.PodName, - RemoteContext: sess.Config.Context, - RemoteNamespace: sess.Config.Namespace, - RemoteStatus: "starting...", - FirstPrompt: fmt.Sprintf("%s/%s/%s", sess.Config.Context, sess.Config.Namespace, sess.PodName), - } - a.sessions = append([]session.Session{virtualSess}, a.sessions...) - a.rebuildSessionList() - - // Select it - for i, item := range a.sessionList.Items() { - if si, ok := item.(sessionItem); ok && si.sess.ID == virtualID { - a.sessionList.Select(i) - break - } - } - - // Show progress in preview - a.remoteProgressSteps = nil - a.remoteContent = a.buildRemoteProgressView(sess, "Initializing...") - a.copiedMsg = fmt.Sprintf("Remote → %s/%s/%s", sess.Config.Context, cfg.Namespace, sess.PodName) - - if !a.sessSplit.Show { - a.sessSplit.Show = true - contentH := max(a.height-3, 1) - a.sessionList.SetSize(a.sessSplit.ListWidth(a.width, a.splitRatio), contentH) - } - a.sessSplit.CacheKey = "remote:" + virtualID - a.sessSplit.Preview.SetContent(a.remoteContent) - - return a, readSetupStep(sess.PodName, steps) + return a.installRemoteSession(sess, steps) } func readSetupStep(podName string, steps <-chan remote.SetupStep) tea.Cmd { @@ -325,11 +425,15 @@ func (a *App) openRemoteLivePreview(sess session.Session) (tea.Model, tea.Cmd) { // Close existing pane proxy a.closePaneProxy() - // Build the shell command for the hidden tmux window (runs as non-root claude user) + // Build the shell command for the hidden tmux window. claudeCmd := remote.BuildClaudeCmd(cfg, false) + containerArg := "" + if cfg.Container != "" { + containerArg = " -c " + shellArg(cfg.Container) + } kubectlCmd := fmt.Sprintf( - "kubectl --context=%s -n %s exec -it %s -- su - claude -c 'export PATH=/usr/local/bin:/usr/bin:/bin:$PATH; . ~/.claude_env; cd %s 2>/dev/null; %s'", - cfg.Context, cfg.Namespace, sess.RemotePodName, cfg.WorkDir, claudeCmd) + "kubectl --context=%s -n %s exec -it %s%s -- su - %s -c 'export PATH=$HOME/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH; . ~/.claude_env; cd %s 2>/dev/null; %s'", + shellArg(cfg.Context), shellArg(cfg.Namespace), shellArg(sess.RemotePodName), containerArg, shellArg(cfg.RemoteUser), cfg.WorkDir, claudeCmd) windowName := "ccx-remote-" + sess.RemotePodName[:min(8, len(sess.RemotePodName))] a.copiedMsg = fmt.Sprintf("Spawning live → %s/%s...", cfg.Context, sess.RemotePodName) @@ -456,10 +560,35 @@ func (a *App) updateRemoteSessionStatus(podName, status string) { // --- Stop / Attach --- func (a *App) stopRemoteSession() (tea.Model, tea.Cmd) { + return a.stopRemoteSessionInternal(false) +} + +// stopRemoteSessionWithPull pulls the workdir back to LocalDir before deleting +// the pod. The pull is best-effort — stop proceeds even if it fails. +func (a *App) stopRemoteSessionWithPull() (tea.Model, tea.Cmd) { + return a.stopRemoteSessionInternal(true) +} + +func (a *App) stopRemoteSessionInternal(pull bool) (tea.Model, tea.Cmd) { var podName string + var pullCfg remote.Config + var pullDest string if a.remoteSession != nil { podName = a.remoteSession.PodName + if pull { + pullCfg = a.remoteSession.Config + pullDest = a.remoteSession.Config.LocalDir + } + if pull && pullDest != "" && pullCfg.Context != "" { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + if err := remote.FetchWorkdirToDir(ctx, pullCfg, podName, pullDest); err != nil { + a.copiedMsg = "Pull failed, stopping anyway: " + err.Error() + } else { + a.copiedMsg = fmt.Sprintf("Pulled workdir → %s, stopped %s", pullDest, podName) + } + cancel() + } a.remoteSession.Stop() a.remoteSession = nil a.remoteContent = "" @@ -475,7 +604,20 @@ func (a *App) stopRemoteSession() (tea.Model, tea.Cmd) { podName = sess.RemotePodName for _, saved := range remote.LoadSavedSessions() { if saved.PodName == podName { - cfg := remote.Config{Context: saved.Context, Namespace: saved.Namespace} + cfg := remote.Config{Context: saved.Context, Namespace: saved.Namespace, WorkDir: saved.WorkDir} + if pull { + pullCfg = cfg + pullDest = saved.LocalDir + if pullDest != "" { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + if err := remote.FetchWorkdirToDir(ctx, pullCfg, podName, pullDest); err != nil { + a.copiedMsg = "Pull failed, stopping anyway: " + err.Error() + } else { + a.copiedMsg = fmt.Sprintf("Pulled workdir → %s, stopped %s", pullDest, podName) + } + cancel() + } + } remote.DeletePod(context.Background(), cfg, podName) break } @@ -495,7 +637,9 @@ func (a *App) stopRemoteSession() (tea.Model, tea.Cmd) { } a.sessions = filtered a.rebuildSessionList() - a.copiedMsg = fmt.Sprintf("Stopped pod %s", podName) + if a.copiedMsg == "" || !strings.HasPrefix(a.copiedMsg, "Pulled") { + a.copiedMsg = fmt.Sprintf("Stopped pod %s", podName) + } return a, nil } @@ -573,9 +717,574 @@ func (a *App) executeCmdRemoteStart(input string) (tea.Model, tea.Cmd) { cfg.SessionFile = sess.FilePath } - if len(parts) >= 2 { - cfg.Prompt = strings.Join(parts[1:], " ") + var promptParts []string + for _, part := range parts[1:] { + key, val, ok := strings.Cut(part, "=") + if !ok { + promptParts = append(promptParts, part) + continue + } + switch strings.ToLower(key) { + case "context", "ctx": + cfg.Context = val + case "namespace", "ns": + cfg.Namespace = val + case "pod", "pod_name", "pod-name": + cfg.PodName = val + case "container": + cfg.Container = val + case "user", "remote_user", "remote-user": + cfg.RemoteUser = val + case "home", "remote_home", "remote-home": + cfg.RemoteHome = val + case "remote_project_path", "remote-project-path", "project_path", "project-path": + cfg.RemoteProjectPath = val + case "workdir", "work_dir", "work-dir": + cfg.WorkDir = val + case "workdir_template", "work_dir_template", "work-dir-template": + cfg.WorkDirTemplate = val + default: + promptParts = append(promptParts, part) + } + } + if len(promptParts) > 0 { + cfg.Prompt = strings.Join(promptParts, " ") } return a.startRemoteSession(cfg) } + +// pollRemotePhasesCmd returns a Cmd that queries pod phase for every saved +// remote session. The returned remotePhaseMsg drives a single batched UI +// refresh, avoiding one tea.Cmd per pod. +func pollRemotePhasesCmd() tea.Cmd { + saved := remote.LoadSavedSessions() + if len(saved) == 0 { + return nil + } + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + phases := make(map[string]string, len(saved)) + for _, s := range saved { + cfg := remote.Config{Context: s.Context, Namespace: s.Namespace} + phase, err := remote.PodPhase(ctx, cfg, s.PodName) + if err != nil { + phases[s.PodName] = "NotFound" + continue + } + if phase == "" { + phase = "Unknown" + } + phases[s.PodName] = phase + } + return remotePhaseMsg{phases: phases} + } +} + +// handleRemotePhase merges polled pod phases into in-memory and on-disk state. +// NotFound pods are dropped from saved sessions. +func (a *App) handleRemotePhase(msg remotePhaseMsg) (tea.Model, tea.Cmd) { + if len(msg.phases) == 0 { + return a, nil + } + changed := false + for pod, phase := range msg.phases { + status := strings.ToLower(phase) + for i := range a.sessions { + s := &a.sessions[i] + if !(s.IsRemote && s.RemotePodName == pod) { + continue + } + // Preserve in-progress setup state (e.g. "syncing config..."). + if a.remoteSession != nil && a.remoteSession.PodName == pod && a.remoteSetupSteps != nil { + break + } + if s.RemoteStatus != status { + s.RemoteStatus = status + s.FirstPrompt = fmt.Sprintf("%s/%s/%s [%s]", s.RemoteContext, s.RemoteNamespace, pod, status) + changed = true + } + break + } + if phase == "NotFound" { + remote.RemoveSavedSession(pod) + var filtered []session.Session + for _, s := range a.sessions { + if !(s.IsRemote && s.RemotePodName == pod) { + filtered = append(filtered, s) + } + } + if len(filtered) != len(a.sessions) { + a.sessions = filtered + changed = true + } + } else { + remote.UpdateSavedSessionStatus(pod, status) + } + } + if changed { + a.rebuildSessionList() + } + return a, nil +} + +// executeCmdRemoteExec runs an ad-hoc command in the selected remote pod and +// reports the output via copiedMsg. Long output is truncated. +func (a *App) executeCmdRemoteExec(input string) (tea.Model, tea.Cmd) { + parts := strings.Fields(input) + if len(parts) < 2 { + a.copiedMsg = "Usage: remote:exec " + return a, nil + } + cmdParts := parts[1:] + + sess, ok := a.selectedSession() + if !ok || !sess.IsRemote { + a.copiedMsg = "Select a remote session first" + return a, nil + } + + cfg, ok := a.resolveRemoteConfig(sess.RemotePodName) + if !ok { + a.copiedMsg = "No config for remote session" + return a, nil + } + + a.copiedMsg = fmt.Sprintf("Exec on %s: %s", sess.RemotePodName, strings.Join(cmdParts, " ")) + pod := sess.RemotePodName + return a, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + out, err := remote.ExecInPod(ctx, cfg, pod, cmdParts...) + return remoteExecOutputMsg{podName: pod, out: out, err: err} + } +} + +// handleRemoteExecOutput renders ad-hoc exec output as a transient status line. +func (a *App) handleRemoteExecOutput(msg remoteExecOutputMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + out := strings.TrimSpace(string(msg.out)) + if out != "" { + a.copiedMsg = fmt.Sprintf("Exec failed: %s: %s", msg.err.Error(), trimLine(out, 80)) + } else { + a.copiedMsg = "Exec failed: " + msg.err.Error() + } + return a, nil + } + out := strings.TrimSpace(string(msg.out)) + if out == "" { + a.copiedMsg = "Exec ok (no output)" + return a, nil + } + a.copiedMsg = trimLine(strings.ReplaceAll(out, "\n", " | "), 160) + return a, nil +} + +// resolveRemoteConfig returns the kubectl-target config for a saved or active pod. +func (a *App) resolveRemoteConfig(podName string) (remote.Config, bool) { + if a.remoteSession != nil && a.remoteSession.PodName == podName { + return a.remoteSession.Config, true + } + for _, saved := range remote.LoadSavedSessions() { + if saved.PodName == podName { + cfg := remote.Config{ + Context: saved.Context, + Namespace: saved.Namespace, + WorkDir: saved.WorkDir, + SessionID: saved.SessionID, + } + cfg = mergeRemoteConfig(a.remoteDefaults, cfg) + cfg = cfg.Defaults() + return cfg, true + } + } + return remote.Config{}, false +} + +// executeCmdRemotePhase queries the current pod phase for the selected remote +// session and surfaces it as a status line. +func (a *App) executeCmdRemotePhase() (tea.Model, tea.Cmd) { + sess, ok := a.selectedSession() + if !ok || !sess.IsRemote { + a.copiedMsg = "Select a remote session first" + return a, nil + } + cfg, ok := a.resolveRemoteConfig(sess.RemotePodName) + if !ok { + a.copiedMsg = "No config for remote session" + return a, nil + } + pod := sess.RemotePodName + a.copiedMsg = fmt.Sprintf("Querying %s/%s/%s...", cfg.Context, cfg.Namespace, pod) + return a, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + phase, err := remote.PodPhase(ctx, cfg, pod) + if err != nil { + phase = "NotFound" + } + return remotePhaseMsg{phases: map[string]string{pod: phase}} + } +} + +// executeCmdRemoteLs jumps to the first remote session in the list. If none +// exist, surfaces a hint. +func (a *App) executeCmdRemoteLs() (tea.Model, tea.Cmd) { + items := a.sessionList.Items() + for i, item := range items { + if si, ok := item.(sessionItem); ok && si.sess.IsRemote { + a.sessionList.Select(i) + a.copiedMsg = fmt.Sprintf("Remote: %s", si.sess.RemotePodName) + return a, nil + } + } + a.copiedMsg = "No remote sessions" + return a, nil +} + +func trimLine(s string, n int) string { + if len(s) <= n { + return s + } + if n <= 1 { + return s[:n] + } + return s[:n-1] + "…" +} + +// executeCmdRemoteSnapshot captures the selected pod's transcript + workdir +// into ~/.config/ccx/snapshots//. +func (a *App) executeCmdRemoteSnapshot(input string) (tea.Model, tea.Cmd) { + parts := strings.Fields(input) + name := "" + if len(parts) >= 2 { + name = parts[1] + } + + sess, ok := a.selectedSession() + if !ok || !sess.IsRemote { + a.copiedMsg = "Select a remote session first" + return a, nil + } + cfg, ok := a.resolveRemoteConfig(sess.RemotePodName) + if !ok { + a.copiedMsg = "No config for remote session" + return a, nil + } + + src := remote.SavedSession{LocalDir: sess.ProjectPath} + for _, s := range remote.LoadSavedSessions() { + if s.PodName == sess.RemotePodName { + src = s + break + } + } + + pod := sess.RemotePodName + a.copiedMsg = fmt.Sprintf("Snapshotting %s...", pod) + return a, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + meta, err := remote.SaveSnapshot(ctx, cfg, pod, name, src) + return remoteSnapshotMsg{name: meta.Name, meta: meta, err: err} + } +} + +func (a *App) handleRemoteSnapshot(msg remoteSnapshotMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + a.copiedMsg = "Snapshot failed: " + msg.err.Error() + return a, nil + } + parts := []string{"snapshot " + msg.name} + if msg.meta.HasSession { + parts = append(parts, "session") + } + if msg.meta.HasWorkdir { + parts = append(parts, fmt.Sprintf("workdir %s", formatBytes(msg.meta.WorkdirSize))) + } + a.copiedMsg = strings.Join(parts, " · ") + return a, nil +} + +// executeCmdRemoteRestore boots a fresh pod from a saved snapshot. +// Usage: remote:restore [context] [namespace] +func (a *App) executeCmdRemoteRestore(input string) (tea.Model, tea.Cmd) { + parts := strings.Fields(input) + if len(parts) < 2 { + a.copiedMsg = "Usage: remote:restore [context] [namespace]" + return a, nil + } + name := parts[1] + + overrides := mergeRemoteConfig(a.remoteDefaults, remote.Config{}) + if len(parts) >= 3 { + overrides.Context = parts[2] + } + if len(parts) >= 4 { + overrides.Namespace = parts[3] + } + sess, steps, err := remote.StartFromSnapshot(name, overrides, a.config.ClaudeDir) + if err != nil { + a.copiedMsg = "Restore failed: " + err.Error() + return a, nil + } + return a.installRemoteSession(sess, steps) +} + +// executeCmdRemoteFork clones the selected pod's config + workdir snapshot +// into a fresh sibling pod. Captures from the live pod first (no on-disk +// snapshot is left behind). +func (a *App) executeCmdRemoteFork(input string) (tea.Model, tea.Cmd) { + sess, ok := a.selectedSession() + if !ok || !sess.IsRemote { + a.copiedMsg = "Select a remote session first to fork" + return a, nil + } + cfg, ok := a.resolveRemoteConfig(sess.RemotePodName) + if !ok { + a.copiedMsg = "No config for remote session" + return a, nil + } + pod := sess.RemotePodName + + a.copiedMsg = fmt.Sprintf("Forking %s...", pod) + return a, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + src := remote.SavedSession{LocalDir: sess.ProjectPath} + for _, s := range remote.LoadSavedSessions() { + if s.PodName == pod { + src = s + break + } + } + name := fmt.Sprintf("fork-%s-%d", pod, time.Now().Unix()) + meta, err := remote.SaveSnapshot(ctx, cfg, pod, name, src) + if err != nil { + return remoteSnapshotMsg{err: fmt.Errorf("fork: %w", err)} + } + // Tag this snapshot as a pending fork-restore; handled in handler. + return remoteForkReadyMsg{snapshot: meta.Name, cleanup: true} + } +} + +type remoteForkReadyMsg struct { + snapshot string + cleanup bool +} + +func (a *App) handleRemoteForkReady(msg remoteForkReadyMsg) (tea.Model, tea.Cmd) { + overrides := mergeRemoteConfig(a.remoteDefaults, remote.Config{}) + sess, steps, err := remote.StartFromSnapshot(msg.snapshot, overrides, a.config.ClaudeDir) + if err != nil { + a.copiedMsg = "Fork restore failed: " + err.Error() + return a, nil + } + if msg.cleanup { + _ = remote.DeleteSnapshot(msg.snapshot) + } + return a.installRemoteSession(sess, steps) +} + +// executeCmdRemotePull tars the pod's workdir and extracts it over the +// selected session's LocalDir on the host. Equivalent of machinen `mount-live` +// write-back, condensed to a single explicit pull instead of streaming. +func (a *App) executeCmdRemotePull(input string) (tea.Model, tea.Cmd) { + parts := strings.Fields(input) + sess, ok := a.selectedSession() + if !ok || !sess.IsRemote { + a.copiedMsg = "Select a remote session first" + return a, nil + } + cfg, ok := a.resolveRemoteConfig(sess.RemotePodName) + if !ok { + a.copiedMsg = "No config for remote session" + return a, nil + } + + dest := sess.ProjectPath + if len(parts) >= 2 { + dest = parts[1] + } + if dest == "" { + a.copiedMsg = "Usage: remote:pull [target-dir]" + return a, nil + } + + pod := sess.RemotePodName + a.copiedMsg = fmt.Sprintf("Pulling workdir %s → %s...", pod, dest) + return a, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + err := remote.FetchWorkdirToDir(ctx, cfg, pod, dest) + return remotePullMsg{podName: pod, dest: dest, err: err} + } +} + +func (a *App) handleRemotePull(msg remotePullMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + a.copiedMsg = "Pull failed: " + msg.err.Error() + return a, nil + } + a.copiedMsg = fmt.Sprintf("Pulled workdir → %s", msg.dest) + return a, nil +} + +// installRemoteSession wires a freshly-Started remote.Session into the App +// (virtual session, persistence, preview). Shared by restore + fork paths. +func (a *App) installRemoteSession(sess *remote.Session, steps <-chan remote.SetupStep) (tea.Model, tea.Cmd) { + if a.remoteSession != nil { + a.copiedMsg = "Remote session already active — :remote:stop first" + return a, nil + } + a.remoteSession = sess + a.remoteSetupSteps = steps + + remote.AddSavedSession(remote.SavedSession{ + PodName: sess.PodName, + Context: sess.Config.Context, + Namespace: sess.Config.Namespace, + Image: sess.Config.Image, + LocalDir: sess.Config.LocalDir, + SessionID: sess.Config.SessionID, + WorkDir: sess.Config.WorkDir, + Status: "starting", + }) + + virtualID := "remote-" + sess.PodName + virtualSess := session.Session{ + ID: virtualID, + ShortID: sess.PodName, + ProjectPath: sess.Config.LocalDir, + ProjectName: "remote:" + sess.PodName, + ModTime: time.Now(), + IsRemote: true, + RemotePodName: sess.PodName, + RemoteContext: sess.Config.Context, + RemoteNamespace: sess.Config.Namespace, + RemoteStatus: "starting...", + FirstPrompt: fmt.Sprintf("%s/%s/%s", sess.Config.Context, sess.Config.Namespace, sess.PodName), + } + inserted := false + for i := range a.sessions { + if a.sessions[i].IsRemote && a.sessions[i].RemotePodName == sess.PodName { + a.sessions[i] = virtualSess + inserted = true + break + } + } + if !inserted { + a.sessions = append([]session.Session{virtualSess}, a.sessions...) + } + a.rebuildSessionList() + + for i, item := range a.sessionList.Items() { + if si, ok := item.(sessionItem); ok && si.sess.ID == virtualID { + a.sessionList.Select(i) + break + } + } + + a.remoteProgressSteps = nil + a.remoteContent = a.buildRemoteProgressView(sess, "Initializing...") + a.copiedMsg = fmt.Sprintf("Remote → %s/%s/%s", sess.Config.Context, sess.Config.Namespace, sess.PodName) + if sess.Config.ArchMismatch() { + a.copiedMsg += fmt.Sprintf(" [arch %s ≠ host %s]", sess.Config.Arch, remote.HostArch()) + } + + if !a.sessSplit.Show { + a.sessSplit.Show = true + contentH := max(a.height-3, 1) + a.sessionList.SetSize(a.sessSplit.ListWidth(a.width, a.splitRatio), contentH) + } + a.sessSplit.CacheKey = "remote:" + virtualID + a.sessSplit.Preview.SetContent(a.remoteContent) + + return a, readSetupStep(sess.PodName, steps) +} + +// executeCmdRemoteSnapshots lists saved snapshots as a status line. +func (a *App) executeCmdRemoteSnapshots() (tea.Model, tea.Cmd) { + snaps := remote.ListSnapshots() + if len(snaps) == 0 { + a.copiedMsg = "No snapshots" + return a, nil + } + var names []string + for _, s := range snaps { + names = append(names, s.Name) + if len(names) >= 5 { + break + } + } + more := "" + if len(snaps) > len(names) { + more = fmt.Sprintf(" (+%d more)", len(snaps)-len(names)) + } + a.copiedMsg = "Snapshots: " + strings.Join(names, ", ") + more + return a, nil +} + +// executeCmdRemoteRmSnap deletes a snapshot directory on disk. +func (a *App) executeCmdRemoteRmSnap(input string) (tea.Model, tea.Cmd) { + parts := strings.Fields(input) + if len(parts) < 2 { + a.copiedMsg = "Usage: remote:rm-snap " + return a, nil + } + name := parts[1] + if err := remote.DeleteSnapshot(name); err != nil { + a.copiedMsg = "Delete failed: " + err.Error() + return a, nil + } + a.copiedMsg = "Deleted snapshot " + name + return a, nil +} + +func (a *App) executeCmdRemoteExportSnap(input string) (tea.Model, tea.Cmd) { + parts := strings.Fields(input) + if len(parts) < 3 { + a.copiedMsg = "Usage: remote:export-snap " + return a, nil + } + name, out := parts[1], parts[2] + if err := remote.ExportSnapshot(name, out); err != nil { + a.copiedMsg = "Export failed: " + err.Error() + return a, nil + } + a.copiedMsg = fmt.Sprintf("Exported %s → %s", name, out) + return a, nil +} + +func (a *App) executeCmdRemoteImportSnap(input string) (tea.Model, tea.Cmd) { + parts := strings.Fields(input) + if len(parts) < 2 { + a.copiedMsg = "Usage: remote:import-snap [snapshot-name]" + return a, nil + } + name := "" + if len(parts) >= 3 { + name = parts[2] + } + meta, err := remote.ImportSnapshot(parts[1], name) + if err != nil { + a.copiedMsg = "Import failed: " + err.Error() + return a, nil + } + a.copiedMsg = "Imported snapshot " + meta.Name + return a, nil +} + +func formatBytes(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d B", n) + } + div, exp := int64(unit), 0 + for x := n / unit; x >= unit; x /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) +} diff --git a/main.go b/main.go index 36cba75..248fb1c 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,7 @@ import ( "flag" "fmt" "os" + "os/exec" "path/filepath" "strings" @@ -13,10 +14,150 @@ import ( "github.com/sendbird/ccx/internal/session" "github.com/sendbird/ccx/internal/tmux" "github.com/sendbird/ccx/internal/tui" + "gopkg.in/yaml.v3" ) var version = "dev" +func defaultConfigHeader() string { + return "# ccx configuration\n# Keybindings: session, actions, views, navigation\n# Preferences: preferences section (auto-saved on quit)\n# Claude: command_template controls local Claude launches; {{args}} expands to ccx-provided args.\n\n" +} + +func runConfigCommand(args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: ccx config [path] [value]") + } + path := filepath.Join(os.Getenv("HOME"), ".config", "ccx", "config.yaml") + switch args[0] { + case "path": + fmt.Println(path) + return nil + case "view", "list", "ls": + data, err := os.ReadFile(path) + if err != nil { + return err + } + fmt.Print(string(data)) + return nil + case "edit": + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := os.WriteFile(path, []byte(defaultConfigHeader()), 0644); err != nil { + return err + } + } + editor := os.Getenv("EDITOR") + if editor == "" { + editor = "vi" + } + cmd := exec.Command(editor, path) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + case "get": + if len(args) != 2 { + return fmt.Errorf("usage: ccx config get ") + } + cfg, err := readConfigMap(path) + if err != nil { + return err + } + val, ok := getConfigPath(cfg, args[1]) + if !ok { + return fmt.Errorf("config path not found: %s", args[1]) + } + data, err := yaml.Marshal(val) + if err != nil { + return err + } + fmt.Print(string(data)) + return nil + case "set": + if len(args) != 3 { + return fmt.Errorf("usage: ccx config set ") + } + cfg, err := readConfigMap(path) + if err != nil { + return err + } + setConfigPath(cfg, args[1], parseConfigValue(args[2])) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + if err := os.WriteFile(path, []byte(defaultConfigHeader()+string(data)), 0644); err != nil { + return err + } + fmt.Printf("%s = %v\n", args[1], parseConfigValue(args[2])) + return nil + default: + return fmt.Errorf("unknown config command %q", args[0]) + } +} + +func readConfigMap(path string) (map[string]interface{}, error) { + cfg := map[string]interface{}{} + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return nil, err + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return cfg, nil +} + +func getConfigPath(cfg map[string]interface{}, dotPath string) (interface{}, bool) { + cur := interface{}(cfg) + for _, part := range strings.Split(dotPath, ".") { + m, ok := cur.(map[string]interface{}) + if !ok { + return nil, false + } + cur, ok = m[part] + if !ok { + return nil, false + } + } + return cur, true +} + +func setConfigPath(cfg map[string]interface{}, dotPath string, value interface{}) { + parts := strings.Split(dotPath, ".") + cur := cfg + for _, part := range parts[:len(parts)-1] { + next, _ := cur[part].(map[string]interface{}) + if next == nil { + next = map[string]interface{}{} + cur[part] = next + } + cur = next + } + cur[parts[len(parts)-1]] = value +} + +func parseConfigValue(value string) interface{} { + switch value { + case "true": + return true + case "false": + return false + case "null", "nil", "~": + return nil + default: + return value + } +} + func main() { var ( showVersion bool @@ -53,7 +194,13 @@ func main() { os.Exit(1) } os.Exit(0) - case "urls", "files", "changes", "images", "conversation", "help": + case "config": + if err := runConfigCommand(os.Args[2:]); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + os.Exit(0) + case "urls", "files", "changes", "images", "conversation", "info", "help": subcmd := os.Args[1] fs := flag.NewFlagSet(subcmd, flag.ExitOnError) plain := fs.Bool("plain", false, "force plain text output (no interactive picker)") @@ -92,7 +239,8 @@ func main() { flag.Usage = func() { fmt.Fprintf(os.Stderr, "ccx — Claude Code Explorer\n\n") fmt.Fprintf(os.Stderr, "Usage: ccx [flags]\n") - fmt.Fprintf(os.Stderr, " ccx [--plain]\n\n") + fmt.Fprintf(os.Stderr, " ccx [--plain]\n") + fmt.Fprintf(os.Stderr, " ccx config ...\n\n") fmt.Fprintf(os.Stderr, "Commands:\n") for _, c := range cli.Commands { fmt.Fprintf(os.Stderr, " %-10s %s\n", c.Name, c.Desc)