Part III Chapter 6: Opening a Reliable Gate — CLI

Chapter guide: You will write
runCli,runInteractive, and the pure functionexitCodeForStopReasoninsrc/cli.ts. What matters at this step is separating the contracts: the testable CLI function breaks free of the terminal through an injectedask/write, exit codes 0/2/130 each carry their own meaning, andmain()is the boundary that turns exceptions into stderr.
The session and the loop were both in place. Xiao A was eager to treat main.ts as the single entry point for everything — whatever the command, it would come in through there. Lao Z stopped him: "Don't merge in a hurry. A testable CLI function and a production argv entry carry different contracts. Mash them into one and you can't write tests, and you can't guarantee an exit code either."
"So how many entry points do we actually need?"
"Three, each minding its own business. This chapter implements only a one-shot Q&A. No REPL, no streaming UI, no diagnostics framework."
Three entry points, three responsibilities
| Symbol | Input | Output / exit semantics | What it does not own |
|---|---|---|---|
main.ts | process.argv, environment variables | prints only the last assistant content and maps the stop reason to a process exit code | signal, session, REPL |
runCli() | injected client, tools, ask, write | prints every non-empty assistant content and returns a Promise<number> | creating a real Provider |
runInteractive() | standard input and output | calls runCli() once, then closes readline | a continuous REPL |
The production path through argv
"First, look at what happens once argv comes in." Lao Z opened main.ts:
user: process.argv.slice(2).join(" ") || "Describe the workspace.",
maxTurns: 8,
beforeToolCall: denyBashByDefault,"This joins the remaining argv items into one user message, assembles the real AnthropicModelClient, the workspace tools, and the default bash deny policy, calls runAgent once, then filters out the last assistant text and prints it. When argv is empty, the conversation loop never starts — the default question is used instead."
Xiao A: "Then how is the API key handled? Tests must not read a real key."
"That is the credential boundary." Lao Z typed out another section of the same file:
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) throw new Error("ANTHROPIC_API_KEY is required for production use; npm test uses no API key.");"The normal path requires both a key and a model name, and a missing one of either fails before any network call. When main() catches a startup or runtime exception, it emits a controlled diagnostic to stderr under the stable mini-agent: failed to start or run: prefix and sets exit code 1. During formatting it replaces the configured API key — the key's value must never be written to stderr. Exception messages are not silently swallowed either."
"Why redact the key out of the error message? The exception isn't something the model produced."
"What we're guarding against is our own error log," Lao Z said. "How does the SDK report an authentication failure? It may dump the request headers into the message, and those headers contain x-api-key: sk-ant-.... If main() wrote the raw error.message straight to stderr, the key would leak into the log. So formatMainError does exactly one thing: detail.split(apiKey).join("[REDACTED]") — it replaces the key literal. The tests deliberately construct a new Error(\provider rejected ${apiKey}`)and assert that the output matches[REDACTED]` and does not match the original key."
"Then why not redact at the request stage instead of waiting for the catch?" Xiao A pressed.
"Because the key is what sends the request — it can't be altered on the way out," Lao Z said. "Redaction can only happen at the moment information is written somewhere, which is stderr. In memory the key must stay the real value, otherwise the SDK cannot authenticate with it."
Xiao A: "Then why is formatMainError an exported pure function instead of something nested inside the catch?"
"So it can be tested directly," Lao Z said. "cli-exit.test.ts simply calls formatMainError(new Error(...), apiKey). That requires it to touch no process.env and write to no terminal — the input and the output are both values. Nest it in main's catch and a test would have to actually start a process and fake ANTHROPIC_API_KEY; the unit test would carry the burden of an integration test. After the split, main is left with exactly four jobs: read env, call runAgent, print, set the exit code. Everything else is a pure function."
"And the exit codes?" Xiao A went on. "We can't just return 0 for all of them."
"After printing the last assistant content, both main() and runCli() call the same pure mapping: complete gives 0, max_turns gives 2, aborted gives 130. So npm start no longer exits 0 when the turns run out. main currently wires up no signal; aborted here only means the mapping is complete, not that Ctrl-C is already supported."
The full semantics of the four exit codes, laid out:
| Exit code | stop reason | Meaning | How a script judges it |
|---|---|---|---|
| 0 | complete | the loop finished intact | normal wrap-up |
| 2 | max_turns | turns exhausted, not finished | the result may be incomplete |
| 130 | aborted | the caller aborted on purpose | $? -eq 130 means it was interrupted |
| 1 | (exception caught by main) | startup or run failure | read the stderr diagnostic |
"Note that 1 is not in exitCodeForStopReason," Lao Z said. "1 is set by main()'s catch branch (see process.exitCode = 1 in main.ts), and it corresponds to an exception, not to a stop reason. Of the four outcomes, the three normal exits get their stop reason mapped by the pure function; the remaining exception outcome is backstopped by main."
The production path of main, drawn as one picture:
process.argv.slice(2).join(" ") or the default question
│
▼
main(): validate ANTHROPIC_API_KEY / ANTHROPIC_MODEL
│ missing either → throw → formatMainError → stderr + exit 1
▼
runAgent({ client, tools, user, maxTurns: 8, beforeToolCall })
│
▼
pick the last assistant content ── print (?? "" when empty)
│
▼
exitCodeForStopReason(stopReason) → process.exitCodeA testable one-shot Q&A
"For the runCli() layer, surely we can't spin up a real terminal inside a test?"
"By injection." Lao Z switched to cli.ts and pointed at the signature first:
// examples/mini-agent/src/cli.ts, excerpt
export async function runCli(options: {
client: ModelClient;
tools: Tool[];
ask: (prompt: string) => Promise<string>;
write: (text: string) => void;
signal?: AbortSignal;
beforeToolCall?: BeforeToolCall;
}): Promise<number>"Note that ask returns Promise<string>: it can be a terminal, it can be a fake, it can be any input source. write is a synchronous void whose only job is to push text out. Tests rely on injecting exactly these two and never touch a real terminal."
"Why is write synchronous? Model output is obviously asynchronous."
"Because write doesn't receive the model stream — it receives the whole message after runAgent returns." Lao Z pointed at the loop further down. "runCli first does await runAgent(...) to get the complete result, then walks result.messages and writes out each assistant content. The streaming process is absorbed inside the loop; by the time it reaches write it is already a string. write is just a side-effect interface, so it doesn't need to be async. That way a test can pass an array in as write and collect every output for assertions."
"Then what about ask? Couldn't that be synchronous too?"
"No — behind ask there may be a terminal, and terminal I/O is asynchronous," Lao Z said. "In runInteractive, ask is wired to rl.question(p), and question from readline/promises returns a Promise<string>. In a test it can be wired to async () => 'hello', or even to a Promise wrapper around a synchronous function. Whether one interface is async or sync depends on the heaviest implementation behind it. The heaviest implementation of ask is a terminal, so it's async; the heaviest implementation of write is stdout.write, and synchronous is enough."
const input = await options.ask("> ");
if (!input.trim()) return 0;
const result = await runAgent({ client: options.client, tools: options.tools, user: input,
maxTurns: 8, signal: options.signal, beforeToolCall: options.beforeToolCall });Xiao A stared at the first line and asked: "Returning 0 on empty input — isn't that lying? It clearly did nothing."
"Because this is 'the user gave up on purpose', not 'the system failed'." Lao Z said. "An empty Enter is not one of the three stop reasons, and no exception happened — input simply wasn't supplied. Returning 0 tells the script 'there was nothing to handle this time', so no alarm is needed. The tests specifically pass ask: async () => " " to verify this case, and also assert client.calls === 0: the return happens before any model call."
"signal is passed into the loop. Cancellation takes effect at runAgent()'s checkpoints; if a client or a tool does not respond to the signal, the caller can only wait for it to hand control back."
Xiao A: "Then what is runCli's system prompt? Does it define one itself?"
"Yes — it has one line built in: Careful coding assistant. Never include credentials in messages." Lao Z said. "That line stresses that credentials never go into messages. It echoes the least-exposure principle from the security chapter, and it is the only rule the CLI layer hardcodes itself. Every layer may add its own system supplement, but before writing a line, be clear about why that line is there."
Xiao A jumped in to reason: "What it outputs should be every message in result.messages, right?"
"Only the non-empty content of assistant messages. Look at this line: for (const message of result.messages) if (message.role === "assistant" && message.content) options.write(message.content). And main prints only the last one."
The contract differences between the two entry points, side by side:
| Dimension | runCli() | main() |
|---|---|---|
| Input | ask (injectable) | process.argv |
| Output | every non-empty assistant content | the last one, ?? "" when empty |
| Exit code | returns a number | sets process.exitCode |
| Exceptions | rejects as-is | formats to stderr + exit 1 |
| Credentials | never touched | validated and redacted |
| Typical use | multi-turn display, tests | a script taking the final result |
"Note the last line: runCli does not validate the API key, because the client is injected — it doesn't create one. Only main deals with credentials, because it has to new AnthropicModelClient. Whoever creates a resource owns the validation. That is also why the tests pass: once a fake client is injected, runCli never knows an API key exists."
"Where is the exit-code mapping?" Xiao A asked.
"The same pure function. Tests assert it directly:"
export function exitCodeForStopReason(stopReason: RunResult["stopReason"]) {
switch (stopReason) {
case "complete": return 0;
case "max_turns": return 2;
case "aborted": return 130;
}
}"runCli() still hands model and tool exceptions to its caller as a rejection; the production main() is the boundary that formats those exceptions into stderr and sets exit code 1."
"Why 130 and not some other number?"
"Unix convention," Lao Z said. "When a process is killed by a signal, the exit code is usually 128 plus the signal number. SIGINT is 2, so 128+2=130. The aborted here stands for 'the caller aborted on purpose', and taking 130 lets an outer script keep the same habit: $? -eq 130 means 'it was interrupted'. But remember — main doesn't wire up a signal today, so 130 never actually appears on the npm start path. It is returned only when runCli receives an already-aborted signal. exitCodeForStopReason covering all three stop reasons completely is not the same thing as all three paths being triggerable from the production entry."
Why runInteractive is not a REPL
Xiao A saw the third function's name and asked: "Is this runInteractive the interactive command line?"
"Look at its implementation." Lao Z turned to cli.ts:
return await runCli({ ...options,
ask: (p) => rl.question(p),
write: (text) => stdout.write(`${text}\n`),
});"The rl.close() in the finally block guarantees the handle is closed whether things succeed, throw, or abort. But note: it calls runCli exactly once."
"Then why is it called interactive?"
"The name says the input comes from a terminal. It does not say the function maintains a multi-turn session."
Xiao A wasn't ready to give up: "Then if I wrap runCli in a while loop, isn't that a REPL?"
"On the surface, yes. But a REPL has to answer four questions, and right now none of them has an answer," Lao Z said. "First, how the session is kept: runInteractive starts a new runAgent every time, and the previous round's messages are not carried into the next. Second, how the screen is refreshed: today stdout.write dumps a whole message at once, with no streaming refresh. Third, how cancellation propagates: runInteractive forwards a caller-supplied signal, but does not connect Ctrl-C's SIGINT to an AbortController, so terminal cancellation does not automatically reach the loop. Fourth, history and command completion: readline has them by default, but you haven't configured them. Adding a while loop only wraps these four 'not there' states inside a loop."
The gap between an ideal REPL and the current state, quantified in a table:
| What a REPL should have | Current state | Where the gap is |
|---|---|---|
| multi-turn session persistence | a new runAgent per round, messages don't continue | needs sessionPath or passing messages inside the loop |
| streaming output | stdout.write dumps a whole message at once | needs to hook into client.stream instead of runAgent |
| Ctrl-C cancellation | forwards a signal, but does not connect SIGINT | needs to connect SIGINT to an AbortController |
| history, completion | readline's default behaviour | needs a prompt policy configured; untouched |
"Every cell is an independent work item; a while loop can't cover them," Lao Z said. "Mind the third cell: runInteractive passes a caller-supplied signal to runCli through ...options, and runCli forwards it to runAgent. The cancellation channel already exists. What is missing is the terminal wiring: create an AbortController, listen for SIGINT and call abort(), and pass its signal into that channel."
Success and failure paths
"Then let's walk it from the top: how does the normal path go, and how does the failure path go?"
"The normal path has two branches," Lao Z said. "main is argv input → runAgent → print the last assistant content → set 0, 2, or 130. runCli() is Q&A input → runAgent → output each non-empty assistant content in order, returning the same mapping. The failure paths are collected in the table below."
"And cancellation during approval?"
"If cancellation happens inside the approval await, the loop does not start the tool. It fills in the cancellation result and returns aborted, so runCli() returns 130, and this race already has its own fake regression test." Lao Z paused and added one more point. "There is also a case that's easy to miss: the text output may be empty. main prints it with ?? "", so don't treat empty output as proof that the answer succeeded."
"Then what about the session? Does this entry continue it automatically?"
"No. Neither main.ts nor runCli() passes a sessionPath, so today's one-shot entry neither restores automatically nor writes JSONL. A session round-trip holds only when you call runAgent({ sessionPath }) directly. Putting session capability into the core, and wiring it into a product entry, are two different pieces of work."
The routing and exit code of every path in this chapter — check them line by line at acceptance:
| Scenario | Path taken | Exit code |
|---|---|---|
| empty input | returns before any model call | 0 |
| normal answer | complete | 0 |
| turns exhausted | max_turns | 2 |
| signal fires at a checkpoint | aborted (cancellation result filled in) | 130 |
| missing API key or model name | formatMainError to stderr | 1 |
| model / tool exception | runCli rejects → main formats | 1 |
"The exit code of every scenario in the table has a matching mapping point in the code," Lao Z said. "Empty input is at the top of runCli, max_turns is in exitCodeForStopReason, and exceptions are in main's catch."
The propagation path of signal inside the loop, drawn as a sequence:
runCli({ signal }) ──► runAgent({ signal })
│
checkpoint 1 (pre-turn) ←── signal.aborted? ──► return { aborted }
│
checkpoint 2 (pre-call) ←── signal.aborted? ──► recordAbortedCalls
│ return { aborted }
checkpoint 3 (post-call) ←── signal.aborted? ──► recordAbortedCalls
│ return { aborted }
client.stream({ signal }) ←─ the SDK observes the signal itself
▼
runCli gets stopReason "aborted" → returns 130"Note that runAgent returns immediately when aborted() is true. It does not enter the next turn and does not open a new stream (see agent.ts)." Lao Z said. "The test returns aborted before opening a model stream (see agent-branches.test.ts) asserts exactly this: with the signal already aborted, client.calls === 0 — the model was never called at all, the same early-exit route empty input takes."
Summary
The value of this CLI entry step is that it exposes dependency wiring and exit semantics explicitly instead of hiding them in global state. runCli achieves terminal-free testing by injecting ask, write, signal, and beforeToolCall, and empty input returns 0 before any model call. The exit-code mapping is a pure function: 0 means the loop finished intact, 2 means turns exhausted, 130 means an external abort. All three numbers follow the conventions of Unix process-exit semantics, so a script can tell from them what happened. Exceptions thrown by the model or by a tool are handed to the caller as a rejection; only main() takes responsibility for formatting them into stderr and setting exit code 1.
The division of labour among the three entry points is this chapter's skeleton: the testable CLI function breaks free of the terminal through injection, the production argv entry lets main validate credentials and format errors, and runInteractive does nothing more than wrap readline once. The responsibility to validate follows resource creation: runCli only consumes the injected client and tools, so it never needs to touch credentials. One shared exitCodeForStopReason serves both entry points, keeping the exit semantics of the script path and the Q&A path consistent. formatMainError is an exported pure function that tests assert directly for [REDACTED], never touching process.env at any point.
On exit codes there are also two separate contracts: the mapping being complete is one, and the entry point being wired up is the other. Don't describe the former as a sentence like "supports Ctrl-C".
Today's entries are only fit for scripted one-shot Q&A. Sustained interaction needs a whole new set of contracts: session persistence, screen refresh, and cancellation propagation, plus history and completion. Each of runInteractive's four gaps is an independent work item.
Stage Acceptance
cd examples/mini-agent
npm test
npm run typecheckThe offline tests inject a fake client for runCli and assert directly on the shared pure mapping's three exit codes — complete / max turns / aborted — and on the API-key-redacted error format. They start no real subprocess and call no Provider. Do not add npm start to the credential-free tests.
