Automatic codebase structure context via precis

A CLI tool that extracts a token-efficient summary of a path, designed to replace most Explore agent use with a single fast command. It uses tree-sitter to parse source files, extracts structural symbols (functions, types, interfaces, headings), and ranks them by importance to fit within a token budget.
Upgrading from v0.1? The flags and the output notation changed; see CHANGELOG.md.
Here's what precis shows for developit/mitt, a tiny TypeScript event emitter, at a 1200-token budget:
<!-- precis-example-start -->
.editorconfig
.eslintrc
.github/
PULL_REQUEST_TEMPLATE.md
workflows/
compressed-size.yml
main.yml
.gitignore
LICENSE
README.md
…
9→# Mitt
10→
11→> Tiny 200b functional event emitter / pubsub.
12→
13→- **Microscopic:** weighs less than 200 bytes gzipped
14→- **Useful:** a wildcard `"*"` event type listens to all events
15→- **Familiar:** same names & ideas as [Node's EventEmitter](https://nodejs.org/api/events.html#events_class_eventemitter)
16→- **Functional:** methods don't rely on `this`
17→- **Great Name:** somehow [mitt](https://npm.im/mitt) wasn't taken
18→
19→Mitt was made for the browser, but works in any JavaScript runtime. It has no dependencies and supports IE9+.
21→## Table of Contents
…
30→## Install
…
56→## Usage
…
81→### Typescript
…
113→## Examples & Demos
…
123→## API
…
127→#### Table of Contents
…
138→### mitt
139→
140→Mitt: Tiny (~200b) functional event emitter / pubsub.
141→
142→Returns **Mitt**
143→
144→### all
145→
146→A Map of event names to registered handler functions.
147→
148→### on
…
157→### off
…
167→### emit
…
179→## Contribute
…
184→### Reporting Issues
…
189→### Submitting pull requests
…
203→## License
…
package.json
…
2→ "name": "mitt",
3→ "version": "3.0.1",
4→ "description": "Tiny 200b functional Event Emitter / pubsub.",
5→ "module": "dist/mitt.mjs",
6→ "main": "dist/mitt.js",
7→ "jsnext:main": "dist/mitt.mjs",
8→ "umd:main": "dist/mitt.umd.js",
9→ "source": "src/index.ts",
10→ "typings": "index.d.ts",
11→ "exports": {
12→ "types": "./index.d.ts",
13→ "module": "./dist/mitt.mjs",
14→ "import": "./dist/mitt.mjs",
15→ "require": "./dist/mitt.js",
16→ "default": "./dist/mitt.mjs"
17→ },
18→ "scripts": {
19→ "test": "npm-run-all --silent typecheck lint mocha test-types",
20→ "mocha": "mocha test",
21→ "test-types": "tsc test/test-types-compilation.ts --noEmit --strict",
22→ "lint": "eslint src test --ext ts --ext js",
23→ "typecheck": "tsc --noEmit",
24→ "bundle": "microbundle -f es,cjs,umd",
25→ "build": "npm-run-all --silent clean -p bundle -s docs",
26→ "clean": "rimraf dist",
27→ "docs": "documentation readme src/index.ts --section API -q --parse-extension ts",
28→ "release": "npm run -s build -s && npm t && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish"
29→ },
…
41→ "license": "MIT",
42→ "files": [
43→ "dist",
44→ "index.d.ts"
45→ ],
…
src/
index.ts
1→export type EventType = string | symbol;
…
5→export type Handler<T = unknown> = (event: T) => void;
6→export type WildcardHandler<T = Record<string, unknown>> = (
…
12→export type EventHandlerList<T = unknown> = Array<Handler<T>>;
13→export type WildCardEventHandlerList<T = Record<string, unknown>> = Array<
14→ WildcardHandler<T>
15→>;
…
18→export type EventHandlerMap<Events extends Record<EventType, unknown>> = Map<
…
23→export interface Emitter<Events extends Record<EventType, unknown>> {
…
46→export default function mitt<Events extends Record<EventType, unknown>>(
47→ all?: EventHandlerMap<Events>
48→): Emitter<Events> {
…
test/
index_test.ts
test-types-compilation.ts
tsconfig.json
<!-- precis-example-end -->
The file tree shows every file in the project; the README's lede and headings say what the package is and how its docs are organized; package.json identifies the package and its entry points; and src/index.ts shows its exported types and the signature of mitt itself. Line numbers make every entry a precise jump target for follow-up reads.
A … row means "there is more here that isn't shown": source inside a file, or further entries in a directory. A jump in line numbers without a … row skips only blank lines. A source line longer than 500 characters is cut short with …. A row like src/main/java/ is a chain of directories that each hold only the next. An entry with nothing under it wasn't expanded (or, for a directory, couldn't be read), unless it is marked (empty): a zero-byte file, or a directory holding nothing precis lists. A directory marked (submodule) holds nothing precis lists and is named in the root .gitmodules: a git submodule, usually one that isn't checked out, whose content lives in another repository. A link to a file in its own directory (CLAUDE.md -> AGENTS.md) shows its text only under the target.
The precis plugin automatically puts a structural overview of your project into Claude's context at the start of every session, and again after /clear. This eliminates the need for long, manually-maintained CLAUDE.md files describing your codebase, and removes the overhead of Explore agents. The plugin also gives Claude a summarize tool so it can zoom into specific directories or files on demand. The tool summarizes a path Claude's Read tool may read without asking, refuses one your Read deny rules cover, and leaves any other to your permission mode, as it would any MCP tool call: the permission prompt, auto mode's classifier, or a refusal where nobody can be asked. Only that path is checked: a deny rule on a file inside a summarized directory, or inside the working directory for the overview, doesn't hide the file.
The plugin comes in one build per machine:
| Machine | Plugin |
|---|---|
| macOS, Apple Silicon | precis-aarch64-apple-darwin |
| macOS, Intel | precis-x86_64-apple-darwin |
| Linux, arm64 | precis-aarch64-unknown-linux-gnu |
| Linux, x86_64 | precis-x86_64-unknown-linux-gnu |
Install yours, for example on an Apple Silicon Mac:
claude plugin marketplace add Crazytieguy/precis
claude plugin install precis-aarch64-apple-darwin@precis
Install it for your user, not in a checked-in .claude/settings.json: teammates on other machines need a different build. Below, <plugin> stands for your build's name from the table.
Each build carries the precis binary for its machine, so it works offline from the first session. New versions arrive as plugin updates: turn on auto-update for the precis marketplace (/plugin, Marketplaces tab), or run claude plugin update <plugin>@precis now and then. If you install the wrong build, a row above the prompt says so, and its Fix button installs the right one. On other platforms, install the standalone CLI and configure your agent as below.
The overview and the tool default to a 3000-token budget. To change it, set the plugin's token_budget option in /config, with /plugin configure <plugin>@precis, or when installing with --config token_budget=6000.
Where an organization manages Claude Code, a plugin you install yourself can't add to a session's starting context: you get the summarize tool but no overview, unless the organization enables precis in its managed settings.
Upgrading from the precis plugin: it keeps working, and a row above the prompt offers the build for your machine. Switch installs that build and takes precis out; where a checked-in .claude/settings.json enables precis@precis, it disables it in your local settings and leaves that file alone. By hand: claude plugin install <plugin>@precis, then claude plugin uninstall precis@precis (or claude plugin disable precis@precis --scope local if the project enables it). If your CLAUDE.md tells Claude to run precis, point it at the summarize tool instead, or install the standalone CLI.
cargo install precis
Or with Homebrew:
brew install Crazytieguy/tap/precis
Or with the install script:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/Crazytieguy/precis/releases/latest/download/precis-installer.sh | sh
Add this to your CLAUDE.md, AGENTS.md, or equivalent:
## Codebase exploration
Always use `precis` for codebase exploration. Run `precis .` for a full overview, or `precis src/some/directory` to zoom into a specific area.
precis . # summarize the current directory
precis ./src # zoom into a subdirectory
precis ./src/main.rs # or into a single file
precis . --token-budget 8000 # with a larger token budget
precis . --char-budget 9000 # also cap the output's length
The default budget is 3000 BPE tokens (o200k_base tokenizer). Output is plain text with line numbers preserving source indentation. --char-budget counts UTF-16 code units, the unit Claude Code measures hook output in.
Given a single file, precis shows its structure first, then spends whatever budget is left on the file's text from the top, so a file that fits prints whole. A binary file prints only its name.
When the path is the root of a git repository, precis honours .gitignore (including nested ones, .git/info/exclude, and your global excludes file), so build output, virtualenvs and dependency trees don't eat the budget. precis matches those patterns without reading the git index, so a file git tracks but a .gitignore pattern matches (one added with git add -f) is hidden. A path below the repository root, such as ./src, is walked without the repository's ignore rules. In any tree, target, node_modules, dist, build, .next and __pycache__ directories are listed but never expanded (except a build that holds Rust source files). .git/ itself never appears in a listing. Non-ignored dotfiles such as .github/ and .gitignore are repository content and are treated like any other file.
Nothing outside the path appears: a link that resolves outside it or onto an ignored entry, and a broken link, are left out of the tree, as are FIFOs, sockets and devices. A credential file appears by name only, whichever kind of file precis would read it as and even when it is the path given: dotenv files (.env, .env.local, production.env), data, config and script files named for secrets or credentials (secrets.yml, credentials.json, creds_staging.conf, an extensionless secrets script) or for tokens, passwords or API keys (token.txt, password.txt, api_key.txt, auth.json), .key and .pem files, Terraform .tfvars, service-account JSON keys, tool auth files (.npmrc, .netrc, .pypirc, .git-credentials, .htpasswd, .pgpass, .my.cnf, kubeconfig), links to any of these, and any file holding a PEM or PGP private key. Samples (.env.example, or any name ending .sample, .template or .dist) and source code or documents named after credentials (credentials.py, secrets.md) show as usual. A password written into a URL (postgresql://admin:REDACTED@db/app) shows as …, and so does a quoted literal assigned to a credential-named key in code or config (password: "hunter2", API_KEY = "sk-…"); documents keep theirs, which are placeholders. Other secrets inside files named otherwise (an unquoted value, or a key not named for a credential) are not detected.
.ts, .tsx, .mts, .cts, .js, .jsx, .mjs, .cjs), Python (.py, .pyi), Go, C (.c, and .h unless it declares C++) and Lua: module docs, declaration names and signatures, then doc comments and bodies as the budget allows.README.md, README.rst, README.adoc, or an extensionless README): its lede, heading outline, the commands under its build, test, run and development headings (else the checkout commands, such as a clone, make or a build, test or run step, under its setup or install headings), and section bodies. A block under a heading that names a platform (Building on Windows) is taken only when no other is found. A root build or contributing guide (BUILDING.md, INSTALL.md, CONTRIBUTING.md, README.DEVELOPER, …) shows the commands of its first such section only. Other documents appear in the directory tree; name one directly (precis docs/guide.md) to summarize it.package.json, Cargo.toml, pyproject.toml (and any TOML that declares a package), go.mod / go.work and Prisma schemas: identity, entry points, scripts and dependencies. Small root JSON configs render whole. A manifest in another format at the root, in src/ or in the directory named after the repository (pom.xml, composer.json, *.cabal, build.sbt, CMakeLists.txt, action.yml, R's DESCRIPTION, rebar.config, dune-project, deps.edn, pubspec.yaml, shard.yml, *.nimble) shows its leading fields, or the whole file when it is short..nvmrc, .python-version, .tool-versions) and pnpm-workspace.yaml.Other files, such as YAML, XML other than such a manifest, templates and documents other than the root README, appear in the directory tree by name only until the budget outlasts everything above. What is left over then shows their first lines, leaving out as … a line an earlier file already showed, except for licenses, credential files, generated files and anything under a dot-prefixed name (CI workflows, ignore lists, editor and lint config).
hooks/precis.ts 214 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { drawOffer, platformTarget, refreshOffer } from './switch'
4
5const TOOL = 'mcp__precis__summarize'
6
7/** What the engine answers a call of a plugin's tool that its permission check let through. */
8const UNSERVED = 'no tool.call hook answered this call'
9
10/** The tool's arguments, as the model gives them and as the permission check reads them. */
11type Args = { path?: string; token_budget?: number }
12
13/** This machine's target, and the binary bundle this plugin carries, if any. */
14type Platform = { target: string | undefined; bundle: { target: string; path: string } | undefined }
15
16/** What the session needs once: where it runs, and the binary that runs there. */
17type Setup = { platform: Promise<Platform>; binary: Promise<string | undefined> }
18
19export const register: Register = (on, options) => {
20 const tokenBudget = Number(options.token_budget)
21 let setup: Setup | undefined
22 // The input each call's permission check read, by `tool_use_id`: a
23 // PreToolUse hook's rewrite shows here and not on the call's own `e`.
24 const checked = new Map<string, Args>()
25
26 drawOffer(on)
27
28 on('session.start', async ($, e, next) => {
29 const bin = await (setup ??= setUp($)).binary
30 if (bin !== undefined) {
31 await $.tool.register({
32 name: 'summarize',
33 description: await about($, bin),
34 inputSchema: {
35 type: 'object',
36 properties: {
37 path: {
38 type: 'string',
39 description: 'Directory or file to summarize. Default: the working directory.',
40 },
41 token_budget: {
42 type: 'integer',
43 minimum: 1,
44 description: `Output budget in o200k_base tokens. Default: ${tokenBudget}.`,
45 },
46 },
47 },
48 isDeferred: false,
49 })
50 }
51 return next(e)
52 })
53
54 on('tool.check', { tool: TOOL }, ($, e, next) => {
55 if (e.tool_use_id !== undefined) checked.set(e.tool_use_id, e.input as Args)
56 return next(e)
57 })
58
59 on('tool.call', { tool: TOOL }, async ($, e, next) => {
60 const args = e as Args
61 const bin = await (setup ??= setUp($)).binary
62 if (bin === undefined) return { deny: 'precis has no binary to run' }
63 const path = await expandHome($, args.path ?? '.')
64 const read = await readDecision($, path)
65 if ('deny' in read) return read
66 if (read.decision === 'ask') {
67 const decided = await next(e)
68 const input = checked.get(e.tool_use_id)
69 checked.delete(e.tool_use_id)
70 if (decided.isError !== true || !decided.text?.includes(UNSERVED)) return decided
71 if (input === undefined || input.path !== args.path || input.token_budget !== args.token_budget) {
72 return { deny: 'A hook changed the path or budget before the permission check; precis runs only what it checked' }
73 }
74 }
75 const run = await $.process.run(
76 [bin, '--token-budget', String(args.token_budget ?? tokenBudget), '--', path],
77 { timeoutMs: 120_000 },
78 )
79 if (run.exitCode !== 0) return { deny: run.stderr.trim() }
80 return { result: run.stdout === '' ? run.stderr.trim() : run.stdout }
81 })
82
83 // The main conversation's context, not subagents'. A compacted conversation
84 // keeps what it needs of the overview in its summary, and a resumed one
85 // still holds it.
86 on('classic.SessionStart', async ($, e, next) => {
87 if (e.source !== 'startup' && e.source !== 'clear') return next(e)
88 const { platform, binary } = (setup ??= setUp($))
89 const [below, overview] = await Promise.all([
90 next(e),
91 workingDirOverview($, binary, tokenBudget),
92 offerFix($, platform),
93 ])
94 if (overview === undefined) return below
95 return { ...below, additionalContext: [...(below.additionalContext ?? []), overview] }
96 })
97}
98
99function setUp($: EngineInterface): Setup {
100 const platform = detectPlatform($)
101 return { platform, binary: bundledBinary($, platform) }
102}
103
104async function workingDirOverview(
105 $: EngineInterface,
106 binary: Promise<string | undefined>,
107 tokenBudget: number,
108): Promise<string | undefined> {
109 const bin = await binary
110 if (bin === undefined) return undefined
111 const run = await $.process
112 .run([bin, '--token-budget', String(tokenBudget), '.'], { timeoutMs: 60_000 })
113 .catch((error: unknown) => {
114 $.ui.log(`precis: ${String(error)}`)
115 return undefined
116 })
117 if (run === undefined) return undefined
118 if (run.exitCode !== 0) {
119 $.ui.log(`precis: ${run.stderr.trim()}`)
120 return undefined
121 }
122 if (run.stdout === '') return undefined
123 const fence = '`'.repeat(Math.max(3, longestBacktickRun(run.stdout) + 1))
124 return `## precis\n\nOutput of \`${TOOL}\` on the working directory:\n\n${fence}\n${run.stdout.trimEnd()}\n${fence}`
125}
126
127/**
128 * The precis binary: `PRECIS_DEV` when set, for a plugin loaded from source,
129 * which bundles none; else the one this plugin bundles for this machine,
130 * unpacked on first use. A bundle for another machine is the switch row's to
131 * report.
132 */
133async function bundledBinary($: EngineInterface, platform: Promise<Platform>): Promise<string | undefined> {
134 const dev = await $.env.get('PRECIS_DEV')
135 if (dev !== undefined) return dev
136 const { target, bundle } = await platform
137 if (target === undefined) {
138 $.ui.log('precis: no plugin build for this machine; install the standalone CLI')
139 return undefined
140 }
141 if (bundle === undefined) {
142 $.ui.log('precis: no binary bundled; set PRECIS_DEV to run the plugin from source')
143 return undefined
144 }
145 if (bundle.target !== target) return undefined
146 const run = await $.process.run(['bash', `${$.plugin.root}/scripts/unpack.sh`, bundle.path])
147 if (run.exitCode === 0) return run.stdout.trim()
148 $.ui.log(`precis: ${run.stderr.trim()}`)
149 return undefined
150}
151
152async function detectPlatform($: EngineInterface): Promise<Platform> {
153 const bin = `${$.plugin.root}/bin`
154 const [target, entries] = await Promise.all([platformTarget($), $.fs.list(bin).catch(() => [])])
155 for (const { name } of entries) {
156 const bundled = name.match(/^precis-(.+)\.tar\.xz$/)
157 if (bundled) return { target, bundle: { target: bundled[1]!, path: `${bin}/${name}` } }
158 }
159 return { target, bundle: undefined }
160}
161
162/** Offers this machine's plugin in place of one bundling another's binary. */
163async function offerFix($: EngineInterface, platform: Promise<Platform>): Promise<void> {
164 const { target, bundle } = await platform
165 const isWrong = target !== undefined && bundle !== undefined && bundle.target !== target
166 await refreshOffer(
167 $,
168 isWrong
169 ? {
170 severity: 'problem',
171 text: 'wrong platform variant installed',
172 label: 'Fix',
173 from: `precis-${bundle.target}@precis`,
174 to: `precis-${target}@precis`,
175 }
176 : undefined,
177 )
178}
179
180/** The part of `precis --help` above its usage line: what it does and the notation. */
181async function about($: EngineInterface, bin: string): Promise<string> {
182 const { stdout } = await $.process.run([bin, '--help'])
183 return stdout.split('\n\nUsage:')[0]!.trim()
184}
185
186async function expandHome($: EngineInterface, path: string): Promise<string> {
187 if (path !== '~' && !path.startsWith('~/')) return path
188 return `${await $.env.get('HOME')}${path.slice(1)}`
189}
190
191/**
192 * Read's decision on `path`, or why precis may not read it. A plugin's own
193 * tool reaches the permission check only through `next`, which ends at the
194 * engine's error for a call no hook served, so the tool follows Read's
195 * decision on the same path and puts only Read's ask to the permission mode:
196 * the dialog, the auto-mode classifier, a headless host's refusal. Plan mode
197 * refuses every call that reaches it, so an allow skips it.
198 */
199async function readDecision(
200 $: EngineInterface,
201 path: string,
202): Promise<{ decision: 'allow' | 'ask' } | { deny: string }> {
203 const stat = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
204 if (stat === undefined) return { deny: `${path} does not exist` }
205 const target = stat.realPath ?? path
206 const { decision, reason } = await $.tool.check({ tool: 'Read', input: { file_path: target } })
207 if (decision === 'deny') return { deny: reason ?? `Reading ${target} is denied` }
208 return { decision }
209}
210
211function longestBacktickRun(text: string): number {
212 return Math.max(0, ...(text.match(/`+/g) ?? []).map(run => run.length))
213}
214hooks/switch.tsx 103 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, PluginState } from 'claude-code'
3
4import { noticeRows } from './notice-band'
5
6// Moving an install onto the precis plugin built for this machine, offered as
7// a row in the band above the prompt. Both plugins carry this file;
8// plugin_shared_files_match keeps the copies identical.
9
10/** A plugin to put in another's place, and the row that offers it. */
11export type Switch = Omit<NonNullable<PluginState['precis']['offer']>, 'busy'>
12
13const offer = atom({ plugin: 'precis', key: 'offer' } as const, null)
14const dismissed = atom({ plugin: 'precis', key: 'dismissed' } as const, false)
15
16/** The target triple the `precis-<target>` plugins are named by, or undefined off macOS and Linux on x86_64 or arm64. */
17export async function platformTarget($: EngineInterface): Promise<string | undefined> {
18 const { stdout } = await $.process.run(['uname', '-sm']).catch(() => ({ stdout: '' }))
19 const [os, arch] = stdout.trim().split(/\s+/)
20 const osPart = os === 'Darwin' ? 'apple-darwin' : os === 'Linux' ? 'unknown-linux-gnu' : undefined
21 const archPart = arch === 'x86_64' ? 'x86_64' : arch === 'arm64' || arch === 'aarch64' ? 'aarch64' : undefined
22 return osPart && archPart ? `${archPart}-${osPart}` : undefined
23}
24
25/** Puts `found` in the band, or takes the row down when there is none. */
26export async function refreshOffer($: EngineInterface, found: Switch | undefined): Promise<void> {
27 await update($, offer, () => found ?? null)
28}
29
30/** Draws the offered switch as a band row whose button makes it. */
31export function drawOffer(on: On): void {
32 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
33 const [below, current, isDismissed] = await Promise.all([next(e), read($, offer), read($, dismissed)])
34 if (current === null || isDismissed) return below
35
36 const ui = $.ui.resolve(e)
37 const notice = {
38 id: 'switch',
39 severity: current.severity,
40 text: current.text,
41 actions: [{ id: 'switch', label: current.label, kind: 'plugin' as const }],
42 busy: current.busy,
43 }
44 const rows = noticeRows(ui, 'precis', [notice], {
45 onAction: () => makeSwitch($, current),
46 onDismiss: () => update($, dismissed, () => true),
47 })
48 return (
49 <ui.Box flexDirection="column">
50 {rows}
51 {below}
52 </ui.Box>
53 )
54 })
55}
56
57async function makeSwitch($: EngineInterface, current: Switch) {
58 const setBusy = (busy: string | undefined) =>
59 update($, offer, value => (value === null ? value : { ...value, busy }))
60 await setBusy('switching…')
61 const error = await switchPlugin($, current.from, current.to).catch(String)
62 if (error !== undefined) {
63 await setBusy(undefined)
64 $.ui.toast(`Switch failed: ${error}`)
65 return
66 }
67 // Both plugins are named precis and so share this state: the row goes
68 // before the reload hands it to the plugin just installed.
69 await update($, offer, () => null)
70 $.ui.toast(`Switched to ${current.to.replace(/@precis$/, '')}`)
71 await $.command.run({ command: 'reload-plugins' })
72}
73
74/**
75 * Installs `to` where `from` is enabled and takes `from` out there.
76 * Resolves the error text when a step fails.
77 *
78 * A platform's plugin in a checked-in project file would break teammates on
79 * other machines, so a project that enables `from` gets `to` installed and
80 * `from` disabled in local settings, its own file untouched.
81 */
82async function switchPlugin($: EngineInterface, from: string, to: string): Promise<string | undefined> {
83 const sources = ['user', 'project', 'local'] as const
84 const settings = await Promise.all(sources.map(source => $.settings.read({ source })))
85 const enabledIn = sources.filter((_, index) => {
86 const enabled = settings[index]!.enabledPlugins as Record<string, unknown> | undefined
87 return enabled?.[from] === true
88 })
89 const scope = enabledIn.includes('user') ? 'user' : 'local'
90 // A local uninstall clears the local entry a disable writes, so the
91 // disable comes last.
92 const steps = [
93 ['install', to, '--scope', scope],
94 ...enabledIn.filter(source => source !== 'project').map(source => ['uninstall', from, '--scope', source]),
95 ...(enabledIn.includes('project') ? [['disable', from, '--scope', 'local']] : []),
96 ]
97 for (const step of steps) {
98 const { exitCode, stdout, stderr } = await $.process.run(['claude', 'plugin', ...step], { timeoutMs: 120_000 })
99 if (exitCode !== 0) return (stderr.trim() || stdout.trim() || `claude plugin ${step[0]} failed`).split('\n')[0]
100 }
101 return undefined
102}
103hooks/notice-band.tsx 92 lines1import type { EngineInterface, RenderElement } from 'claude-code'
2
3// One plugin's rows in the band above the prompt, drawn without `$`: each
4// plugin's own `ui.render` hook reads its state and stacks this over what the
5// plugins beneath drew, so third-party plugins on the band keep working.
6//
7// A plugin cannot import another's files, so each plugin that shows notices
8// carries this file; plugins/notice-band.test.ts keeps the copies identical.
9// The `notices` its check prints are this file's `Notice` as JSON.
10
11export type Severity = 'urgent' | 'problem' | 'action' | 'info'
12
13export type NoticeAction = {
14 id: string
15 label: string
16 // command: a slash command; copy: `command` goes on the clipboard, for a
17 // terminal command that is interactive; plugin: the plugin's own handler decides.
18 kind: 'command' | 'copy' | 'plugin'
19 command?: string
20}
21
22export type Notice = {
23 id: string
24 severity: Severity
25 // The whole message: rows wrap rather than hide what the person needs to act.
26 text: string
27 actions?: NoticeAction[]
28 // Set on "updated" notices: a dismissal holds until the plugin's next
29 // version. Without it, a dismissal holds for this session only.
30 version?: string
31 // What a pressed button is doing ("restarting…"): drawn after the text in
32 // place of the buttons, so the row never looks inert. Set by the plugin.
33 busy?: string
34}
35
36export type NoticeHandlers = {
37 onAction: (notice: Notice, action: NoticeAction) => unknown
38 onDismiss: (notice: Notice) => unknown
39}
40
41const MARK: Record<Severity, { rank: number; glyph: string; color: string }> = {
42 urgent: { rank: 0, glyph: '✖', color: 'error' },
43 problem: { rank: 1, glyph: '!', color: 'warning' },
44 action: { rank: 2, glyph: '›', color: 'suggestion' },
45 info: { rank: 3, glyph: '↑', color: 'ide' },
46}
47
48export function noticeRows(
49 ui: ReturnType<EngineInterface['ui']['resolve']>,
50 plugin: string,
51 notices: Notice[],
52 { onAction, onDismiss }: NoticeHandlers,
53): RenderElement[] {
54 const { Box, Button, Text } = ui
55
56 return [...notices]
57 .sort((a, b) => MARK[a.severity].rank - MARK[b.severity].rank)
58 .map(notice => {
59 const mark = MARK[notice.severity]
60 const key = `${plugin}:${notice.id}`
61
62 const buttons = (notice.busy ? [] : (notice.actions ?? [])).map((action, index) => (
63 <Button
64 key={`${key}:${action.id}`}
65 label={action.label}
66 variant={index === 0 ? 'primary' : 'secondary'}
67 onPress={() => onAction(notice, action)}
68 />
69 ))
70 // An info row is a state, not a request: the band's own collapse hides it.
71 if (notice.severity !== 'info' && !notice.busy) {
72 buttons.push(<Button key={`${key}:dismiss`} label="Dismiss" dimColor onPress={() => onDismiss(notice)} />)
73 }
74
75 // The buttons wrap under the text where the row does not fit.
76 return (
77 <Box key={key} flexDirection="row" flexWrap="wrap" justifyContent="space-between" columnGap={2}>
78 <Text>
79 <Text color={mark.color} weight="semibold">
80 {mark.glyph}{' '}
81 </Text>
82 <Text weight="semibold">{plugin}</Text> {notice.busy ? `${notice.text} · ${notice.busy}` : notice.text}
83 </Text>
84 {/* Grows to fill its line, so the buttons stay right-aligned when they wrap. */}
85 <Box flexDirection="row" flexGrow={1} justifyContent="flex-end" columnGap={1}>
86 {buttons}
87 </Box>
88 </Box>
89 )
90 })
91}
92types/index.d.ts 18 lines1declare module 'claude-code' {
2 interface PluginState {
3 precis: {
4 // The switch hooks/switch.tsx offers in the band, with what its button
5 // is doing while it runs; null when there is none.
6 offer: {
7 severity: 'urgent' | 'problem' | 'action' | 'info'
8 text: string
9 label: string
10 from: string
11 to: string
12 busy?: string
13 } | null
14 dismissed: boolean
15 }
16 }
17}
18