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
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ Mutation tests (run via `make mutation-ci-threshold`) act as the safety net that

For skill evals (LLM-graded), see the **Skill evals** section below.

## Python code conventions (`app/cli/`)

The Python CLI follows the [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html). Ruff (configured in `app/cli/pyproject.toml`) enforces the mechanical rules: line length 100, import order, pep8-naming, pydocstyle (Google convention), pyupgrade, bugbear, comprehensions (C4), simplify, return, pathlib, unused-args, eradicate, builtins, tidy-imports. Run `make apply-sensors` to auto-fix + verify the full quality pipeline (lint, unit tests, mutation gate).

The rules below cannot be enforced by Ruff and must be applied by hand:

- **Docstring content (Google sections)**: the first line is a one-sentence imperative summary. When parameters, return value, raised exceptions, or yielded items deserve documentation, use `Args:`, `Returns:`, `Raises:`, `Yields:` sections indented 4 spaces under the summary.
- **`TODO` markers**: always `TODO(@username):` or `TODO(#issue-id):` — never a bare `TODO`. The identifier makes ownership explicit and lets `git grep TODO` find actionable items.
- **`TYPE_CHECKING` imports**: imports used only inside type annotations go inside `if TYPE_CHECKING:` and are referenced as forward-reference strings. Avoid runtime cost for typing-only dependencies.
- **`assert` only in tests**: production code under `src/` must not rely on `assert` for control flow — `python -O` strips them. Raise specific exceptions instead (`raise ValueError(...)`, `raise typer.Exit(1)`).
- **Specific exceptions**: never `except Exception:` bare; catch the concrete types relevant to the operation (`subprocess.CalledProcessError`, `OSError`, `typer.Exit`).
- **`typer.echo` vs `logging`**: `typer.echo` is for user-facing CLI output only. Any diagnostic or trace information goes through `logger = logging.getLogger(__name__)`.
- **Naming semantics**: variable names must not shadow built-ins (`vars`, `id`, `type`, `list`, `dict`, `input`). Prefer descriptive names: `make_vars`, `env_overrides`, `image_tag`. Function names are imperative verbs in `snake_case`.
- **No mutable default arguments**: never `def f(x: list = []):`. Use `None` and materialise inside (`x = x if x is not None else []`).
- **Absolute imports**: inside `container_cli/` always import with the full package path (`from container_cli.utils import run_make`), never relative.
- **Function size**: revisit any function past ~25 lines and consider splitting; single responsibility per function.

## Architecture

- **`config/`** — Container infrastructure: `Dockerfile.wolfi` (production, multi-stage: Rust tool compilation → runtime with Claude CLI, Node, Python), `entrypoint.sh` (credential injection + worktree creation + su-exec privilege drop), `Makefile` (orchestration)
Expand Down
13 changes: 13 additions & 0 deletions app/cli/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,16 @@ eval-skills:

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

# Quality sensors: lint + format + unit tests + mutation gate (no acceptance)
.PHONY: lint unit-test apply-sensors

lint:
uv run ruff check --fix .
uv run ruff format .

unit-test:
uv run pytest tests --ignore=tests/acceptance -v

apply-sensors: lint unit-test mutation-ci-threshold
@echo "[apply-sensors] OK — lint clean, unit tests green, mutation >= 70%"
46 changes: 46 additions & 0 deletions app/cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,49 @@ paths_to_mutate = ["src/container_cli/"]
only_covered_tests = false
# CI gate threshold (enforced by `make mutation-ci-threshold`, not by mutmut itself)
# threshold = 70

[tool.ruff]
line-length = 100
target-version = "py313"
src = ["src", "tests"]
extend-exclude = ["mutants"]

[tool.ruff.lint]
select = [
"E", "W",
"F",
"I",
"N",
"D",
"UP",
"B",
"C4",
"SIM",
"RET",
"PTH",
"ARG",
"ERA",
"A",
"TID",
]
ignore = [
"D203",
"D213",
"D406",
"D407",
"D413",
]

[tool.ruff.lint.pydocstyle]
convention = "google"

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["D", "ARG001", "ARG002", "ARG005", "E501"]
"src/container_cli/__init__.py" = ["D104"]
"src/container_cli/commands/__init__.py" = ["D104"]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "lf"
docstring-code-format = true
1 change: 1 addition & 0 deletions app/cli/src/container_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Container management CLI (`q`) for Claude agent containers."""
1 change: 1 addition & 0 deletions app/cli/src/container_cli/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Typer subcommand modules exposed by the `q` CLI."""
12 changes: 7 additions & 5 deletions app/cli/src/container_cli/commands/agents.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Agent lifecycle commands (spawn, list, logs, follow, stop, status, summary)."""

import json
import os
from pathlib import Path
Expand Down Expand Up @@ -28,14 +30,14 @@ def spawn(
) -> None:
"""Spawn a detached headless agent container."""
check_token()
vars: dict[str, str] = {"BRANCH": branch, "TASK": task}
make_vars: dict[str, str] = {"BRANCH": branch, "TASK": task}
if cpus is not None:
vars["CPUS"] = str(cpus)
make_vars["CPUS"] = str(cpus)
if memory:
vars["MEMORY"] = memory
make_vars["MEMORY"] = memory
if image:
vars["IMAGE"] = image
run_make("spawn", vars)
make_vars["IMAGE"] = image
run_make("spawn", make_vars)


@app.command(name="list")
Expand Down
10 changes: 6 additions & 4 deletions app/cli/src/container_cli/commands/build.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Image build and cleanup commands."""

from typing import Annotated

import typer
Expand All @@ -13,12 +15,12 @@ def build(
dockerfile: Annotated[str | None, typer.Option("--dockerfile", help="Dockerfile path")] = None,
) -> None:
"""Build the container image (no cache)."""
vars: dict[str, str] = {}
make_vars: dict[str, str] = {}
if image:
vars["IMAGE"] = image
make_vars["IMAGE"] = image
if dockerfile:
vars["DOCKERFILE"] = dockerfile
run_make("build", vars)
make_vars["DOCKERFILE"] = dockerfile
run_make("build", make_vars)


@app.command()
Expand Down
10 changes: 6 additions & 4 deletions app/cli/src/container_cli/commands/network.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Bridge network management commands."""

from typing import Annotated

import typer
Expand All @@ -13,9 +15,9 @@ def network(
network_name: Annotated[str | None, typer.Option("--network-name", help="Network name")] = None,
) -> None:
"""Create the isolated bridge network."""
vars: dict[str, str] = {}
make_vars: dict[str, str] = {}
if subnet:
vars["SUBNET"] = subnet
make_vars["SUBNET"] = subnet
if network_name:
vars["NETWORK"] = network_name
run_make("network", vars)
make_vars["NETWORK"] = network_name
run_make("network", make_vars)
41 changes: 16 additions & 25 deletions app/cli/src/container_cli/commands/pi_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,34 +32,26 @@ def _agents_home() -> Path:

@app.command()
def build(
image: Annotated[
str | None, typer.Option("--image", help="PI image tag")
] = None,
image: Annotated[str | None, typer.Option("--image", help="PI image tag")] = None,
dockerfile: Annotated[
str | None, typer.Option("--dockerfile", help="Path to PI Dockerfile")
] = None,
) -> None:
"""Build the PI agent image (Ubuntu 26.04 + PI SDK)."""
vars: dict[str, str] = {}
make_vars: dict[str, str] = {}
if image:
vars["PI_IMAGE"] = image
make_vars["PI_IMAGE"] = image
if dockerfile:
vars["PI_DOCKERFILE"] = dockerfile
run_make("build-pi", vars)
make_vars["PI_DOCKERFILE"] = dockerfile
run_make("build-pi", make_vars)


@app.command()
def spawn(
branch: Annotated[
str, typer.Option("--branch", help="Git branch for the PI agent worktree")
],
task: Annotated[
str, typer.Option("--task", help="Task description for the PI agent")
],
branch: Annotated[str, typer.Option("--branch", help="Git branch for the PI agent worktree")],
task: Annotated[str, typer.Option("--task", help="Task description for the PI agent")],
cpus: Annotated[int | None, typer.Option("--cpus", help="CPU count")] = None,
memory: Annotated[
str | None, typer.Option("--memory", help="Memory limit (e.g. 3G)")
] = None,
memory: Annotated[str | None, typer.Option("--memory", help="Memory limit (e.g. 3G)")] = None,
image: Annotated[str | None, typer.Option("--image", help="PI image tag")] = None,
base_url: Annotated[
str | None,
Expand All @@ -82,21 +74,20 @@ def spawn(
uv run iac server status
"""
typer.echo(
"[pi] reminder: ensure mlx_lm.server is running "
"(`uv run iac server status` from /iac)"
"[pi] reminder: ensure mlx_lm.server is running (`uv run iac server status` from /iac)"
)
vars: dict[str, str] = {"BRANCH": branch, "TASK": task}
make_vars: dict[str, str] = {"BRANCH": branch, "TASK": task}
if cpus is not None:
vars["CPUS"] = str(cpus)
make_vars["CPUS"] = str(cpus)
if memory:
vars["MEMORY"] = memory
make_vars["MEMORY"] = memory
if image:
vars["PI_IMAGE"] = image
make_vars["PI_IMAGE"] = image
if base_url:
vars["PI_BASE_URL"] = base_url
make_vars["PI_BASE_URL"] = base_url
if model_id:
vars["PI_MODEL_ID"] = model_id
run_make("spawn-pi", vars)
make_vars["PI_MODEL_ID"] = model_id
run_make("spawn-pi", make_vars)


@app.command(name="list")
Expand Down
22 changes: 12 additions & 10 deletions app/cli/src/container_cli/commands/run.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Interactive coordinator container (`run` and `shell` aliases)."""

from typing import Annotated

import typer
Expand All @@ -15,14 +17,14 @@ def run(
) -> None:
"""Run an interactive coordinator shell (hands off TTY)."""
check_token()
vars: dict[str, str] = {}
make_vars: dict[str, str] = {}
if cpus is not None:
vars["CPUS"] = str(cpus)
make_vars["CPUS"] = str(cpus)
if memory:
vars["MEMORY"] = memory
make_vars["MEMORY"] = memory
if name:
vars["NAME"] = name
run_make("run", vars, tty=True)
make_vars["NAME"] = name
run_make("run", make_vars, tty=True)


@app.command()
Expand All @@ -33,11 +35,11 @@ def shell(
) -> None:
"""Alias for run — open an interactive shell in the coordinator container."""
check_token()
vars: dict[str, str] = {}
make_vars: dict[str, str] = {}
if cpus is not None:
vars["CPUS"] = str(cpus)
make_vars["CPUS"] = str(cpus)
if memory:
vars["MEMORY"] = memory
make_vars["MEMORY"] = memory
if name:
vars["NAME"] = name
run_make("shell", vars, tty=True)
make_vars["NAME"] = name
run_make("shell", make_vars, tty=True)
2 changes: 2 additions & 0 deletions app/cli/src/container_cli/main.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Entry point: registers top-level commands and sub-apps on the `q` Typer app."""

import typer

from container_cli.commands import agents, build, network, pi_agents, run
Expand Down
32 changes: 32 additions & 0 deletions app/cli/src/container_cli/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Shared helpers: git root resolution, Makefile runner, and token validation."""

import os
import subprocess
from pathlib import Path
Expand All @@ -6,6 +8,15 @@


def find_git_root() -> Path:
"""Return the absolute path of the repository root.

Returns:
Path to the top-level directory reported by `git rev-parse --show-toplevel`.

Raises:
subprocess.CalledProcessError: If the current working directory is not inside
a git repository.
"""
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
Expand All @@ -16,10 +27,20 @@ def find_git_root() -> Path:


def makefile_dir() -> Path:
"""Return the directory that contains the orchestration Makefile.

Returns:
Path to `config/` under the git root.
"""
return find_git_root() / "config"


def check_token() -> None:
"""Verify that the Claude container OAuth token is exported.

Raises:
typer.Exit: With code 1 if `CLAUDE_CONTAINER_OAUTH_TOKEN` is unset or empty.
"""
token = os.environ.get("CLAUDE_CONTAINER_OAUTH_TOKEN")
if not token:
typer.echo(
Expand All @@ -30,6 +51,17 @@ def check_token() -> None:


def run_make(target: str, extra_vars: dict[str, str] | None = None, *, tty: bool = False) -> None:
"""Invoke a Makefile target inside the project's `config/` directory.

Args:
target: The Make target name to execute.
extra_vars: Optional mapping of variables passed as `KEY=VALUE` to make.
tty: If True, replace the current process with `make` so it inherits the TTY
(used by interactive commands like `run` and `follow`).

Raises:
typer.Exit: When the subprocess returns a non-zero exit code (non-TTY path).
"""
vars_list = [f"{k}={v}" for k, v in (extra_vars or {}).items()]
cmd = ["make", "-C", str(makefile_dir()), target, *vars_list]

Expand Down
16 changes: 9 additions & 7 deletions app/cli/tests/acceptance/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ def invocation_context(

monkeypatch.delenv("AGENTS_HOME", raising=False)

with patch("container_cli.commands.agents.run_make") as m_agents, \
patch("container_cli.commands.build.run_make") as m_build, \
patch("container_cli.commands.run.run_make") as m_run, \
patch("container_cli.commands.network.run_make") as m_network, \
patch("container_cli.commands.pi_agents.run_make") as m_pi, \
patch("container_cli.commands.agents.find_git_root", return_value=repo), \
patch("container_cli.commands.pi_agents.find_git_root", return_value=repo):
with (
patch("container_cli.commands.agents.run_make") as m_agents,
patch("container_cli.commands.build.run_make") as m_build,
patch("container_cli.commands.run.run_make") as m_run,
patch("container_cli.commands.network.run_make") as m_network,
patch("container_cli.commands.pi_agents.run_make") as m_pi,
patch("container_cli.commands.agents.find_git_root", return_value=repo),
patch("container_cli.commands.pi_agents.find_git_root", return_value=repo),
):
ctx = InvocationContext(
runner=CliRunner(),
mocks={
Expand Down
4 changes: 1 addition & 3 deletions app/cli/tests/acceptance/steps/agents_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
from tests.acceptance.steps.common_steps import * # noqa: F401, F403


@given(parsers.parse(
'a status file exists for branch "{branch}" with payload {payload}'
))
@given(parsers.parse('a status file exists for branch "{branch}" with payload {payload}'))
def _status_file_exists(invocation_context, branch: str, payload: str) -> None:
status_dir = invocation_context.agents_home / branch / ".agent"
status_dir.mkdir(parents=True, exist_ok=True)
Expand Down
Loading
Loading