diff --git a/docs/docs/decisions/adr-006-oci-customization.md b/docs/docs/decisions/adr-006-oci-customization.md index e3dd32a..c94011b 100644 --- a/docs/docs/decisions/adr-006-oci-customization.md +++ b/docs/docs/decisions/adr-006-oci-customization.md @@ -41,7 +41,7 @@ This adds complexity: **Pure OCI image approach** - Ship a default base image with opinionated tooling -- Users override with `--base ` or `--base ` +- Users override with `--image ` or `--image ` - All customization delegated to standard OCI tooling This approach: @@ -56,9 +56,10 @@ Use **OCI images exclusively** for environment customization. No first-class sup The customization model is: -1. **Default**: Headjack ships a base image (`ghcr.io/headjack/base:latest`) with opinionated tooling -2. **Image override**: Users specify an alternative image via `--base ` -3. **Dockerfile override**: Users specify a Dockerfile via `--base ` +1. **Devcontainer (default)**: If a `devcontainer.json` exists, use it automatically +2. **Image override**: Users specify an alternative image via `--image ` +3. **Dockerfile override**: Users specify a Dockerfile via `--image ` +4. **Fallback**: If no devcontainer and no `--image`, use configured `default.base_image` When a Dockerfile path is provided (detected by filename ending in `Dockerfile` or `Containerfile`), Headjack runs `container build` before launching the instance. Layer caching is handled by the container runtime. diff --git a/docs/docs/explanation/authentication.md b/docs/docs/explanation/authentication.md index aa0b736..9906afa 100644 --- a/docs/docs/explanation/authentication.md +++ b/docs/docs/explanation/authentication.md @@ -277,7 +277,7 @@ Currently, Headjack stores one set of credentials per agent type. Multi-account ### Container Filesystem Persistence -Once credentials are written inside a container, they persist until the container is recreated. A `hjk recreate` is needed to rotate credentials if they change on the host. +Once credentials are written inside a container, they persist until the container is recreated. Use `hjk rm` followed by `hjk run` to recreate an instance if credentials change on the host. ### OAuth Token Expiry @@ -299,7 +299,7 @@ The token may have expired: ```bash hjk auth claude # Re-capture fresh credential -hjk recreate # Recreate container with new credentials +hjk rm && hjk run # Recreate instance with new credentials ``` ### Claude onboarding prompt @@ -312,7 +312,7 @@ To switch authentication methods, simply run `hjk auth` again and select the oth ```bash hjk auth claude # Select option 2 for API key -hjk recreate # Recreate to use new credential type +hjk rm && hjk run # Recreate instance with new credential type ``` ## Why Not SSH Agent Forwarding? diff --git a/docs/docs/explanation/image-customization.md b/docs/docs/explanation/image-customization.md index 5e756f6..e053229 100644 --- a/docs/docs/explanation/image-customization.md +++ b/docs/docs/explanation/image-customization.md @@ -208,17 +208,26 @@ Each instance uses a full container image. There's no Nix-style deduplication wh Custom images must be built before use. For complex images, this can take minutes. Pre-building and pushing to a registry mitigates this. -## Image Variants +## Devcontainers vs Custom Images -The base image comes in variants for different use cases: +Headjack supports two approaches to environment customization: -| Variant | Features | Use Case | -|---------|----------|----------| -| `base` | Agent CLIs, version managers | Most development work | -| `systemd` | Adds systemd support | Projects requiring services | -| `dind` | Adds Docker-in-Docker | Testing Docker workflows | +### Devcontainers (Recommended) -Each variant extends the previous, adding capabilities at the cost of image size. +If your repository contains a `devcontainer.json`, Headjack uses it automatically. This is the preferred approach because: + +- Configuration lives with the code +- Standard format understood by VS Code, GitHub Codespaces, and other tools +- Supports Dev Container Features for modular customization +- Team members get consistent environments automatically + +### Custom Images + +Build a custom OCI image when: + +- Your repository doesn't have a devcontainer configuration +- You need to share the same image across multiple repositories +- You want faster startup (pre-built vs building at runtime) ## Best Practices diff --git a/docs/docs/how-to/authenticate.md b/docs/docs/how-to/authenticate.md index f56020f..0e78e3b 100644 --- a/docs/docs/how-to/authenticate.md +++ b/docs/docs/how-to/authenticate.md @@ -112,7 +112,7 @@ To switch between subscription and API key: ```bash hjk auth claude # Select the other option -hjk recreate my-feature # Recreate container with new credentials +hjk rm my-feature && hjk run my-feature # Recreate instance with new credentials ``` ## Notes diff --git a/docs/docs/how-to/build-custom-image.md b/docs/docs/how-to/build-custom-image.md index f87220e..fe61a84 100644 --- a/docs/docs/how-to/build-custom-image.md +++ b/docs/docs/how-to/build-custom-image.md @@ -6,22 +6,14 @@ description: Build a custom container image with project dependencies and use it # Build and Use Custom Images -Create a custom image with your project's dependencies pre-installed for faster container startup, or use one of the official Headjack image variants. +Create a custom image with your project's dependencies pre-installed for faster container startup. -## Use an official variant - -Headjack provides three image variants: - -```bash -# Base image (default) - minimal with agent CLIs -hjk run feat/auth --base ghcr.io/gilmanlab/headjack:base - -# Systemd variant - includes init system -hjk run feat/auth --base ghcr.io/gilmanlab/headjack:systemd - -# Docker-in-Docker variant - includes Docker daemon -hjk run feat/auth --base ghcr.io/gilmanlab/headjack:dind -``` +:::tip Prefer Devcontainers +If your repository has a `devcontainer.json`, Headjack uses it automatically. You only need a custom image when: +- Your repository doesn't have a devcontainer configuration +- You want to share a pre-built image across multiple repositories +- You need faster startup than devcontainer building provides +::: ## Build a custom image @@ -114,16 +106,16 @@ docker push ghcr.io/your-org/my-custom-headjack:latest ### Override for a single run -Use the `--base` flag: +Use the `--image` flag: ```bash -hjk run feat/auth --base my-registry.io/my-custom-headjack:latest +hjk run feat/auth --image my-registry.io/my-custom-headjack:latest ``` Combine with `--agent`: ```bash -hjk run feat/auth --base my-registry.io/my-custom-headjack:latest --agent claude "Implement the feature" +hjk run feat/auth --image my-registry.io/my-custom-headjack:latest --agent claude "Implement the feature" ``` ### Set as permanent default diff --git a/docs/docs/how-to/manage-sessions.md b/docs/docs/how-to/manage-sessions.md index c9ef2d0..493db92 100644 --- a/docs/docs/how-to/manage-sessions.md +++ b/docs/docs/how-to/manage-sessions.md @@ -153,12 +153,16 @@ hjk logs feat/auth happy-panda --full # complete log hjk run feat/auth --agent claude --name jwt-implementation "Implement JWT" ``` -### Custom base image +### Custom container image ```bash -hjk run feat/auth --agent claude --base my-registry.io/custom-image:latest +hjk run feat/auth --agent claude --image my-registry.io/custom-image:latest ``` +:::note +Using `--image` bypasses devcontainer detection. If your repository has a `devcontainer.json`, it will be used automatically without needing `--image`. +::: + ## Troubleshooting **"no sessions exist"** - No sessions are running. Start one with `hjk run`. diff --git a/docs/docs/how-to/troubleshoot-auth.md b/docs/docs/how-to/troubleshoot-auth.md index d736ed0..f84666c 100644 --- a/docs/docs/how-to/troubleshoot-auth.md +++ b/docs/docs/how-to/troubleshoot-auth.md @@ -120,10 +120,11 @@ hjk auth codex hjk auth # Select option 2 and enter your API key ``` -After re-authenticating, recreate your instance: +After re-authenticating, remove and recreate your instance to apply the new credentials: ```bash -hjk recreate my-feature +hjk rm my-feature +hjk run my-feature ``` ## Keychain Access Issues @@ -209,7 +210,7 @@ To switch authentication methods: ```bash hjk auth claude # Select the other option when prompted -hjk recreate my-feature # Apply new credentials to existing instance +hjk rm my-feature && hjk run my-feature # Recreate instance with new credentials ``` ## Related diff --git a/docs/docs/reference/cli/recreate.md b/docs/docs/reference/cli/recreate.md deleted file mode 100644 index cace46a..0000000 --- a/docs/docs/reference/cli/recreate.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -sidebar_position: 8 -title: hjk recreate -description: Recreate an instance container ---- - -# hjk recreate - -Recreate the container for an instance while preserving the worktree. - -## Synopsis - -```bash -hjk recreate [flags] -``` - -## Description - -Recreates the container for an instance. This command: - -1. Stops and deletes the existing container -2. Creates a new container with the same worktree - -Useful when the container environment is corrupted or needs a fresh state. The worktree (and all git-tracked and untracked files) is preserved. - -## Arguments - -| Argument | Description | -|----------|-------------| -| `branch` | Git branch name of the instance to recreate (required) | - -## Flags - -| Flag | Type | Default | Description | -|------|------|---------|-------------| -| `--base` | string | | Use a different base image for the new container | - -## Examples - -```bash -# Recreate with same image -hjk recreate feat/auth - -# Recreate with new image -hjk recreate feat/auth --base my-registry.io/new-image:v2 -``` - -## Use Cases - -- **Corrupted container**: When a container's environment becomes corrupted or unstable -- **Image update**: When you want to use a newer version of the base image -- **Clean slate**: When you want to reset the container state without losing code changes -- **Configuration change**: When you need to apply new container configuration - -## Behavior - -- All running sessions in the container are terminated -- The container is deleted and a new one is created -- The git worktree directory is preserved and remounted -- A new instance ID is generated for the new container - -## See Also - -- [hjk stop](stop.md) - Stop without recreating -- [hjk rm](rm.md) - Remove instance entirely -- [hjk run](run.md) - Create a new session after recreating diff --git a/docs/docs/reference/cli/rm.md b/docs/docs/reference/cli/rm.md index afeec80..82bad4d 100644 --- a/docs/docs/reference/cli/rm.md +++ b/docs/docs/reference/cli/rm.md @@ -63,4 +63,4 @@ Type `y` or `yes` to confirm, or any other input (including Enter) to cancel. - [hjk stop](stop.md) - Stop without removing - [hjk ps](ps.md) - List instances -- [hjk recreate](recreate.md) - Recreate container without removing worktree +- [hjk run](run.md) - Create a new instance diff --git a/docs/docs/reference/cli/run.md b/docs/docs/reference/cli/run.md index 06302bc..2505bcd 100644 --- a/docs/docs/reference/cli/run.md +++ b/docs/docs/reference/cli/run.md @@ -16,10 +16,10 @@ hjk run [prompt] [flags] ## Description -Creates a new session within an instance for the specified branch. If no instance exists for the branch, one is created first by: +Creates a new session within an instance for the specified branch. If no instance exists for the branch, one is created first. The container environment is determined by: -- Creating a git worktree at the configured location -- Spawning a new container with the worktree mounted +1. **Devcontainer (default)**: If the repository contains a `devcontainer.json`, it is used to build and run the container environment automatically. +2. **Base image**: Use `--image` to specify a container image directly, bypassing devcontainer detection. A new session is always created within the instance. If `--agent` is specified, the agent is started with an optional prompt. Otherwise, the default shell is started. @@ -40,16 +40,16 @@ If an instance exists but is stopped, it is automatically restarted before creat |------|-------|------|---------|-------------| | `--agent` | | string | | Start the specified agent instead of a shell. Valid values: `claude`, `gemini`, `codex`. If specified without a value, uses the configured `default.agent`. | | `--name` | | string | | Override the auto-generated session name | -| `--base` | | string | | Override the default base image | +| `--image` | | string | | Use a container image instead of devcontainer | | `--detached` | `-d` | bool | `false` | Create session but do not attach (run in background) | ## Examples ```bash -# Create instance with shell session +# Auto-detect devcontainer.json (recommended) hjk run feat/auth -# Create instance with Claude agent +# Start Claude agent in devcontainer hjk run feat/auth --agent claude "Implement JWT authentication" # Create additional session in existing instance @@ -62,8 +62,8 @@ hjk run feat/auth --name debug-shell hjk run feat/auth --agent claude -d "Refactor the auth module" hjk run feat/auth --agent claude -d "Write tests for auth module" -# Use a custom base image -hjk run feat/auth --base my-registry.io/custom-image:latest +# Use a specific container image (bypasses devcontainer) +hjk run feat/auth --image my-registry.io/custom-image:latest # Use default agent from config hjk run feat/auth --agent diff --git a/docs/docs/reference/cli/stop.md b/docs/docs/reference/cli/stop.md index f5b8f92..e0775a6 100644 --- a/docs/docs/reference/cli/stop.md +++ b/docs/docs/reference/cli/stop.md @@ -47,4 +47,3 @@ When an instance is stopped: - [hjk run](run.md) - Restart a stopped instance - [hjk rm](rm.md) - Remove an instance entirely - [hjk ps](ps.md) - List instances and their status -- [hjk recreate](recreate.md) - Recreate the container diff --git a/docs/docs/reference/configuration.md b/docs/docs/reference/configuration.md index 596ccfe..517951c 100644 --- a/docs/docs/reference/configuration.md +++ b/docs/docs/reference/configuration.md @@ -36,7 +36,7 @@ Default values applied when creating new instances. | Key | Type | Default | Description | |-----|------|---------|-------------| | `default.agent` | string | `""` (empty) | Default agent to use. Valid values: `claude`, `gemini`, `codex`. Empty means no default. | -| `default.base_image` | string | `ghcr.io/gilmanlab/headjack:base` | Container image to use for instances. | +| `default.base_image` | string | `""` (empty) | Fallback container image when no devcontainer is found. If empty and no devcontainer.json exists, `hjk run` will error with guidance. | ### agents @@ -74,7 +74,7 @@ A complete configuration file with all options: ```yaml default: agent: claude - base_image: ghcr.io/gilmanlab/headjack:base + base_image: "" # Empty by default; set if you want a fallback when no devcontainer exists agents: claude: @@ -144,8 +144,17 @@ The following environment variables override their corresponding configuration k Headjack validates configuration values when loading and setting them: - `default.agent` must be one of: `claude`, `gemini`, `codex` (or empty) -- `default.base_image` is required and cannot be empty +- `default.base_image` is optional; if empty, a devcontainer.json must exist in the repository - `runtime.name` must be one of: `podman`, `docker` - All storage paths are required Invalid values will result in an error message describing the validation failure. + +## Devcontainer Priority + +When running `hjk run`, Headjack determines the container environment as follows: + +1. If `--image` is specified, use that image (bypasses devcontainer detection) +2. If a `devcontainer.json` exists in the repository, use devcontainer mode +3. If `default.base_image` is configured, use that image +4. Otherwise, error with guidance on how to configure the environment diff --git a/docs/docs/reference/environment.md b/docs/docs/reference/environment.md index 1ab05d4..67c6a82 100644 --- a/docs/docs/reference/environment.md +++ b/docs/docs/reference/environment.md @@ -15,7 +15,7 @@ These environment variables override values in the configuration file. They foll | Variable | Type | Description | Overrides | |----------|------|-------------|-----------| | `HEADJACK_DEFAULT_AGENT` | string | Default agent for new instances | `default.agent` | -| `HEADJACK_BASE_IMAGE` | string | Default container image | `default.base_image` | +| `HEADJACK_BASE_IMAGE` | string | Fallback container image when no devcontainer exists | `default.base_image` | | `HEADJACK_MULTIPLEXER` | string | Terminal multiplexer | `default.multiplexer` | | `HEADJACK_WORKTREE_DIR` | string | Worktree storage directory | `storage.worktrees` | @@ -25,7 +25,7 @@ These environment variables override values in the configuration file. They foll # Use Claude as the default agent export HEADJACK_DEFAULT_AGENT=claude -# Use a custom container image +# Set a fallback container image (used when no devcontainer.json exists) export HEADJACK_BASE_IMAGE=myregistry.com/myimage:latest # Override worktree directory diff --git a/docs/docs/tutorials/custom-image.md b/docs/docs/tutorials/custom-image.md index 0c855e8..9a36407 100644 --- a/docs/docs/tutorials/custom-image.md +++ b/docs/docs/tutorials/custom-image.md @@ -226,17 +226,21 @@ Type `exit` to leave the container. ## Step 9: Use the Image with Headjack -Now use your custom image with Headjack. Specify it with the `--base` flag: +Now use your custom image with Headjack. Specify it with the `--image` flag: ```bash -hjk run feat/new-feature --base my-app-headjack:latest --agent claude "Add user authentication using PostgreSQL sessions" +hjk run feat/new-feature --image my-app-headjack:latest --agent claude "Add user authentication using PostgreSQL sessions" ``` The agent starts immediately with all dependencies available. No waiting for Python or Node.js installation. +:::note +Using `--image` bypasses devcontainer detection. If your repository has a `devcontainer.json`, you typically don't need a custom image—just run `hjk run feat/new-feature` and the devcontainer will be used automatically. +::: + ## Step 10: Set as Default -To avoid specifying `--base` every time, set your image as the default: +To avoid specifying `--image` every time, set your image as the default: ```bash hjk config default.base_image my-app-headjack:latest diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 85fb3c9..392b70a 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -98,7 +98,6 @@ const sidebars: SidebarsConfig = { 'reference/cli/stop', 'reference/cli/kill', 'reference/cli/rm', - 'reference/cli/recreate', 'reference/cli/auth', 'reference/cli/config', 'reference/cli/version', diff --git a/integration/testdata/scripts/recreate.txtar b/integration/testdata/scripts/recreate.txtar deleted file mode 100644 index 581f49b..0000000 --- a/integration/testdata/scripts/recreate.txtar +++ /dev/null @@ -1,41 +0,0 @@ -# Test hjk recreate command -# This test requires a container runtime - -# Create a test git repository with unique name -exec git init testrepo-recreate -cd testrepo-recreate -exec git config user.email 'test@example.com' -exec git config user.name 'Test User' -exec git commit --allow-empty -m 'initial commit' -exec git branch test/recreate-instance - -# Create an instance -exec hjk run test/recreate-instance -d -stdout 'Created' -! stderr . - -# Wait for container to be running -wait_running test/recreate-instance - -# Verify instance is running -exec hjk ps -stdout 'test/recreate-instance' -stdout 'running' -! stderr . - -# Recreate the instance -exec hjk recreate test/recreate-instance -stdout 'Recreated instance' -stdout 'test/recreate-instance' -! stderr . - -# Verify instance is still running after recreate -exec hjk ps -stdout 'test/recreate-instance' -stdout 'running' -! stderr . - -# Clean up -exec hjk rm test/recreate-instance --force -stdout 'Removed' -! stderr . diff --git a/internal/cmd/helpers.go b/internal/cmd/helpers.go index eb8c7aa..72c9280 100644 --- a/internal/cmd/helpers.go +++ b/internal/cmd/helpers.go @@ -39,10 +39,10 @@ func resolveBaseImage(ctx context.Context, override string) string { if override != "" { return override } - if cfg := ConfigFromContext(ctx); cfg != nil && cfg.Default.BaseImage != "" { + if cfg := ConfigFromContext(ctx); cfg != nil { return cfg.Default.BaseImage } - return config.DefaultBaseImage + return "" } func getInstanceByBranch(ctx context.Context, mgr *instance.Manager, branch, notFoundMsg string) (*instance.Instance, error) { diff --git a/internal/cmd/recreate.go b/internal/cmd/recreate.go deleted file mode 100644 index 57a4a34..0000000 --- a/internal/cmd/recreate.go +++ /dev/null @@ -1,62 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -var recreateCmd = &cobra.Command{ - Use: "recreate ", - Short: "Recreate an instance's container without losing worktree state", - Long: `Recreate the container for an instance while preserving the worktree. - -This command: -- Stops and deletes the existing container -- Creates a new container with the same worktree - -Useful when the container environment is corrupted or needs a fresh state. -The worktree (and all git-tracked and untracked files) is preserved.`, - Example: ` # Recreate with same image - headjack recreate feat/auth - - # Recreate with new image - headjack recreate feat/auth --base my-registry.io/new-image:v2`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - branch := args[0] - - mgr, err := requireManager(cmd.Context()) - if err != nil { - return err - } - - inst, err := getInstanceByBranch(cmd.Context(), mgr, branch, "no instance found for branch %q") - if err != nil { - return err - } - - // Determine image to use (precedence: flag > config) - // Config already has defaults set via Viper, so just use it - imageOverride, err := cmd.Flags().GetString("base") - if err != nil { - return fmt.Errorf("get base flag: %w", err) - } - image := resolveBaseImage(cmd.Context(), imageOverride) - - // Recreate the instance - newInst, err := mgr.Recreate(cmd.Context(), inst.ID, image) - if err != nil { - return fmt.Errorf("recreate instance: %w", err) - } - - fmt.Printf("Recreated instance %s for branch %s with image %s\n", newInst.ID, newInst.Branch, image) - return nil - }, -} - -func init() { - rootCmd.AddCommand(recreateCmd) - - recreateCmd.Flags().String("base", "", "use a different base image") -} diff --git a/internal/cmd/run.go b/internal/cmd/run.go index b6ef93b..ddb1e47 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -3,6 +3,7 @@ package cmd import ( "errors" "fmt" + "os/exec" "github.com/spf13/cobra" @@ -22,9 +23,13 @@ var runCmd = &cobra.Command{ Short: "Create a new session (and instance if needed), then attach", Long: `Create a new session within an instance for the specified branch. -If no instance exists for the branch, one is created first: - - Creates a git worktree at the configured location - - Spawns a new container with the worktree mounted +If no instance exists for the branch, one is created first. The container +environment is determined by: + + 1. Devcontainer (default): If the repository contains a devcontainer.json, + it is used to build and run the container environment automatically. + 2. Base image: Use --image to specify a container image directly, bypassing + devcontainer detection. A new session is always created within the instance. If --agent is specified, the agent is started (with an optional prompt). Otherwise, the default shell @@ -32,10 +37,10 @@ is started. Unless --detached is specified, the terminal attaches to the session. All session output is captured to a log file regardless of attached/detached mode.`, - Example: ` # New instance with shell session + Example: ` # Auto-detect devcontainer.json (recommended) headjack run feat/auth - # New instance with Claude agent + # Start Claude agent in devcontainer headjack run feat/auth --agent claude "Implement JWT authentication" # Additional session in existing instance @@ -48,8 +53,8 @@ All session output is captured to a log file regardless of attached/detached mod headjack run feat/auth --agent claude -d "Refactor the auth module" headjack run feat/auth --agent claude -d "Write tests for auth module" - # Use a custom base image - headjack run feat/auth --base my-registry.io/custom-image:latest`, + # Use a specific container image (bypasses devcontainer) + headjack run feat/auth --image my-registry.io/custom-image:latest`, Args: cobra.RangeArgs(1, 2), RunE: runRunCmd, } @@ -57,7 +62,7 @@ All session output is captured to a log file regardless of attached/detached mod // runFlags holds parsed flags for the run command. type runFlags struct { image string - imageExplicit bool // true if --base was explicitly passed + imageExplicit bool // true if --image was explicitly passed agent string sessionName string detached bool @@ -65,11 +70,11 @@ type runFlags struct { // parseRunFlags extracts and validates flags from the command. func parseRunFlags(cmd *cobra.Command) (*runFlags, error) { - image, err := cmd.Flags().GetString("base") + image, err := cmd.Flags().GetString("image") if err != nil { - return nil, fmt.Errorf("get base flag: %w", err) + return nil, fmt.Errorf("get image flag: %w", err) } - imageExplicit := cmd.Flags().Changed("base") + imageExplicit := cmd.Flags().Changed("image") agent, err := cmd.Flags().GetString("agent") if err != nil { @@ -291,7 +296,10 @@ func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, br } // Build create config - detect devcontainer mode if applicable - createCfg := buildCreateConfig(cmd, repoPath, branch, image, imageExplicit) + createCfg, err := buildCreateConfig(cmd, repoPath, branch, image, imageExplicit) + if err != nil { + return nil, err + } // Create new instance inst, err = mgr.Create(cmd.Context(), repoPath, createCfg) @@ -303,44 +311,75 @@ func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, br return inst, nil } +// devcontainerCLI is the name of the devcontainer CLI binary. +const devcontainerCLI = "devcontainer" + // buildCreateConfig builds the instance creation config, detecting devcontainer mode if applicable. // Devcontainer mode is used when: -// - No --base flag was explicitly passed (imageExplicit is false) +// - No --image flag was explicitly passed (imageExplicit is false) // - A devcontainer.json exists in the repo -func buildCreateConfig(cmd *cobra.Command, repoPath, branch, image string, imageExplicit bool) instance.CreateConfig { +// - The devcontainer CLI is available +// +// Returns an error if no devcontainer.json is found and no image is configured. +func buildCreateConfig(cmd *cobra.Command, repoPath, branch, image string, imageExplicit bool) (instance.CreateConfig, error) { cfg := instance.CreateConfig{ Branch: branch, Image: image, } + // Always check if devcontainer CLI is available and warn if not + devcontainerAvailable := isDevcontainerCLIAvailable() + if !devcontainerAvailable { + fmt.Println("Warning: devcontainer CLI not found in PATH") + fmt.Println(" Install with: npm install -g @devcontainers/cli") + fmt.Println(" See: https://github.com/devcontainers/cli") + } + // If image was explicitly passed, use vanilla mode if imageExplicit { - return cfg + return cfg, nil } // Check for devcontainer.json - if !devcontainer.HasConfig(repoPath) { - return cfg - } + hasDevcontainer := devcontainer.HasConfig(repoPath) - // Create devcontainer runtime wrapping the underlying runtime - runtimeName := runtimeNameDocker - if appCfg := ConfigFromContext(cmd.Context()); appCfg != nil && appCfg.Runtime.Name != "" { - runtimeName = appCfg.Runtime.Name - } - dcRuntime := createDevcontainerRuntime(cmd, runtimeName) - if dcRuntime == nil { - // Fall back to vanilla mode if we can't create the devcontainer runtime - return cfg + if hasDevcontainer { + if !devcontainerAvailable { + // Devcontainer exists but CLI not available - error + return cfg, errors.New("devcontainer.json found but devcontainer CLI is not installed") + } + + // Create devcontainer runtime wrapping the underlying runtime + runtimeName := runtimeNameDocker + if appCfg := ConfigFromContext(cmd.Context()); appCfg != nil && appCfg.Runtime.Name != "" { + runtimeName = appCfg.Runtime.Name + } + dcRuntime := createDevcontainerRuntime(cmd, runtimeName) + if dcRuntime == nil { + return cfg, errors.New("failed to create devcontainer runtime") + } + + fmt.Println("Detected devcontainer.json, using devcontainer mode") + + cfg.WorkspaceFolder = repoPath + cfg.Runtime = dcRuntime + cfg.Image = "" // Not needed in devcontainer mode + + return cfg, nil } - fmt.Println("Detected devcontainer.json, using devcontainer mode") + // No devcontainer.json - need an image + if image == "" { + return cfg, errors.New("no devcontainer.json found and no image configured\n\nTo fix this, either:\n 1. Add a devcontainer.json to your repository\n 2. Use --image to specify a container image\n 3. Set default.base_image in your config") + } - cfg.WorkspaceFolder = repoPath - cfg.Runtime = dcRuntime - cfg.Image = "" // Not needed in devcontainer mode + return cfg, nil +} - return cfg +// isDevcontainerCLIAvailable checks if the devcontainer CLI is in PATH. +func isDevcontainerCLIAvailable() bool { + _, err := exec.LookPath(devcontainerCLI) + return err == nil } // createDevcontainerRuntime creates a DevcontainerRuntime wrapping the appropriate underlying runtime. @@ -402,7 +441,7 @@ func init() { runCmd.Flags().String("agent", "", "start an agent (claude, gemini, codex, or 'default' for configured default)") runCmd.Flags().String("name", "", "override auto-generated session name") - runCmd.Flags().String("base", "", "override the default base image") + runCmd.Flags().String("image", "", "use a container image instead of devcontainer") runCmd.Flags().BoolP("detached", "d", false, "create session but don't attach (run in background)") agentFlag := runCmd.Flags().Lookup("agent") diff --git a/internal/config/config.go b/internal/config/config.go index 7a49a7b..36d09a0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,7 +21,8 @@ const ( DefaultDataDir = ".local/share/headjack" ) -// DefaultBaseImage is the default container image. +// DefaultBaseImage is the fallback container image when no devcontainer is found. +// This is only used when explicitly requested or when config has no base_image set. const DefaultBaseImage = "ghcr.io/gilmanlab/headjack:base" // Sentinel errors for configuration operations. @@ -62,7 +63,7 @@ type Config struct { // DefaultConfig holds default values for new instances. type DefaultConfig struct { Agent string `mapstructure:"agent" validate:"omitempty,oneof=claude gemini codex"` - BaseImage string `mapstructure:"base_image" validate:"required"` + BaseImage string `mapstructure:"base_image"` } // AgentConfig holds agent-specific configuration. @@ -140,7 +141,7 @@ func NewLoader() (*Loader, error) { // setDefaults sets all default configuration values using Viper. func (l *Loader) setDefaults() { l.v.SetDefault("default.agent", "") - l.v.SetDefault("default.base_image", DefaultBaseImage) + l.v.SetDefault("default.base_image", "") l.v.SetDefault("storage.worktrees", "~/.local/share/headjack/git") l.v.SetDefault("storage.catalog", "~/.local/share/headjack/catalog.json") l.v.SetDefault("storage.logs", "~/.local/share/headjack/logs") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e634347..8fd0b4f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -21,7 +21,7 @@ func TestLoader_Load_CreatesDefaultIfMissing(t *testing.T) { // Check defaults assert.Empty(t, cfg.Default.Agent) - assert.Equal(t, DefaultBaseImage, cfg.Default.BaseImage) + assert.Empty(t, cfg.Default.BaseImage) assert.Contains(t, cfg.Storage.Worktrees, "headjack") assert.Contains(t, cfg.Storage.Catalog, "catalog.json") assert.Contains(t, cfg.Storage.Logs, "logs") @@ -117,7 +117,7 @@ func TestLoader_Get(t *testing.T) { t.Run("valid key returns value", func(t *testing.T) { val, err := loader.Get("default.base_image") require.NoError(t, err) - assert.Equal(t, DefaultBaseImage, val) + assert.Empty(t, val) }) t.Run("invalid key returns error", func(t *testing.T) { @@ -199,14 +199,12 @@ func TestConfig_Validate(t *testing.T) { require.Error(t, err) }) - t.Run("missing required base_image", func(t *testing.T) { + t.Run("valid config without base_image", func(t *testing.T) { cfg := &Config{ Default: DefaultConfig{Agent: ""}, Storage: StorageConfig{Worktrees: "/tmp/worktrees", Catalog: "/tmp/catalog.json", Logs: "/tmp/logs"}, } - err := cfg.Validate() - require.Error(t, err) - assert.Contains(t, err.Error(), "BaseImage") + assert.NoError(t, cfg.Validate()) }) } diff --git a/internal/instance/manager.go b/internal/instance/manager.go index 128b22a..8fcd5fa 100644 --- a/internal/instance/manager.go +++ b/internal/instance/manager.go @@ -555,56 +555,6 @@ func (m *Manager) Remove(ctx context.Context, id string) error { return nil } -// Recreate removes the container and creates a new one with the specified image. -func (m *Manager) Recreate(ctx context.Context, id, image string) (*Instance, error) { - entry, err := m.catalog.Get(ctx, id) - if err != nil { - if errors.Is(err, catalog.ErrNotFound) { - return nil, ErrNotFound - } - return nil, fmt.Errorf("get catalog entry: %w", err) - } - - if shutdownErr := m.shutdownContainer(ctx, entry, shutdownContainerOpts{RemoveContainer: true}); shutdownErr != nil { - return nil, shutdownErr - } - - // Create new container - containerName := m.containerName(entry.RepoID, entry.Branch) - c, err := m.runtime.Run(ctx, &container.RunConfig{ - Name: containerName, - Image: image, - Mounts: []container.Mount{ - {Source: entry.Worktree, Target: "/workspace", ReadOnly: false}, - }, - Flags: flags.ToArgs(m.configFlags), - }) - if err != nil { - entry.Status = catalog.StatusError - _ = m.catalog.Update(ctx, entry) //nolint:errcheck // best-effort status update - return nil, fmt.Errorf("create container: %w", err) - } - - // Update catalog - entry.ContainerID = c.ID - entry.Status = catalog.StatusRunning - if err := m.catalog.Update(ctx, entry); err != nil { - return nil, fmt.Errorf("update catalog entry: %w", err) - } - - return &Instance{ - ID: entry.ID, - Repo: entry.Repo, - RepoID: entry.RepoID, - Branch: entry.Branch, - Worktree: entry.Worktree, - ContainerID: c.ID, - Container: c, - CreatedAt: entry.CreatedAt, - Status: StatusRunning, - }, nil -} - // Attach executes a command in an instance's container. // If the instance is stopped, it will be started first. func (m *Manager) Attach(ctx context.Context, id string, cfg AttachConfig) error { diff --git a/internal/instance/manager_test.go b/internal/instance/manager_test.go index 670a3f5..81133c9 100644 --- a/internal/instance/manager_test.go +++ b/internal/instance/manager_test.go @@ -488,75 +488,6 @@ func TestManager_Remove(t *testing.T) { }) } -func TestManager_Recreate(t *testing.T) { - ctx := context.Background() - - t.Run("recreates container with new image", func(t *testing.T) { - store := &catalogmocks.StoreMock{ - GetFunc: func(ctx context.Context, id string) (*catalog.Entry, error) { - return &catalog.Entry{ - ID: "abc123", - RepoID: testRepoID, - Branch: "main", - Worktree: "/data/git/myrepo/main", - ContainerID: "old-container", - CreatedAt: time.Now(), - Status: catalog.StatusRunning, - }, nil - }, - UpdateFunc: func(ctx context.Context, entry *catalog.Entry) error { - return nil - }, - } - runtime := &containermocks.RuntimeMock{ - StopFunc: func(ctx context.Context, id string) error { - return nil - }, - RemoveFunc: func(ctx context.Context, id string) error { - return nil - }, - RunFunc: func(ctx context.Context, cfg *container.RunConfig) (*container.Container, error) { - return &container.Container{ - ID: "new-container", - Name: cfg.Name, - Image: cfg.Image, - Status: container.StatusRunning, - }, nil - }, - } - - mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) - - inst, err := mgr.Recreate(ctx, "abc123", "newimage:v2") - - require.NoError(t, err) - assert.Equal(t, "new-container", inst.ContainerID) - assert.Equal(t, StatusRunning, inst.Status) - - // Verify old container was removed - require.Len(t, runtime.StopCalls(), 1) - require.Len(t, runtime.RemoveCalls(), 1) - - // Verify new container was created with new image - require.Len(t, runtime.RunCalls(), 1) - assert.Equal(t, "newimage:v2", runtime.RunCalls()[0].Cfg.Image) - }) - - t.Run("returns ErrNotFound for missing instance", func(t *testing.T) { - store := &catalogmocks.StoreMock{ - GetFunc: func(ctx context.Context, id string) (*catalog.Entry, error) { - return nil, catalog.ErrNotFound - }, - } - - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) - - _, err := mgr.Recreate(ctx, "nonexistent", "image") - - assert.ErrorIs(t, err, ErrNotFound) - }) -} - func TestManager_Attach(t *testing.T) { ctx := context.Background()