SLOPSHOPPER

precis

Automatic codebase structure context via precis

bandtoastprocess
★ 1v0.2.1MITupdated 2026-10-18Crazytieguy/precis/plugins/precis-legacy
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/register.tsx 32 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { drawOffer, platformTarget, refreshOffer } from './switch'
4
5// The plain `precis` plugin: the shell hooks beside this module keep working,
6// and the band offers the plugin built for this machine in their place.
7
8export const register: Register = on => {
9  on('classic.SessionStart', async ($, e, next) => {
10    if (e.source !== 'startup' && e.source !== 'clear') return next(e)
11    const [below] = await Promise.all([next(e), offerReplacement($)])
12    return below
13  })
14  drawOffer(on)
15}
16
17async function offerReplacement($: EngineInterface): Promise<void> {
18  const target = await platformTarget($)
19  await refreshOffer(
20    $,
21    target === undefined
22      ? undefined
23      : {
24          severity: 'action',
25          text: `superseded by precis-${target}`,
26          label: 'Switch',
27          from: 'precis@precis',
28          to: `precis-${target}@precis`,
29        },
30  )
31}
32
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