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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .claude/skills/spawn-agent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,17 @@ PROJECT_NAME=$(basename "$GIT_ROOT") # e.g. stackai
WORKTREES_DIR="${AGENTS_HOME}" # from env var, e.g. ~/agents
NETWORK=claude-agent-net
IMAGE=claude-agent:wolfi
AGENT_MODEL=opus # model the headless agent runs
```

**Agent model.** The headless agent runs whatever `AGENT_MODEL` is passed via
`-e AGENT_MODEL`; it defaults to `opus` so the agent runs with the most capable
model out of the box. Do **not** rely on the host's `~/.claude/settings.json`
`model` preference — it is copied into the container but a headless agent must
not inherit a personal interactive setting, and the container's OAuth token may
belong to a different plan. Override per spawn when a lighter task warrants it
(`q spawn --model sonnet`, or `-e AGENT_MODEL=sonnet` on a raw `container run`).

Container names follow the pattern: `<project>-<sanitized-branch>`
Branch sanitization — each `/`, `_`, or space becomes a single `-`, lowercased:
```bash
Expand Down Expand Up @@ -143,6 +152,7 @@ container run -d --rm \
-v "${HOME}/.claude:/root/.claudenew:ro" \
-v "${HOME}/.claude.json:/root/.claudenew.json:ro" \
-e CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 \
-e "AGENT_MODEL=${AGENT_MODEL:-opus}" \
-e "CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CONTAINER_OAUTH_TOKEN}" \
claude-agent:wolfi \
--worktree "${BRANCH}" --task "${TASK}"
Expand Down Expand Up @@ -405,6 +415,8 @@ Full docs: https://github.com/apple/container/blob/main/docs/command-reference.m
the container exits so you can review the agent's work.
- **CLAUDE_CONTAINER_OAUTH_TOKEN** must be set — containers authenticate with this,
not the host Claude session.
- **AGENT_MODEL** controls the agent's Claude model (`opus` by default). The agent
does not inherit the host's `settings.json` model preference — pass it explicitly.
- The image `claude-agent:wolfi` must exist. If not: `cd <git-root>/config && make build`
- Multiple agents run in parallel — each gets a unique container + worktree.
- Branch names with `/` (e.g., `feat/auth`) are valid for git; sanitized for container
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ uv run pytest -k test_spawn # single test by name
# Acceptance tests only (Gherkin/BDD, local — no real containers)
make acceptance-test

# End-to-end tests (real containers, opt-in, local-only — see docs/agents/e2e-tests.md)
make e2e-test

# Mutation testing gate (≥ 70% kill rate)
make mutation-ci-threshold

Expand Down
7 changes: 7 additions & 0 deletions app/cli/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ test-all:
eval-skills:
claude -p "/skill-creator:skill-creator run evals for the spawn-agent skill at ~/.claude/skills/spawn-agent/"

# End-to-end container tests — opt-in, local-only (needs Apple Container CLI).
# Spawns real agent containers; the Claude round-trip spends Claude credits.
# Excluded from CI and from `local-qa` by design.
.PHONY: e2e-test
e2e-test:
STACKAI_E2E=1 uv run pytest tests/e2e -v -m e2e

# Pre-PR quality gate
local-qa: acceptance-test eval-skills

Expand Down
3 changes: 3 additions & 0 deletions app/cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ dev = [
[tool.pytest.ini_options]
testpaths = ["tests", "tests/acceptance"]
python_files = ["test_*.py", "*_steps.py"]
markers = [
"e2e: real-container end-to-end tests (opt-in via STACKAI_E2E=1, local-only)",
]

[tool.mutmut]
paths_to_mutate = ["src/container_cli/"]
Expand Down
41 changes: 15 additions & 26 deletions app/cli/src/container_cli/commands/agents.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,26 @@
"""Agent lifecycle commands (spawn, list, logs, follow, stop, status, summary)."""

import json
import os
from pathlib import Path
from typing import Annotated

import typer

from container_cli.utils import check_token, find_git_root, run_make
from container_cli.targets import Target
from container_cli.utils import check_token, print_agent_status, run_make

app = typer.Typer(help="Agent lifecycle commands")


def _agents_home() -> Path:
"""Resolve AGENTS_HOME, falling back to sibling .worktrees/ directory."""
env_val = os.environ.get("AGENTS_HOME")
if env_val:
return Path(env_val)
return find_git_root().parent / ".worktrees"


@app.command()
def spawn(
branch: Annotated[str, typer.Option("--branch", help="Git branch for the agent worktree")],
task: Annotated[str, typer.Option("--task", help="Task description for the agent")],
cpus: Annotated[int | None, typer.Option("--cpus", help="CPU count")] = None,
memory: Annotated[str | None, typer.Option("--memory", help="Memory limit (e.g. 12G)")] = None,
image: Annotated[str | None, typer.Option("--image", help="Image tag")] = None,
model: Annotated[
str | None,
typer.Option("--model", help="Claude model the agent runs (e.g. sonnet, opus, haiku)"),
] = None,
) -> None:
"""Spawn a detached headless agent container."""
check_token()
Expand All @@ -37,57 +31,52 @@ def spawn(
make_vars["MEMORY"] = memory
if image:
make_vars["IMAGE"] = image
run_make("spawn", make_vars)
if model:
make_vars["MODEL"] = model
run_make(Target.SPAWN, make_vars)


@app.command(name="list")
def list_agents() -> None:
"""List active agent containers and worktrees."""
run_make("list-agents")
run_make(Target.LIST_AGENTS)


@app.command()
def logs(
branch: Annotated[str, typer.Option("--branch", help="Agent branch name")],
) -> None:
"""Show logs for a branch agent."""
run_make("logs-agent", {"BRANCH": branch})
run_make(Target.LOGS_AGENT, {"BRANCH": branch})


@app.command()
def follow(
branch: Annotated[str, typer.Option("--branch", help="Agent branch name")],
) -> None:
"""Follow live streaming logs for a branch agent."""
run_make("follow-agent", {"BRANCH": branch}, tty=True)
run_make(Target.FOLLOW_AGENT, {"BRANCH": branch}, tty=True)


@app.command()
def stop(
branch: Annotated[str, typer.Option("--branch", help="Agent branch name")],
) -> None:
"""Stop a branch agent container."""
run_make("stop-agent", {"BRANCH": branch})
run_make(Target.STOP_AGENT, {"BRANCH": branch})


@app.command()
def status(
branch: Annotated[str, typer.Option("--branch", help="Agent branch name")],
) -> None:
"""Show agent status from persisted status.json file."""
status_file = _agents_home() / branch / ".agent" / "status.json"
if not status_file.exists():
typer.echo(f"[status] No status file found for branch '{branch}'.")
typer.echo(f"[status] Expected at: {status_file}")
raise typer.Exit(1)

data = json.loads(status_file.read_text())
typer.echo(json.dumps(data, indent=2))
print_agent_status(branch, label="status")


@app.command()
def summary(
branch: Annotated[str, typer.Option("--branch", help="Agent branch name")],
) -> None:
"""Show structured lifecycle events for a branch agent."""
run_make("summary-agent", {"BRANCH": branch})
run_make(Target.SUMMARY_AGENT, {"BRANCH": branch})
20 changes: 9 additions & 11 deletions app/cli/src/container_cli/commands/build.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
"""Image build and cleanup commands."""
"""Image build and cleanup commands.

These functions are registered as top-level commands by `container_cli.main`.
"""

from typing import Annotated

import typer

from container_cli.targets import Target
from container_cli.utils import run_make

app = typer.Typer(help="Image build commands")


@app.command()
def build(
image: Annotated[str | None, typer.Option("--image", help="Image tag")] = None,
dockerfile: Annotated[str | None, typer.Option("--dockerfile", help="Dockerfile path")] = None,
Expand All @@ -20,22 +21,19 @@ def build(
make_vars["IMAGE"] = image
if dockerfile:
make_vars["DOCKERFILE"] = dockerfile
run_make("build", make_vars)
run_make(Target.BUILD, make_vars)


@app.command()
def clean() -> None:
"""Remove the container image."""
run_make("clean")
run_make(Target.CLEAN)


@app.command(name="clean-network")
def clean_network() -> None:
"""Remove the bridge network."""
run_make("clean-network")
run_make(Target.CLEAN_NETWORK)


@app.command(name="clean-all")
def clean_all() -> None:
"""Remove image and network."""
run_make("clean-all")
run_make(Target.CLEAN_ALL)
11 changes: 6 additions & 5 deletions app/cli/src/container_cli/commands/network.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
"""Bridge network management commands."""
"""Bridge network management command.

Registered as a top-level command by `container_cli.main`.
"""

from typing import Annotated

import typer

from container_cli.targets import Target
from container_cli.utils import run_make

app = typer.Typer(help="Network management commands")


@app.command()
def network(
subnet: Annotated[str | None, typer.Option("--subnet", help="Subnet CIDR")] = None,
network_name: Annotated[str | None, typer.Option("--network-name", help="Network name")] = None,
Expand All @@ -20,4 +21,4 @@ def network(
make_vars["SUBNET"] = subnet
if network_name:
make_vars["NETWORK"] = network_name
run_make("network", make_vars)
run_make(Target.NETWORK, make_vars)
34 changes: 9 additions & 25 deletions app/cli/src/container_cli/commands/pi_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,16 @@

from __future__ import annotations

import json
import os
from pathlib import Path
from typing import Annotated

import typer

from container_cli.utils import find_git_root, run_make
from container_cli.targets import Target
from container_cli.utils import print_agent_status, run_make

app = typer.Typer(help="PI agent lifecycle (local mlx_lm.server backend)")


def _agents_home() -> Path:
"""Resolve AGENTS_HOME, falling back to sibling .worktrees/ directory."""
env_val = os.environ.get("AGENTS_HOME")
if env_val:
return Path(env_val)
return find_git_root().parent / ".worktrees"


@app.command()
def build(
image: Annotated[str | None, typer.Option("--image", help="PI image tag")] = None,
Expand All @@ -43,7 +33,7 @@ def build(
make_vars["PI_IMAGE"] = image
if dockerfile:
make_vars["PI_DOCKERFILE"] = dockerfile
run_make("build-pi", make_vars)
run_make(Target.BUILD_PI, make_vars)


@app.command()
Expand Down Expand Up @@ -87,48 +77,42 @@ def spawn(
make_vars["PI_BASE_URL"] = base_url
if model_id:
make_vars["PI_MODEL_ID"] = model_id
run_make("spawn-pi", make_vars)
run_make(Target.SPAWN_PI, make_vars)


@app.command(name="list")
def list_agents() -> None:
"""List active PI agent containers and PI worktrees."""
run_make("list-pi-agents")
run_make(Target.LIST_PI_AGENTS)


@app.command()
def logs(
branch: Annotated[str, typer.Option("--branch", help="PI agent branch name")],
) -> None:
"""Show logs for a PI agent (live container or persisted log)."""
run_make("logs-pi-agent", {"BRANCH": branch})
run_make(Target.LOGS_PI_AGENT, {"BRANCH": branch})


@app.command()
def follow(
branch: Annotated[str, typer.Option("--branch", help="PI agent branch name")],
) -> None:
"""Follow live streaming logs for a PI agent."""
run_make("follow-pi-agent", {"BRANCH": branch}, tty=True)
run_make(Target.FOLLOW_PI_AGENT, {"BRANCH": branch}, tty=True)


@app.command()
def stop(
branch: Annotated[str, typer.Option("--branch", help="PI agent branch name")],
) -> None:
"""Stop a PI agent container."""
run_make("stop-pi-agent", {"BRANCH": branch})
run_make(Target.STOP_PI_AGENT, {"BRANCH": branch})


@app.command()
def status(
branch: Annotated[str, typer.Option("--branch", help="PI agent branch name")],
) -> None:
"""Show PI agent status from persisted status.json file."""
status_file = _agents_home() / branch / ".agent" / "status.json"
if not status_file.exists():
typer.echo(f"[pi-status] No status file found for branch '{branch}'.")
typer.echo(f"[pi-status] Expected at: {status_file}")
raise typer.Exit(1)
data = json.loads(status_file.read_text())
typer.echo(json.dumps(data, indent=2))
print_agent_status(branch, label="pi-status")
Loading
Loading