SLOPSHOPPER

precis

Automatic codebase structure context via precis

bandguardtoasttoolprocess
★ 1v0.2.2MITupdated 2026-10-22Crazytieguy/precis/plugins/precis
A shopper browsing a rack in a slop shop
README

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.

Example

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.

Installation

Claude Code plugin (recommended)

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:

MachinePlugin
macOS, Apple Siliconprecis-aarch64-apple-darwin
macOS, Intelprecis-x86_64-apple-darwin
Linux, arm64precis-aarch64-unknown-linux-gnu
Linux, x86_64precis-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.

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
Configure your AI agent

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.

Usage

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.

Supported languages

  • Parsed source — Rust, TypeScript / JavaScript (.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 — the root README in Markdown, reStructuredText or AsciiDoc (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.
  • Manifests — 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.
  • Build and ops files — Makefile, Taskfile, justfile, Dockerfile, compose files, dotenv samples, runtime version pins (.nvmrc, .python-version, .tool-versions) and pnpm-workspace.yaml.
  • Every other source language — Java, Kotlin, Swift, C++, C#, Ruby, PHP, Scala, Elixir, Zig, Solidity, Verilog, Vue, Svelte and more: each file's top-level declaration lines, or the whole file when it is short. In a repository written mostly in one of these languages, its declaration lines rank like parsed declarations. Stylesheets show their top-level selector lines, and shell scripts, including extensionless ones that start with a shebang, their top-level lines; both are priced below parsed declarations.

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).

Source 4 files
hooks/precis.ts 214 lines
1import 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}
214
hooks/switch.tsx 103 lines
1import { 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}
103
hooks/notice-band.tsx 92 lines
1import 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}
92
types/index.d.ts 18 lines
1declare 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