diff --git a/docs/docs/explanation/architecture.md b/docs/docs/explanation/architecture.md index 0f9cb0f..595fd21 100644 --- a/docs/docs/explanation/architecture.md +++ b/docs/docs/explanation/architecture.md @@ -16,7 +16,7 @@ An **instance** is Headjack's central concept. It represents a complete, isolate - A **container** that provides an isolated execution environment - One or more **sessions** that provide persistent terminal access -When you run `hjk run feature-branch`, Headjack creates an instance by wiring these three components together. The instance remains linked to your repository and branch throughout its lifecycle. +When you run `hjk run feature-branch`, Headjack creates the instance (worktree and container). Then `hjk agent` or `hjk exec` creates sessions within that instance. The instance remains linked to your repository and branch throughout its lifecycle. import ThemedImage from '@theme/ThemedImage'; import useBaseUrl from '@docusaurus/useBaseUrl'; @@ -68,7 +68,9 @@ The session abstraction allows agents and shells to coexist. A typical workflow ## The Data Flow -When you run a command like `hjk run feature-branch`, here's how data flows through the system: +Instance creation and session management are separate operations. + +### Creating an Instance (`hjk run`) 1. **Repository identification**: Headjack opens your git repository and computes a stable identifier from the remote URL or path @@ -76,11 +78,15 @@ When you run a command like `hjk run feature-branch`, here's how data flows thro 3. **Container launch**: A container starts with the worktree mounted at `/workspace`. The container runs `sleep infinity` as its init process, keeping it alive indefinitely -4. **Session creation**: A tmux session starts inside the container, running the specified agent CLI (or shell) +4. **Catalog persistence**: The instance metadata is written to `~/.local/share/headjack/catalog.json` + +### Starting Sessions (`hjk agent` / `hjk exec`) + +1. **Instance lookup**: Headjack finds the instance for the specified branch -5. **Catalog persistence**: The instance metadata is written to `~/.local/share/headjack/catalog.json` +2. **Session creation**: A tmux session starts inside the container, running the specified agent CLI or shell -6. **Terminal attachment**: Your terminal attaches to the tmux session +3. **Terminal attachment**: Your terminal attaches to the tmux session (unless `-d` detached mode is used) When you detach (Ctrl+B, D), the session continues running in the container. The agent keeps working. When you `hjk attach` later, you reconnect to that same session and see everything that happened while you were away. diff --git a/docs/docs/explanation/session-lifecycle.md b/docs/docs/explanation/session-lifecycle.md index c4c83c8..3ff76d1 100644 --- a/docs/docs/explanation/session-lifecycle.md +++ b/docs/docs/explanation/session-lifecycle.md @@ -24,31 +24,44 @@ The session abstraction provides: ## Session Creation -When you run `hjk run feature-branch`, Headjack creates both an instance and an initial session: +Sessions are created separately from instances. First, `hjk run` creates the instance: ``` +------------------------------------------------------------+ | hjk run | | | | | +-------------+-------------+ | -| v v v | -| Create worktree Launch Create session | -| container | | -| | | | | +| v v | +| Create worktree Launch container | +| | | | | +-------------+-------------+ | | v | | Instance running | -| with one session | +| (no sessions yet) | ++------------------------------------------------------------+ +``` + +Then `hjk agent` or `hjk exec` creates sessions within that instance: + +``` ++------------------------------------------------------------+ +| hjk agent / hjk exec | +| | | +| v | +| Create tmux session | +| | | +| v | +| Attach terminal | +------------------------------------------------------------+ ``` The session is created in "detached" mode, meaning tmux starts the session but doesn't attach your terminal. Then Headjack immediately attaches, giving the appearance of a single operation. -You can also create additional sessions in an existing instance: +You can create multiple sessions in an existing instance: ```bash # Create a shell session while a Claude session is running -hjk session create my-instance --type shell +hjk exec feat/auth ``` This creates a second tmux session in the same container. Both sessions share the container's filesystem, network, and resources. @@ -116,13 +129,14 @@ Headjack tracks when you last accessed each session. This enables the "most rece Each `hjk attach` without a session name attaches to that instance's most recently used session: ```bash -# Create instance with claude session -hjk run feature-a +# Create instance and start claude session +hjk run feat/auth +hjk agent feat/auth claude # Later: detach (Ctrl+B, D) # Reattach to the same (MRU) session -hjk attach feature-a +hjk attach feat/auth ``` ### Global MRU @@ -131,14 +145,16 @@ Running `hjk attach` with no arguments attaches to the globally most recently us ```bash # Work on feature-a -hjk run feature-a +hjk run feat/auth +hjk agent feat/auth claude # Detach # Work on feature-b -hjk run feature-b +hjk run feat/api +hjk agent feat/api claude # Detach -# Resume most recent work (feature-b) +# Resume most recent work (feat/api) hjk attach ``` @@ -230,10 +246,11 @@ Sessions can be named for easier reference: ```bash # Auto-generated name (e.g., "happy-panda") -hjk session create my-instance +hjk agent feat/auth claude # Custom name -hjk session create my-instance --name testing +hjk agent feat/auth claude --name auth-impl +hjk exec feat/auth --name debug-shell ``` Names must be unique within an instance. The auto-generated names use a word list to create memorable combinations. @@ -241,7 +258,7 @@ Names must be unique within an instance. The auto-generated names use a word lis Named sessions can be attached by name: ```bash -hjk attach my-instance --session testing +hjk attach feat/auth auth-impl ``` ## Session State Machine diff --git a/docs/docs/how-to/authenticate.md b/docs/docs/how-to/authenticate.md index 0e78e3b..845e3ae 100644 --- a/docs/docs/how-to/authenticate.md +++ b/docs/docs/how-to/authenticate.md @@ -101,7 +101,8 @@ If not found: After authentication, verify by running an agent: ```bash -hjk run my-feature --agent claude # or gemini, codex +hjk run feat/test +hjk agent feat/test claude # or gemini, codex ``` The agent should authenticate without prompting for login. @@ -112,7 +113,8 @@ To switch between subscription and API key: ```bash hjk auth claude # Select the other option -hjk rm my-feature && hjk run my-feature # Recreate instance with new credentials +hjk rm feat/test && hjk run feat/test # Recreate instance with new credentials +hjk agent feat/test claude ``` ## Notes diff --git a/docs/docs/how-to/build-custom-image.md b/docs/docs/how-to/build-custom-image.md index fe61a84..49a63e3 100644 --- a/docs/docs/how-to/build-custom-image.md +++ b/docs/docs/how-to/build-custom-image.md @@ -112,10 +112,10 @@ Use the `--image` flag: hjk run feat/auth --image my-registry.io/my-custom-headjack:latest ``` -Combine with `--agent`: +Then start an agent session: ```bash -hjk run feat/auth --image my-registry.io/my-custom-headjack:latest --agent claude "Implement the feature" +hjk agent feat/auth claude --prompt "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 493db92..555daf0 100644 --- a/docs/docs/how-to/manage-sessions.md +++ b/docs/docs/how-to/manage-sessions.md @@ -8,51 +8,81 @@ description: Start, monitor, and manage agent and shell sessions in isolated con This guide covers the complete session lifecycle: starting agent and shell sessions, attaching and detaching, and monitoring background sessions. +## Create an instance first + +Before starting any sessions, create an instance for your branch: + +```bash +hjk run feat/auth +``` + +This creates a git worktree for the branch and a container with the worktree mounted. The instance is now ready for sessions. + ## Start an agent session Run an LLM coding agent (Claude, Gemini, or Codex) with a prompt: ```bash -hjk run feat/auth --agent claude "Implement JWT authentication" +hjk agent feat/auth claude --prompt "Implement JWT authentication" ``` -This creates a git worktree for the branch, a container with the worktree mounted, and starts the agent with your prompt. The terminal attaches automatically. +The terminal attaches automatically to the agent session. ### Start without a prompt Run an agent in interactive mode: ```bash -hjk run fix/header-bug --agent gemini +hjk agent fix/header-bug gemini ``` ### Choose an agent -Specify the agent with `--agent`: +Specify the agent as the second argument: ```bash -hjk run feat/api --agent claude "Add rate limiting" -hjk run feat/api --agent gemini "Add rate limiting" -hjk run feat/api --agent codex "Add rate limiting" +hjk agent feat/api claude --prompt "Add rate limiting" +hjk agent feat/api gemini --prompt "Add rate limiting" +hjk agent feat/api codex --prompt "Add rate limiting" ``` ## Start a shell session -Run an interactive shell without an agent: +Run an interactive shell using `hjk exec`: ```bash -hjk run feat/auth +hjk exec feat/auth ``` -This creates the same isolated environment but drops you into a shell instead of starting an agent. Useful for manual debugging or running commands alongside agent sessions. +This opens a bash shell inside the container. Useful for manual debugging or running commands alongside agent sessions. + +### Run a command + +Execute a specific command: + +```bash +hjk exec feat/auth npm test +hjk exec feat/auth npm run build +``` + +### Direct execution (no tmux) + +For quick commands without session persistence, use `--no-mux`: + +```bash +hjk exec feat/auth --no-mux ls -la +hjk exec feat/auth --no-mux pwd +``` + +This prints output directly to your terminal without creating a tmux session. ## Run in detached mode Start any session in the background with `-d`: ```bash -hjk run feat/auth --agent claude -d "Refactor the auth module" -hjk run feat/auth -d # detached shell +hjk agent feat/auth claude -d --prompt "Refactor the auth module" +hjk exec feat/auth -d npm run build ``` Use `hjk logs` or `hjk attach` to monitor or interact later. @@ -62,9 +92,15 @@ Use `hjk logs` or `hjk attach` to monitor or interact later. Start multiple agents on different branches, each in its own isolated container: ```bash -hjk run feat/auth --agent claude -d "Implement JWT authentication" -hjk run feat/api --agent claude -d "Add rate limiting to the API" -hjk run fix/header-bug --agent gemini -d "Fix the header rendering bug" +# Create instances first +hjk run feat/auth +hjk run feat/api +hjk run fix/header-bug + +# Then start agents in detached mode +hjk agent feat/auth claude -d --prompt "Implement JWT authentication" +hjk agent feat/api claude -d --prompt "Add rate limiting to the API" +hjk agent fix/header-bug gemini -d --prompt "Fix the header rendering bug" ``` Monitor all running instances: @@ -78,9 +114,9 @@ hjk ps Run multiple agents within a single instance using `--name`: ```bash -hjk run feat/auth --agent claude -d --name auth-impl "Implement the auth module" -hjk run feat/auth --agent claude -d --name auth-tests "Write tests for the auth module" -hjk run feat/auth --name debug-shell # add a shell session +hjk agent feat/auth claude -d --name auth-impl --prompt "Implement the auth module" +hjk agent feat/auth claude -d --name auth-tests --prompt "Write tests for the auth module" +hjk exec feat/auth --name debug-shell # add a shell session ``` All sessions share the same git worktree but run independently. @@ -150,13 +186,14 @@ hjk logs feat/auth happy-panda --full # complete log ### Custom session name ```bash -hjk run feat/auth --agent claude --name jwt-implementation "Implement JWT" +hjk agent feat/auth claude --name jwt-implementation --prompt "Implement JWT" +hjk exec feat/auth --name build-session npm run build ``` ### Custom container image ```bash -hjk run feat/auth --agent claude --image my-registry.io/custom-image:latest +hjk run feat/auth --image my-registry.io/custom-image:latest ``` :::note @@ -165,7 +202,7 @@ Using `--image` bypasses devcontainer detection. If your repository has a `devco ## Troubleshooting -**"no sessions exist"** - No sessions are running. Start one with `hjk run`. +**"no sessions exist"** - No sessions are running. Start one with `hjk agent` or `hjk exec`. **"no instance found for branch"** - The branch doesn't have an instance. Create one with `hjk run `. diff --git a/docs/docs/how-to/recover-from-crash.md b/docs/docs/how-to/recover-from-crash.md index 3919204..6ef9938 100644 --- a/docs/docs/how-to/recover-from-crash.md +++ b/docs/docs/how-to/recover-from-crash.md @@ -26,10 +26,10 @@ feat/api running 2 1h ago ## Resume a stopped instance -If the container stopped but the instance still exists, simply run a new session: +If the container stopped but the instance still exists, simply start a new session: ```bash -hjk run feat/auth --agent claude "Continue where we left off" +hjk agent feat/auth claude --prompt "Continue where we left off" ``` Headjack automatically restarts the container and creates a new session. Your git worktree is preserved with all previous work. @@ -55,7 +55,7 @@ hjk kill feat/auth/stuck-session Then start a fresh session: ```bash -hjk run feat/auth --agent claude +hjk agent feat/auth claude ``` ## Force remove a broken instance @@ -69,7 +69,8 @@ hjk rm feat/auth --force This removes the instance from Headjack's catalog and cleans up the worktree. You can then start fresh: ```bash -hjk run feat/auth --agent claude +hjk run feat/auth +hjk agent feat/auth claude ``` ## Recover work from the worktree diff --git a/docs/docs/how-to/stop-cleanup.md b/docs/docs/how-to/stop-cleanup.md index 16dcdc3..8126f7a 100644 --- a/docs/docs/how-to/stop-cleanup.md +++ b/docs/docs/how-to/stop-cleanup.md @@ -16,10 +16,10 @@ Stop the container but preserve the worktree: hjk stop feat/auth ``` -The instance remains in the catalog. Resume it later by running any `hjk run` command for that branch: +The instance remains in the catalog. Resume it later by running any command for that branch—Headjack automatically restarts stopped instances: ```bash -hjk run feat/auth --agent claude "Continue working on auth" +hjk agent feat/auth claude --prompt "Continue working on auth" ``` ## Kill a specific session diff --git a/docs/docs/intro.md b/docs/docs/intro.md index c51f901..e6ab7b3 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -22,7 +22,7 @@ Running multiple AI coding agents simultaneously presents challenges: they can i ### Instance -An **instance** is a running container environment tied to a specific git branch. When you create an instance, Headjack: +An **instance** is a running container environment tied to a specific git branch. When you create an instance with `hjk run`, Headjack: 1. Creates a git worktree for the branch 2. Spawns a container with the worktree mounted at `/workspace` @@ -32,7 +32,12 @@ Instances persist across sessions and can be stopped, started, or removed as nee ### Session -A **session** is a terminal multiplexer pane running inside an instance. Each instance can have multiple sessions, allowing you to run an agent alongside a shell for debugging or run multiple agents with different prompts. +A **session** is a terminal multiplexer pane running inside an instance. Sessions are created with: + +- `hjk agent` - Start an agent session (Claude, Gemini, or Codex) +- `hjk exec` - Start a shell session or run commands + +Each instance can have multiple sessions, allowing you to run an agent alongside a shell for debugging or run multiple agents with different prompts. Sessions can run in attached mode (interactive) or detached mode (background). @@ -49,23 +54,46 @@ Agents are authenticated via the `hjk auth` command before first use. ## Quick Example ```bash -# Start Claude on a feature branch with a prompt -hjk run feat/auth --agent claude "Implement JWT authentication" +# Create an instance for the feature branch +hjk run feat/auth + +# Start Claude agent with a prompt +hjk agent feat/auth claude --prompt "Implement JWT authentication" -# Run another agent in the background on the same branch -hjk run feat/auth --agent claude -d "Write tests for the auth module" +# Run another agent in the background on the same instance +hjk agent feat/auth claude -d --prompt "Write tests for the auth module" + +# Start a shell session for debugging +hjk exec feat/auth # List all running instances -hjk ls +hjk ps # Attach to an existing session hjk attach feat/auth ``` :::tip -The `hjk run` command is idempotent for instances: if an instance already exists for the branch, it creates a new session within it rather than a new instance. +Instance creation (`hjk run`) is separate from session management (`hjk agent`, `hjk exec`). This gives you flexibility to create instances without immediately starting sessions, and to choose between agent or shell sessions. ::: +## Workflow Overview + +The typical Headjack workflow has three steps: + +```bash +# Step 1: Create an instance +hjk run feat/auth + +# Step 2: Start an agent or shell session +hjk agent feat/auth claude --prompt "Your task description" + +# Step 3: Manage sessions +hjk attach # Reattach to session +hjk logs feat/auth # View session output +hjk ps feat/auth # List sessions +``` + ## Next Steps - [Getting Started Tutorial](/tutorials/getting-started) - Set up Headjack and run your first agent diff --git a/docs/docs/reference/cli/agent.md b/docs/docs/reference/cli/agent.md new file mode 100644 index 0000000..763c0ec --- /dev/null +++ b/docs/docs/reference/cli/agent.md @@ -0,0 +1,138 @@ +--- +sidebar_position: 2 +title: hjk agent +description: Start an agent session in an existing instance +--- + +# hjk agent + +Start an agent session (Claude, Gemini, or Codex) in an existing instance. + +## Synopsis + +```bash +hjk agent [agent_name] [flags] +``` + +## Description + +Creates a new session running the specified agent within an existing instance. The instance must already exist (created with `hjk run`). + +This command: + +1. Looks up the instance for the specified branch +2. Validates the agent name and authentication +3. Creates a new tmux session running the agent +4. Attaches to the session (unless `--detached` is specified) + +If the instance is stopped, it is automatically restarted before creating the session. + +All session output is captured to a log file regardless of attached/detached mode. + +## Arguments + +| Argument | Description | +|----------|-------------| +| `branch` | Git branch name of the instance (required) | +| `agent_name` | Agent to start: `claude`, `gemini`, or `codex` (optional, uses default if not specified) | + +## Flags + +| Flag | Short | Type | Default | Description | +|------|-------|------|---------|-------------| +| `--name` | `-n` | string | | Override the auto-generated session name | +| `--detached` | `-d` | bool | `false` | Create session but do not attach (run in background) | +| `--prompt` | `-p` | string | | Initial prompt to send to the agent | + +## Examples + +```bash +# Start default agent on existing instance +hjk agent feat/auth + +# Start Claude agent explicitly +hjk agent feat/auth claude + +# Start agent with a prompt +hjk agent feat/auth --prompt "Implement JWT authentication" +hjk agent feat/auth claude --prompt "Implement JWT authentication" + +# Short form with -p flag +hjk agent feat/auth -p "Fix the login bug" + +# Start Gemini agent with custom session name +hjk agent feat/auth gemini --name auth-session + +# Start agent in detached mode (run in background) +hjk agent feat/auth -d --prompt "Refactor the auth module" + +# Run multiple agents in parallel on the same instance +hjk agent feat/auth -d --prompt "Implement the login endpoint" +hjk agent feat/auth -d --prompt "Write tests for authentication" +``` + +## Default Agent + +If no agent name is specified, the default from configuration is used. Set the default with: + +```bash +hjk config default.agent claude +``` + +If no default is configured and no agent is specified, you'll see an error: + +``` +Error: no default agent configured and none specified +hint: run 'hjk config default.agent ' to set a default +``` + +## Authentication + +Before using an agent, you must configure authentication: + +```bash +# For Claude (Anthropic) +hjk auth claude + +# For Gemini (Google) +hjk auth gemini + +# For Codex (OpenAI) +hjk auth codex +``` + +Authentication tokens are securely stored in your system keychain and automatically injected into the container environment when starting an agent session. + +## Workflow + +The typical workflow separates instance creation from agent sessions: + +```bash +# Step 1: Create the instance +hjk run feat/auth + +# Step 2: Start an agent session +hjk agent feat/auth --prompt "Your prompt here" + +# Step 3: Detach from session (Ctrl+B, d) + +# Step 4: Later, reattach to the session +hjk attach feat/auth +``` + +## Error Handling + +If no instance exists for the branch, you'll see an error with a helpful hint: + +``` +Error: no instance found for branch "feat/auth" +hint: run 'hjk run feat/auth' to create one +``` + +## See Also + +- [hjk run](run.md) - Create an instance +- [hjk exec](exec.md) - Execute commands or start shell sessions +- [hjk attach](attach.md) - Attach to an existing session +- [hjk auth](auth.md) - Configure agent authentication +- [hjk logs](logs.md) - View session output diff --git a/docs/docs/reference/cli/attach.md b/docs/docs/reference/cli/attach.md index 9da7083..2d8bcd1 100644 --- a/docs/docs/reference/cli/attach.md +++ b/docs/docs/reference/cli/attach.md @@ -22,7 +22,7 @@ Attaches to an existing session using a most-recently-used (MRU) strategy: - **Branch only**: Attach to the most recently accessed session for that instance - **Branch and session**: Attach to the specified session -If no sessions exist for the resolved scope, the command displays an error suggesting `hjk run` to create one. +If no sessions exist for the resolved scope, the command displays an error suggesting `hjk agent` or `hjk exec` to create one. To detach from a session without terminating it, use the tmux detach keybinding (`Ctrl+B, d`). This returns you to your host terminal while the session continues running. @@ -56,6 +56,8 @@ The attach command tracks session access times and uses this to determine which ## See Also -- [hjk run](run.md) - Create a new session +- [hjk run](run.md) - Create an instance +- [hjk agent](agent.md) - Start an agent session +- [hjk exec](exec.md) - Execute commands or start shell sessions - [hjk ps](ps.md) - List instances and sessions - [hjk kill](kill.md) - Kill a session diff --git a/docs/docs/reference/cli/auth.md b/docs/docs/reference/cli/auth.md index bb36c07..483ca55 100644 --- a/docs/docs/reference/cli/auth.md +++ b/docs/docs/reference/cli/auth.md @@ -16,7 +16,7 @@ hjk auth ## Description -Configures agent authentication and stores credentials securely in the system keychain. These credentials are automatically injected into containers when running agents with `hjk run --agent`. +Configures agent authentication and stores credentials securely in the system keychain. These credentials are automatically injected into containers when running agents with `hjk agent`. Each agent supports two authentication methods: @@ -150,5 +150,6 @@ When injected into containers, credentials are set via environment variables: ## See Also -- [hjk run](run.md) - Use authenticated agents with `--agent` flag +- [hjk agent](agent.md) - Start agent sessions using stored credentials +- [hjk run](run.md) - Create instances for running agents - [Authentication](../../explanation/authentication.md) - How credential storage works diff --git a/docs/docs/reference/cli/exec.md b/docs/docs/reference/cli/exec.md new file mode 100644 index 0000000..e861902 --- /dev/null +++ b/docs/docs/reference/cli/exec.md @@ -0,0 +1,134 @@ +--- +sidebar_position: 3 +title: hjk exec +description: Execute a command in an instance's container +--- + +# hjk exec + +Execute a command within an existing instance's container. + +## Synopsis + +```bash +hjk exec [command...] [flags] +``` + +## Description + +Executes a command within an existing instance's container. The instance must already exist (created with `hjk run`). + +By default, creates a new tmux session and attaches to it. Use `--no-mux` to bypass the multiplexer and execute the command directly (like `docker exec`). + +If no command is specified, the default shell (`/bin/bash`) is started. + +If the instance is stopped, it is automatically restarted before executing. + +## Arguments + +| Argument | Description | +|----------|-------------| +| `branch` | Git branch name of the instance (required) | +| `command` | Command and arguments to execute (optional, defaults to shell) | + +## Flags + +| Flag | Short | Type | Default | Description | +|------|-------|------|---------|-------------| +| `--no-mux` | | bool | `false` | Bypass tmux and execute directly | +| `--name` | `-n` | string | | Override the auto-generated session name (ignored with `--no-mux`) | +| `--detached` | `-d` | bool | `false` | Create session but do not attach (ignored with `--no-mux`) | + +## Examples + +```bash +# Start an interactive shell session (in tmux) +hjk exec feat/auth + +# Run a command in tmux +hjk exec feat/auth npm test + +# Run a command directly (bypass tmux, output to terminal) +hjk exec feat/auth --no-mux ls -la + +# Start shell directly (bypass tmux) +hjk exec feat/auth --no-mux + +# Run with custom session name +hjk exec feat/auth --name build-session npm run build + +# Run command in background (detached tmux session) +hjk exec feat/auth -d npm run build +``` + +## Modes + +### Multiplexer Mode (default) + +Without `--no-mux`, the command runs inside a tmux session: + +- Session is created and attached +- Output is logged to a file +- You can detach (`Ctrl+B, d`) and reattach later +- Session persists even if the command completes + +### Direct Mode (`--no-mux`) + +With `--no-mux`, the command runs directly: + +- Output prints directly to your terminal +- No tmux session is created +- No log file is created +- Similar behavior to `docker exec` + +Use direct mode for quick one-off commands that don't need session persistence. + +## Error Handling + +If no instance exists for the branch, you'll see an error with a helpful hint: + +``` +Error: no instance found for branch "feat/auth" +hint: run 'hjk run feat/auth' to create one +``` + +## Use Cases + +### Interactive Shell + +```bash +# Start a persistent shell session +hjk exec feat/auth + +# Detach with Ctrl+B, d +# Reattach later with: +hjk attach feat/auth +``` + +### Running Tests + +```bash +# Run tests in a persistent session (can reattach if disconnected) +hjk exec feat/auth npm test + +# Or run directly for quick feedback +hjk exec feat/auth --no-mux npm test +``` + +### Build Commands + +```bash +# Run build in background +hjk exec feat/auth -d npm run build + +# Check build logs +hjk logs feat/auth +``` + +## See Also + +- [hjk run](run.md) - Create an instance +- [hjk agent](agent.md) - Start an agent session +- [hjk attach](attach.md) - Attach to an existing session +- [hjk logs](logs.md) - View session output +- [hjk kill](kill.md) - Kill a session diff --git a/docs/docs/reference/cli/run.md b/docs/docs/reference/cli/run.md index 2505bcd..ae15185 100644 --- a/docs/docs/reference/cli/run.md +++ b/docs/docs/reference/cli/run.md @@ -1,47 +1,50 @@ --- sidebar_position: 1 title: hjk run -description: Create an instance and session for a branch +description: Create an instance for a branch --- # hjk run -Create a new session within an instance for a specified branch. +Create a new instance (worktree + container) for a specified branch. ## Synopsis ```bash -hjk run [prompt] [flags] +hjk run [flags] ``` ## Description -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: +Creates a new instance for the specified branch. An instance consists of: + +1. A git worktree for the branch +2. A container with the worktree mounted at `/workspace` +3. A catalog entry tracking the instance + +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 is started. +This command only creates the instance. To start a session within the instance, use: -Unless `--detached` is specified, the terminal attaches to the session. All session output is captured to a log file regardless of attached/detached mode. +- [`hjk agent`](agent.md) - Start an agent session (Claude, Gemini, or Codex) +- [`hjk exec`](exec.md) - Execute a command or start a shell session -If an instance exists but is stopped, it is automatically restarted before creating the new session. +If an instance already exists for the branch, it is reused. If the instance is stopped, it is automatically restarted. ## Arguments | Argument | Description | |----------|-------------| | `branch` | Git branch name for the instance (required) | -| `prompt` | Instructions to pass to the agent (optional, only used with `--agent`) | ## Flags -| Flag | Short | Type | Default | Description | -|------|-------|------|---------|-------------| -| `--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 | -| `--image` | | string | | Use a container image instead of devcontainer | -| `--detached` | `-d` | bool | `false` | Create session but do not attach (run in background) | +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--image` | string | | Use a container image instead of devcontainer | ## Examples @@ -49,39 +52,43 @@ If an instance exists but is stopped, it is automatically restarted before creat # Auto-detect devcontainer.json (recommended) hjk run feat/auth -# Start Claude agent in devcontainer -hjk run feat/auth --agent claude "Implement JWT authentication" +# Use a specific container image (bypasses devcontainer) +hjk run feat/auth --image my-registry.io/custom-image:latest -# Create additional session in existing instance -hjk run feat/auth --agent gemini --name gemini-experiment +# Typical workflow: create instance, then start agent +hjk run feat/auth +hjk agent feat/auth claude --prompt "Implement JWT authentication" -# Create shell session with custom name -hjk run feat/auth --name debug-shell +# Or start a shell session +hjk run feat/auth +hjk exec feat/auth +``` -# Create detached sessions (run in background) -hjk run feat/auth --agent claude -d "Refactor the auth module" -hjk run feat/auth --agent claude -d "Write tests for auth module" +## Workflow -# Use a specific container image (bypasses devcontainer) -hjk run feat/auth --image my-registry.io/custom-image:latest +The typical workflow separates instance creation from session management: -# Use default agent from config -hjk run feat/auth --agent -``` +```bash +# Step 1: Create the instance +hjk run feat/auth -## Authentication +# Step 2: Start an agent session +hjk agent feat/auth claude --prompt "Your prompt here" -When using an agent, the command requires authentication to be configured first: +# Step 3: Later, attach to the session +hjk attach feat/auth +``` -- **Claude**: Run `hjk auth claude` first -- **Gemini**: Run `hjk auth gemini` first -- **Codex**: Run `hjk auth codex` first +This separation allows you to: -Authentication tokens are automatically injected into the container environment. +- Create instances without immediately starting sessions +- Choose between agent sessions (`hjk agent`) or shell sessions (`hjk exec`) +- Run quick commands without creating persistent sessions (`hjk exec --no-mux`) ## See Also -- [hjk attach](attach.md) - Attach to an existing session +- [hjk agent](agent.md) - Start an agent session +- [hjk exec](exec.md) - Execute commands or start shell sessions - [hjk ps](ps.md) - List instances and sessions -- [hjk logs](logs.md) - View session output -- [hjk auth](auth.md) - Configure agent authentication +- [hjk stop](stop.md) - Stop an instance +- [hjk rm](rm.md) - Remove an instance diff --git a/docs/docs/reference/environment.md b/docs/docs/reference/environment.md index 67c6a82..74fd9e7 100644 --- a/docs/docs/reference/environment.md +++ b/docs/docs/reference/environment.md @@ -103,7 +103,7 @@ When running agent sessions, Headjack injects credential environment variables b | `CODEX_AUTH_JSON` | Subscription | OAuth credentials JSON from `~/.codex/auth.json` | | `OPENAI_API_KEY` | API Key | OpenAI API key for pay-per-use billing | -These variables are set automatically when you run `hjk run --agent `. You configure which credential type to use via `hjk auth `. +These variables are set automatically when you run `hjk agent`. You configure which credential type to use via `hjk auth `. ## Keyring Environment Variables diff --git a/docs/docs/tutorials/custom-image.md b/docs/docs/tutorials/custom-image.md index 9a36407..d1d969c 100644 --- a/docs/docs/tutorials/custom-image.md +++ b/docs/docs/tutorials/custom-image.md @@ -226,10 +226,11 @@ Type `exit` to leave the container. ## Step 9: Use the Image with Headjack -Now use your custom image with Headjack. Specify it with the `--image` flag: +Now use your custom image with Headjack. Specify it with the `--image` flag when creating the instance: ```bash -hjk run feat/new-feature --image my-app-headjack:latest --agent claude "Add user authentication using PostgreSQL sessions" +hjk run feat/new-feature --image my-app-headjack:latest +hjk agent feat/new-feature claude --prompt "Add user authentication using PostgreSQL sessions" ``` The agent starts immediately with all dependencies available. No waiting for Python or Node.js installation. @@ -257,7 +258,8 @@ Add this to your shell profile (`.bashrc`, `.zshrc`) to make it permanent. Now all `hjk run` commands use your custom image automatically: ```bash -hjk run feat/new-feature --agent claude "Add user authentication" +hjk run feat/new-feature +hjk agent feat/new-feature claude --prompt "Add user authentication" ``` ## Step 11: Share with Your Team diff --git a/docs/docs/tutorials/first-coding-task.md b/docs/docs/tutorials/first-coding-task.md index 0801123..15631c1 100644 --- a/docs/docs/tutorials/first-coding-task.md +++ b/docs/docs/tutorials/first-coding-task.md @@ -50,23 +50,32 @@ Throw descriptive errors for invalid input. Add unit tests for the validation logic. ``` -## Step 3: Launch the Agent +## Step 3: Create the Instance -Create an instance and start the agent with your task: +First, create an instance for your feature branch: ```bash -hjk run feat/user-validation --agent claude "Add input validation to the createUser function in src/users.js. Validate that email is a valid format and name is non-empty. Throw descriptive errors for invalid input. Add unit tests for the validation logic." +hjk run feat/user-validation ``` You will see output indicating the instance was created: ``` Created instance abc123 for branch feat/user-validation +Instance abc123 ready for branch feat/user-validation ``` -Then your terminal attaches to the Claude session. Watch as Claude begins working. +## Step 4: Launch the Agent -## Step 4: Observe the Agent Working +Now start Claude with your task: + +```bash +hjk agent feat/user-validation claude --prompt "Add input validation to the createUser function in src/users.js. Validate that email is a valid format and name is non-empty. Throw descriptive errors for invalid input. Add unit tests for the validation logic." +``` + +Your terminal attaches to the Claude session. Watch as Claude begins working. + +## Step 5: Observe the Agent Working Claude will start by analyzing your codebase. You will see it: @@ -86,7 +95,7 @@ specific to user creation? Answer questions to guide the agent toward your preferred solution. -## Step 5: Detach and Monitor +## Step 6: Detach and Monitor If Claude is working independently and you want to do other work, detach from the session: @@ -123,7 +132,7 @@ hjk logs feat/user-validation happy-panda -f Press `Ctrl+C` to stop following. -## Step 6: Reattach and Review +## Step 7: Reattach and Review When you are ready to check on Claude directly, reattach: @@ -133,7 +142,7 @@ hjk attach feat/user-validation If Claude has finished, you will see a summary of what it accomplished. If it is still working, you can watch it continue or provide additional guidance. -## Step 7: Review the Changes +## Step 8: Review the Changes Once Claude indicates it has completed the task, review what it produced. Detach from the session if attached: @@ -160,12 +169,12 @@ npm test Alternatively, start a shell session in the same instance to explore: ```bash -hjk run feat/user-validation --name review-shell +hjk exec feat/user-validation ``` This opens a shell in the same container where the agent worked. You can run commands, inspect files, and verify the changes. -## Step 8: Iterate if Needed +## Step 9: Iterate if Needed If the changes need adjustments, you have several options: @@ -184,13 +193,13 @@ The email validation looks good, but please also check for maximum length (255 characters) and add a test case for that. ``` -**Option B: Start a new session with corrections** +**Option B: Start a new agent session with corrections** ```bash -hjk run feat/user-validation --agent claude "The email validation in createUser needs one adjustment: also validate maximum length of 255 characters. Add a test for this case." +hjk agent feat/user-validation claude --prompt "The email validation in createUser needs one adjustment: also validate maximum length of 255 characters. Add a test for this case." ``` -## Step 9: Commit the Work +## Step 10: Commit the Work When you are satisfied with the changes, commit them. You can do this from your host terminal after checking out the branch, or ask Claude to commit: @@ -212,7 +221,7 @@ git add -A git commit -m "feat(users): add input validation to createUser" ``` -## Step 10: Clean Up +## Step 11: Clean Up Stop the instance when you are finished: @@ -231,9 +240,11 @@ hjk rm feat/user-validation In this tutorial, we: - Wrote an effective prompt with context, goals, and constraints -- Launched an agent with a real coding task +- Created an instance with `hjk run` +- Launched an agent session with `hjk agent` and a real coding task - Observed how Claude analyzes code and implements changes - Monitored background work using logs +- Started a shell session with `hjk exec` to inspect changes - Reviewed changes in the git worktree - Iterated on the results with follow-up instructions diff --git a/docs/docs/tutorials/getting-started.md b/docs/docs/tutorials/getting-started.md index 97b9aa7..2cd34ad 100644 --- a/docs/docs/tutorials/getting-started.md +++ b/docs/docs/tutorials/getting-started.md @@ -106,7 +106,7 @@ Credentials stored securely. The credential persists across sessions and only needs to be configured once. -## Step 3: Create Your First Instance and Session +## Step 3: Create Your First Instance Now we are ready to spawn an agent. Navigate to a git repository where you want the agent to work: @@ -114,32 +114,48 @@ Now we are ready to spawn an agent. Navigate to a git repository where you want cd ~/projects/my-app ``` -Create an instance and spawn Claude with a task: +First, create an instance for your feature branch: ```bash -hjk run feat/add-login --agent claude "Add a login page to the application" +hjk run feat/add-login +``` + +This creates the isolated environment. You will see output like: + +``` +Created instance abc123 for branch feat/add-login +Instance abc123 ready for branch feat/add-login ``` Let us break down what happens: 1. **Branch setup**: Headjack creates a git worktree for `feat/add-login` if it does not exist 2. **Container creation**: A VM-isolated container is spawned with the worktree mounted at `/workspace` -3. **Session creation**: A terminal session is created inside the container -4. **Agent launch**: Claude Code starts with your prompt +3. **Catalog entry**: The instance is tracked for later management + +:::note +The first run may take a moment while the container image is pulled. Subsequent runs are faster. +::: -You will see output like: +## Step 4: Start an Agent Session -``` -Created instance abc123 for branch feat/add-login +Now start Claude with your task: + +```bash +hjk agent feat/add-login claude --prompt "Add a login page to the application" ``` -Then your terminal attaches to the session, and you will see Claude Code starting up and beginning to work on your task. +This creates a session running Claude Code with your prompt. Your terminal attaches to the session, and you will see Claude Code starting up and beginning to work on your task. -:::note -The first run may take a moment while the container image is pulled. Subsequent runs are faster. +:::tip +You can also start an agent without a prompt: +```bash +hjk agent feat/add-login claude +``` +This launches Claude interactively, ready for you to type instructions. ::: -## Step 4: Interact with the Agent +## Step 5: Interact with the Agent Once attached, you are in an interactive Claude Code session. The agent has full access to the repository files within its isolated environment. @@ -161,7 +177,7 @@ Ctrl+B, then d This is the tmux detach shortcut. It returns you to your host terminal while the agent continues running in the background. All output is captured to a log file for later review. -## Step 5: View and Manage Sessions +## Step 6: View and Manage Sessions With the agent running in the background, let us explore session management. @@ -192,7 +208,7 @@ Output: ``` SESSION TYPE STATUS CREATED ACCESSED -claude-main claude detached 2m ago just now +happy-panda claude detached 2m ago just now ``` ### Reattach to a Session @@ -209,7 +225,17 @@ This attaches to the most recently accessed session for that branch. You can als hjk attach ``` -## Step 6: Stop and Clean Up +### Start a Shell Session + +Need to debug or inspect files manually? Start a shell session: + +```bash +hjk exec feat/add-login +``` + +This opens a bash shell inside the container. You can run this alongside your agent session. + +## Step 7: Stop and Clean Up When you are finished working on a feature, you have several options. @@ -225,7 +251,7 @@ hjk stop feat/add-login Stopped instance abc123 for branch feat/add-login ``` -Stopped instances can be resumed later with `hjk run`. When you run a command against a stopped instance, Headjack automatically restarts it. +Stopped instances can be resumed later. When you run a command against a stopped instance, Headjack automatically restarts it. ### Remove the Instance Entirely @@ -252,9 +278,10 @@ Removing an instance deletes uncommitted changes in the worktree. Ensure you hav In this tutorial, we: - Installed Headjack and configured Claude Code authentication -- Created an isolated instance tied to a feature branch -- Spawned an agent session and interacted with Claude Code +- Created an isolated instance tied to a feature branch with `hjk run` +- Started an agent session with `hjk agent` - Detached from and reattached to running sessions +- Started shell sessions with `hjk exec` - Stopped and removed instances when finished Each agent runs in complete isolation with its own container and worktree. This means you can safely run multiple agents on different branches without them interfering with each other. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 392b70a..c8c780b 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -92,6 +92,8 @@ const sidebars: SidebarsConfig = { }, items: [ 'reference/cli/run', + 'reference/cli/agent', + 'reference/cli/exec', 'reference/cli/attach', 'reference/cli/ps', 'reference/cli/logs', diff --git a/integration/testdata/scripts/exec_nomux.txtar b/integration/testdata/scripts/exec_nomux.txtar new file mode 100644 index 0000000..330c896 --- /dev/null +++ b/integration/testdata/scripts/exec_nomux.txtar @@ -0,0 +1,38 @@ +# Test hjk exec --no-mux command for direct container execution +# This test requires a container runtime + +# Create a test git repository with unique name +exec git init testrepo-exec +cd testrepo-exec +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/exec-nomux + +# Create an instance first +exec hjk run test/exec-nomux +stdout 'Instance .* ready' +! stderr . + +# Wait for container to be running +wait_running test/exec-nomux + +# Run a simple command with --no-mux (should print output directly) +exec hjk exec test/exec-nomux --no-mux echo hello +stdout 'hello' +! stderr . + +# Run pwd to verify we're in the right directory +exec hjk exec test/exec-nomux --no-mux pwd +stdout '/' +! stderr . + +# Verify no sessions were created (--no-mux bypasses tmux) +exec hjk ps test/exec-nomux +stdout 'No sessions found' +! stderr . + +# Clean up +exec hjk rm test/exec-nomux --force +stdout 'Removed' +! stderr . diff --git a/integration/testdata/scripts/kill.txtar b/integration/testdata/scripts/kill.txtar index fb19f65..945a2e5 100644 --- a/integration/testdata/scripts/kill.txtar +++ b/integration/testdata/scripts/kill.txtar @@ -9,14 +9,19 @@ exec git config user.name 'Test User' exec git commit --allow-empty -m 'initial commit' exec git branch test/kill-session -# Create an instance with a named session -exec hjk run test/kill-session -d --name my-session -stdout 'Created' +# Create an instance first +exec hjk run test/kill-session +stdout 'Instance .* ready' ! stderr . # Wait for container to be running wait_running test/kill-session +# Create a named session using exec +exec hjk exec test/kill-session -d --name my-session +stdout 'Created' +! stderr . + # Verify the session exists exec hjk ps test/kill-session stdout 'my-session' diff --git a/integration/testdata/scripts/no_instance_error.txtar b/integration/testdata/scripts/no_instance_error.txtar new file mode 100644 index 0000000..11d9272 --- /dev/null +++ b/integration/testdata/scripts/no_instance_error.txtar @@ -0,0 +1,25 @@ +# Test error messages when no instance exists +# This test verifies helpful hints are provided + +# Create a test git repository with unique name +exec git init testrepo-noinstance +cd testrepo-noinstance +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/no-instance + +# Verify no instances exist +exec hjk ps +stdout 'No instances found' +! stderr . + +# Try to exec without an instance - should error with hint +! exec hjk exec test/no-instance +stderr 'no instance found' +stderr 'hjk run' + +# Try to exec --no-mux without an instance - should error with hint +! exec hjk exec test/no-instance --no-mux ls +stderr 'no instance found' +stderr 'hjk run' diff --git a/integration/testdata/scripts/run_detached.txtar b/integration/testdata/scripts/run_detached.txtar index 6fb33d2..b5ee5fe 100644 --- a/integration/testdata/scripts/run_detached.txtar +++ b/integration/testdata/scripts/run_detached.txtar @@ -1,4 +1,4 @@ -# Test hjk run with detached mode and full instance lifecycle +# Test hjk run, exec, and full instance lifecycle # This test requires a container runtime # Create a test git repository with unique name @@ -16,21 +16,31 @@ exec hjk ps stdout 'No instances found' ! stderr . -# Run instance in detached mode with shell session -exec hjk run test/integration-lifecycle -d -stdout 'Created instance' -stdout 'detached' +# Create instance (no session - just worktree + container) +exec hjk run test/integration-lifecycle +stdout 'Instance .* ready for branch' ! stderr . # Wait for container to be running wait_running test/integration-lifecycle -# Verify instance is listed and running +# Verify instance is listed and running (with 0 sessions) exec hjk ps stdout 'test/integration-lifecycle' stdout 'running' ! stderr . +# Verify no sessions exist yet +exec hjk ps test/integration-lifecycle +stdout 'No sessions found' +! stderr . + +# Create a shell session in detached mode using exec +exec hjk exec test/integration-lifecycle -d +stdout 'Created session' +stdout 'detached' +! stderr . + # List sessions for the instance exec hjk ps test/integration-lifecycle stdout 'SESSION' diff --git a/internal/cmd/agent.go b/internal/cmd/agent.go new file mode 100644 index 0000000..c6a1ad6 --- /dev/null +++ b/internal/cmd/agent.go @@ -0,0 +1,250 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/jmgilman/headjack/internal/auth" + "github.com/jmgilman/headjack/internal/config" + "github.com/jmgilman/headjack/internal/instance" + "github.com/jmgilman/headjack/internal/keychain" +) + +var agentCmd = &cobra.Command{ + Use: "agent [agent_name]", + Short: "Start an agent session in an existing instance", + Long: `Start an agent session within an existing instance for the specified branch. + +The instance must already exist (created with 'hjk run'). This command creates +a new session running the specified agent (claude, gemini, or codex) and attaches +to it unless --detached is specified. + +If agent_name is not specified, the default agent from configuration is used. +Set the default with 'hjk config default.agent '. + +All session output is captured to a log file regardless of attached/detached mode.`, + Example: ` # Start default agent on existing instance + hjk agent feat/auth + + # Start Claude agent explicitly + hjk agent feat/auth claude + + # Start agent with a prompt + hjk agent feat/auth --prompt "Implement JWT authentication" + hjk agent feat/auth claude --prompt "Implement JWT authentication" + + # Start Gemini agent with custom session name + hjk agent feat/auth gemini --name auth-session + + # Start agent in detached mode (run in background) + hjk agent feat/auth -d --prompt "Refactor the auth module"`, + Args: cobra.RangeArgs(1, 2), + RunE: runAgentCmd, +} + +// agentFlags holds parsed flags for the agent command. +type agentFlags struct { + sessionName string + detached bool + prompt string +} + +// parseAgentFlags extracts and validates flags from the command. +func parseAgentFlags(cmd *cobra.Command) (*agentFlags, error) { + sessionName, err := cmd.Flags().GetString("name") + if err != nil { + return nil, fmt.Errorf("get name flag: %w", err) + } + detached, err := cmd.Flags().GetBool("detached") + if err != nil { + return nil, fmt.Errorf("get detached flag: %w", err) + } + prompt, err := cmd.Flags().GetString("prompt") + if err != nil { + return nil, fmt.Errorf("get prompt flag: %w", err) + } + + return &agentFlags{ + sessionName: sessionName, + detached: detached, + prompt: prompt, + }, nil +} + +// agentAuthSpec maps agent names to their providers. +type agentAuthSpec struct { + provider func() auth.Provider + notConfiguredMsg string +} + +var agentAuthSpecs = map[string]agentAuthSpec{ + "claude": { + provider: func() auth.Provider { return auth.NewClaudeProvider() }, + notConfiguredMsg: "claude auth not configured: run 'hjk auth claude' first", + }, + "gemini": { + provider: func() auth.Provider { return auth.NewGeminiProvider() }, + notConfiguredMsg: "gemini auth not configured: run 'hjk auth gemini' first", + }, + "codex": { + provider: func() auth.Provider { return auth.NewCodexProvider() }, + notConfiguredMsg: "codex auth not configured: run 'hjk auth codex' first", + }, +} + +// injectAuthCredential retrieves the credential for the agent and configures the session. +func injectAuthCredential(agent string, cfg *instance.CreateSessionConfig) error { + spec, ok := agentAuthSpecs[agent] + if !ok { + return nil + } + + storage, err := keychain.New() + if err != nil { + return fmt.Errorf("initialize credential storage: %w", err) + } + + provider := spec.provider() + cred, err := provider.Load(storage) + if err != nil { + if errors.Is(err, keychain.ErrNotFound) { + return errors.New(spec.notConfiguredMsg) + } + return fmt.Errorf("load %s credential: %w", agent, err) + } + + info := provider.Info() + + // Set environment variable based on credential type + switch cred.Type { + case auth.CredentialTypeSubscription: + cfg.Env = append(cfg.Env, info.SubscriptionEnvVar+"="+cred.Value) + cfg.CredentialType = string(auth.CredentialTypeSubscription) + cfg.RequiresAgentSetup = info.RequiresContainerSetup + case auth.CredentialTypeAPIKey: + cfg.Env = append(cfg.Env, info.APIKeyEnvVar+"="+cred.Value) + cfg.CredentialType = string(auth.CredentialTypeAPIKey) + cfg.RequiresAgentSetup = false // API keys don't need file setup + default: + return fmt.Errorf("unknown credential type: %s", cred.Type) + } + + return nil +} + +// buildAgentCommand builds the command for launching an agent. +func buildAgentCommand(agent, prompt string) []string { + cmd := []string{agent} + if prompt != "" { + cmd = append(cmd, prompt) + } + return cmd +} + +// resolveAgentName determines the agent name from args or config default. +func resolveAgentName(ctx context.Context, args []string) (string, error) { + if len(args) > 1 { + return args[1], nil + } + + // Get default agent from config + loader := LoaderFromContext(ctx) + if loader == nil { + return "", errors.New("no default agent configured and none specified\nhint: run 'hjk config default.agent ' to set a default") + } + cfg, err := loader.Load() + if err != nil { + return "", fmt.Errorf("load config: %w", err) + } + if cfg.Default.Agent == "" { + return "", errors.New("no default agent configured and none specified\nhint: run 'hjk config default.agent ' to set a default") + } + return cfg.Default.Agent, nil +} + +func runAgentCmd(cmd *cobra.Command, args []string) error { + branch := args[0] + + mgr, err := requireManager(cmd.Context()) + if err != nil { + return err + } + + flags, err := parseAgentFlags(cmd) + if err != nil { + return err + } + + agentName, err := resolveAgentName(cmd.Context(), args) + if err != nil { + return err + } + + // Get existing instance (do NOT create) + inst, err := getInstanceByBranch(cmd.Context(), mgr, branch) + if err != nil { + return err + } + + // Validate agent name + if !config.IsValidAgent(agentName) { + return fmt.Errorf("invalid agent %q (valid: %s)", agentName, formatList(config.ValidAgentNames())) + } + + // Build session config + sessionCfg := &instance.CreateSessionConfig{ + Type: agentName, + Name: flags.sessionName, + Command: buildAgentCommand(agentName, flags.prompt), + } + + // Inject agent-specific environment variables from config + if loader := LoaderFromContext(cmd.Context()); loader != nil { + for k, v := range loader.GetAgentEnv(agentName) { + sessionCfg.Env = append(sessionCfg.Env, k+"="+v) + } + } + + // Inject authentication credentials from keychain + if authErr := injectAuthCredential(agentName, sessionCfg); authErr != nil { + return authErr + } + + // Create session + session, err := mgr.CreateSession(cmd.Context(), inst.ID, sessionCfg) + if err != nil { + if errors.Is(err, instance.ErrSessionExists) { + return fmt.Errorf("session %q already exists in instance %s", flags.sessionName, inst.ID) + } + var notRunningErr *instance.NotRunningError + if errors.As(err, ¬RunningErr) { + hint := formatAgentNotRunningHint(cmd, notRunningErr) + if hint != "" { + return fmt.Errorf("create session: %w\nhint: %s", err, hint) + } + } + return fmt.Errorf("create session: %w", err) + } + + if flags.detached { + fmt.Printf("Created session %s in instance %s (detached)\n", session.Name, inst.ID) + return nil + } + + return mgr.AttachSession(cmd.Context(), inst.ID, session.Name) +} + +func formatAgentNotRunningHint(cmd *cobra.Command, err *instance.NotRunningError) string { + return formatNotRunningHint(cmd, err) +} + +func init() { + rootCmd.AddCommand(agentCmd) + + agentCmd.Flags().StringP("name", "n", "", "override auto-generated session name") + agentCmd.Flags().BoolP("detached", "d", false, "create session but don't attach (run in background)") + agentCmd.Flags().StringP("prompt", "p", "", "initial prompt to send to the agent") +} diff --git a/internal/cmd/attach.go b/internal/cmd/attach.go index e1b73ba..7b6ffc9 100644 --- a/internal/cmd/attach.go +++ b/internal/cmd/attach.go @@ -68,7 +68,7 @@ func attachGlobalMRU(cmd *cobra.Command, mgr *instance.Manager) error { // attachInstanceMRU attaches to the most recently accessed session for a specific instance. func attachInstanceMRU(cmd *cobra.Command, mgr *instance.Manager, branch string) error { - inst, err := getInstanceByBranch(cmd.Context(), mgr, branch, "no instance found for branch %q (use 'hjk run' to create one)") + inst, err := getInstanceByBranch(cmd.Context(), mgr, branch) if err != nil { return err } @@ -86,7 +86,7 @@ func attachInstanceMRU(cmd *cobra.Command, mgr *instance.Manager, branch string) // attachExplicitSession attaches to a specific session by name. func attachExplicitSession(cmd *cobra.Command, mgr *instance.Manager, branch, sessionName string) error { - inst, err := getInstanceByBranch(cmd.Context(), mgr, branch, "no instance found for branch %q (use 'hjk run' to create one)") + inst, err := getInstanceByBranch(cmd.Context(), mgr, branch) if err != nil { return err } diff --git a/internal/cmd/exec.go b/internal/cmd/exec.go new file mode 100644 index 0000000..013b3c6 --- /dev/null +++ b/internal/cmd/exec.go @@ -0,0 +1,141 @@ +package cmd + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/jmgilman/headjack/internal/instance" +) + +var execCmd = &cobra.Command{ + Use: "exec [command...]", + Short: "Execute a command in an instance's container", + Long: `Execute a command within an existing instance's container. + +By default, a new tmux session is created and attached. Use --no-mux to bypass +the multiplexer and execute the command directly (like 'docker exec'). + +If no command is specified, the default shell is started. + +All session output is captured to a log file (when using tmux).`, + Example: ` # Start a shell session in tmux + hjk exec feat/auth + + # Run a command in tmux + hjk exec feat/auth npm test + + # Run a command directly (bypass tmux) + hjk exec feat/auth --no-mux ls -la + + # Start shell directly (bypass tmux) + hjk exec feat/auth --no-mux + + # Run with custom session name + hjk exec feat/auth --name build-session npm run build`, + Args: cobra.MinimumNArgs(1), + RunE: runExecCmd, + DisableFlagParsing: false, +} + +// execFlags holds parsed flags for the exec command. +type execFlags struct { + noMux bool + sessionName string + detached bool +} + +// parseExecFlags extracts and validates flags from the command. +func parseExecFlags(cmd *cobra.Command) (*execFlags, error) { + noMux, err := cmd.Flags().GetBool("no-mux") + if err != nil { + return nil, fmt.Errorf("get no-mux flag: %w", err) + } + sessionName, err := cmd.Flags().GetString("name") + if err != nil { + return nil, fmt.Errorf("get name flag: %w", err) + } + detached, err := cmd.Flags().GetBool("detached") + if err != nil { + return nil, fmt.Errorf("get detached flag: %w", err) + } + + return &execFlags{ + noMux: noMux, + sessionName: sessionName, + detached: detached, + }, nil +} + +func runExecCmd(cmd *cobra.Command, args []string) error { + branch := args[0] + cmdArgs := args[1:] // May be empty (means shell) + + mgr, err := requireManager(cmd.Context()) + if err != nil { + return err + } + + flags, err := parseExecFlags(cmd) + if err != nil { + return err + } + + // Get existing instance (do NOT create) + inst, err := getInstanceByBranch(cmd.Context(), mgr, branch) + if err != nil { + return err + } + + if flags.noMux { + // Direct execution (bypasses multiplexer entirely) + // Only use interactive mode when starting a shell (no command specified) + // Running a specific command doesn't need TTY allocation + return mgr.Attach(cmd.Context(), inst.ID, instance.AttachConfig{ + Command: cmdArgs, // Empty = shell + Interactive: len(cmdArgs) == 0, // Interactive only for shell + }) + } + + // Create tmux session for the command + sessionCfg := &instance.CreateSessionConfig{ + Type: "shell", + Name: flags.sessionName, + Command: cmdArgs, // Empty = shell + } + + session, err := mgr.CreateSession(cmd.Context(), inst.ID, sessionCfg) + if err != nil { + if errors.Is(err, instance.ErrSessionExists) { + return fmt.Errorf("session %q already exists in instance %s", flags.sessionName, inst.ID) + } + var notRunningErr *instance.NotRunningError + if errors.As(err, ¬RunningErr) { + hint := formatExecNotRunningHint(cmd, notRunningErr) + if hint != "" { + return fmt.Errorf("create session: %w\nhint: %s", err, hint) + } + } + return fmt.Errorf("create session: %w", err) + } + + if flags.detached { + fmt.Printf("Created session %s in instance %s (detached)\n", session.Name, inst.ID) + return nil + } + + return mgr.AttachSession(cmd.Context(), inst.ID, session.Name) +} + +func formatExecNotRunningHint(cmd *cobra.Command, err *instance.NotRunningError) string { + return formatNotRunningHint(cmd, err) +} + +func init() { + rootCmd.AddCommand(execCmd) + + execCmd.Flags().Bool("no-mux", false, "bypass tmux and execute directly") + execCmd.Flags().StringP("name", "n", "", "override auto-generated session name (ignored with --no-mux)") + execCmd.Flags().BoolP("detached", "d", false, "create session but don't attach (ignored with --no-mux)") +} diff --git a/internal/cmd/helpers.go b/internal/cmd/helpers.go index 72c9280..cbb30bd 100644 --- a/internal/cmd/helpers.go +++ b/internal/cmd/helpers.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" + "github.com/spf13/cobra" + "github.com/jmgilman/headjack/internal/config" "github.com/jmgilman/headjack/internal/instance" ) @@ -45,7 +47,35 @@ func resolveBaseImage(ctx context.Context, override string) string { return "" } -func getInstanceByBranch(ctx context.Context, mgr *instance.Manager, branch, notFoundMsg string) (*instance.Instance, error) { +// runtimeLogsCommand returns the command to view container logs for the given runtime. +func runtimeLogsCommand(runtimeName, containerID string) string { + switch runtimeName { + case runtimeNameDocker: + return "docker logs " + containerID + default: + return "podman logs " + containerID + } +} + +// formatNotRunningHint formats a hint for when a container is not running. +func formatNotRunningHint(cmd *cobra.Command, err *instance.NotRunningError) string { + if err == nil || err.ContainerID == "" { + return "" + } + runtimeName := runtimeNameDocker + if cfg := ConfigFromContext(cmd.Context()); cfg != nil && cfg.Runtime.Name != "" { + runtimeName = cfg.Runtime.Name + } + logsCmd := runtimeLogsCommand(runtimeName, err.ContainerID) + if logsCmd == "" { + return fmt.Sprintf("container %s is %s", err.ContainerID, err.Status) + } + return fmt.Sprintf("container %s is %s; check logs with `%s`", err.ContainerID, err.Status, logsCmd) +} + +// getInstanceByBranch gets an existing instance by branch, returning an error with hint if not found. +// If the instance is stopped, it will be automatically restarted. +func getInstanceByBranch(ctx context.Context, mgr *instance.Manager, branch string) (*instance.Instance, error) { repoPath, err := repoPath() if err != nil { return nil, err @@ -53,11 +83,24 @@ func getInstanceByBranch(ctx context.Context, mgr *instance.Manager, branch, not inst, err := mgr.GetByBranch(ctx, repoPath, branch) if err != nil { - if errors.Is(err, instance.ErrNotFound) && notFoundMsg != "" { - return nil, fmt.Errorf(notFoundMsg, branch) + if errors.Is(err, instance.ErrNotFound) { + return nil, fmt.Errorf("no instance found for branch %q\nhint: run 'hjk run %s' to create one", branch, branch) } return nil, fmt.Errorf("get instance: %w", err) } + // Auto-restart if stopped + if inst.Status == instance.StatusStopped { + if startErr := mgr.Start(ctx, inst.ID); startErr != nil { + return nil, fmt.Errorf("start stopped instance: %w", startErr) + } + fmt.Printf("Restarted instance %s for branch %s\n", inst.ID, inst.Branch) + // Refresh the instance to get updated status + inst, err = mgr.GetByBranch(ctx, repoPath, branch) + if err != nil { + return nil, fmt.Errorf("get restarted instance: %w", err) + } + } + return inst, nil } diff --git a/internal/cmd/kill.go b/internal/cmd/kill.go index 10b6263..46c6c0e 100644 --- a/internal/cmd/kill.go +++ b/internal/cmd/kill.go @@ -36,7 +36,7 @@ func runKillCmd(cmd *cobra.Command, args []string) error { return err } - inst, err := getInstanceByBranch(cmd.Context(), mgr, branch, "") + inst, err := getInstanceByBranch(cmd.Context(), mgr, branch) if err != nil { return err } diff --git a/internal/cmd/logs.go b/internal/cmd/logs.go index dddc2a5..fe9430f 100644 --- a/internal/cmd/logs.go +++ b/internal/cmd/logs.go @@ -62,7 +62,7 @@ func runLogsCmd(cmd *cobra.Command, args []string) error { return err } - inst, err := getInstanceByBranch(cmd.Context(), mgr, branch, "") + inst, err := getInstanceByBranch(cmd.Context(), mgr, branch) if err != nil { return err } diff --git a/internal/cmd/ps.go b/internal/cmd/ps.go index 43f916d..f1ea2ae 100644 --- a/internal/cmd/ps.go +++ b/internal/cmd/ps.go @@ -122,7 +122,7 @@ func listSessions(cmd *cobra.Command, branch string) error { return err } - inst, err := getInstanceByBranch(cmd.Context(), mgr, branch, "") + inst, err := getInstanceByBranch(cmd.Context(), mgr, branch) if err != nil { return err } diff --git a/internal/cmd/rm.go b/internal/cmd/rm.go index fb4b3a0..4669fc3 100644 --- a/internal/cmd/rm.go +++ b/internal/cmd/rm.go @@ -39,7 +39,7 @@ WARNING: This deletes uncommitted work in the worktree.`, return err } - inst, err := getInstanceByBranch(cmd.Context(), mgr, branch, "no instance found for branch %q") + inst, err := getInstanceByBranch(cmd.Context(), mgr, branch) if err != nil { return err } diff --git a/internal/cmd/run.go b/internal/cmd/run.go index ddb1e47..1e0862c 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -7,55 +7,37 @@ import ( "github.com/spf13/cobra" - "github.com/jmgilman/headjack/internal/auth" - "github.com/jmgilman/headjack/internal/config" "github.com/jmgilman/headjack/internal/container" "github.com/jmgilman/headjack/internal/devcontainer" "github.com/jmgilman/headjack/internal/instance" - "github.com/jmgilman/headjack/internal/keychain" ) -// agentDefaultSentinel is the sentinel value used when --agent flag is specified without a value. -const agentDefaultSentinel = "default" - var runCmd = &cobra.Command{ - Use: "run [prompt]", - Short: "Create a new session (and instance if needed), then attach", - Long: `Create a new session within an instance for the specified branch. + Use: "run ", + Short: "Create a new instance for the specified branch", + Long: `Create a new instance (worktree + container) for the specified branch. -If no instance exists for the branch, one is created first. The container -environment is determined by: +If an instance already exists for the branch, it is reused (and restarted if +stopped). 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 -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.`, +This command only creates the instance. To start a session, use: + - 'hjk agent ' to start an agent session + - 'hjk exec ' to start a shell session`, Example: ` # Auto-detect devcontainer.json (recommended) - headjack run feat/auth - - # Start Claude agent in devcontainer - headjack run feat/auth --agent claude "Implement JWT authentication" - - # Additional session in existing instance - headjack run feat/auth --agent gemini --name gemini-experiment - - # Shell session with custom name - headjack run feat/auth --name debug-shell - - # Detached sessions (run in background) - headjack run feat/auth --agent claude -d "Refactor the auth module" - headjack run feat/auth --agent claude -d "Write tests for auth module" + hjk run feat/auth # Use a specific container image (bypasses devcontainer) - headjack run feat/auth --image my-registry.io/custom-image:latest`, - Args: cobra.RangeArgs(1, 2), + hjk run feat/auth --image my-registry.io/custom-image:latest + + # Typical workflow: create instance, then start agent + hjk run feat/auth + hjk agent feat/auth claude --prompt "Implement JWT authentication"`, + Args: cobra.ExactArgs(1), RunE: runRunCmd, } @@ -63,9 +45,6 @@ All session output is captured to a log file regardless of attached/detached mod type runFlags struct { image string imageExplicit bool // true if --image was explicitly passed - agent string - sessionName string - detached bool } // parseRunFlags extracts and validates flags from the command. @@ -76,125 +55,14 @@ func parseRunFlags(cmd *cobra.Command) (*runFlags, error) { } imageExplicit := cmd.Flags().Changed("image") - agent, err := cmd.Flags().GetString("agent") - if err != nil { - return nil, fmt.Errorf("get agent flag: %w", err) - } - sessionName, err := cmd.Flags().GetString("name") - if err != nil { - return nil, fmt.Errorf("get name flag: %w", err) - } - detached, err := cmd.Flags().GetBool("detached") - if err != nil { - return nil, fmt.Errorf("get detached flag: %w", err) - } - image = resolveBaseImage(cmd.Context(), image) return &runFlags{ image: image, imageExplicit: imageExplicit, - agent: agent, - sessionName: sessionName, - detached: detached, }, nil } -// buildSessionConfig builds a session configuration from flags and args. -func buildSessionConfig(cmd *cobra.Command, flags *runFlags, args []string) (*instance.CreateSessionConfig, error) { - cfg := &instance.CreateSessionConfig{ - Type: "shell", - Name: flags.sessionName, - } - - if flags.agent == "" { - return cfg, nil - } - - agent, err := resolveAgent(cmd, flags.agent) - if err != nil { - return nil, err - } - - cfg.Type = agent - cfg.Command = buildAgentCommand(agent, args) - - // Inject agent-specific environment variables from config - if loader := LoaderFromContext(cmd.Context()); loader != nil { - for k, v := range loader.GetAgentEnv(agent) { - cfg.Env = append(cfg.Env, k+"="+v) - } - } - - // Inject authentication credentials from keychain - if err := injectAuthCredential(agent, cfg); err != nil { - return nil, err - } - - return cfg, nil -} - -// agentAuthSpec maps agent names to their providers. -type agentAuthSpec struct { - provider func() auth.Provider - notConfiguredMsg string -} - -var agentAuthSpecs = map[string]agentAuthSpec{ - "claude": { - provider: func() auth.Provider { return auth.NewClaudeProvider() }, - notConfiguredMsg: "claude auth not configured: run 'hjk auth claude' first", - }, - "gemini": { - provider: func() auth.Provider { return auth.NewGeminiProvider() }, - notConfiguredMsg: "gemini auth not configured: run 'hjk auth gemini' first", - }, - "codex": { - provider: func() auth.Provider { return auth.NewCodexProvider() }, - notConfiguredMsg: "codex auth not configured: run 'hjk auth codex' first", - }, -} - -// injectAuthCredential retrieves the credential for the agent and configures the session. -func injectAuthCredential(agent string, cfg *instance.CreateSessionConfig) error { - spec, ok := agentAuthSpecs[agent] - if !ok { - return nil - } - - storage, err := keychain.New() - if err != nil { - return fmt.Errorf("initialize credential storage: %w", err) - } - - provider := spec.provider() - cred, err := provider.Load(storage) - if err != nil { - if errors.Is(err, keychain.ErrNotFound) { - return errors.New(spec.notConfiguredMsg) - } - return fmt.Errorf("load %s credential: %w", agent, err) - } - - info := provider.Info() - - // Set environment variable based on credential type - switch cred.Type { - case auth.CredentialTypeSubscription: - cfg.Env = append(cfg.Env, info.SubscriptionEnvVar+"="+cred.Value) - cfg.CredentialType = string(auth.CredentialTypeSubscription) - cfg.RequiresAgentSetup = info.RequiresContainerSetup - case auth.CredentialTypeAPIKey: - cfg.Env = append(cfg.Env, info.APIKeyEnvVar+"="+cred.Value) - cfg.CredentialType = string(auth.CredentialTypeAPIKey) - cfg.RequiresAgentSetup = false // API keys don't need file setup - default: - return fmt.Errorf("unknown credential type: %s", cred.Type) - } - - return nil -} - func runRunCmd(cmd *cobra.Command, args []string) error { branch := args[0] @@ -218,56 +86,8 @@ func runRunCmd(cmd *cobra.Command, args []string) error { return err } - sessionCfg, err := buildSessionConfig(cmd, flags, args) - if err != nil { - return err - } - - session, err := mgr.CreateSession(cmd.Context(), inst.ID, sessionCfg) - if err != nil { - if errors.Is(err, instance.ErrSessionExists) { - return fmt.Errorf("session %q already exists in instance %s", flags.sessionName, inst.ID) - } - var notRunningErr *instance.NotRunningError - if errors.As(err, ¬RunningErr) { - hint := formatInstanceNotRunningHint(cmd, notRunningErr) - if hint != "" { - return fmt.Errorf("create session: %w\nhint: %s", err, hint) - } - } - return fmt.Errorf("create session: %w", err) - } - - if flags.detached { - fmt.Printf("Created session %s in instance %s (detached)\n", session.Name, inst.ID) - return nil - } - - return mgr.AttachSession(cmd.Context(), inst.ID, session.Name) -} - -func formatInstanceNotRunningHint(cmd *cobra.Command, err *instance.NotRunningError) string { - if err == nil || err.ContainerID == "" { - return "" - } - runtimeName := runtimeNameDocker - if cfg := ConfigFromContext(cmd.Context()); cfg != nil && cfg.Runtime.Name != "" { - runtimeName = cfg.Runtime.Name - } - logsCmd := runtimeLogsCommand(runtimeName, err.ContainerID) - if logsCmd == "" { - return fmt.Sprintf("container %s is %s", err.ContainerID, err.Status) - } - return fmt.Sprintf("container %s is %s; check logs with `%s`", err.ContainerID, err.Status, logsCmd) -} - -func runtimeLogsCommand(runtimeName, containerID string) string { - switch runtimeName { - case runtimeNameDocker: - return "docker logs " + containerID - default: - return "podman logs " + containerID - } + fmt.Printf("Instance %s ready for branch %s\n", inst.ID, inst.Branch) + return nil } // getOrCreateInstance retrieves an existing instance or creates a new one. @@ -409,43 +229,8 @@ func createDevcontainerRuntime(cmd *cobra.Command, runtimeName string) container ) } -// resolveAgent resolves the agent name, handling the default sentinel. -func resolveAgent(cmd *cobra.Command, agent string) (string, error) { - if agent == agentDefaultSentinel { - cfg := ConfigFromContext(cmd.Context()) - if cfg != nil && cfg.Default.Agent != "" { - return cfg.Default.Agent, nil - } - return "", errors.New("--agent specified without value but no default.agent configured") - } - - // Validate agent name - if !config.IsValidAgent(agent) { - return "", fmt.Errorf("invalid agent %q (valid: %s)", agent, formatList(config.ValidAgentNames())) - } - - return agent, nil -} - -// buildAgentCommand builds the command for launching an agent. -func buildAgentCommand(agent string, args []string) []string { - cmd := []string{agent} - if len(args) > 1 { - cmd = append(cmd, args[1]) - } - return cmd -} - func init() { rootCmd.AddCommand(runCmd) - 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("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") - if agentFlag != nil { - agentFlag.NoOptDefVal = agentDefaultSentinel - } } diff --git a/internal/cmd/stop.go b/internal/cmd/stop.go index 08fa7db..a27c45b 100644 --- a/internal/cmd/stop.go +++ b/internal/cmd/stop.go @@ -22,7 +22,7 @@ The worktree is preserved and the instance can be resumed later with 'hjk run'.` return err } - inst, err := getInstanceByBranch(cmd.Context(), mgr, branch, "no instance found for branch %q") + inst, err := getInstanceByBranch(cmd.Context(), mgr, branch) if err != nil { return err } diff --git a/internal/container/common.go b/internal/container/common.go index 9ee7f0f..84d9f1a 100644 --- a/internal/container/common.go +++ b/internal/container/common.go @@ -91,15 +91,14 @@ func (r *baseRuntime) Exec(ctx context.Context, id string, cfg *ExecConfig) erro return r.execInteractive(ctx, args) } - result, err := r.exec.Run(ctx, &exec.RunOptions{ - Name: r.binaryName, - Args: args, + // Non-interactive mode: connect stdout/stderr directly + _, err = r.exec.Run(ctx, &exec.RunOptions{ + Name: r.binaryName, + Args: args, + Stdout: os.Stdout, + Stderr: os.Stderr, }) - if err != nil { - return cliError("exec in container", result, err) - } - - return nil + return err } // Stop stops a running container gracefully.