Build your own Claude Code
Ship Claude Code·25 min

The agent CLI

0 / 1
# grade this stage (from your course directory)
npx theinference test --stage the-cli --watch

Fourteen chapters, fourteen organs — built and graded one at a time, in isolation. This step assembles them into the thing you actually run: claude-code run <task-dir>. One command boots the loop, registers the tools, wires the permission engine and the sandbox under them, loads any hooks and skills the repo configured, connects MCP if there's a .mcp.json, drives a planted task to green, and prints the trajectory so you can watch it work.

Nothing new gets built here — it's wiring. The whole point is that every floor of the tower now stands together under one entry point, and a real task runs through all of them at once.

What `run` wires together, in order
claude-code run <task-dir>
  ├─ load settings.json   ── permission rules + modes + hooks
  ├─ load .mcp.json       ── connect MCP servers (if present)
  ├─ discover skills      ── .claude/skills/*/SKILL.md
  ├─ build the sandbox    ── confine every file/bash side effect to <task-dir>
  ├─ register tools       ── Read/Write/Edit/Glob/Grep/Bash (+ MCP, +Task)
  └─ runLoop(...)         ── permission-gated, hook-fired execute()
        each turn ─▶ print { tool, observation summary }
        on end_turn ─▶ run oracle ─▶ print PASS / FAIL

What to build

Your task

Wire the CLI entry point

Implement runTask(taskDir) — the body of claude-code run <task-dir>. Read the seed's settings.json (permissions + hooks) and, if present, .mcp.json and .claude/skills/. Build the sandbox pinned to taskDir, register the full toolset behind a single execute that runs each call through the permission engine and fires any matching hooks, then drive runLoop to end_turn. As each turn resolves, print one trajectory line — the tool name plus a one-line observation summary. When the loop stops, exec the planted oracle and print PASS (exit 0) or FAIL.

Graded by program.ts run-task: it copies the seed into a fresh sandbox, points ANTHROPIC_BASE_URL at the cassette proxy, runs your CLI on the planted bug, and compares { trajectory, stopReason, files, oracleExit } to the reference.

tests/adapters.ts
import type { TaskResult } from '../src/types.ts'

export async function runTask(taskDir: string): Promise<TaskResult> {
  // 1. load settings.json (permissions + hooks); load .mcp.json + skills if present
  // 2. const sb = sandbox(taskDir)
  // 3. register tools; wrap execute() so every call is permission-gated + hook-fired
  // 4. const { trajectory, stopReason } = await runLoop(seedMessages, model, execute, cap)
  //    -> print one line per turn: `${tool}  ${summarize(observation)}`
  // 5. const oracleExit = await sb.bash(oracle)
  // 6. return { trajectory, stopReason, files: sb.snapshot(report), oracleExit }
}
The CLI is glue — don't re-implement the organs

Every subsystem here already exists and is already graded: decide from Chapter 06, resolveInSandbox from Chapter 07, the hook matcher from Chapter 11, the skill loader from Chapter 12. runTask calls them, it doesn't reimplement them. The one new responsibility is order — permission check before the side effect, sandbox guard under every path, hooks fired around execute — and printing the trajectory. If a turn fails the grade, it's almost always a wiring-order bug, not a logic bug.

Print the trajectory the model actually walked

The trajectory line is for the human watching, but the grader checks the same list. Record { tool, input } for each tool_use the loop actually executed — after permission and hooks may have rewritten or blocked it — not the raw cassette response. A call the permission engine denied never ran, so it isn't a trajectory entry; a hook that rewrote input means you log the rewritten input. The printed summary is free-form, but the recorded { tool, input } must match expected.json exactly.

How it's graded

Process mode: your_program.sh run-task runs program.ts run-task against fixtures/case.json — the seed (sum.js returns a - b), a five-turn cassette (read test → read source → edit → bash → end_turn), the files to report, and the oracle (node test.js). The grader compares your printed { trajectory, stopReason, files, oracleExit } to fixtures/expected.json: trajectory equal, stopReason: "end_turn", sum.js byte-equal to the fixed source (a + b), and oracleExit: 0. A stalled loop, a wrong edit, or a non-zero oracle each surface as a located mismatch.