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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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 <command> Run a subcommand\n\n")
fmt.Fprintf(os.Stderr, " ccx <command> Run a subcommand\n")
fmt.Fprintf(os.Stderr, " ccx config <view|edit|path|get|set> ...\n\n")
fmt.Fprintf(os.Stderr, "Commands:\n")
for _, c := range Commands {
fmt.Fprintf(os.Stderr, " %-10s %s\n", c.Name, c.Desc)
Expand All @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down
74 changes: 53 additions & 21 deletions internal/remote/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"crypto/rand"
"fmt"
"os/exec"
"runtime"
"strings"
)

Expand All @@ -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.
Expand All @@ -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"
}
Expand All @@ -61,9 +81,6 @@ func (c Config) Defaults() Config {
if c.MemoryLimit == "" {
c.MemoryLimit = "4Gi"
}
if c.Arch == "" {
c.Arch = "amd64"
}
return c
}

Expand All @@ -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())
}
15 changes: 13 additions & 2 deletions internal/remote/pod.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
Expand All @@ -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...)
}
Expand Down
Loading
Loading