|
| 1 | +# Extending the CLI |
| 2 | + |
| 3 | +How to add the three most common things. Patterns are stable; if you need to |
| 4 | +deviate, find an existing example first and follow it. |
| 5 | + |
| 6 | +## Add a tool |
| 7 | + |
| 8 | +Tools are self-contained modules in `src/tools/`. Each one exports a `Tool` |
| 9 | +that the agent registry picks up. The canonical small example is |
| 10 | +`src/tools/glob.ts`. Copy its shape: |
| 11 | + |
| 12 | +```ts |
| 13 | +// src/tools/my-tool.ts |
| 14 | +import { Type } from "@sinclair/typebox"; |
| 15 | +import type { Tool } from "./types.js"; |
| 16 | + |
| 17 | +export const myTool: Tool = { |
| 18 | + name: "my_tool", |
| 19 | + description: "One-line summary the model sees. Be specific; this drives selection.", |
| 20 | + parameters: Type.Object({ |
| 21 | + target: Type.String({ description: "What to operate on" }), |
| 22 | + mode: Type.Optional(Type.Union([Type.Literal("fast"), Type.Literal("safe")])), |
| 23 | + }), |
| 24 | + effects: ["reads_fs"], // or ["writes_fs"], ["runs_shell"], ["network"] |
| 25 | + execute: async (args, ctx) => { |
| 26 | + // ctx.cwd, ctx.permissions, ctx.abortSignal, ctx.tasks, ctx.fileStateCache |
| 27 | + // Return a string (default), or { content, isError } for richer outputs. |
| 28 | + return "result string"; |
| 29 | + }, |
| 30 | +}; |
| 31 | +``` |
| 32 | + |
| 33 | +Then register it in `src/tools/registry.ts` (alphabetical, please): |
| 34 | + |
| 35 | +```ts |
| 36 | +import { myTool } from "./my-tool.js"; |
| 37 | +// ... |
| 38 | +export const BUILTIN_TOOLS = [ |
| 39 | + // ... |
| 40 | + myTool, |
| 41 | +]; |
| 42 | +``` |
| 43 | + |
| 44 | +**Test it.** Drop `src/tools/my-tool.test.ts` next to the source. Use |
| 45 | +`makeToolContext` from `src/tools/__test__/mock-tool-context.ts` for the |
| 46 | +context fixture — don't hand-roll `{} as any`. Vitest convention is |
| 47 | +`describe(toolName, () => { ... })`. |
| 48 | + |
| 49 | +**Display polish.** Add a present-tense + past-tense label in |
| 50 | +`src/ui/tool-labels.ts`: |
| 51 | + |
| 52 | +```ts |
| 53 | +// In toolActionLabel: |
| 54 | +case "my_tool": |
| 55 | + return `Doing ${displayPath(str("target"))}`; |
| 56 | +// In toolActionPast: |
| 57 | +case "my_tool": |
| 58 | + return `Did ${displayPath(str("target"))}`; |
| 59 | +``` |
| 60 | + |
| 61 | +If your tool reads files and produces deterministic small output that |
| 62 | +collapses well in runs, add it to `COLLAPSIBLE_READ_TOOLS` in |
| 63 | +`src/ui/tool-call-line.tsx`. |
| 64 | + |
| 65 | +## Add a slash command |
| 66 | + |
| 67 | +Commands live in `src/commands/builtins.ts`. Add one: |
| 68 | + |
| 69 | +```ts |
| 70 | +const myCommand: Command = { |
| 71 | + name: "my-thing", |
| 72 | + aliases: ["mt"], |
| 73 | + description: "What this does, ≤ 60 chars — shown in /help.", |
| 74 | + // mutates: true, // only if it changes session state (clear, compact) |
| 75 | + handler: async (args, ctx) => { |
| 76 | + // ctx.bundle, ctx.state, ctx.emit, ctx.clearDisplay, ctx.exit, ctx.registry |
| 77 | + ctx.emit("did the thing"); |
| 78 | + return { handled: true }; |
| 79 | + }, |
| 80 | +}; |
| 81 | +``` |
| 82 | + |
| 83 | +Then add it to `BUILTIN_COMMANDS` (alphabetical). The registry handles |
| 84 | +parsing, dispatch, unknown-command typo suggestions, and the help listing. |
| 85 | + |
| 86 | +Slash commands that need to render persistent assistant-style messages |
| 87 | +should call `bundle.appendSyntheticAssistantMessage(...)` (TODO: check the |
| 88 | +exact name in `src/agent/agent.ts`) rather than `emit()`, which is for |
| 89 | +short status lines. |
| 90 | + |
| 91 | +**Test it.** `src/commands/registry.test.ts` shows the pattern. Use |
| 92 | +`fakeCtx()` to construct the context; only fill the fields your handler |
| 93 | +reads. |
| 94 | + |
| 95 | +## Add an LLM provider |
| 96 | + |
| 97 | +The agent never talks to a provider directly — `@earendil-works/pi-ai` |
| 98 | +does. If the provider has an OpenAI-compatible API, you usually don't need |
| 99 | +to add anything: users set `OPENAI_BASE_URL` + `OPENAI_API_KEY` and the |
| 100 | +existing path handles it. |
| 101 | + |
| 102 | +For a provider that needs a new protocol adapter, the work lives upstream |
| 103 | +in pi-ai, not here. Open an issue / PR on `@earendil-works/pi-ai` first; |
| 104 | +once it lands, expose it in our config layer: |
| 105 | + |
| 106 | +- Add a model entry to `src/config/models.ts` (look for the existing |
| 107 | + Groq / Anthropic / OpenRouter entries as templates). |
| 108 | +- Add credential resolution to `src/auth/credentials.ts` if it needs |
| 109 | + a non-standard env var or OAuth flow. |
| 110 | +- Add provider-specific display tweaks (icon, label, etc.) if the |
| 111 | + default rendering looks off. |
| 112 | + |
| 113 | +The first-run wizard (`src/ui/FirstRunSetup.tsx`) auto-detects available |
| 114 | +provider credentials and offers them as options — no hardcoded provider |
| 115 | +list to maintain in the wizard. |
| 116 | + |
| 117 | +## Add a permission policy |
| 118 | + |
| 119 | +Effect-based, not tool-name-based. Policies live in |
| 120 | +`src/permissions/policies.ts`. To pre-approve a category: |
| 121 | + |
| 122 | +```ts |
| 123 | +{ |
| 124 | + match: (req) => req.effects.includes("reads_fs") && isInsideCwd(req.args.path), |
| 125 | + decision: "allow", |
| 126 | + reason: "read-only access inside cwd is auto-approved", |
| 127 | +} |
| 128 | +``` |
| 129 | + |
| 130 | +To pre-deny: |
| 131 | + |
| 132 | +```ts |
| 133 | +{ |
| 134 | + match: (req) => req.effects.includes("runs_shell") && isDangerous(req.args.command), |
| 135 | + decision: "deny", |
| 136 | + reason: "dangerous shell command", |
| 137 | +} |
| 138 | +``` |
| 139 | + |
| 140 | +The store evaluates policies in order until one matches; otherwise the |
| 141 | +user is prompted. |
| 142 | + |
| 143 | +## Add a glue task |
| 144 | + |
| 145 | +Glue is the cheap/fast sidecar model. Routing, narration, plan-mode Q&A, |
| 146 | +prompt suggestions all live here. |
| 147 | + |
| 148 | +A glue call should: |
| 149 | +- Have a graceful fallback when the glue model is unconfigured or fails. |
| 150 | +- Be cheap to abort — wrap the network call with `AbortController`. |
| 151 | +- Never feed its output into the main agent's context. Glue is for *UI |
| 152 | + enrichment*, not agent reasoning. |
| 153 | + |
| 154 | +The pattern in `src/agent/prompt-suggestion.ts` is the template: small |
| 155 | +prompt, abort signal threaded through, silent failure mode. |
| 156 | + |
| 157 | +## Add a microbenchmark |
| 158 | + |
| 159 | +If you're touching a hot path (reducer, coalesce, diff, wrap), add a bench |
| 160 | +case to `src/agent/events.bench.ts` (or a peer `*.bench.ts` file). |
| 161 | +`npm run bench:micro` runs them. They're not part of `npm test` so they |
| 162 | +don't slow CI — but they give us a baseline number to spot regressions. |
| 163 | + |
| 164 | +## What NOT to extend |
| 165 | + |
| 166 | +- **Don't add a new chat-state field unless every reducer branch handles it.** |
| 167 | + ChatState is the single source of truth for what the UI shows; partial |
| 168 | + fields produce mysterious render bugs. |
| 169 | +- **Don't reach into pi-agent-core's internals.** If you need something the |
| 170 | + public API doesn't give you, the fix is upstream. |
| 171 | +- **Don't add a separate persistence file format for new state.** Sessions, |
| 172 | + history, memory, credentials, and tasks each have one canonical JSON |
| 173 | + schema. Add fields, don't add sidecars. |
0 commit comments