diff --git a/internal/cmd/root.go b/internal/cmd/root.go index e574cf6..dc85c47 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -1,3 +1,6 @@ +// Package cmd implements the Headjack CLI commands using Cobra. +// It provides commands for managing isolated LLM agent instances, +// including create, attach, stop, remove, and session management. package cmd import ( diff --git a/internal/config/config.go b/internal/config/config.go index f0646b4..634a7b6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -100,36 +100,6 @@ func (c *Config) Validate() error { return nil } -// IsValidAgent returns true if the agent name is valid. -func (c *Config) IsValidAgent(name string) bool { - return validAgents[name] -} - -// ValidAgentNames returns the list of valid agent names. -func (c *Config) ValidAgentNames() []string { - return []string{"claude", "gemini", "codex"} -} - -// IsValidMultiplexer returns true if the multiplexer name is valid. -func (c *Config) IsValidMultiplexer(name string) bool { - return validMultiplexers[name] -} - -// ValidMultiplexerNames returns the list of valid multiplexer names. -func (c *Config) ValidMultiplexerNames() []string { - return []string{"tmux", "zellij"} -} - -// IsValidRuntime returns true if the runtime name is valid. -func (c *Config) IsValidRuntime(name string) bool { - return validRuntimes[name] -} - -// ValidRuntimeNames returns the list of valid runtime names. -func (c *Config) ValidRuntimeNames() []string { - return []string{"podman", "apple"} -} - // Loader provides configuration loading and saving. type Loader struct { v *viper.Viper diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 02b21c0..e634347 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -210,19 +210,16 @@ func TestConfig_Validate(t *testing.T) { }) } -func TestConfig_IsValidAgent(t *testing.T) { - cfg := &Config{} - - assert.True(t, cfg.IsValidAgent("claude")) - assert.True(t, cfg.IsValidAgent("gemini")) - assert.True(t, cfg.IsValidAgent("codex")) - assert.False(t, cfg.IsValidAgent("invalid")) - assert.False(t, cfg.IsValidAgent("")) +func TestIsValidAgent(t *testing.T) { + assert.True(t, IsValidAgent("claude")) + assert.True(t, IsValidAgent("gemini")) + assert.True(t, IsValidAgent("codex")) + assert.False(t, IsValidAgent("invalid")) + assert.False(t, IsValidAgent("")) } -func TestConfig_ValidAgentNames(t *testing.T) { - cfg := &Config{} - names := cfg.ValidAgentNames() +func TestValidAgentNames(t *testing.T) { + names := ValidAgentNames() assert.Contains(t, names, "claude") assert.Contains(t, names, "gemini") diff --git a/internal/container/apple.go b/internal/container/apple.go index 7a32585..a3ffb18 100644 --- a/internal/container/apple.go +++ b/internal/container/apple.go @@ -4,14 +4,9 @@ import ( "context" "encoding/json" "fmt" - "os" - "os/signal" "strings" - "syscall" "time" - "golang.org/x/term" - "github.com/jmgilman/headjack/internal/exec" ) @@ -22,63 +17,35 @@ type AppleConfig struct { } type appleRuntime struct { - exec exec.Executor + baseRuntime config AppleConfig } // NewAppleRuntime creates a Runtime using Apple Containerization CLI. func NewAppleRuntime(e exec.Executor, cfg AppleConfig) Runtime { - return &appleRuntime{exec: e, config: cfg} -} - -// containerError formats an error from the container CLI, including stderr if available. -func containerError(operation string, result *exec.Result, err error) error { - if result != nil { - stderr := strings.TrimSpace(string(result.Stderr)) - if stderr != "" { - return fmt.Errorf("%s: %s", operation, stderr) - } + return &appleRuntime{ + baseRuntime: baseRuntime{ + exec: e, + binaryName: "container", + execCommand: []string{"container", "exec"}, + }, + config: cfg, } - return fmt.Errorf("%s: %w", operation, err) } func (r *appleRuntime) Run(ctx context.Context, cfg *RunConfig) (*Container, error) { - args := []string{"run", "--detach", "--name", cfg.Name} - - // Add merged flags (image labels + config, merged by manager) - args = append(args, cfg.Flags...) - - for _, m := range cfg.Mounts { - mountSpec := fmt.Sprintf("%s:%s", m.Source, m.Target) - if m.ReadOnly { - mountSpec += ":ro" - } - args = append(args, "-v", mountSpec) - } - - for _, e := range cfg.Env { - args = append(args, "-e", e) - } - - args = append(args, cfg.Image) - - // Add init command (default to "sleep infinity" if not specified) - initCmd := cfg.Init - if initCmd == "" { - initCmd = "sleep infinity" - } - args = append(args, strings.Fields(initCmd)...) + args := buildRunArgs(cfg) result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "container", + Name: r.binaryName, Args: args, }) if err != nil { stderr := string(result.Stderr) - if strings.Contains(stderr, "already exists") { + if isAlreadyExistsError(stderr) { return nil, ErrAlreadyExists } - return nil, containerError("run container", result, err) + return nil, cliError("run container", result, err) } // Container ID is returned on stdout @@ -103,79 +70,23 @@ func (r *appleRuntime) Exec(ctx context.Context, id string, cfg ExecConfig) erro return ErrNotRunning } - args := []string{"exec"} - - if cfg.Interactive { - args = append(args, "-it") - } - - if cfg.Workdir != "" { - args = append(args, "-w", cfg.Workdir) - } - - for _, e := range cfg.Env { - args = append(args, "-e", e) - } - - args = append(args, id) - args = append(args, cfg.Command...) + args := buildExecArgs(id, cfg) if cfg.Interactive { return r.execInteractive(ctx, args) } result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "container", + Name: r.binaryName, Args: args, }) if err != nil { - return containerError("exec in container", result, err) + return cliError("exec in container", result, err) } return nil } -// execInteractive runs the container exec command with TTY support. -func (r *appleRuntime) execInteractive(ctx context.Context, args []string) error { - stdinFd := int(os.Stdin.Fd()) - - // Check if stdin is a terminal - if !term.IsTerminal(stdinFd) { - // Fall back to non-interactive mode - _, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "container", - Args: args, - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - }) - return err - } - - // Put terminal in raw mode - oldState, err := term.MakeRaw(stdinFd) - if err != nil { - return fmt.Errorf("set terminal raw mode: %w", err) - } - defer func() { _ = term.Restore(stdinFd, oldState) }() - - // Handle window resize signals - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGWINCH) - defer signal.Stop(sigCh) - - // Run the command with stdio attached - _, err = r.exec.Run(ctx, &exec.RunOptions{ - Name: "container", - Args: args, - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - }) - - return err -} - func (r *appleRuntime) Stop(ctx context.Context, id string) error { // Verify container exists c, err := r.Get(ctx, id) @@ -189,11 +100,11 @@ func (r *appleRuntime) Stop(ctx context.Context, id string) error { } result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "container", + Name: r.binaryName, Args: []string{"stop", id}, }) if err != nil { - return containerError("stop container", result, err) + return cliError("stop container", result, err) } return nil @@ -212,11 +123,11 @@ func (r *appleRuntime) Start(ctx context.Context, id string) error { } result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "container", + Name: r.binaryName, Args: []string{"start", id}, }) if err != nil { - return containerError("start container", result, err) + return cliError("start container", result, err) } return nil @@ -224,15 +135,15 @@ func (r *appleRuntime) Start(ctx context.Context, id string) error { func (r *appleRuntime) Remove(ctx context.Context, id string) error { result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "container", + Name: r.binaryName, Args: []string{"rm", id}, }) if err != nil { stderr := string(result.Stderr) - if strings.Contains(stderr, "not found") || strings.Contains(stderr, "no such") { + if isNotFoundError(stderr) { return ErrNotFound } - return containerError("remove container", result, err) + return cliError("remove container", result, err) } return nil @@ -240,18 +151,18 @@ func (r *appleRuntime) Remove(ctx context.Context, id string) error { func (r *appleRuntime) Get(ctx context.Context, id string) (*Container, error) { result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "container", + Name: r.binaryName, Args: []string{"inspect", id}, }) if err != nil { stderr := string(result.Stderr) - if strings.Contains(stderr, "not found") || strings.Contains(stderr, "no such") { + if isNotFoundError(stderr) { return nil, ErrNotFound } - return nil, containerError("inspect container", result, err) + return nil, cliError("inspect container", result, err) } - var infos []containerInspect + var infos []appleInspect if err := json.Unmarshal(result.Stdout, &infos); err != nil { return nil, fmt.Errorf("parse container info: %w", err) } @@ -271,11 +182,11 @@ func (r *appleRuntime) List(ctx context.Context, filter ListFilter) ([]Container } result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "container", + Name: r.binaryName, Args: args, }) if err != nil { - return nil, containerError("list containers", result, err) + return nil, cliError("list containers", result, err) } // Handle empty list @@ -284,7 +195,7 @@ func (r *appleRuntime) List(ctx context.Context, filter ListFilter) ([]Container return []Container{}, nil } - var items []containerListItem + var items []appleListItem if err := json.Unmarshal(result.Stdout, &items); err != nil { return nil, fmt.Errorf("parse container list: %w", err) } @@ -298,16 +209,10 @@ func (r *appleRuntime) List(ctx context.Context, filter ListFilter) ([]Container } func (r *appleRuntime) Build(ctx context.Context, cfg *BuildConfig) error { - args := []string{"build", "-t", cfg.Tag} - - if cfg.Dockerfile != "" { - args = append(args, "-f", cfg.Dockerfile) - } - - args = append(args, cfg.Context) + args := buildBuildArgs(cfg) result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "container", + Name: r.binaryName, Args: args, }) if err != nil { @@ -317,8 +222,8 @@ func (r *appleRuntime) Build(ctx context.Context, cfg *BuildConfig) error { return nil } -// containerInspect represents the JSON output of `container inspect`. -type containerInspect struct { +// appleInspect represents the JSON output of `container inspect`. +type appleInspect struct { Status string `json:"status"` Configuration struct { ID string `json:"id"` @@ -328,26 +233,18 @@ type containerInspect struct { } `json:"configuration"` } -func (c *containerInspect) toContainer() *Container { - status := StatusUnknown - switch strings.ToLower(c.Status) { - case cliStatusRunning: - status = StatusRunning - case cliStatusStopped, cliStatusExited: - status = StatusStopped - } - +func (c *appleInspect) toContainer() *Container { return &Container{ ID: c.Configuration.ID, Name: c.Configuration.ID, Image: c.Configuration.Image.Reference, - Status: status, + Status: parseContainerStatus(c.Status), } } -// containerListItem represents a single item in `container list` JSON output. +// appleListItem represents a single item in `container list` JSON output. // Note: Apple container list has same format as inspect. -type containerListItem struct { +type appleListItem struct { Status string `json:"status"` Configuration struct { ID string `json:"id"` @@ -357,23 +254,11 @@ type containerListItem struct { } `json:"configuration"` } -func (c *containerListItem) toContainer() Container { - status := StatusUnknown - switch strings.ToLower(c.Status) { - case cliStatusRunning: - status = StatusRunning - case cliStatusStopped, cliStatusExited: - status = StatusStopped - } - +func (c *appleListItem) toContainer() Container { return Container{ ID: c.Configuration.ID, Name: c.Configuration.ID, Image: c.Configuration.Image.Reference, - Status: status, + Status: parseContainerStatus(c.Status), } } - -func (r *appleRuntime) ExecCommand() []string { - return []string{"container", "exec"} -} diff --git a/internal/container/common.go b/internal/container/common.go new file mode 100644 index 0000000..38cb6fd --- /dev/null +++ b/internal/container/common.go @@ -0,0 +1,169 @@ +package container + +import ( + "context" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + + "golang.org/x/term" + + "github.com/jmgilman/headjack/internal/exec" +) + +// baseRuntime provides shared functionality for container runtimes. +// Concrete implementations (Podman, Apple) embed this and provide runtime-specific behavior. +type baseRuntime struct { + exec exec.Executor + binaryName string + execCommand []string +} + +// cliError formats an error from a container CLI, including stderr if available. +func cliError(operation string, result *exec.Result, err error) error { + if result != nil { + stderr := strings.TrimSpace(string(result.Stderr)) + if stderr != "" { + return fmt.Errorf("%s: %s", operation, stderr) + } + } + return fmt.Errorf("%s: %w", operation, err) +} + +// execInteractive runs a container exec command with TTY support. +// This is shared between runtime implementations. +func (r *baseRuntime) execInteractive(ctx context.Context, args []string) error { + stdinFd := int(os.Stdin.Fd()) + + // Check if stdin is a terminal + if !term.IsTerminal(stdinFd) { + // Fall back to non-interactive mode + _, err := r.exec.Run(ctx, &exec.RunOptions{ + Name: r.binaryName, + Args: args, + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + }) + return err + } + + // Put terminal in raw mode + oldState, err := term.MakeRaw(stdinFd) + if err != nil { + return fmt.Errorf("set terminal raw mode: %w", err) + } + defer func() { _ = term.Restore(stdinFd, oldState) }() + + // Handle window resize signals + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGWINCH) + defer signal.Stop(sigCh) + + // Run the command with stdio attached + _, err = r.exec.Run(ctx, &exec.RunOptions{ + Name: r.binaryName, + Args: args, + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + }) + + return err +} + +// ExecCommand returns the command prefix for executing commands in a container. +func (r *baseRuntime) ExecCommand() []string { + return r.execCommand +} + +// buildRunArgs constructs the common container run arguments. +func buildRunArgs(cfg *RunConfig) []string { + args := []string{"run", "--detach", "--name", cfg.Name} + + // Add merged flags (image labels + config, merged by manager) + args = append(args, cfg.Flags...) + + for _, m := range cfg.Mounts { + mountSpec := fmt.Sprintf("%s:%s", m.Source, m.Target) + if m.ReadOnly { + mountSpec += ":ro" + } + args = append(args, "-v", mountSpec) + } + + for _, e := range cfg.Env { + args = append(args, "-e", e) + } + + args = append(args, cfg.Image) + + // Add init command (default to "sleep infinity" if not specified) + initCmd := cfg.Init + if initCmd == "" { + initCmd = "sleep infinity" + } + args = append(args, strings.Fields(initCmd)...) + + return args +} + +// buildExecArgs constructs the common container exec arguments. +func buildExecArgs(id string, cfg ExecConfig) []string { + args := []string{"exec"} + + if cfg.Interactive { + args = append(args, "-it") + } + + if cfg.Workdir != "" { + args = append(args, "-w", cfg.Workdir) + } + + for _, e := range cfg.Env { + args = append(args, "-e", e) + } + + args = append(args, id) + args = append(args, cfg.Command...) + + return args +} + +// buildBuildArgs constructs the common image build arguments. +func buildBuildArgs(cfg *BuildConfig) []string { + args := []string{"build", "-t", cfg.Tag} + + if cfg.Dockerfile != "" { + args = append(args, "-f", cfg.Dockerfile) + } + + args = append(args, cfg.Context) + return args +} + +// parseContainerStatus converts CLI status strings to Status constants. +func parseContainerStatus(cliStatus string) Status { + switch strings.ToLower(cliStatus) { + case cliStatusRunning: + return StatusRunning + case cliStatusStopped, cliStatusExited, cliStatusCreated: + return StatusStopped + default: + return StatusUnknown + } +} + +// isAlreadyExistsError checks if stderr indicates container already exists. +func isAlreadyExistsError(stderr string) bool { + return strings.Contains(stderr, "already in use") || strings.Contains(stderr, "already exists") +} + +// isNotFoundError checks if stderr indicates container not found. +func isNotFoundError(stderr string) bool { + return strings.Contains(stderr, "no such") || + strings.Contains(stderr, "no container") || + strings.Contains(stderr, "not found") +} diff --git a/internal/container/podman.go b/internal/container/podman.go index c12797b..83b45dc 100644 --- a/internal/container/podman.go +++ b/internal/container/podman.go @@ -4,14 +4,9 @@ import ( "context" "encoding/json" "fmt" - "os" - "os/signal" "strings" - "syscall" "time" - "golang.org/x/term" - "github.com/jmgilman/headjack/internal/exec" ) @@ -22,63 +17,35 @@ type PodmanConfig struct { } type podmanRuntime struct { - exec exec.Executor + baseRuntime config PodmanConfig } // NewPodmanRuntime creates a Runtime using Podman CLI. func NewPodmanRuntime(e exec.Executor, cfg PodmanConfig) Runtime { - return &podmanRuntime{exec: e, config: cfg} -} - -// podmanError formats an error from the podman CLI, including stderr if available. -func podmanError(operation string, result *exec.Result, err error) error { - if result != nil { - stderr := strings.TrimSpace(string(result.Stderr)) - if stderr != "" { - return fmt.Errorf("%s: %s", operation, stderr) - } + return &podmanRuntime{ + baseRuntime: baseRuntime{ + exec: e, + binaryName: "podman", + execCommand: []string{"podman", "exec"}, + }, + config: cfg, } - return fmt.Errorf("%s: %w", operation, err) } func (r *podmanRuntime) Run(ctx context.Context, cfg *RunConfig) (*Container, error) { - args := []string{"run", "--detach", "--name", cfg.Name} - - // Add merged flags (image labels + config, merged by manager) - args = append(args, cfg.Flags...) - - for _, m := range cfg.Mounts { - mountSpec := fmt.Sprintf("%s:%s", m.Source, m.Target) - if m.ReadOnly { - mountSpec += ":ro" - } - args = append(args, "-v", mountSpec) - } - - for _, e := range cfg.Env { - args = append(args, "-e", e) - } - - args = append(args, cfg.Image) - - // Add init command (default to "sleep infinity" if not specified) - initCmd := cfg.Init - if initCmd == "" { - initCmd = "sleep infinity" - } - args = append(args, strings.Fields(initCmd)...) + args := buildRunArgs(cfg) result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "podman", + Name: r.binaryName, Args: args, }) if err != nil { stderr := string(result.Stderr) - if strings.Contains(stderr, "already in use") || strings.Contains(stderr, "already exists") { + if isAlreadyExistsError(stderr) { return nil, ErrAlreadyExists } - return nil, podmanError("run container", result, err) + return nil, cliError("run container", result, err) } // Container ID is returned on stdout @@ -103,79 +70,23 @@ func (r *podmanRuntime) Exec(ctx context.Context, id string, cfg ExecConfig) err return ErrNotRunning } - args := []string{"exec"} - - if cfg.Interactive { - args = append(args, "-it") - } - - if cfg.Workdir != "" { - args = append(args, "-w", cfg.Workdir) - } - - for _, e := range cfg.Env { - args = append(args, "-e", e) - } - - args = append(args, id) - args = append(args, cfg.Command...) + args := buildExecArgs(id, cfg) if cfg.Interactive { return r.execInteractive(ctx, args) } result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "podman", + Name: r.binaryName, Args: args, }) if err != nil { - return podmanError("exec in container", result, err) + return cliError("exec in container", result, err) } return nil } -// execInteractive runs the podman exec command with TTY support. -func (r *podmanRuntime) execInteractive(ctx context.Context, args []string) error { - stdinFd := int(os.Stdin.Fd()) - - // Check if stdin is a terminal - if !term.IsTerminal(stdinFd) { - // Fall back to non-interactive mode - _, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "podman", - Args: args, - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - }) - return err - } - - // Put terminal in raw mode - oldState, err := term.MakeRaw(stdinFd) - if err != nil { - return fmt.Errorf("set terminal raw mode: %w", err) - } - defer func() { _ = term.Restore(stdinFd, oldState) }() - - // Handle window resize signals - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGWINCH) - defer signal.Stop(sigCh) - - // Run the command with stdio attached - _, err = r.exec.Run(ctx, &exec.RunOptions{ - Name: "podman", - Args: args, - Stdin: os.Stdin, - Stdout: os.Stdout, - Stderr: os.Stderr, - }) - - return err -} - func (r *podmanRuntime) Stop(ctx context.Context, id string) error { // Verify container exists c, err := r.Get(ctx, id) @@ -189,11 +100,11 @@ func (r *podmanRuntime) Stop(ctx context.Context, id string) error { } result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "podman", + Name: r.binaryName, Args: []string{"stop", id}, }) if err != nil { - return podmanError("stop container", result, err) + return cliError("stop container", result, err) } return nil @@ -212,11 +123,11 @@ func (r *podmanRuntime) Start(ctx context.Context, id string) error { } result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "podman", + Name: r.binaryName, Args: []string{"start", id}, }) if err != nil { - return podmanError("start container", result, err) + return cliError("start container", result, err) } return nil @@ -224,15 +135,15 @@ func (r *podmanRuntime) Start(ctx context.Context, id string) error { func (r *podmanRuntime) Remove(ctx context.Context, id string) error { result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "podman", + Name: r.binaryName, Args: []string{"rm", id}, }) if err != nil { stderr := string(result.Stderr) - if strings.Contains(stderr, "no such") || strings.Contains(stderr, "no container") { + if isNotFoundError(stderr) { return ErrNotFound } - return podmanError("remove container", result, err) + return cliError("remove container", result, err) } return nil @@ -240,15 +151,15 @@ func (r *podmanRuntime) Remove(ctx context.Context, id string) error { func (r *podmanRuntime) Get(ctx context.Context, id string) (*Container, error) { result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "podman", + Name: r.binaryName, Args: []string{"inspect", id}, }) if err != nil { stderr := string(result.Stderr) - if strings.Contains(stderr, "no such") || strings.Contains(stderr, "no container") { + if isNotFoundError(stderr) { return nil, ErrNotFound } - return nil, podmanError("inspect container", result, err) + return nil, cliError("inspect container", result, err) } var infos []podmanInspect @@ -271,11 +182,11 @@ func (r *podmanRuntime) List(ctx context.Context, filter ListFilter) ([]Containe } result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "podman", + Name: r.binaryName, Args: args, }) if err != nil { - return nil, podmanError("list containers", result, err) + return nil, cliError("list containers", result, err) } // Handle empty list @@ -298,16 +209,10 @@ func (r *podmanRuntime) List(ctx context.Context, filter ListFilter) ([]Containe } func (r *podmanRuntime) Build(ctx context.Context, cfg *BuildConfig) error { - args := []string{"build", "-t", cfg.Tag} - - if cfg.Dockerfile != "" { - args = append(args, "-f", cfg.Dockerfile) - } - - args = append(args, cfg.Context) + args := buildBuildArgs(cfg) result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: "podman", + Name: r.binaryName, Args: args, }) if err != nil { @@ -332,13 +237,7 @@ type podmanInspect struct { } func (p *podmanInspect) toContainer() *Container { - status := StatusUnknown - switch strings.ToLower(p.State.Status) { - case cliStatusRunning: - status = StatusRunning - case cliStatusStopped, cliStatusExited, cliStatusCreated: - status = StatusStopped - } + status := parseContainerStatus(p.State.Status) // Remove leading "/" from name if present name := strings.TrimPrefix(p.Name, "/") @@ -377,13 +276,7 @@ type podmanListItem struct { } func (p *podmanListItem) toContainer() Container { - status := StatusUnknown - switch strings.ToLower(p.State) { - case cliStatusRunning: - status = StatusRunning - case cliStatusStopped, cliStatusExited, cliStatusCreated: - status = StatusStopped - } + status := parseContainerStatus(p.State) name := "" if len(p.Names) > 0 { @@ -398,7 +291,3 @@ func (p *podmanListItem) toContainer() Container { CreatedAt: time.Unix(p.Created, 0), } } - -func (r *podmanRuntime) ExecCommand() []string { - return []string{"podman", "exec"} -} diff --git a/internal/git/git.go b/internal/git/git.go index 3e8dd90..b586245 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -4,6 +4,10 @@ package git import ( "context" "errors" + "fmt" + "strings" + + "github.com/jmgilman/headjack/internal/exec" ) // Sentinel errors for git operations. @@ -15,6 +19,17 @@ var ( ErrWorktreeNotFound = errors.New("worktree not found") ) +// gitError formats an error from a git command, including stderr if available. +func gitError(operation string, result *exec.Result, err error) error { + if result != nil { + stderr := strings.TrimSpace(string(result.Stderr)) + if stderr != "" { + return fmt.Errorf("%s: %s", operation, stderr) + } + } + return fmt.Errorf("%s: %w", operation, err) +} + // Worktree represents a git worktree. type Worktree struct { Path string // Filesystem path to the worktree diff --git a/internal/git/opener.go b/internal/git/opener.go index d211593..8483e3e 100644 --- a/internal/git/opener.go +++ b/internal/git/opener.go @@ -10,17 +10,6 @@ import ( "github.com/jmgilman/headjack/internal/exec" ) -// openerGitError formats an error from a git command in the opener, including stderr if available. -func openerGitError(operation string, result *exec.Result, err error) error { - if result != nil { - stderr := strings.TrimSpace(string(result.Stderr)) - if stderr != "" { - return fmt.Errorf("%s: %s", operation, stderr) - } - } - return fmt.Errorf("%s: %w", operation, err) -} - type opener struct { exec exec.Executor } @@ -65,7 +54,7 @@ func (o *opener) getRepoRoot(ctx context.Context, path string) (string, error) { return "", ErrNotRepository } } - return "", openerGitError("get repository root", result, err) + return "", gitError("get repository root", result, err) } return strings.TrimSpace(string(result.Stdout)), nil @@ -81,7 +70,7 @@ func (o *opener) generateIdentifier(ctx context.Context, root string) (string, e Dir: root, }) if err != nil { - return "", openerGitError("get initial commit", result, err) + return "", gitError("get initial commit", result, err) } // Take first line (in case of multiple roots) and first 7 chars diff --git a/internal/git/repository.go b/internal/git/repository.go index 7e71865..e55c80c 100644 --- a/internal/git/repository.go +++ b/internal/git/repository.go @@ -9,17 +9,6 @@ import ( "github.com/jmgilman/headjack/internal/exec" ) -// gitError formats an error from a git command, including stderr if available. -func gitError(operation string, result *exec.Result, err error) error { - if result != nil { - stderr := strings.TrimSpace(string(result.Stderr)) - if stderr != "" { - return fmt.Errorf("%s: %s", operation, stderr) - } - } - return fmt.Errorf("%s: %w", operation, err) -} - type repository struct { root string identifier string diff --git a/internal/instance/manager.go b/internal/instance/manager.go index fc21b0c..9edb318 100644 --- a/internal/instance/manager.go +++ b/internal/instance/manager.go @@ -298,7 +298,7 @@ func (m *Manager) Create(ctx context.Context, repoPath string, cfg CreateConfig) func (m *Manager) Get(ctx context.Context, id string) (*Instance, error) { entry, err := m.catalog.Get(ctx, id) if err != nil { - if err == catalog.ErrNotFound { + if errors.Is(err, catalog.ErrNotFound) { return nil, ErrNotFound } return nil, fmt.Errorf("get catalog entry: %w", err) @@ -316,7 +316,7 @@ func (m *Manager) GetByBranch(ctx context.Context, repoPath, branch string) (*In entry, err := m.catalog.GetByRepoBranch(ctx, repo.Identifier(), branch) if err != nil { - if err == catalog.ErrNotFound { + if errors.Is(err, catalog.ErrNotFound) { return nil, ErrNotFound } return nil, fmt.Errorf("get catalog entry: %w", err) @@ -326,6 +326,8 @@ func (m *Manager) GetByBranch(ctx context.Context, repoPath, branch string) (*In } // List returns all instances matching the filter. +// Instances with degraded containers (e.g., container not found) are silently +// skipped to ensure the list operation completes even if some instances have issues. func (m *Manager) List(ctx context.Context, filter ListFilter) ([]Instance, error) { entries, err := m.catalog.List(ctx, catalog.ListFilter{ RepoID: filter.RepoID, @@ -339,7 +341,8 @@ func (m *Manager) List(ctx context.Context, filter ListFilter) ([]Instance, erro for i := range entries { inst, err := m.entryToInstance(ctx, &entries[i]) if err != nil { - // Log and continue on individual failures + // Skip degraded instances (e.g., container not found) to ensure + // the list operation completes successfully continue } instances = append(instances, *inst) @@ -352,7 +355,7 @@ func (m *Manager) List(ctx context.Context, filter ListFilter) ([]Instance, erro func (m *Manager) Stop(ctx context.Context, id string) error { entry, err := m.catalog.Get(ctx, id) if err != nil { - if err == catalog.ErrNotFound { + if errors.Is(err, catalog.ErrNotFound) { return ErrNotFound } return fmt.Errorf("get catalog entry: %w", err) @@ -374,7 +377,7 @@ func (m *Manager) Stop(ctx context.Context, id string) error { func (m *Manager) Start(ctx context.Context, id string) error { entry, err := m.catalog.Get(ctx, id) if err != nil { - if err == catalog.ErrNotFound { + if errors.Is(err, catalog.ErrNotFound) { return ErrNotFound } return fmt.Errorf("get catalog entry: %w", err) @@ -533,7 +536,7 @@ func (m *Manager) stopContainerWithRetry(ctx context.Context, containerID string func (m *Manager) Remove(ctx context.Context, id string) error { entry, err := m.catalog.Get(ctx, id) if err != nil { - if err == catalog.ErrNotFound { + if errors.Is(err, catalog.ErrNotFound) { return ErrNotFound } return fmt.Errorf("get catalog entry: %w", err) @@ -576,7 +579,7 @@ func (m *Manager) Remove(ctx context.Context, id string) error { func (m *Manager) Recreate(ctx context.Context, id, image string) (*Instance, error) { entry, err := m.catalog.Get(ctx, id) if err != nil { - if err == catalog.ErrNotFound { + if errors.Is(err, catalog.ErrNotFound) { return nil, ErrNotFound } return nil, fmt.Errorf("get catalog entry: %w", err) @@ -634,7 +637,7 @@ func (m *Manager) Recreate(ctx context.Context, id, image string) (*Instance, er func (m *Manager) Attach(ctx context.Context, id string, cfg AttachConfig) error { entry, err := m.catalog.Get(ctx, id) if err != nil { - if err == catalog.ErrNotFound { + if errors.Is(err, catalog.ErrNotFound) { return ErrNotFound } return fmt.Errorf("get catalog entry: %w", err) @@ -914,7 +917,7 @@ echo '{"security":{"auth":{"selectedType":"oauth-personal"}}}' > ~/.gemini/setti func (m *Manager) getRunningInstance(ctx context.Context, instanceID string) (*catalog.Entry, error) { entry, err := m.catalog.Get(ctx, instanceID) if err != nil { - if err == catalog.ErrNotFound { + if errors.Is(err, catalog.ErrNotFound) { return nil, ErrNotFound } return nil, fmt.Errorf("get catalog entry: %w", err) @@ -940,7 +943,7 @@ func (m *Manager) getRunningInstance(ctx context.Context, instanceID string) (*c func (m *Manager) GetSession(ctx context.Context, instanceID, sessionName string) (*Session, error) { entry, err := m.catalog.Get(ctx, instanceID) if err != nil { - if err == catalog.ErrNotFound { + if errors.Is(err, catalog.ErrNotFound) { return nil, ErrNotFound } return nil, fmt.Errorf("get catalog entry: %w", err) @@ -966,7 +969,7 @@ func (m *Manager) GetSession(ctx context.Context, instanceID, sessionName string func (m *Manager) ListSessions(ctx context.Context, instanceID string) ([]Session, error) { entry, err := m.catalog.Get(ctx, instanceID) if err != nil { - if err == catalog.ErrNotFound { + if errors.Is(err, catalog.ErrNotFound) { return nil, ErrNotFound } return nil, fmt.Errorf("get catalog entry: %w", err) @@ -991,7 +994,7 @@ func (m *Manager) ListSessions(ctx context.Context, instanceID string) ([]Sessio func (m *Manager) KillSession(ctx context.Context, instanceID, sessionName string) error { entry, err := m.catalog.Get(ctx, instanceID) if err != nil { - if err == catalog.ErrNotFound { + if errors.Is(err, catalog.ErrNotFound) { return ErrNotFound } return fmt.Errorf("get catalog entry: %w", err) @@ -1036,7 +1039,7 @@ func (m *Manager) KillSession(ctx context.Context, instanceID, sessionName strin func (m *Manager) AttachSession(ctx context.Context, instanceID, sessionName string) error { entry, err := m.catalog.Get(ctx, instanceID) if err != nil { - if err == catalog.ErrNotFound { + if errors.Is(err, catalog.ErrNotFound) { return ErrNotFound } return fmt.Errorf("get catalog entry: %w", err) @@ -1110,7 +1113,7 @@ func (m *Manager) cleanupExitedSession(ctx context.Context, instanceID, sessionN func (m *Manager) GetMRUSession(ctx context.Context, instanceID string) (*Session, error) { entry, err := m.catalog.Get(ctx, instanceID) if err != nil { - if err == catalog.ErrNotFound { + if errors.Is(err, catalog.ErrNotFound) { return nil, ErrNotFound } return nil, fmt.Errorf("get catalog entry: %w", err) @@ -1138,10 +1141,12 @@ func (m *Manager) GetMRUSession(ctx context.Context, instanceID string) (*Sessio }, nil } -// GlobalMRUSession represents a session with its instance context. +// GlobalMRUSession represents the most recently used session along with +// its containing instance information. This is returned by GetGlobalMRUSession +// to provide context about which instance owns the session. type GlobalMRUSession struct { - InstanceID string - Session Session + InstanceID string // ID of the instance containing the session + Session Session // The most recently used session } // GetGlobalMRUSession returns the most recently used session across all instances. diff --git a/internal/instance/manager_test.go b/internal/instance/manager_test.go index 4c036b3..2fe464a 100644 --- a/internal/instance/manager_test.go +++ b/internal/instance/manager_test.go @@ -651,6 +651,10 @@ func TestManager_Attach(t *testing.T) { }) } +// TestSanitizeBranch tests the branch name sanitization logic used for +// generating container names and worktree paths. This is tested directly +// because the behavior is critical for naming consistency and hard to verify +// through the public API without creating actual containers. func TestSanitizeBranch(t *testing.T) { tests := []struct { input string diff --git a/internal/keychain/keychain_darwin.go b/internal/keychain/keychain_darwin.go index 6312cdf..091c9d4 100644 --- a/internal/keychain/keychain_darwin.go +++ b/internal/keychain/keychain_darwin.go @@ -2,7 +2,11 @@ package keychain -import gokeychain "github.com/keybase/go-keychain" +import ( + "errors" + + gokeychain "github.com/keybase/go-keychain" +) // serviceName is the service identifier used for all headjack credentials. const serviceName = "com.headjack.cli" @@ -40,7 +44,7 @@ func (k *keychain) Get(account string) (string, error) { query.SetReturnData(true) results, err := gokeychain.QueryItem(query) - if err == gokeychain.ErrorItemNotFound { + if errors.Is(err, gokeychain.ErrorItemNotFound) { return "", ErrNotFound } if err != nil { @@ -60,7 +64,7 @@ func (k *keychain) Delete(account string) error { item.SetAccount(account) err := gokeychain.DeleteItem(item) - if err == gokeychain.ErrorItemNotFound { + if errors.Is(err, gokeychain.ErrorItemNotFound) { return nil } return err