CLI and chat (src/cli/)
This page is generated from
docs/lld/cli.mdin the repository.
Purpose
Section titled “Purpose”Parse the command line, run the chosen mode, show agent events, and ask the user for approvals. The CLI owns the terminal. Nothing below it writes to the terminal directly.
| File | Role |
|---|---|
index.ts |
Entry point (commander). Modes: chat, -p, stdin task, --resume, --replay, eval, acp (0.15, loaded with import()), setup (0.16, loaded with import()), init (the chat with /init as the first line, firstInput). With no first line, the banner gets initTip (see init.md). |
turn.ts |
runTurnInTerminal: one turn with Ctrl-C handling, usage line and stop message; an optional TurnWatcher (0.6) is told when it starts and ends. |
notify.ts |
Notifications (0.6): pickChannel, notificationBytes, Notifier. See below. |
focus.ts |
Terminal focus (0.6): reportsFocus, FocusTracker, focusEvent, the mode 1004 codes. See below. |
repl.ts |
Plain chat (readline). Used for pipes, GARUDA_PLAIN=1, and the single binary. |
renderer.ts |
Renderer interface and PlainRenderer: model text to stdout, activity to stderr. |
approver.ts |
TerminalApprover (inquirer select), SwitchApprover (with onAsk, 0.6), shared header, colorPreview and colorDiff; visible, hasHidden, and (0.15) the plain headerText and HIDDEN_WARNING, which ACP uses too. |
setupCommand.ts |
garuda setup [--show] [--forget [name]] (0.16, loaded with import()): provider, model, hidden key, a free key check (checkKey), then the default model and the stored key. The questions go through SetupIO (inquirer in a terminal, scripted answers in tests). No terminal: a message, exit 1; Ctrl-C: exit 130. See setup.md. |
acpCommand.ts |
garuda acp [-m model] [setup] (0.15): the model, providers, team policy and search as for the chat, read again for each session (0.16, prepare; a missing model or key is ACP “auth required”); garuda acp setup runs garuda setup (Terminal Auth, 0.16); protocolOutput (stdout only for protocol messages, all else to stderr); one runtime per editor session through src/acp/. See acp.md. |
report.ts |
Usage line and stop messages. |
jobCommand.ts |
garuda run <job> [--at HH:MM] (0.7; the hidden --from-launchd marks a start from the agent): prepareJob, testsBefore (0.11) and finishJob; start() builds the runtime in the worktree. See jobs.md. |
nightCommand.ts |
garuda night [--at HH:MM] [--parallel n] (0.11): wait, run the queue, write the digest, notify. See jobs.md. |
jsonOutput.ts |
-p --output-format json|stream-json (0.5): JsonOutput (a Renderer) and the pure initLine, assistantLine, resultLine. See below. |
banner.ts |
The chat start banner: GARUDA wordmark (saffron-to-gold gradient on true-color terminals), a card with version, model, sandbox, folder and extras (the detected build tools first, for example Java (Maven)), and a tips line. The card only below 60 columns; no colors with NO_COLOR or a pipe. Not shown for -p. |
errors.ts |
describeError: the error chain as one message. |
evalCommand.ts |
garuda eval: options, toolchain check before the run, --prepare java|python, --subagents on|off, --subagent-model, --todo on|off, --lsp on|off (checks for a server and the sandbox first), executor choice, run, report files. |
lspCommand.ts |
garuda lsp (the state; starts no server) and garuda lsp install typescript|python|java. See lsp.md. |
chat/* |
The Ink chat (below). |
Start sequence (index.ts)
Section titled “Start sequence (index.ts)”realpath(cwd)becomes the root. A task comes from-por from stdin when stdin is not a TTY.Runtime.createwith aSwitchApprover(wrapsTerminalApprover) and a switchable event target (starts asPlainRenderer).onNoticegoes to the same target.- Exit handlers:
process.on("exit")callsexecutor.shutdown(), so no command or server outlives Garuda. -p: onerunTurnInTerminal, thenruntime.close(). Exit code: 0 done, 1 error, 2 limit (or the model’s own max_tokens or refusal stop), 130 Ctrl-C. The exit codes are the same for all output formats.- Chat: if stdin and stdout are TTYs,
GARUDA_PLAINis not1andTERMis notdumb, loadchat/inkChat.jswithimport(). If the import fails (the single binary), use the plain REPL.
JSON output (jsonOutput.ts, 0.5)
Section titled “JSON output (jsonOutput.ts, 0.5)”--output-format text|json|stream-json works only with a task (-p or stdin). The field names are those
of Claude Code’s headless mode (checked against the @anthropic-ai/claude-agent-sdk types), so scripts
for claude -p --output-format … read Garuda too.
- stdout holds only JSON, one object per line. The model text never goes there:
JsonOutputsends events to aPlainRendererwhose stdout is a null stream. stderr gets warnings and errors;--verboseadds the tool activity and the usage line. (Claude Code needs--verbosefor stream-json; Garuda does not.) json: oneresultline at the end.stream-json, in order:
| Line | When | Main fields |
|---|---|---|
system/init |
before the first other line | cwd, tools, mcp_servers (name, status), model, permissionMode (default or plan), slash_commands, apiKeySource (ANTHROPIC_API_KEY, credentials (0.16) or none), garuda_version |
assistant |
each model response (step_end) |
message: an Anthropic Messages API message with all blocks (text, tool_use), stop_reason, usage |
user |
each tool result | message.content: one tool_result block (tool_use_id, content, is_error) |
system/api_retry |
a broken stream is sent again | attempt, max_retries, retry_delay_ms, message |
system/compact_boundary |
compaction | compact_metadata: trigger: "auto", pre_tokens, post_tokens |
result |
last | see below |
Every line has session_id and uuid; parent_tool_use_id is null (subagent steps are not streamed).
The result line: subtype, is_error, duration_ms, duration_api_ms, num_turns (model calls),
result (the last response’s text, success only), stop_reason, total_cost_usd (the session total;
a number for compatibility with Claude Code: 0 with total_cost_is_unknown when the price is unknown,
a known minimum with total_cost_is_incomplete when some spending has no known cost; review of
1370184), cost_is_unknown / cost_is_incomplete (the same for this run), usage (this run), modelUsage (one entry for the main model; subagent use is
included in it), permission_denials, errors (error subtypes), terminal_reason.
| Garuda stop | subtype |
terminal_reason |
|---|---|---|
| done, max_tokens, refusal | success |
completed |
| max_steps | error_max_turns |
max_turns |
| token_budget | error_during_execution (the budget counts tokens, not dollars) |
budget_exhausted |
| repeated_calls | error_during_execution |
– |
| an error | error_during_execution, is_error: true |
– |
| Ctrl-C | error_during_execution, errors ["Interrupted."] |
– |
Not in 0.5: --input-format stream-json, --include-partial-messages (stream_event lines), --json-schema.
commander uses enablePositionalOptions(), so options after eval belong to eval (garuda eval -m x).
Ctrl-C (turn.ts)
Section titled “Ctrl-C (turn.ts)”runTurnInTerminal(runtime, interruptible, renderer, prompt, exitNow, watcher?):
- It creates an
AbortControllerfor the turn and setsinterruptible.onInterrupt = stop. - First Ctrl-C: prints “Stopping…” and aborts. The executor kills the command’s process group.
- Second Ctrl-C during the same turn:
exitNow()(exit 130). - On abort it records
end: interruptedin the journal; on an error,end: error.
Notifications (notify.ts, 0.6)
Section titled “Notifications (notify.ts, 0.6)”pickChannel(setting, env):GARUDA_NOTIFYfirst, thennotifications.channel, thenauto.autogivesosc9whenTERM_PROGRAMisiTerm.app,ghosttyorWezTermand there is no tmux or screen (they drop the code), elsebell.notificationBytes:ESC ] 9 ; text BEL(control characters in the text become spaces, at most 120 characters), orBEL, or nothing foroff. It only writes to the terminal; it starts no process (N8).Notifieris aTurnWatcher.approval(request)notifies only while a turn runs (a question after/undocomes when the user is there).turnEndednotifies when the turn took at leastafterSeconds(default 10) and the user did not stop it: done, stopped (the stop reason) or failed.- Focus (
focus.ts): in the Ink chat, whenTERM_PROGRAMisiTerm.app,ghostty,WezTermorvscode(not in tmux or screen) and the channel is notoff,runInkChatwritesESC [?1004hafter the first render. The terminal then sendsESC [I/ESC [Owhen the window gets or loses focus. Ink gives each as one input ([I,[O);onKeysends it tostore.onFocus(aFocusTracker) and never to the input line. The tracker starts as focused. TheNotifiersends nothing whileisFocused()is true; unknown (undefined) means send. Before$EDITORand at exit (alsoprocess.on("exit")) Garuda writesESC [?1004l; after the editor it turns reporting on again and counts the window as focused. The plain chat does not use focus: it reads the keyboard only at its prompt, so it would see a change too late. index.tsmakes the notifier only for a chat with stdout on a terminal:approver.onAskcallsapproval, andrunInkChat/runReplpass it torunTurnInTerminal.-pand JSON output never notify.
Plain renderer
Section titled “Plain renderer”- Model text: stdout, as it streams. Everything else: stderr, on a new line.
- Tool lines:
● name summaryand⎿ result summary(summariseCall,summariseResult). For MCP results the summary skips the<mcp_result …>line. Since 0.5 the web and extension tools get short summaries instead of their markers:web_search→5 results;web_fetch→<page title> · 12,400 characters(or the part read);skill→loaded <name>orread <file>;agent→answer (N line(s)) · <name>: N steps · Xk tokens. - Claude’s web search (0.6,
server_toolevent):● web_search (Claude) <query>and⎿ 5 results(or the error code), fromserverToolText; the Ink chat keeps the page list for Ctrl-O (serverToolOutput). - Colors only when stderr is a TTY and
NO_COLORis not set.
Retry notices: a model_retry event prints “The connection to the model broke (terminated). Retrying
(1/2)…” as a warning. The Ink chat also drops the text that streamed but did not finish a block, because
the request goes again.
Terminal approver
Section titled “Terminal approver”- Header by target kind: command (with sandbox state), path (
wants to change), URL (wants to fetch from <host>), input, or the request’s owntitle. - Choices: once, session, deny; the request can show fewer (
choices), give its own labels (labels) and its own question (question, default “Allow?”; for example “Build this plan?”, “Install it?”). - No TTY on stdin: deny, with a message.
- inquirer loads on first use (N3). Ctrl-C inside the prompt calls
onInterrupt.
Ink chat (chat/)
Section titled “Ink chat (chat/)”flowchart LR keys[Keys, paste] --> ui[ui.tsx: App, onKey, typeAhead] ui -->|actions| store[store.ts: ChatStore] store -->|useSyncExternalStore| ui ctrl[controller.ts: runChat] -->|nextInput| store ctrl --> cmds[commands.ts: /help /usage /session /where /defs /refs /callers /impact /map /mcp /hooks /lsp /commands /plan /build /undo /redo /init /agents /new /sessions /models /export /compact /details /thinking /search /diff /schedule /jobs /exit; /name: skill or command] ctrl -->|runTurnInTerminal| rt[Runtime] rt -->|events, approvals, notices| store
| File | Role |
|---|---|
inkChat.ts |
Entry. Creates the store, sets it as approver and event target, renders App, runs runChat, unmounts. Never awaits waitUntilExit() (it hangs after unmount). |
store.ts |
ChatStore: all chat state and logic. Implements Renderer, Approver and Interruptible. |
controller.ts |
runChat: next line → command or turn; statusOf for the footer; CommandArgs (0.8): Tab choices for command arguments. |
palette.ts |
The command palette (0.9): PaletteEntry, filterPalette (the fuzzy name search of @; the help text only when no name matches), paletteWindow. paletteEntries(runtime) in commands.ts builds the rows from HELP, custom commands and skills. |
complete.ts |
Tab completion (0.6): commands, @paths, command arguments and the fuzzy file search (0.8: fuzzyFiles, rootFiles). |
ui.tsx |
Ink view: <Static> for finished items, a small live area, the key map. |
lineEditor.ts |
Pure line editor: insert, delete, words, kill, history with draft. |
markdown.ts |
takeBlocks (split a stream at blank lines, not inside fences) and renderMarkdown. |
commands.ts |
Slash commands, shared with the plain REPL. |
ChatState
Section titled “ChatState”Approvals (0.14.1, review): header and colorPreview (cli/approver.ts) show hidden characters
(visible: controls as ␍ and ␛, invisible and bidirectional characters as [U+…]) and add a warning
line, so a carriage return in a command cannot redraw what the user approves. Both approvers ask one
question at a time (a queue), and the Ink chat drops keys in the first 400 ms after a question appears
(takesApprovalKeys: type-ahead for the next prompt must not answer it). A failed /command shows its
error and the chat goes on; Ctrl-C during a !command in the plain chat stops the command, not Garuda.
interface ChatState { items: Item[]; // printed once: user, text, tool, note, output streaming: string; // the open text block, plain running: RunningTool[]; // spinner lines approval?: PendingApproval; // request, choices (with labels), selected, shownAt queue: string[]; // type-ahead editor: EditorState; busy: boolean; status: Status; // model, sandbox, context %, cost exiting: boolean;}Behaviour
Section titled “Behaviour”- Text:
text_deltaappends tostreaming;takeBlocksmoves finished blocks toitems, styled withrenderMarkdown. A tool call, a step end or a note flushes the open block. - Tools:
tool_calladds a running line;tool_resultprints the call and result lines and keeps the full output for Ctrl-O. Atodo_writeresult also prints its checklist (✔done,▶in progress,○pending); the plain renderer prints the same lines. - Plan mode (0.4):
/planand/buildswitch the mode (both chats); with text after them (0.8) the command returns the text as the prompt, so the task runs at once in the new mode; Shift+Tab toggles it in the Ink chat (store.onToggleMode, set by the controller);garuda --planstarts in plan mode. The footer shows a yellowPLAN; the plain chat’s prompt isplan›. The switch applies from the next turn. After a plan turn that ends withdone,planHandoffasks “Build this plan?”: Yes, build it now switches to build mode and runs “Carry out the plan above.” as the next turn; Switch to build mode; I will type the task only switches; No stays in plan mode.-pnever asks. - Custom commands (0.4): a line that starts with
/goes torunCommand; a custom command gives back its prompt, the chat shows the typed line once and runs the turn with the prompt. See commands.md. - Approvals:
ask()prints the full header and preview into the scrollback, then shows the request’s question and choices. Keys: ↑↓ Enter, the numbers of the choices shown, y / a / n, Esc (approvalKeyswrites the hint line). A key for a choice that is not shown does nothing. Abort of the turn rejects the promise. - MCP (0.4):
/mcpshows each server (remote ones with their URL and sign-in state);/mcp logout <server>removes a remote server’s tokens. See mcp.md. - Undo (0.4):
/undoand/redoask first (the turn, the files, the conversation), then restore. See undo.md. - Init (0.5):
/initrunsRuntime.init(migration and git init, with questions), prints the report and returns the init turn’s prompt.garuda initqueues/initwithstore.enqueue(the plain REPL takes it as its first line). See init.md. - LSP (0.4):
--lspturns diagnostics on for the run;/lspshows the state, and/lsp install <language>runs the managed install. See lsp.md. - Queue: Enter while busy appends to
queue;nextInput()takes from the queue first. - Esc (0.6): busy →
stopTurn(): drop the queue and callonInterruptonce per turn (astoppingflag, also set by Ctrl-C, reset bybegin/end), so Esc never reaches the second-stop exit. Idle → drop the queue. - New lines (0.6): Enter with
meta(Alt/Option+Enter) inserts\n; Enter after a\at the cursor replaces the backslash with\n; else Enter submits.typeAheaddoes the same when “" and Enter arrive in one chunk (fast typing). The footer shows\ then Enter: new line · Ctrl-G editor · Ctrl-P commands(0.9); the start banner’s tips line says the same. @path(0.6): the runtime attaches mentioned files (see runtime-and-loop.md); the chat shows thenoticeevent (“Attached src/a.ts (42 lines).”).!command(0.6): the controller (Ink) and the REPL send a line that starts with!toruntime.runUserCommand; the Ink chat is busy while it runs (Esc/Ctrl-C abort it), prints the output and says it goes with the next message. No session file is made for a command alone.- Tab (0.6,
complete.ts):complete(text, cursor, sources):/wordat the start of the line → built-in commands, custom commands and user-invocable skills (commandNames);@pathanywhere → entries of that folder in the root (rootLister: inside the root only, no.git/node_modules, hidden entries only after a.). One match completes (a command gets a space, a folder keeps its/); several complete their common start and are listed (at most 30). Ink:store.completeLine()(Shift+Tab stays plan mode); the REPL: readline’scompleter. - Details (0.9):
Renderer.details(the Ink store andPlainRenderer; default true)./details [on|off]flips or sets it. Off: atool_resultorserver_toolevent adds only the call line, unless the call failed; todo lines stay. Items already drawn do not change (<Static>). Ctrl-O keeps the last output. - Search (0.14):
/searchprintsruntime.searchStatus();/search claude|provider|offcallsruntime.setSearch(use, false)(this session);/search default <use>saves it in~/.garuda/search.json. Tab completes the words. - Thinking (0.9):
/thinkingprintsruntime.thinkingStatus();/thinking <word>callsruntime.setThinking(word)(changeThinkinginmodel/thinking.ts: on, off, show, hide, default, an effort; refused when the model cannot do it). The runtime writes athinkingrecord, and a new or resumed session carries the choice;/modelskeeps what the new model can use (fitThinking).thinking_deltaevents: the Ink store keeps them until the next text, tool call or step end, then adds the first 4 lines dimmed (thinkingPreview) and keeps the whole text for Ctrl-O; the plain renderer writes them dimmed to stderr in full. - Command palette (0.9): Ctrl-P calls
store.togglePalette():state.paletteholds the query, the matching rows and the selected row, andPaletteViewreplaces the input line. While it is open, keys go to it: text filters, Backspace, ↑/↓ (wrapping), EnterpickPalette(), Esc or Ctrl-P closes. Enter on a row withruns(no arguments or a bare toggle: help, usage, session, hooks, commands, agents, plan, build, undo, redo, init, editor, details, new, exit) sets the line to/nameand submits it; other rows set/namewith the cursor at the end. The plain chat has no palette:/and Tab do the same. - Arguments (0.8): after
/commandthe word at the cursor completes fromsources.args(command, before).CommandArgs(controller.ts, both chats) gives:/modelsaliases and model specs;/sessionsrename,deleteand session ids (then ids after rename or delete);/jobscanceland job ids;/diff last;/mcp logoutand server names;/lsp installand the languages. Choices with a hint (a session’s title, a job’s status and title) are listed one per line. Sessions and jobs come from files, sorefresh()reads them before each input line (not awaited) and completion stays synchronous; readline gets the words without hints. - Fuzzy @ (0.8): when the
@pathprefix matches no entry,fuzzyFiles(query, sources.files(), 10)searches all files of the root. A file matches when its path holds the query’s characters in order (case-insensitive); the score adds 1 per character, 5 for a run, 3 at a word start (after/ - _ .), minus the gap since the last character (at most 10), and 20 when the whole query fits in the file name, 8 more when it starts the name, minus a tenth of the name length; shorter paths win a tie.rootFiles(root)walks the root with no hidden entries, no symbolic links and no.git,node_modules,.garuda,dist,build,target,.venv,venv,__pycache__; at most 20,000 files, kept for 10 s. One match completes; several are listed one per line and the line stays as typed. - External editor (0.6,
externalEditor.ts): Ctrl-G or/editor(the controller handles it beforerunCommand) callsstore.openEditor().inkChatsetsstore.externalEdit: raw mode off, stdin paused,editInEditor(text)(a 0600 temp file,$VISUAL/$EDITOR/vias argv with quotes,runInTerminalfromsrc/sandbox/terminal.tswith the real terminal, trailing new lines dropped, the temp folder removed), then raw mode on andink.clear(). The text replaces the input line; it is not sent. A failed editor gives a warning and keeps the line. The plain REPL joins lines that end with\(prompt…). - Sessions, models, export (0.6):
/sessionsprintsruntime.listSessions()(at most 20, two lines each; the open one is marked);/sessions <n|id>callsruntime.switchSession(ref)(a number from the list, an id, or the unique start of one)./sessions rename <ref> <title>(0.8) callsruntime.renameSession;/sessions delete <ref>callsruntime.deleteSession, which asks first./modelsprintsmodelsText(runtime)fromruntime.modelList()(● marks the current model;modelFactsgives “1.0M context, $4/$20 per M tokens”);/models <ref>callsruntime.setModel(ref). The footer shows the new model after the command./compact [focus](0.8) callsruntime.compact(focus, signal); the Ink chat shows it as busy (store.begin), and Esc or Ctrl-C aborts the summary request throughCommandContext.signal./export [file]loadssrc/cli/export.tswithimport():sessionMarkdown(records, id)builds the Markdown from the session records (so it is redacted; notes and file text are left out, but each attachment and!commandgets a line; undo, redo, compaction, a model change and a stopped turn get an italic line), andwriteExportwrites it inside the root with thewxflag (never overwrites). /diff(0.6):runtime.diff(scope, path)gives the file stats and the patch;commands.tsprints the list (A/M/D,+/−counts, “binary”) and the colored patch (colorDiff), cut atDIFF_LINES(300). The Ink controller passesoutputin theCommandContext: it prints the text as it is and keeps the full patch for Ctrl-O (store.keepOutput). The plain chat prints withrenderer.info./languages(0.16):runtime.knowledge.languageStatuses()reports all registered language plugins (built-in, user, project), their supported extensions, active/standby state, and file counts. Also reports any warnings (such as unapproved project plugins). Runs immediately from the command palette.- Interactive patch staging (0.14,
hunkStaging.ts; enforced in the merge gate, U0): In the Ink approval view of anedit_filecall whose request hasselectHunks(the tool can apply single hunks, and the preview is complete), pressingh(orp) opensHunkStagingView:yor Enter stages the current hunk and advances.nskips the current hunk without staging.astages all remaining hunks in one keypress.ddiscards/skips all remaining hunks.←/→/↑/↓navigates between hunks without modifying staging state.qor Esc exits hunk review back to standard approval mode. At the endfinishHunkReviewanswers: all staged → “once” (the whole edit); none → “deny”; some →request.selectHunks(ids)then “once”, andedit_filewrites only those hunks. The parser treats---/+++as file headers only before the first@@, so a removed-- commentline is kept.
- Type-ahead at start: keys typed before raw mode arrive as one chunk with
\n(cooked mode);typeAheadtreats each line end as Enter. Bracketed paste (usePaste) inserts text and never submits. - Ctrl-C: busy →
onInterrupt; idle with text → clear the line; idle and empty → exit on a second press within 2 s. Ctrl-D on an empty line exits.
test/notifyDiff.test.ts (0.6: notification channel, bytes, rules and wiring; /diff),
test/focus.test.tsx (0.6: focus codes, terminals, a quiet notifier while focused, the codes in real Ink input),
test/chat.test.tsx (markdown, editor, store, type-ahead, Ink integration with ink-testing-library),
test/hunkStaging.test.tsx (0.14: hunk parser, staging state transitions, interactive keyboard navigation), test/hunkApproval.test.ts (U0: the file on disk after each choice),
test/m5.acceptance.test.ts (plain chat and -p), test/cli.test.ts. The cloud workspace also
drives the Ink chat in a real pseudo-terminal for smoke tests.