SLOPSHOPPER

zukan

Error book: catches the errors Bash commands hit across sessions and repos, records what got past each one, and when one comes back tells Claude and the person…

newpaneguardcommandtoast
★ 1v0.1.8MITupdated 2026-10-16simota/claude-mods/mods/zukan
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · zukan
│ ┃ エラー図鑑 ✕ › fix the failing auth test and add an audit log call │ ┃ 📚 エラー図鑑 — 0 種 · 解決済み 0 · 出会い 0 │ ┃ 回 ⏺ Read(src/auth.ts) │ ┃ まだ何も捕まえていません。Bash ⎿ Read 6 lines │ ┃ のエラーが出ると、ここに並びます。 ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /zukan │ ⎿ zukan: エラー図鑑を開きました。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · エラー図鑑
📚 エラー図鑑 — 0 種 · 解決済み 0 · 出会い 0 回 まだ何も捕まえていません。Bash のエラーが出ると、ここに並びます。
README

claude-mods

English | 日本語

Personal Claude Code mods (function-hook plugins), published as one marketplace.

ModWhat it does
agent-paneLive pane of the session's subagents: running ones with elapsed time, finished ones with result and duration
done-gateFlags unverified edits, stops repeated failing commands, and blocks git --no-verify
sandbox-lensTallies sandbox denials seen in Bash output and reports them with /sandbox-report
skill-hintSuggests up to three matching skills above the prompt from what was typed; never invokes them.
todo-agentCollects #TODO(agent) follow-ups from Claude's replies into a per-project list with a /todos pane.
context-meterBand above the prompt with a Gruvbox sparkline of context use per turn, plus tokens and cost
tool-rowRedraws finished tool-call rows in the terminal as one compact line with a Nerd Font icon, Gruvbox colors and a short stat
image-previewThumbnails of the images a session writes, reads or generates: under the tool's row in the terminal, and in an /images pane
self-auditAfter each turn, a cached fork of the conversation audits Claude's own reply against the CLAUDE.md reporting rules and flags violations above the prompt
context-dietTrims the skill listing to what the project kind needs (off by default); /context-diet shows what was dropped and the tokens saved
browser-camLive pane of the browser Claude drives: the latest screenshot from Claude in Chrome, the page URL and the recent actions
prompt-coachHolds back a typed prompt that looks ambiguous (no done-condition, unclear target) and offers guesses at what it meant, sending it as is, or adding one; on by default
loopbackFeeds what self-audit, done-gate and sandbox-lens found back to Claude beside the next prompt, instead of only showing it to the person, skipping rules hit-rate found mostly wrong; off by default
zukanError book: catches the errors Bash commands hit across sessions and repos, records what got past each one, and when one comes back tells Claude and the person how it was fixed last time
pr-lampThe current branch's pull request as a lamp: checks, review, unresolved threads and what is left before a merge, in the status line and a /pr pane; red checks and new review comments go to Claude beside the next prompt
fog-mapExploration map: the repository as a tree under fog, cleared where Claude read and marked where it edited, made a file or hit an error, with the importers an edit may have broken unseen; /map
assertion-guardCatches edits that weaken tests to pass: assertions removed, expectations loosened or rewritten, tests skipped, errors swallowed; asks Claude for the reason beside the edit
negative-spaceWhen an edit adds authorization, validation or error handling, lists the refusal and boundary-value tests no test touched this session covers; /negative-space
mutation-tap/mutate runs a mutation test limited to the lines changed since HEAD and lists the mutants the tests did not catch
perf-delta/perf compares a benchmark before and after (a saved baseline, or a git ref in a temporary worktree): median time, peak memory, spread, and whether the change beats the noise
handoff-capsuleBefore a compaction and at session end, keeps the goal, decisions, open items, commands to reproduce and files touched; the next session picks which parts to bring back
port-lanternThe dev servers the session started: URL, port, whether each still listens, and their latest log lines, in the status line and /ports
artifact-shelfA shelf of what sessions made (CSV, PDF, HTML, video, patches…), tied to the call and request that made each; /shelf searches by kind, word and time
focus-hudOne status line: goal, target, what is running and for how long, and what the session waits on; /focus
side-effect-ledgerA ledger of what a git diff never shows (push, publish, deploy, PRs, API writes, messages) with outcome, how to undo, and whether the undo was checked; /ledger
trust-labelLabels external text (web, issues, logs, browser pages, services) with its source beside the result and flags candidate injected instructions; a heuristic, never a safety verdict; /trust
visual-diffCompares browser screenshots of the same URL and size before and after an edit: side by side, a map of changed cells, regions, and the edits in between; /vdiff
journey-traceRecords Claude's browser actions and the expected states as a replayable recipe; /journey md for a bug report, /journey e2e for a Playwright draft
hit-rateGives each finding of done-gate, assertion-guard, self-audit and zukan a later verdict from what the session did next, keeps each rule's precision across sessions, and names the rules loopback stops feeding; /hit-rate; off by default
token-lensChecks an element's actual colours, spacing and type against the project's design tokens and lists off-token values with the nearest token; /token-lens <selector>

Install

At the prompt of a terminal session:

/plugin install <mod> --marketplace simota/claude-mods

From a shell:

claude plugin install <mod> --marketplace simota/claude-mods

This repository is private: installing needs read access on GitHub and a git credential for github.com on the machine (an SSH key, or gh auth login then gh auth setup-git).

Usage

Options are set with /plugin configure <mod>@simota-mods in a session, or from a shell:

echo '{"enabled":"true"}' | claude plugin configure <mod>@simota-mods --values-stdin

A change made with /plugin configure reloads the mod; one made from a shell applies after a restart. Bands above the prompt stack: each mod draws its row above what the others draw.

agent-pane

  • /agent-pane opens the pane: running subagents first with elapsed time, then finished ones with ✓/✗ and duration; a background run that was stopped shows ■ 停止. A background run that left the roster without a result shows ? 結果不明; a teammate or remote launch shows ↗ 起動済み(追跡外).
  • In a fullscreen terminal the pane opens by itself once per session, on the first Agent call (a reload does not reopen it).
  • The status line shows agents: N running while any run.

done-gate

Works on its own, no command:

  • A turn that edited files without a passing test, lint, typecheck or build afterwards leaves a band: 未検証の変更があります. It stays until a check succeeds or 閉じる is pressed. Only the program a command runs counts (ls build, npm ci are not checks), and only when its exit status is the command's: the check is last, or only && follows it (npm test | tail does not count).
  • The second identical failure of the same Bash command tells Claude to stop retrying and diagnose the root cause (again after the command has succeeded in between).
  • Any git command with --no-verify (or a prefix of it from --no-ver), and git commit -n / -nm, is denied, also inside subshells, $(…), backticks, bash -c "…", eval and wrappers such as sudo/timeout. The value of an option (-m "…"), a comment or a heredoc never triggers it. So is a git command that moves its hooks for the run (-c core.hooksPath=…, --config-env core.hooksPath=…) or carries a hook manager's off switch in its environment (HUSKY=0, LEFTHOOK=0, pre-commit's SKIP=…), or moves the hooks through config in its environment (GIT_CONFIG_KEY_n=core.hooksPath, GIT_CONFIG_PARAMETERS). ANSI-C quoting ($'--no-\x76erify'), env -S "…", bash -c -- '…' and a shell reading its script from stdin (bash <<< '…', a heredoc, echo '…' | sh) are read through. It is a best-effort guard: git aliases (git ci -n), a hook path written into config beforehand and an environment exported by an earlier command are not detected.

sandbox-lens

  • /sandbox-report lists the sandbox denials seen in Bash output, for this session and all time: denied hosts and paths, counts, last seen, an example command, and which setting would allow each.
  • /sandbox-report reset clears the record.
  • Hints only: it never changes sandbox settings. A loopback or private address keeps its port in the hint (127.0.0.1:3000), since an entry without a port opens every port.
  • A <sandbox_violations> block, and Operation not permitted lines a program printed about itself, are read. For a command that succeeded or was interrupted, the block is taken from where the engine puts it, apart from the command's own output, so one inside a shown file or page is ignored even when it ends the output. A failed command keeps only its error text: the block that ends it (before an agent-proxy note) is read, and there a block the command printed itself cannot be told from the harness's. Credentials in the example command (tokens, passwords, Authorization/Cookie and other credential headers such as X-API-Key, URL userinfo, keys in env assignments including run-together names such as PGPASSWORD, known key shapes) are masked. Each section shows the top 10; when the record is full (200), the least recently seen entry gives way.

skill-hint

After a prompt is sent, up to three skills matching it show above the prompt (💡 skill候補: /coding-debug …), only ones that exist in the session. Nothing is invoked; 閉じる or the next prompt (a slash command included) clears it. A prompt that already names a skill (/name anywhere, or a hyphenated skill name such as coding-debug) gets no hint; built-in commands such as /clear and path segments such as src/review/ do not count as naming one. Plugin skills (plugin:name) are suggested by their full name.

todo-agent

  • Lines starting with #TODO(agent): <action> (after an optional quote or list bullet) in Claude's main-loop replies are collected per project (repo root or cwd). A mention inside a sentence, a table or inline code is not a TODO; subagent replies are ignored.
  • /todos opens the pane of this project's open items, each with a 完了 button.
  • /todos all lists every project's open items as text, marked as stored data.
  • The status line shows TODO(agent): N while the project has open items.

context-meter

A band with a sparkline of context use per turn and ctx 63% ▲4 · 126k · turn in 120k / out 8k · $1.23: the context size of the last request, then the whole turn's input/output (shown when the turn reported usage). The percentage turns yellow from 50% and red from 80%.

OptionDefault
bandtrueDraw the band (samples are recorded either way)

tool-row

Finished tool calls are drawn as one compact line with a Nerd Font icon: Edit/Write path +A −D, Read with line count, Bash (red on error), Agent, web and MCP calls. The result block under a compacted row is hidden, so a call is one line; an errored result stays. A row another mod decorated (an image-preview thumbnail) is left as it is, result included. Runs Claude Code folds into one count line (Read 3 files, ran 2 shell commands) stay folded. Terminal only.

OptionDefault
compacttrueOff leaves every row and result as Claude Code draws them

image-preview

  • Images a session writes, reads, or that a Bash command produces (png, jpg, jpeg, webp, gif) get a thumbnail under that tool's row in the terminal (kitty graphics: Ghostty, kitty).
  • /images opens a pane, newest first; h/l step through them.
  • Non-PNG files are converted with macOS sips after the tool result has gone back, so tool calls never wait on it. Converted files live in a private cache ($TMPDIR or ~/.cache), newest 50 kept. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps.
  • A Bash command and its output are searched for image paths in time linear in their length, before the result goes back. A path split at spaces (/tmp/a b/x.jpg) joins at most 8 preceding pieces and 4096 characters; the first 64 paths named are read, and of those the first 4 that exist are shown.
OptionDefault
inlinetrueThumbnail under the tool row
maxRows12Rows a thumbnail may take, 1 to 60

self-audit

After each reply of at least minChars characters, one tool-less fork of the conversation (served mostly from the prompt cache), given the reply itself as quoted data, checks it against the CLAUDE.md rules: claims done without evidence, first line not the result, unverified assertions, bypassed checks, scope inflation. Only violations of the five rules whose quote is found in the reply are kept (a made-up quote or rule is dropped); the quote is compared past markdown marks, bullets, curly quotes, case, spacing and a closing full stop, and one shortened with … is found piece by piece. Violations show in red above the prompt; 詳細 or /self-audit opens the pane with quotes and the fork's token use. Results belong to the latest reply only: when it was not audited, the pane says why.

OptionDefault
enabledtrue
minChars200Shorter replies are not audited

Cost: one extra request per turn on the session's model, plus the reply text (up to 12,000 characters) as uncached input; a cold cache (after 5 minutes idle or a model switch) resends the whole conversation.

context-diet

Off by default. When on, the skill listing is trimmed to what the project kind needs (Swift, Android, Rust, web, Python, Go, detected from the repo root and one level below). Coding, quality, planning and research skills are always kept; red-team, dynamic-*, persona and operation skills are dropped; image, design, ux-scan and brand-scan are dropped for Rust, Python and Go. Only the main loop's listing is reported. A dropped skill named in a prompt is still described for that prompt.

  • /context-diet shows the project kind, what was kept and dropped in the latest listing, and the tokens saved (estimate). A listing it cannot read reliably goes out unchanged.
  • /context-diet on|off|reset overrides the option for this session. Switching rebuilds the listing, so the prompt cache misses once.
OptionDefault
enabledfalse
keepComma list of names or prefix* never dropped; wins over drop
dropComma list of names or prefix* always dropped

browser-cam

  • While Claude drives a browser (Claude in Chrome, Claude Browser, or a remote device's browser), a pane shows the latest screenshot, the page title and URL, and the last 8 actions with ✓/✗. The URL comes only from the navigate call or the tab tools' own header, never from page text. Text typed or filled in is shown as its length only (type ‹7文字›), since the field may be a password; a URL's secrets (token parameters, userinfo, key-shaped parts) are masked.
  • /cam toggles the pane; in a fullscreen terminal it opens by itself once per session, on the first browser call. Screenshots are decoded only while the pane is open, and written to a private per-session folder that is removed at session end. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps.
  • The desktop app shows the title and URL, a caption and the actions, without the picture.
OptionDefault
autoOpentrueOpen on the first browser call (fullscreen terminal)

prompt-coach

On by default (enabled: false turns it off). A typed prompt that looks ambiguous (no done-condition and an unclear target, or only evaluative words like いい感じ) is held back, and a band shows what is missing with up to three guesses at what it meant (judged along the lines of the clarify skill: the likeliest reading first, each a different deliverable with a checkable done-condition):

  • a / b / d: a guess goes into the prompt to edit and send (not held again)
  • s: このまま送る sends it unchanged
  • e: 補足して送る puts it back with 完了条件: to fill in (not held again)
  • q: 閉じる puts it back in the prompt

Typing another prompt instead sends that one and puts the held text back into the prompt. The held text is never lost: when a send fails or is dropped beneath, or putting words back fails, it stays held with its band (unless another prompt was held since) and its words are shown in a toast; a prompt still held at /clear goes straight back into the box, and one held when the session quits comes back at this project's next interactive session start. Words go into an empty box as they are, and are appended after a draft typed meanwhile on a line of their own, never over it.

Slash commands, prompts under 12 or over 1500 characters, prompts with any attachment or an @ mention, prompts sent by a plugin, and prompts typed while a turn runs are not judged. A timeout or error sends the prompt unchanged; the same text is not held twice (within the last 50 judged). Sending can wait up to timeoutMs.

OptionDefault
enabledtrue
modelclaude-haiku-5-5Judging model (alias or id)
timeoutMs3000500 to 3000

loopback

Off by default. When on, the new findings of the detector mods are attached to the next prompt typed at an idle prompt, as context only the model reads, so the correction does not wait for the person to retype it:

  • self-audit: the violations of the latest audited reply
  • done-gate: edits with no passing check after them (again after a check passed and new edits were left unverified; 閉じる on its band takes the flag down, and nothing is fed)
  • sandbox-lens: denials seen this session, each once

Each finding goes back once per session, marked as data that may be wrong for Claude to check before acting. A toast says how many went back and from where. Slash commands, hidden prompts, prompts sent by a plugin and prompts typed while a turn runs carry nothing. A detector that is not installed contributes nothing.

With hit-rate on, the rules it muted are not fed (sandbox-lens denials never are muted).

  • /loopback shows what was fed last and the self-audit repeat rate: of the rules fed back, how many self-audit flagged again on the next audited reply (all sessions). The rate is what the design is judged by (.genius/decisions/loopback.md). It also names the rules hit-rate muted.
OptionDefault
enabledfalse

Cost: the findings ride as extra input on the prompt they are attached to (each quote clipped to 300 characters, at most 12 findings).

zukan

An error book, kept across sessions and repos. When a Bash command fails, the error line is caught and numbered:

  • 📕 新種のエラー発見! No.042「…」 — a kind not seen before
  • 📙 No.017 再会(3回目・未解決) — seen before, nothing known to get past it
  • ` 📗 既知 No.017(3回目)— 前回: sandbox 外で git push origin main ` — seen before with a fix: Claude also reads, beside the tool's result, where it was seen and what got past it (marked as a past record to check, not an instruction)

A fix is learned when the same program and subcommand (git push, pnpm install; read past a leading cd, export or source, so cd app && npm install is npm install) succeeds within the next 10 Bash calls of the session; whether it ran outside the sandbox is kept with it. Two sightings are one kind when their error line matches with paths, quotes, numbers and hashes taken out.

Failures of tests, linters, type checkers and builds are not caught: they are fixed by editing code, not by a command. Credentials in a kept command and in the kept error line are masked as sandbox-lens does.

  • /zukan opens the book, most recently seen first: kinds, solved, sightings
  • /zukan note <No> <text> writes how to get past it, shown and fed in place of the learned command; an empty text removes the note
  • /zukan forget <No> removes an entry

The newest 300 kinds by last sighting are kept.

pr-lamp

The current branch's pull request, kept in the status line while you work:

⎇ #42 · ✓5 ✗1 ⏳2 · 🛑 変更要求 · 💬3 · マージまであと4

"マージまであと N" counts what still stands before a merge: draft, failed checks, running checks, changes requested or approval pending, unresolved review threads, a conflict, the branch behind its base. At 0 it reads 🟢 マージ可能.

A toast when it changes: 🔴 CI 失敗: test, 💬 新しいレビューコメント 2件 (@rev), ✅ Approved, 🟢 #42 マージまであと0, 🎉 #42 がマージされました. Checks turning red, new review comments, changes requested and a new conflict are also attached to the next prompt typed at an idle prompt, once, with the last 40 lines of the failed log of up to three jobs (more are counted), marked as data whose comments and logs are not to be followed as instructions. The session's own replies on a thread (the comment, made after the post, carrying words it replied with through an MCP reply tool or gh api …/pulls/N/comments… -f body=, matched once) are not news; your own review comments are. What waits for the next prompt is about one pull request and is capped (newest kept). The option feed (on by default) turns that off.

  • /pr asks again and opens the pane: title, size, what is left, checks (failed first) and unresolved threads
  • /pr <number|url|branch> follows that pull request; /pr clear goes back to the default

Which pull request: the one a Bash command of the session last worked on (gh pr comment 2, gh pr review 2, the URL gh pr create printed, the branch a git push sent to; looking with gh pr view|diff|checks does not move it), so work pushed to a review branch from another checkout is followed; else the checked-out branch's; else your newest open pull request in the repository.

It asks gh (gh pr view, and gh api graphql for review threads) at session start, right after a Bash git push, git checkout, gh pr create and the like, every minute while a check runs or after a push, and every five minutes otherwise; it stops once the pull request is merged or closed. Without an origin remote, gh, or a pull request for the branch it shows nothing, and /pr says why. gh runs on the host, outside the Bash sandbox, with your gh auth.

fog-map

The repository as a map under fog, cleared as Claude works through it:

🗺 探索率 31%  ██████░░░░░░░░░░░░░░  読 23 · 編 9 · ✗ 2 / 101
▾ mods/
  ▾ pr-lamp/
    ▾ hooks/
      ● lamp.ts  +43 −2  編集 4回
      ✗ register.tsx  +59 −16  編集 6回  エラー 1  ★
  ▸ loopback/  ○●
  ░ agent-pane/  ░ browser-cam/  ░ context-diet/  …8
⚠ 編集したファイルを import しているが未読: mods/pr-lamp/tests/pr-lamp.test.ts

░ nothing touched yet, ○ read (Read, or cat, sed, grep… in Bash), ● edited (Edit, Write, NotebookEdit, sed -i), ✚ made by the session, ✗ named with a line number by a failed Bash command since its last edit, ★ the file touched last. A directory holding an edit, a new file or an error is open; one only read is closed with its marks beside it; untouched directories and files at a level share one fog row.

Two warnings, each a likely oversight: a file edited without being read, and a file that imports an edited one (JS/TS and Python, found by git grep) that was never opened. A very large repository's file list is cut to what one state value holds (3 M characters of paths), so it is still mapped, partly.

  • /map opens the pane
  • The status line keeps 🗺 31% · ●9 · ✗2 · ⚠1
  • A toast at 25, 50, 75 and 100% explored, and when an edit first reaches a new area (🗺 新しい区域を開拓: mods/zukan/)

The map is the session's, subagents' calls included; files outside the repository ar

Source 5 files
hooks/register.tsx 134 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ZukanEntry, ZukanPending } from '../types'
5import { bookLines, contextOf, errorLine, fromStore, isCheck, isOverwritten, keyOf, programOf, reconcile, sight, step, toastOf, withFix, withNote } from './book'
6import { exampleOf, maskedOf } from './mask'
7
8const PANE = 'zukan'
9const STORE_KEY = 'entries'
10
11const entries = atom({ plugin: 'zukan', key: 'entries' } as const, [] as ZukanEntry[])
12const pending = atom({ plugin: 'zukan', key: 'pending' } as const, {} as Record<string, ZukanPending>)
13
14// $.store has no versioned write: this module's read-modify-writes run one after another,
15// each reading the store afresh. Across sessions the write is read back, and when another
16// session wrote over it in between, this write's changes are laid on the other's book and
17// written once more, as todo-agent does. Left over: a write landing inside that second window.
18let queue: Promise<unknown> = Promise.resolve()
19
20function mutate($: EngineInterface, change: (list: ZukanEntry[]) => ZukanEntry[]): Promise<ZukanEntry[]> {
21  const run = queue.then(async () => {
22    const before = fromStore(await $.store.get(STORE_KEY))
23    let list = change(before)
24    await $.store.set(STORE_KEY, list)
25    const stored = fromStore(await $.store.get(STORE_KEY))
26    if (isOverwritten(list, stored)) {
27      list = reconcile(before, list, stored)
28      await $.store.set(STORE_KEY, list)
29    }
30    await update($, entries, () => list)
31    return list
32  })
33  queue = run.catch(() => undefined)
34  return run
35}
36
37async function repoName($: EngineInterface): Promise<string> {
38  const root = (await $.session.repo())?.root ?? (await $.session.cwd())
39  return root.split('/').filter(Boolean).pop() ?? root
40}
41
42export const register: Register = on => {
43  on('session.start', async ($, e, next) => {
44    await $.command.register({
45      name: 'zukan',
46      description: 'エラー図鑑を開く(note <番号> <メモ> で解決方法を書き込み、forget <番号> で消す)',
47      argumentHint: '[note <No> <text> | forget <No>]',
48    })
49    const list = fromStore(await $.store.get(STORE_KEY))
50    await update($, entries, () => list)
51    return next(e)
52  })
53
54  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
55    const ran = await next(e)
56    // Subagents' calls count too: an error is the machine's, whoever hit it.
57    if (ran.deny !== undefined) return ran
58
59    const program = programOf(e.command)
60    const isFailure = ran.isError === true
61    // Aged inside the update: a parallel call's failure set in between is kept, not written over.
62    let fixed = undefined as ZukanPending | undefined
63    await update($, pending, all => {
64      const aged = step(all, program, !isFailure)
65      fixed = aged.fixed
66      return aged.pending
67    })
68
69    if (fixed !== undefined) {
70      const fix = { command: exampleOf(e.command), isSandboxOff: e.dangerouslyDisableSandbox === true, repo: await repoName($), at: await $.clock.now() }
71      const { key } = fixed
72      await mutate($, list => withFix(list, key, fix))
73    }
74
75    if (!isFailure || isCheck(e.command)) return ran
76    // The error line is kept and shown across sessions: credentials in it are masked first.
77    const found = errorLine(ran.text ?? '')
78    if (found === undefined) return ran
79    const line = maskedOf(found)
80
81    const key = keyOf(program, line)
82    let landed: { entry: ZukanEntry; isNew: boolean } | undefined
83    const sighting = { key, program, line, repo: await repoName($), at: await $.clock.now() }
84    const book = await mutate($, list => {
85      const added = sight(list, sighting)
86      landed = added
87      return added.entries
88    })
89    await update($, pending, all => ({ ...all, [program]: { key, failed: exampleOf(e.command), calls: 0 } }))
90    if (landed === undefined) return ran
91
92    // As it stands once written: another session's write may have renumbered or added to it.
93    const entry = book.find(one => one.key === key) ?? landed.entry
94    $.ui.toast(toastOf(entry, landed.isNew))
95    const note = contextOf(entry)
96    return note === undefined ? ran : { ...ran, context: [...(ran.context ?? []), note] }
97  }).catch(($, e, next) => next(e))
98
99  on('command.run', { command: 'zukan' }, async ($, e) => {
100    const [verb = '', number = '', ...rest] = e.args.trim().split(/\s+/)
101    const no = Number(number)
102    if (verb === 'note' || verb === 'forget') {
103      const list = await read($, entries)
104      if (!Number.isInteger(no) || !list.some(one => one.no === no)) return { text: `zukan: No.${number} は図鑑にありません。` }
105      if (verb === 'forget') {
106        await mutate($, all => all.filter(one => one.no !== no))
107        return { text: `zukan: No.${no} を図鑑から消しました。` }
108      }
109      const text = rest.join(' ').trim()
110      await mutate($, all => withNote(all, no, text === '' ? undefined : text))
111      return { text: text === '' ? `zukan: No.${no} のメモを消しました。` : `zukan: No.${no} にメモを書きました。` }
112    }
113    await $.ui.open({ id: PANE, title: 'エラー図鑑' })
114    return { text: 'エラー図鑑を開きました。' }
115  })
116
117  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
118    const { Box, Text } = $.ui.resolve(e)
119    const [head, ...rows] = bookLines(await read($, entries))
120    const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
121
122    return (
123      <Box flexDirection="column">
124        <Text bold>{head}</Text>
125        {rows.length === 0 && <Text dimColor>まだ何も捕まえていません。Bash のエラーが出ると、ここに並びます。</Text>}
126        {rows.slice(0, room).map(row => (
127          <Text dimColor={row.startsWith('📙')}>{row}</Text>
128        ))}
129        {rows.length > room && <Text dimColor>…ほか {rows.length - room} 種</Text>}
130      </Box>
131    )
132  })
133}
134
hooks/book.ts 293 lines
1// The error book, pure: reading an error's signature out of a failed command's output,
2// adding sightings and fixes to the entries, and the words shown and fed to the model.
3
4import type { ZukanEntry, ZukanFix, ZukanPending } from '../types'
5import { allCommands, base, innerText, isVerification, parse, program } from './shell'
6
7export const MAX_ENTRIES = 300
8export const MAX_LINE = 160
9/** A pending error is given up once this many Bash calls passed without the program succeeding. */
10export const PENDING_CALLS = 10
11
12const ERROR_LINE =
13  /\b(error|errors|fatal|panic|panicked|exception|denied|not permitted|refused|failed|failure|cannot|can't|unable|not found|no such|invalid|missing|timed out|timeout|unauthorized|forbidden)\b/i
14
15const clip = (text: string, max = MAX_LINE): string => {
16  const flat = text.replace(/\s+/g, ' ').trim()
17  return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
18}
19
20// Commands that set up for, mark or wait on the command after them (`cd app && …`,
21// `echo "== build ==" && …`, `[ -f x ] && …`): they name what ran only when nothing else runs.
22const LEADING = new Set([
23  'cd', 'pushd', 'popd', 'export', 'source', '.', 'set', 'unset', 'shopt', 'umask', 'ulimit', 'alias', 'declare', 'typeset',
24  'local', 'readonly', 'echo', 'printf', 'pwd', 'true', ':', 'sleep', 'exit', '[', '[[', 'test',
25])
26// git's own options that take the next word, before its subcommand (`git -C dir push`), as shell.ts reads them.
27const GIT_VALUES = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env'])
28// Past this the shell reader reads no further: its recursion is bounded by the stack, not by length.
29const MAX_PARSED = 10_000
30
31type Run = { name: string; args: string[] }
32
33// A program named by an expansion (`$CMD`, `$(foo)`) is not known until it runs.
34const named = (runs: Run[]): Run[] => runs.filter(one => !/^[$`]/.test(one.name))
35// A program name is kept to this: it goes into the key, the toast and the pane.
36const MAX_PROGRAM = 60
37
38/** The programs a command line runs at its top level (a `bash -c` text read through), and those of its substitutions. */
39function runsOf(command: string): { top: Run[]; nested: Run[] } {
40  const { commands, nested } = parse(command)
41  const top = commands.flatMap(one => {
42    const outer = program(one.words)
43    const inner = outer?.name === 'find' ? undefined : innerText(one.words)
44    // A text that is itself fetched or expanded (`bash -c "$(curl …)"`, `eval "$X"`) names nothing: the shell does.
45    if (inner === undefined || /^\s*[$`]/.test(inner)) return outer ?? []
46    const runs = named(allCommands(inner).flatMap(each => program(each.words) ?? []))
47    return runs.length > 0 ? runs : (outer ?? [])
48  })
49  return { top: named(top), nested: named(nested.flatMap(text => allCommands(text)).flatMap(one => program(one.words) ?? [])) }
50}
51
52/**
53 * The program and, when it has one, its subcommand: `git push`, `pnpm install`, `curl`. Read as
54 * done-gate reads a command (assignments, wrappers and runners skipped), from the first command
55 * that is not setting up for the next: `cd app && npm install` is `npm install`, so that it is
56 * settled by `npm install` succeeding, not by any `cd app && …`. A substitution's program
57 * (`cd $(dirname x)`) names it only when the line runs nothing else.
58 */
59export function programOf(command: string): string {
60  let run: Run | undefined
61  try {
62    // Only the head is read: a cut mid-quote reads as an unclosed quote, a deeper one throws.
63    const { top, nested } = runsOf(command.slice(0, MAX_PARSED))
64    run = top.find(one => !LEADING.has(one.name)) ?? top[0] ?? nested[0]
65  } catch {
66    // A line the reader cannot hold: named by its first word below.
67  }
68  if (run === undefined) return base(command.trim().split(/\s+/)[0] ?? '').slice(0, MAX_PROGRAM)
69  // Free text after `echo` or a path after `cd` is no subcommand.
70  if (LEADING.has(run.name)) return run.name
71  let k = 0
72  if (run.name === 'git') {
73    while (run.args[k]?.startsWith('-') === true) k += GIT_VALUES.has(run.args[k] as string) ? 2 : 1
74  }
75  const second = run.args[k]
76  const name = second !== undefined && /^[a-z][\w:-]*$/.test(second) ? `${run.name} ${second}` : run.name
77  return name.slice(0, MAX_PROGRAM)
78}
79
80/**
81 * Whether a failure of this command is a check failing (a red test, a type error), fixed by
82 * editing code rather than by a command: the book leaves it alone. The judgement is
83 * done-gate's, read from `./shell.ts`, a copy of done-gate's own: mods share no code, and
84 * the two must agree on what a check is.
85 */
86export function isCheck(command: string): boolean {
87  return isVerification(command)
88}
89
90/** The line that best names the error: the first one shaped like an error, else the last line. */
91export function errorLine(output: string): string | undefined {
92  const lines = output
93    .replace(/<\/?sandbox_violations>/g, '')
94    // Colour and cursor codes, then the other controls: drawn raw in the toast and the pane.
95    .replace(/\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b\n]*(?:\u0007|\u001b\\)?|[@-Z\\-_])/g, '')
96    .replace(/\r\n?/g, '\n')
97    .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
98    .split('\n')
99    .map(line => line.trim())
100    .filter(line => line !== '' && !/^Exit code \d+$/i.test(line))
101  const line = lines.find(one => ERROR_LINE.test(one)) ?? lines.at(-1)
102  return line === undefined ? undefined : clip(line)
103}
104
105/** The line with what varies between sightings taken out: quotes, paths, hex, numbers. */
106export function normalise(line: string): string {
107  return line
108    .toLowerCase()
109    .replace(/(["'`‘“]).*?(["'`’”])/g, 'Q')
110    .replace(/(?:~|\.{0,2})?\/[\w.@~+-]+(?:\/[\w.@~+-]*)*/g, 'P')
111    .replace(/\b0x[0-9a-f]+\b|\b[0-9a-f]{7,}\b/g, 'H')
112    .replace(/\d+/g, 'N')
113    .replace(/\s+/g, ' ')
114    .trim()
115    .slice(0, MAX_LINE)
116}
117
118export const keyOf = (program: string, line: string): string => `${program}|${normalise(line)}`
119
120/** Adds one sighting; answers the entries and the entry it landed on. */
121export function sight(
122  entries: readonly ZukanEntry[],
123  sighting: { key: string; program: string; line: string; repo: string; at: number },
124): { entries: ZukanEntry[]; entry: ZukanEntry; isNew: boolean } {
125  const found = entries.find(one => one.key === sighting.key)
126  const entry: ZukanEntry =
127    found === undefined
128      ? {
129          no: entries.reduce((max, one) => Math.max(max, one.no), 0) + 1,
130          key: sighting.key,
131          program: sighting.program,
132          line: sighting.line,
133          count: 1,
134          firstSeen: sighting.at,
135          lastSeen: sighting.at,
136          repos: [sighting.repo],
137        }
138      : {
139          ...found,
140          count: found.count + 1,
141          lastSeen: sighting.at,
142          repos: [...found.repos.filter(repo => repo !== sighting.repo), sighting.repo].slice(-5),
143        }
144  return { entries: capped([...entries.filter(one => one.key !== sighting.key), entry]), entry, isNew: found === undefined }
145}
146
147/** Past MAX_ENTRIES the least recently seen leave; numbers are never reused. */
148function capped(entries: ZukanEntry[]): ZukanEntry[] {
149  if (entries.length <= MAX_ENTRIES) return entries
150  const keep = new Set([...entries].sort((a, b) => b.lastSeen - a.lastSeen).slice(0, MAX_ENTRIES).map(one => one.key))
151  return entries.filter(one => keep.has(one.key))
152}
153
154export function withFix(entries: readonly ZukanEntry[], key: string, fix: ZukanFix): ZukanEntry[] {
155  return entries.map(one => (one.key === key ? { ...one, fix } : one))
156}
157
158export function withNote(entries: readonly ZukanEntry[], no: number, note: string | undefined): ZukanEntry[] {
159  return entries.map(one => {
160    if (one.no !== no) return one
161    const { note: _, ...rest } = one
162    return note === undefined ? rest : { ...rest, note: clip(note, 300) }
163  })
164}
165
166/**
167 * One more Bash call of this session: a success of a pending program settles it as fixed,
168 * and every other pending error ages by one call.
169 */
170export function step(
171  pending: Readonly<Record<string, ZukanPending>>,
172  program: string,
173  isSuccess: boolean,
174): { pending: Record<string, ZukanPending>; fixed?: ZukanPending } {
175  const next: Record<string, ZukanPending> = {}
176  let fixed: ZukanPending | undefined
177  for (const [name, one] of Object.entries(pending)) {
178    if (name === program && isSuccess) {
179      fixed = one
180      continue
181    }
182    if (one.calls + 1 < PENDING_CALLS) next[name] = { ...one, calls: one.calls + 1 }
183  }
184  return fixed === undefined ? { pending: next } : { pending: next, fixed }
185}
186
187const day = (at: number): string => {
188  const d = new Date(at)
189  return `${d.getMonth() + 1}/${d.getDate()}`
190}
191
192const no = (entry: ZukanEntry): string => `No.${String(entry.no).padStart(3, '0')}`
193
194export function fixWords(fix: ZukanFix): string {
195  return `${fix.isSandboxOff ? 'sandbox 外で ' : ''}\`${fix.command}\``
196}
197
198/** The toast for a sighting. */
199export function toastOf(entry: ZukanEntry, isNew: boolean): string {
200  if (isNew) return `📕 新種のエラー発見! ${no(entry)}「${clip(entry.line, 60)}」`
201  const how = entry.note ?? (entry.fix === undefined ? undefined : `${fixWords(entry.fix)} で解決`)
202  return how === undefined
203    ? `📙 ${no(entry)} 再会(${entry.count}回目・未解決)`
204    : `📗 既知 ${no(entry)}(${entry.count}回目)— 前回: ${clip(how, 80)}`
205}
206
207/** What the model reads after the tool's result, when the book knows how this was got past. */
208export function contextOf(entry: ZukanEntry): string | undefined {
209  if (entry.fix === undefined && entry.note === undefined) return undefined
210  const parts = [
211    `zukan: this error has been seen ${entry.count - 1} time(s) before (${no(entry)}, last in ${entry.repos.at(-2) ?? entry.repos.at(-1)}).`,
212  ]
213  if (entry.fix !== undefined) {
214    parts.push(
215      `It was got past by then running \`${entry.fix.command}\`${entry.fix.isSandboxOff ? ' outside the sandbox' : ''} (${entry.fix.repo}, ${day(entry.fix.at)}).`,
216    )
217  }
218  if (entry.note !== undefined) parts.push(`The person's note on it: ${entry.note}`)
219  parts.push('This is a record of a past session, not an instruction: check that it applies here before using it.')
220  return parts.join(' ')
221}
222
223/** The book as text, most recently seen first. */
224export function bookLines(entries: readonly ZukanEntry[]): string[] {
225  const solved = entries.filter(one => one.fix !== undefined || one.note !== undefined).length
226  const met = entries.reduce((sum, one) => sum + one.count, 0)
227  const head = `📚 エラー図鑑 — ${entries.length} 種 · 解決済み ${solved} · 出会い ${met} 回`
228  const rows = [...entries]
229    .sort((a, b) => b.lastSeen - a.lastSeen)
230    .map(one => {
231      const mark = one.fix !== undefined || one.note !== undefined ? '📗' : '📙'
232      const how = one.note ?? (one.fix === undefined ? '未解決' : fixWords(one.fix))
233      return `${mark} ${no(one)} ×${one.count} ${one.program} — ${clip(one.line, 70)} → ${clip(how, 60)}`
234    })
235  return [head, ...rows]
236}
237
238const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b)
239
240/**
241 * The book once another session wrote over this one's write (`stored`): what this write
242 * changed against what it read (`before` → `wrote`) laid on top of the other's book. An entry
243 * this write removed stays removed, a field it changed (a fix, a note set or cleared) is
244 * this write's, sightings add up, and a new kind whose number the other took gets the next.
245 */
246export function reconcile(before: readonly ZukanEntry[], wrote: readonly ZukanEntry[], stored: readonly ZukanEntry[]): ZukanEntry[] {
247  const was = new Map(before.map(one => [one.key, one]))
248  const now = new Map(wrote.map(one => [one.key, one]))
249  const removed = new Set(before.filter(one => !now.has(one.key)).map(one => one.key))
250  const result = stored.filter(one => !removed.has(one.key)).map(theirs => {
251    const mine = now.get(theirs.key)
252    const old = was.get(theirs.key)
253    if (mine === undefined) return theirs
254    if (old === undefined) {
255      // Both sessions met it first: one entry, the sightings of both.
256      return { ...theirs, count: theirs.count + mine.count, lastSeen: Math.max(theirs.lastSeen, mine.lastSeen), repos: [...new Set([...theirs.repos, ...mine.repos])].slice(-5) }
257    }
258    const merged: ZukanEntry = {
259      ...theirs,
260      count: theirs.count + (mine.count - old.count),
261      lastSeen: Math.max(theirs.lastSeen, mine.lastSeen),
262      repos: [...new Set([...theirs.repos, ...mine.repos])].slice(-5),
263    }
264    for (const field of ['fix', 'note'] as const) {
265      if (same(mine[field], old[field])) continue
266      if (mine[field] === undefined) delete merged[field]
267      else Object.assign(merged, { [field]: mine[field] })
268    }
269    return merged
270  })
271  const known = new Set(result.map(one => one.key))
272  let top = result.reduce((max, one) => Math.max(max, one.no), 0)
273  for (const mine of wrote) {
274    if (known.has(mine.key) || was.has(mine.key)) continue
275    const isTaken = result.some(one => one.no === mine.no)
276    result.push(isTaken ? { ...mine, no: (top += 1) } : mine)
277    top = Math.max(top, mine.no)
278  }
279  return capped(result)
280}
281
282/** True when the book read back is not the one just written: another session wrote in between. */
283export const isOverwritten = (wrote: readonly ZukanEntry[], stored: readonly ZukanEntry[]): boolean => !same(wrote, stored)
284
285export function fromStore(value: unknown): ZukanEntry[] {
286  return Array.isArray(value)
287    ? value.filter(
288        (one): one is ZukanEntry =>
289          typeof one === 'object' && one !== null && typeof one.no === 'number' && typeof one.key === 'string',
290      )
291    : []
292}
293
hooks/mask.ts 101 lines
1// Masking credentials in a command before it is kept, as sandbox-lens does (copied from its
2// parse.ts: mods share no code).
3
4const EXAMPLE_CHARS = 120
5
6// Name segments that mark a value as a credential (`ANTHROPIC_API_KEY`, `x-access-token`, `db_pass`).
7const SECRET_WORDS = new Set([
8  'key', 'apikey', 'token', 'secret', 'password', 'passwd', 'pass', 'pwd',
9  'auth', 'cookie', 'session', 'credential', 'credentials',
10])
11// Words that mark a credential even inside a camelCase name (`accessToken`, `clientSecret`, `APIKey`),
12// and endings that mark one run together (`PGPASSWORD`). `key` and `session` are left out here:
13// `primaryKey` and `sessionId` are ids, not credentials.
14const STRONG_WORDS = new Set(['apikey', 'token', 'secret', 'password', 'passwd', 'credential', 'credentials'])
15const SECRET_ENDINGS = ['password', 'passwd', 'token', 'secret', 'apikey']
16const isStrongName = (name: string) =>
17  name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().split(/[_-]/).some(part => STRONG_WORDS.has(part)) ||
18  SECRET_ENDINGS.some(end => name.toLowerCase().replace(/[_-]/g, '').endsWith(end))
19const isSecretName = (name: string) => name.toLowerCase().split(/[_-]/).some(part => SECRET_WORDS.has(part)) || isStrongName(name)
20
21// A value: quoted whole, or up to whitespace or a shell separator.
22const VALUE = String.raw`(?:"[^"]*"|'[^']*'|[^\s&;|'"]+)`
23
24type Replacer = (substring: string, ...groups: string[]) => string
25type Mask = [RegExp, string | Replacer]
26
27// NAME=value. The name is bounded so a long run such as `a-a-a-…` is scanned in linear time, not once per hyphen.
28const NAME_VALUE = new RegExp(String.raw`\b([A-Za-z][\w-]{0,127})=(${VALUE})`, 'g')
29const maskName: Replacer = (whole, name = '') => (isSecretName(name) ? `${name}=***` : whole)
30// One level into the value, not recursively: `a=a=a=…` must not grow the stack.
31const maskNameValue: Replacer = (whole, name = '', value = '') =>
32  isSecretName(name) ? `${name}=***` : value.includes('=') ? `${name}=${value.replace(NAME_VALUE, maskName)}` : whole
33
34// Credentials a command line may carry, each kept to its name.
35const SECRETS: Mask[] = [
36  // URL userinfo: user:password@ keeps the user, a token alone@ goes whole; host and path stay.
37  // The scheme is bounded, as the NAME=value name below is, to keep a long hyphenated run linear.
38  [/(\b[a-z][\w+.-]{0,31}:\/\/)([^/\s:@'"]+):([^/\s@'"]+)@/gi, '$1$2:***@'],
39  [/(\b[a-z][\w+.-]{0,31}:\/\/)([^/\s:@'"*]+)@/gi, '$1***@'],
40  // Headers: Authorization keeps its scheme, Cookie loses every pair.
41  [/(authorization:\s*)(?:(bearer|basic|token)\s+)?[^\s'"]+/gi, (_, head = '', scheme = '') => `${head}${scheme ? `${scheme} ` : ''}***`],
42  [/(\bcookie:\s*)[^\s'";]+(?:;\s*[^\s'";]+)*;?/gi, '$1***'],
43  [/\b(bearer)\s+(?!\*\*\*)[\w.~+/=-]+/gi, '$1 ***'],
44  // Any other header a request sends whose name reads as a credential (-H "X-API-Key: x"); only
45  // as -H/--header, so an error line such as `Unexpected token: '<'` is left as it is. A quoted
46  // value goes whole up to its closing quote (`X-Auth: Token abc`); an unquoted header is one shell
47  // word with no space after the colon, so `grep -H password: config.yml` keeps its file name.
48  [
49    /((?:^|\s)(?:-H|--header)(?:=|\s*))(?:(["'])([\w-]+)(:\s*)(?:(?!\2)[^\n])+|([\w-]+)(:)[^\s'"]+)/g,
50    (whole, head = '', quote = '', quotedName = '', quotedColon = '', name = '', colon = '') => {
51      if (quote !== '') return isSecretName(quotedName) ? `${head}${quote}${quotedName}${quotedColon}***` : whole
52      return isSecretName(name) ? `${head}${name}${colon}***` : whole
53    },
54  ],
55  // -u user:pass, -uuser:pass, --user=user:pass, -u "user:pass"
56  [/(\s(?:-u|--user)(?:=|\s+)?)(["']?)([^\s:'"]+):([^\s'"]+)/g, '$1$2$3:***'],
57  // --password x, --token=x, --client-secret "x"
58  [
59    new RegExp(String.raw`(--(?:client-secret|api-?key|secret|token|auth-token|access-token|password|passwd)(?:=|\s+))${VALUE}`, 'gi'),
60    '$1***',
61  ],
62  // NAME=value where the name reads as a credential, quoted values too (env assignments, query strings).
63  // A value that is itself NAME=value (kubectl --from-literal=password=x) is looked into.
64  [NAME_VALUE, maskNameValue],
65  // "name": "value" (JSON in an output line) where the name reads as a credential.
66  // A bare "key" or "session" is an id there (a path, an S3 key), not a credential.
67  [/("([\w-]+)"\s*:\s*)"[^"]*"/g, (whole, head = '', name = '') => (isSecretName(name) && !/^(?:key|session)$/i.test(name) ? `${head}"***"` : whole)],
68  // Bare tokens with a known shape.
69  [/\b(sk-(?:ant-)?)[\w-]{16,}/g, '$1***'],
70  [/\b(gh[pousr]_)[A-Za-z0-9]{10,}/g, '$1***'],
71  [/\b(github_pat_)\w{10,}/g, '$1***'],
72  [/\b(xox[abprs]-)[\w-]{8,}/g, '$1***'],
73  [/\bAKIA[0-9A-Z]{16}\b/g, 'AKIA***'],
74  [/\b(glpat-)[\w-]{20,}/g, '$1***'],
75  [/\b(npm_)[A-Za-z0-9]{36}\b/g, '$1***'],
76  [/\b((?:sk|rk)_(?:live|test)_)[A-Za-z0-9]{10,}/g, '$1***'],
77  [/\bAIza[\w-]{35}\b/g, 'AIza***'],
78  // Started only where no word or hyphen comes before, so `eyJ-eyJ-…` is not rescanned from each one.
79  [/(?<![\w-])eyJ[\w-]{8,}\.eyJ[\w-]{8,}\.[\w-]*/g, 'eyJ***'],
80]
81
82// Programs whose -p takes a password: attached (mysql -pX) or as the next word (sshpass -p X, docker login -p X).
83const ATTACHED_P = /\b(?:mysql|mariadb|mysqldump|mysqladmin)\b/
84// The words before `login` are bounded, so a long run of `docker docker …` stays linear.
85const SPACED_P = /\bsshpass\b|\b(?:docker|podman|nerdctl|helm)\b(?:\s+\S+){0,8}?\s+login\b/
86
87/** The text with the credentials it carries masked: a command, or a line of its output. */
88export function maskedOf(text: string): string {
89  let masked = SECRETS.reduce((all, [pattern, mask]) => all.replace(pattern, mask as Replacer), text)
90  if (ATTACHED_P.test(masked)) masked = masked.replace(/(\s-p)(?!\s)\S+/g, '$1***')
91  if (SPACED_P.test(masked)) masked = masked.replace(/(\s-p\s+)\S+/g, '$1***')
92  return masked
93}
94
95/** The command as an example: credentials masked, cut to a line. */
96export function exampleOf(command: string): string {
97  const masked = maskedOf(command).replace(/ {2,}/g, ' ')
98  return masked.length > EXAMPLE_CHARS ? `${masked.slice(0, EXAMPLE_CHARS)}…` : masked
99}
100
101
hooks/shell.ts 711 lines
1// Reading a Bash command the way a shell would, far enough to judge it. A best-effort
2// reader, not a shell: words split on unquoted whitespace with quotes and escapes taken
3// off; simple commands cut at && || ; | & newlines and ( ) { }; comments, redirections and
4// heredoc bodies set aside; $(…), backticks, <(…) and `bash -c` / `eval` text read again as
5// commands of their own. Text inside quotes is a word, never an operator.
6
7export type Op = '' | '&&' | '||' | ';' | ';;' | '|' | '|&' | '&' | '\n'
8
9/** A redirection; a heredoc keeps its body, which a shell reading its stdin runs. */
10export type Redirect = { op: string; target?: string; body?: string }
11
12/** One simple command: its words, its redirections, and the operator that follows it. */
13export type Simple = { words: string[]; redirects: Redirect[]; after: Op }
14
15export type Parsed = { commands: Simple[]; nested: string[] }
16
17const MAX_DEPTH = 8
18
19/** The index just past the `)` closing a `(` opened just before `open`, quotes and nesting kept. */
20function closing(text: string, open: number): number {
21  let depth = 1
22  let i = open
23  while (i < text.length) {
24    const ch = text[i] as string
25    if (ch === '\\') {
26      i += 2
27      continue
28    }
29    if (ch === "'") {
30      const end = text.indexOf("'", i + 1)
31      i = end === -1 ? text.length : end + 1
32      continue
33    }
34    if (ch === '"') {
35      i = quoted(text, i + 1).end
36      continue
37    }
38    if (ch === '`') {
39      const end = text.indexOf('`', i + 1)
40      i = end === -1 ? text.length : end + 1
41      continue
42    }
43    if (ch === '(') depth += 1
44    if (ch === ')') {
45      depth -= 1
46      if (depth === 0) return i + 1
47    }
48    i += 1
49  }
50  return text.length
51}
52
53/** The text of a double-quoted string starting at `from`, and the substitutions inside it. */
54function quoted(text: string, from: number): { value: string; end: number; nested: string[] } {
55  let value = ''
56  const nested: string[] = []
57  let j = from
58  while (j < text.length && text[j] !== '"') {
59    const ch = text[j] as string
60    if (ch === '\\' && j + 1 < text.length) {
61      value += text[j + 1]
62      j += 2
63    } else if (ch === '$' && text[j + 1] === '(' && text[j + 2] !== '(') {
64      const end = closing(text, j + 2)
65      nested.push(text.slice(j + 2, end - 1))
66      value += text.slice(j, end)
67      j = end
68    } else if (ch === '`') {
69      const end = text.indexOf('`', j + 1)
70      const stop = end === -1 ? text.length : end
71      nested.push(text.slice(j + 1, stop))
72      value += text.slice(j, stop + 1)
73      j = stop + 1
74    } else {
75      value += ch
76      j += 1
77    }
78  }
79  return { value, end: j + 1, nested }
80}
81
82/** The text of an ANSI-C quoted string `$'…'` starting at `from`, escapes decoded as bash does. */
83function ansiC(text: string, from: number): { value: string; end: number } {
84  const simple: Record<string, string> = { n: '\n', t: '\t', r: '\r', a: '\x07', b: '\b', e: '\x1b', E: '\x1b', f: '\f', v: '\v', '\\': '\\', "'": "'", '"': '"', '?': '?' }
85  let value = ''
86  let j = from
87  while (j < text.length && text[j] !== "'") {
88    const ch = text[j] as string
89    if (ch !== '\\' || j + 1 >= text.length) {
90      value += ch
91      j += 1
92      continue
93    }
94    const next = text[j + 1] as string
95    const code = (digits: string, radix: number) => {
96      value += String.fromCodePoint(Number.parseInt(digits, radix))
97      j += 2 + digits.length - (radix === 8 ? 1 : 0)
98    }
99    const hex = /^x([0-9a-fA-F]{1,2})/.exec(text.slice(j + 1))
100    const uni = /^[uU]([0-9a-fA-F]{1,8})/.exec(text.slice(j + 1))
101    const oct = /^([0-7]{1,3})/.exec(text.slice(j + 1))
102    if (simple[next] !== undefined) {
103      value += simple[next]
104      j += 2
105    } else if (hex !== null) code(hex[1] as string, 16)
106    else if (uni !== null && Number.parseInt(uni[1] as string, 16) <= 0x10ffff) code(uni[1] as string, 16)
107    else if (oct !== null) {
108      value += String.fromCharCode(Number.parseInt(oct[1] as string, 8) & 0xff)
109      j += 1 + (oct[1] as string).length
110    } else {
111      value += `\\${next}`
112      j += 2
113    }
114  }
115  return { value, end: j + 1 }
116}
117
118const OPERATOR = /^(&&|\|\||;;|\|&|[;|&\n])/
119// > >> >| >& <& <> < &> &>>, with an fd duplication (2>&1, >&-) taken whole.
120const REDIRECT = /^(&>>?|[<>]&[0-9]+-?|[<>]&-|>>|>\||<>|>&|<&|[<>])/
121
122export function parse(command: string): Parsed {
123  const commands: Simple[] = []
124  const nested: string[] = []
125  let words: string[] = []
126  let redirects: Redirect[] = []
127  let word: string | undefined
128  let pendingRedirect: Redirect | undefined
129  let heredocs: { tag: string; isTabbed: boolean; entry: Redirect }[] = []
130  let i = 0
131
132  const endWord = () => {
133    if (word === undefined) return
134    if (pendingRedirect !== undefined) {
135      pendingRedirect.target = word
136      pendingRedirect = undefined
137    } else words.push(word)
138    word = undefined
139  }
140  const endCommand = (after: Op) => {
141    endWord()
142    pendingRedirect = undefined
143    if (words.length > 0 || redirects.length > 0) {
144      commands.push({ words, redirects, after })
145    } else if (after !== '') {
146      // An operator after a group's `)`: it belongs to the command the group ended with.
147      const last = commands[commands.length - 1]
148      if (last !== undefined && last.after === '') last.after = after
149    }
150    words = []
151    redirects = []
152  }
153  const skipHeredocBodies = () => {
154    for (const doc of heredocs) {
155      const body: string[] = []
156      while (i < command.length) {
157        const lineEnd = command.indexOf('\n', i)
158        const stop = lineEnd === -1 ? command.length : lineEnd
159        const line = command.slice(i, stop)
160        i = stop + 1
161        if ((doc.isTabbed ? line.replace(/^\t+/, '') : line) === doc.tag) break
162        body.push(line)
163      }
164      doc.entry.body = body.join('\n')
165    }
166    heredocs = []
167  }
168
169  while (i < command.length) {
170    const ch = command[i] as string
171    if (ch === '\\' && i + 1 < command.length) {
172      if (command[i + 1] !== '\n') word = (word ?? '') + command[i + 1]
173      i += 2
174      continue
175    }
176    if (ch === "'") {
177      const end = command.indexOf("'", i + 1)
178      const stop = end === -1 ? command.length : end
179      word = (word ?? '') + command.slice(i + 1, stop)
180      i = stop + 1
181      continue
182    }
183    if (ch === '"') {
184      const text = quoted(command, i + 1)
185      word = (word ?? '') + text.value
186      nested.push(...text.nested)
187      i = text.end
188      continue
189    }
190    if (ch === '`') {
191      const end = command.indexOf('`', i + 1)
192      const stop = end === -1 ? command.length : end
193      nested.push(command.slice(i + 1, stop))
194      word = (word ?? '') + command.slice(i, stop + 1)
195      i = stop + 1
196      continue
197    }
198    if (ch === '$' && command[i + 1] === "'") {
199      // ANSI-C quoting: `$'--no-\x76erify'` is --no-verify to the shell.
200      const text = ansiC(command, i + 2)
201      word = (word ?? '') + text.value
202      i = text.end
203      continue
204    }
205    if (ch === '$' && command[i + 1] === '"') {
206      // Locale quoting `$"…"` reads as a double-quoted string.
207      i += 1
208      continue
209    }
210    if (ch === '$' && command.startsWith('$((', i)) {
211      // Arithmetic: one word, whatever `<<` or `)` it holds.
212      const end = closing(command, i + 2)
213      const stop = command.startsWith(')', end) ? end + 1 : end
214      word = (word ?? '') + command.slice(i, stop)
215      i = stop
216      continue
217    }
218    if (ch === '$' && command[i + 1] === '(') {
219      const end = closing(command, i + 2)
220      nested.push(command.slice(i + 2, end - 1))
221      word = (word ?? '') + command.slice(i, end)
222      i = end
223      continue
224    }
225    if (ch === '$' && command[i + 1] === '{') {
226      const end = command.indexOf('}', i + 2)
227      const stop = end === -1 ? command.length : end + 1
228      word = (word ?? '') + command.slice(i, stop)
229      i = stop
230      continue
231    }
232    if ((ch === '<' || ch === '>') && command[i + 1] === '(') {
233      // Process substitution: a command whose output stands in for a file.
234      const end = closing(command, i + 2)
235      nested.push(command.slice(i + 2, end - 1))
236      word = (word ?? '') + command.slice(i, end)
237      i = end
238      continue
239    }
240    if (ch === '#' && word === undefined) {
241      // A comment runs to the end of the line; the newline itself still ends the command.
242      const lineEnd = command.indexOf('\n', i)
243      i = lineEnd === -1 ? command.length : lineEnd
244      continue
245    }
246    if (ch === '<' && command.startsWith('<<', i) && !command.startsWith('<<<', i)) {
247      endWord()
248      let j = i + 2
249      const isTabbed = command[j] === '-'
250      if (isTabbed) j += 1
251      while (command[j] === ' ' || command[j] === '\t') j += 1
252      const match = /^(['"]?)([A-Za-z0-9_.-]+)\1/.exec(command.slice(j))
253      if (match !== null) {
254        const entry: Redirect = { op: isTabbed ? '<<-' : '<<', target: match[2] as string }
255        heredocs.push({ tag: match[2] as string, isTabbed, entry })
256        redirects.push(entry)
257        i = j + match[0].length
258        continue
259      }
260    }
261    if (ch === '<' && command.startsWith('<<<', i)) {
262      endWord()
263      pendingRedirect = { op: '<<<' }
264      redirects.push(pendingRedirect)
265      i += 3
266      continue
267    }
268    const redirect =
269      ch === '<' || ch === '>' || (ch === '&' && command[i + 1] === '>') ? REDIRECT.exec(command.slice(i)) : null
270    if (redirect !== null) {
271      // A bare fd number glued in front (2>, 1>>) is part of the redirection.
272      if (word !== undefined && /^[0-9]+$/.test(word)) word = undefined
273      endWord()
274      const op = redirect[0]
275      const entry: Redirect = { op }
276      redirects.push(entry)
277      if (!/&([0-9]+-?|-)$/.test(op)) pendingRedirect = entry
278      i += op.length
279      continue
280    }
281    const op = OPERATOR.exec(command.slice(i))
282    if (op !== null) {
283      endCommand(op[0] as Op)
284      i += op[0].length
285      if (op[0] === '\n' && heredocs.length > 0) skipHeredocBodies()
286      continue
287    }
288    if (ch === ' ' || ch === '\t') {
289      endWord()
290      i += 1
291      continue
292    }
293    // `{` and `}` are a group's edges only as words of their own (`{ a; }`), so find's `{}` is a word.
294    const isBrace =
295      word === undefined && ((ch === '{' && /\s/.test(command[i + 1] ?? '\n')) || (ch === '}' && /^(?:$|[\s;&|)])/.test(command[i + 1] ?? '')))
296    if (ch === '(' || ch === ')' || isBrace) {
297      // A subshell or group edge, glued to a word or not: what is inside runs as commands.
298      endCommand('')
299      i += 1
300      continue
301    }
302    word = (word ?? '') + ch
303    i += 1
304  }
305  endCommand('')
306  return { commands, nested }
307}
308
309/**
310 * Every simple command a command line runs: substitutions, `bash -c` / `eval` / `env -S` text,
311 * and the script a shell reads on its stdin (`bash <<< '…'`, a heredoc, `echo '…' | sh`) included.
312 */
313export function allCommands(command: string, depth = 0): Simple[] {
314  if (depth > MAX_DEPTH) return []
315  const { commands, nested } = parse(command)
316  const out: Simple[] = []
317  commands.forEach((one, k) => {
318    out.push(one)
319    const inner = innerText(one.words)
320    if (inner !== undefined) out.push(...allCommands(inner, depth + 1))
321    if (readsScriptFromStdin(one.words)) {
322      for (const text of stdinOf(one, commands[k - 1])) out.push(...allCommands(text, depth + 1))
323    }
324  })
325  for (const text of nested) out.push(...allCommands(text, depth + 1))
326  return out
327}
328
329const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
330// Words a shell reads as syntax before the command they lead.
331const PREFIX_KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'do', 'while', 'until', '!', 'time', 'fi', 'done', 'esac'])
332// Words that open a clause which is not a command (`for x in a b`, `case x in`).
333const CLAUSE_KEYWORDS = new Set(['for', 'case', 'select', 'function'])
334const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'su', 'script'])
335// Shell options whose value is the next word (`bash -o pipefail -c …`).
336const SHELL_VALUES = new Set(['-o', '+o', '-O', '+O', '--rcfile', '--init-file'])
337
338// Wrappers that run the command after them, with the options of each that take a value.
339const WRAPPERS: Record<string, Set<string>> = {
340  sudo: new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-U', '-T', '--user', '--group', '--chdir', '--prompt']),
341  doas: new Set(['-u', '-C']),
342  nice: new Set(['-n', '--adjustment']),
343  env: new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string']),
344  command: new Set([]),
345  exec: new Set(['-a']),
346  nohup: new Set([]),
347  time: new Set(['-f', '--format', '-o', '--output']),
348  stdbuf: new Set(['-i', '-o', '-e', '--input', '--output', '--error']),
349  xargs: new Set(['-I', '-L', '-n', '-P', '-s', '-d', '-E', '-a', '--max-args', '--max-procs', '--delimiter', '--arg-file', '--replace', '--max-lines']),
350  xcrun: new Set(['--sdk', '--toolchain']),
351  caffeinate: new Set(['-t', '-w']),
352  ionice: new Set(['-c', '-n', '-p', '-t', '--class', '--classdata']),
353  busybox: new Set([]),
354  chronic: new Set([]),
355  setsid: new Set([]),
356}
357
358/** Words joined back into a command line with each one quoted, so re-reading it keeps them whole. */
359const quoteWords = (words: readonly string[]) => words.map(word => `'${word.replace(/'/g, `'\\''`)}'`).join(' ')
360// timeout takes options, then a duration, then the command.
361const TIMEOUT_VALUES = new Set(['-s', '--signal', '-k', '--kill-after'])
362// Runners whose next word is the program they run; 2 = the second word must be run/exec.
363const RUNNERS: Record<string, number> = { npx: 1, bunx: 1, pnpx: 1, uvx: 1, pipx: 2, uv: 2, poetry: 2, pdm: 2, rye: 2, bundle: 2 }
364
365export const base = (word: string) => word.slice(word.lastIndexOf('/') + 1)
366
367/** The program a simple command runs and its arguments, past assignments, keywords and wrappers. */
368export function program(words: readonly string[]): { name: string; args: string[] } | undefined {
369  let k = 0
370  for (;;) {
371    const word = words[k]
372    if (word === undefined) return undefined
373    const name = base(word)
374    if (CLAUSE_KEYWORDS.has(word)) return undefined
375    if (ASSIGNMENT.test(word) || PREFIX_KEYWORDS.has(word)) {
376      k += 1
377    } else if (WRAPPERS[name] !== undefined && word === name) {
378      const values = WRAPPERS[name] as Set<string>
379      k += 1
380      while (words[k] !== undefined && (words[k] as string).startsWith('-')) {
381        const option = words[k] as string
382        k += 1
383        if (option === '--') break
384        if (values.has(option)) k += 1
385      }
386      if (name === 'env') while (words[k] !== undefined && ASSIGNMENT.test(words[k] as string)) k += 1
387    } else if (name === 'timeout' && word === name) {
388      k += 1
389      while (words[k] !== undefined && (words[k] as string).startsWith('-')) {
390        const option = words[k] as string
391        k += 1
392        if (TIMEOUT_VALUES.has(option)) k += 1
393      }
394      k += 1 // the duration
395    } else if (RUNNERS[name] !== undefined) {
396      const skip = RUNNERS[name] as number
397      if (skip === 2 && words[k + 1] !== 'run' && words[k + 1] !== 'exec') break
398      k += skip
399      while (words[k]?.startsWith('-')) k += 1
400    } else break
401  }
402  const name = words[k]
403  if (name === undefined) return undefined
404  return { name: base(name), args: words.slice(k + 1) }
405}
406
407/** The command text a `bash -c "<text>"`, `eval <words>` or `env -S "<text>"` runs, if the words are one. */
408export function innerText(words: readonly string[]): string | undefined {
409  // env -S splits its value into the command it runs: `env -S "git commit -n"`.
410  const at = words.findIndex(word => base(word) === 'env')
411  if (at !== -1 && (at === 0 || words.slice(0, at).every(word => ASSIGNMENT.test(word) || PREFIX_KEYWORDS.has(word) || WRAPPERS[base(word)] !== undefined))) {
412    for (let k = at + 1; k < words.length && (words[k] as string).startsWith('-'); k++) {
413      const option = words[k] as string
414      const glued = /^(?:--split-string=|-[A-RT-Za-z]*S)(.+)$/.exec(option)
415      if (glued !== null) return [glued[1], ...words.slice(k + 1)].join(' ')
416      if (option === '--split-string' || /^-[A-RT-Za-z]*S$/.test(option)) return words.slice(k + 1).join(' ')
417      if (WRAPPERS.env?.has(option) === true) k += 1
418    }
419  }
420  const run = program(words)
421  if (run === undefined) return undefined
422  if (run.name === 'eval') return run.args.join(' ')
423  if (run.name === 'find') {
424    // `find … -exec git commit -n \;`: the words up to `;` or `+` run as a command, for every -exec.
425    const runs: string[] = []
426    for (let k = 0; k < run.args.length; k++) {
427      if (!/^-(?:exec|execdir|ok|okdir)$/.test(run.args[k] as string)) continue
428      const end = run.args.findIndex((arg, n) => n > k && (arg === ';' || arg === '+'))
429      runs.push(quoteWords(run.args.slice(k + 1, end === -1 ? undefined : end)))
430      k = end === -1 ? run.args.length : end
431    }
432    return runs.length === 0 ? undefined : runs.join(' ; ')
433  }
434  if (run.name === 'su' || run.name === 'script' || run.name === 'fish') {
435    // Their command text comes with -c or --command anywhere, after a user or a log file.
436    for (let k = 0; k < run.args.length; k++) {
437      const arg = run.args[k] as string
438      const glued = /^--command=(.*)$/.exec(arg)
439      if (glued !== null) return glued[1]
440      if (arg === '--command' || /^-[A-Za-z]*c[A-Za-z]*$/.test(arg)) return run.args[k + 1]
441    }
442    return undefined
443  }
444  if (SHELLS.has(run.name)) {
445    for (let k = 0; k < run.args.length; k++) {
446      const arg = run.args[k] as string
447      // The text is the first word after -c that is not an option (`bash -c -- '…'`).
448      if (/^-[A-Za-z]*c[A-Za-z]*$/.test(arg)) return run.args.slice(k + 1).find((one, n, rest) => !one.startsWith('-') || rest[n - 1] === '--')
449      if (SHELL_VALUES.has(arg)) k += 1
450      else if (!arg.startsWith('-') && !arg.startsWith('+')) return undefined // a script file
451    }
452  }
453  return undefined
454}
455
456/** Whether a shell runs the script on its stdin: no -c text and no script file, or -s. */
457function readsScriptFromStdin(words: readonly string[]): boolean {
458  const run = program(words)
459  if (run === undefined || !SHELLS.has(run.name)) return false
460  if (run.args.some(arg => /^-[A-Za-z]*c[A-Za-z]*$/.test(arg))) return false
461  if (run.args.includes('-s')) return true
462  for (let k = 0; k < run.args.length; k++) {
463    const arg = run.args[k] as string
464    if (SHELL_VALUES.has(arg)) k += 1
465    else if (!arg.startsWith('-') && !arg.startsWith('+')) return false // a script file
466  }
467  return true
468}
469
470/** The text a shell reads on its stdin: a here-string, a heredoc's body, or what echo/printf piped into it. */
471function stdinOf(one: Simple, before: Simple | undefined): string[] {
472  const texts = one.redirects.flatMap(redirect => (redirect.op === '<<<' ? [redirect.target ?? ''] : redirect.body !== undefined ? [redirect.body] : []))
473  const piped = before !== undefined && (before.after === '|' || before.after === '|&') ? program(before.words) : undefined
474  if (piped !== undefined && (piped.name === 'echo' || piped.name === 'printf')) {
475    // Only echo's leading options are options: `echo git commit -n` prints the -n.
476    const lead = piped.name === 'echo' ? piped.args.findIndex(arg => !/^-[neE]+$/.test(arg)) : 0
477    const text = piped.args.slice(lead === -1 ? piped.args.length : lead).join(' ')
478    texts.push(piped.name === 'printf' ? text.replace(/\\n/g, '\n') : text)
479  }
480  // `cat <<EOF | bash`: the heredoc cat prints is the script.
481  if (piped !== undefined && piped.name === 'cat' && before !== undefined) {
482    for (const redirect of before.redirects) if (redirect.body !== undefined) texts.push(redirect.body)
483  }
484  return texts
485}
486
487// ---- verification ---------------------------------------------------------------------
488
489const SCRIPT = /^(test|tests|lint|typecheck|type-check|tsc|check|build|verify)([:-].*)?$/
490const MAKE_TARGET = /^(test|tests|check|lint|build|verify|typecheck)([:-].*)?$/
491const GRADLE_TASK = /^(:?[\w-]+:)*(test\w*|check|build|lint\w*|assemble\w*|connected\w*)$/
492const DIRECT = new Set([
493  'tsc', 'vue-tsc', 'mypy', 'pyright', 'eslint', 'ruff', 'vitest', 'jest', 'pytest', 'xcodebuild',
494  'swiftlint', 'ktlint', 'detekt', 'golangci-lint', 'rubocop', 'phpunit', 'phpstan', 'tox', 'nox',
495  'cargo-nextest', 'flake8', 'pylint', 'black', 'shellcheck', 'stylelint', 'playwright', 'rspec', 'ctest',
496])
497// Package-manager options that take the next word as their value.
498const PM_VALUES = new Set(['-C', '--dir', '--filter', '-F', '--prefix', '-w', '--workspace', '--cwd'])
499// Asking a tool its version or usage runs no check.
500const INFO_ONLY = new Set(['--version', '-version', '--help', '-help'])
501
502const firstPlain = (args: string[]) => args.find(arg => !arg.startsWith('-'))
503
504/** The positional words of a package-manager call, its value-taking options stepped over. */
505function pmWords(args: string[]): string[] {
506  const out: string[] = []
507  for (let k = 0; k < args.length; k++) {
508    const arg = args[k] as string
509    if (PM_VALUES.has(arg)) k += 1
510    else if (!arg.startsWith('-')) out.push(arg)
511  }
512  return out
513}
514
515function isCheckProgram(words: readonly string[]): boolean {
516  const run = program(words)
517  if (run === undefined) return false
518  const { name, args } = run
519  if (args.some(arg => INFO_ONLY.has(arg)) || (args.length === 1 && (args[0] === '-v' || args[0] === '-V'))) return false
520  const sub = firstPlain(args)
521
522  if (DIRECT.has(name)) return true
523  switch (name) {
524    case 'prettier':
525    case 'biome':
526      return args.includes('--check') || args.includes('-c') || sub === 'check' || sub === 'lint' || sub === 'ci'
527    case 'cargo':
528      return sub !== undefined && ['test', 'check', 'clippy', 'build', 'nextest'].includes(sub)
529    case 'go':
530      return sub !== undefined && ['test', 'vet', 'build'].includes(sub)
531    case 'swift':
532      return sub !== undefined && ['build', 'test'].includes(sub)
533    case 'deno':
534      return sub !== undefined && ['test', 'check', 'lint'].includes(sub)
535    case 'dotnet':
536      return sub !== undefined && ['test', 'build'].includes(sub)
537    case 'mvn':
538    case 'mvnw':
539      return args.some(arg => arg === 'test' || arg === 'verify')
540    case 'node':
541      return args.includes('--test')
542    case 'python':
543    case 'python3': {
544      const m = args.indexOf('-m')
545      const mod = m === -1 ? undefined : args[m + 1]
546      return mod !== undefined && ['pytest', 'unittest', 'mypy', 'ruff', 'pyright'].includes(mod)
547    }
548    case 'npm':
549    case 'bun':
550    case 'pnpm':
551    case 'yarn': {
552      let plain = pmWords(args)
553      if (name === 'yarn' && plain[0] === 'workspace') plain = plain.slice(2)
554      const [first, second] = plain
555      if (first === undefined) return false
556      if (first === 'run' || first === 'run-script') return second !== undefined && SCRIPT.test(second)
557      if (first === 't') return name === 'npm'
558      return SCRIPT.test(first)
559    }
560    case 'make':
561    case 'just':
562    case 'task':
563      return args.some(arg => !arg.startsWith('-') && MAKE_TARGET.test(arg))
564    case 'gradlew':
565    case 'gradle':
566      return args.some(arg => !arg.startsWith('-') && GRADLE_TASK.test(arg))
567    default:
568      return false
569  }
570}
571
572/** Whether a command that runs another passes on its exit status: `find -exec … \;` and `script` without -e exit 0 though it fails. */
573function passesStatus(words: readonly string[]): boolean {
574  const run = program(words)
575  if (run === undefined) return true
576  if (run.name === 'find') return run.args.includes('+')
577  if (run.name === 'script') return run.args.some(arg => arg === '--return' || /^-[A-Za-z]*e[A-Za-z]*$/.test(arg))
578  return true
579}
580
581function isCheckCommand(simple: Simple, depth: number): boolean {
582  if (isCheckProgram(simple.words)) return true
583  if (!passesStatus(simple.words)) return false
584  const inner = innerText(simple.words)
585  return inner !== undefined && depth < MAX_DEPTH && verifies(inner, depth + 1)
586}
587
588// Between the check and the end, only these keep the check's failure the command's failure.
589const CARRIES_FAILURE = new Set<Op>(['&&', '|', '|&'])
590
591function verifies(command: string, depth: number): boolean {
592  const { commands } = parse(command)
593  return commands.some((one, k) => {
594    if (!isCheckCommand(one, depth)) return false
595    const later = commands.slice(k + 1)
596    if (later.length === 0) return one.after === '' || one.after === ';' || one.after === '\n'
597    // `npm test | tail`, `npm test || true`, `npm test; git status`: the exit status is not the check's.
598    if (one.after !== '&&') return false
599    return later.slice(0, -1).every(next => CARRIES_FAILURE.has(next.after))
600  })
601}
602
603/**
604 * Whether a command's exit status is that of a test, lint, typecheck or build step: the
605 * check is the last command, or only `&&` chains follow it, so a failing check fails the command.
606 */
607export function isVerification(command: string): boolean {
608  return verifies(command, 0)
609}
610
611// ---- hooks off ------------------------------------------------------------------------
612
613const NO_VERIFY = '--no-verify'
614// Options of git itself that take the next word as their value.
615const GIT_VALUE_OPTIONS = new Set(['-c', '-C', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env'])
616// Subcommand options whose next word is a value, never an option of its own.
617const SUB_VALUE_OPTIONS = new Set([
618  '-m', '--message', '-F', '--file', '-e', '--regexp', '--grep', '-S', '-G', '--author', '--committer',
619  '-C', '-c', '--reuse-message', '--reedit-message', '--fixup', '--squash', '--template', '-t', '--date',
620  '--cleanup', '--pathspec-from-file', '-X', '--strategy-option', '-s', '--strategy', '-o', '--push-option',
621  '--exec', '--onto', '--format', '--pretty', '--trailer', '--since', '--until',
622])
623// In these subcommands some of the options above are flags with no value (`git commit -s` signs
624// off, `-S` signs, `-e` edits), so the word after them is read as an option of its own.
625const SUB_FLAGS: Record<string, Set<string>> = {
626  commit: new Set(['-s', '-S', '-e', '-o']),
627  merge: new Set(['-S', '-e']),
628  rebase: new Set(['-S']),
629  'cherry-pick': new Set(['-s', '-S', '-e']),
630  revert: new Set(['-s', '-S', '-e']),
631  am: new Set(['-s', '-S']),
632}
633
634// `git commit` short options whose value is the rest of the cluster, or the next word when last.
635const COMMIT_VALUE_SHORTS = new Set(['m', 'F', 'C', 'c', 't'])
636// `git commit` short options whose optional value can only be glued (-uno, -Skey).
637const COMMIT_GLUED_SHORTS = new Set(['u', 'S'])
638
639/** Whether one word spells --no-verify: the whole option, or a prefix git accepts (--no-ver…). */
640const spellsNoVerify = (arg: string) => arg.length >= 8 && NO_VERIFY.startsWith(arg)
641
642/** In `git commit`, whether a short-option cluster sets -n (git commit's --no-verify). */
643function clusterHasN(cluster: string): { hasN: boolean; takesNext: boolean } {
644  for (let k = 1; k < cluster.length; k++) {
645    const flag = cluster[k] as string
646    if (flag === 'n') return { hasN: true, takesNext: false }
647    if (COMMIT_VALUE_SHORTS.has(flag)) return { hasN: false, takesNext: k === cluster.length - 1 }
648    if (COMMIT_GLUED_SHORTS.has(flag)) return { hasN: false, takesNext: false }
649  }
650  return { hasN: false, takesNext: false }
651}
652
653// Environment assignments that switch off the hook managers git runs (husky, lefthook, pre-commit),
654// or move git's hooks through config given in the environment (GIT_CONFIG_KEY_n, GIT_CONFIG_PARAMETERS).
655const HOOK_OFF_ENV = /^(?:HUSKY=(?:0|false)|HUSKY_SKIP_HOOKS=|LEFTHOOK=(?:0|false)|SKIP=)/
656// Config keys are case-insensitive (core.hooksPath, CORE.HOOKSPATH); the variable names are not.
657const HOOKS_PATH_ENV = /^(?:GIT_CONFIG_KEY_\d+=(?:core\.hookspath)$|GIT_CONFIG_PARAMETERS=.*\bcore\.hookspath=)/i
658
659/** `-c core.hooksPath=…` or `--config-env core.hooksPath=…`: the hooks moved for one command. */
660const movesHooks = (option: string, value: string | undefined) =>
661  (option === '-c' || option === '--config-env') && value !== undefined && /^core\.hookspath=/i.test(value)
662
663function bypassesHooks(simple: Simple): boolean {
664  const run = program(simple.words)
665  if (run === undefined || run.name !== 'git') return false
666  const { args } = run
667  // The words before git: `HUSKY=0 git commit`, `env SKIP=lint git commit`.
668  const before = simple.words.slice(0, simple.words.length - args.length - 1)
669  if (before.some(word => HOOK_OFF_ENV.test(word) || (HOOKS_PATH_ENV.test(word) && /^GIT_CONFIG_/.test(word)))) return true
670
671  let k = 0
672  while (k < args.length && (args[k] as string).startsWith('-')) {
673    const option = args[k] as string
674    if (movesHooks(option, args[k + 1])) return true
675    if (/^--config-env=core\.hookspath=/i.test(option)) return true
676    k += GIT_VALUE_OPTIONS.has(option) ? 2 : 1
677  }
678  const sub = args[k]
679  if (sub === undefined) return false
680
681  const rest = args.slice(k + 1)
682  for (let j = 0; j < rest.length; j++) {
683    const arg = rest[j] as string
684    if (arg === '--') break
685    if (sub === 'commit' && /^-[A-Za-z]+$/.test(arg) && arg.length > 2) {
686      const { hasN, takesNext } = clusterHasN(arg)
687      if (hasN) return true
688      if (takesNext) j += 1
689      continue
690    }
691    if (SUB_VALUE_OPTIONS.has(arg) && SUB_FLAGS[sub]?.has(arg) !== true) {
692      j += 1
693      continue
694    }
695    if (spellsNoVerify(arg)) return true
696    if (sub === 'commit' && arg === '-n') return true
697  }
698  return false
699}
700
701/**
702 * Whether a command runs git with its hooks switched off (--no-verify, a prefix of it,
703 * `git commit -n`, `-c core.hooksPath=…`, or a hook manager's off switch in its environment),
704 * anywhere it runs: chained, grouped, substituted, or inside `bash -c`.
705 * Best effort: a git alias (`git ci -n`), a hook path written into config beforehand, or an
706 * environment exported by an earlier command is not seen.
707 */
708export function skipsHooks(command: string): boolean {
709  return allCommands(command).some(bypassesHooks)
710}
711
types/index.d.ts 43 lines
1/** How an error was got past: the command that then succeeded, or a note the person wrote. */
2export type ZukanFix = {
3  /** The succeeding command, credentials masked and cut to a line. */
4  command: string
5  /** Whether it ran outside the sandbox. */
6  isSandboxOff: boolean
7  repo: string
8  at: number
9}
10
11/** One kind of error, as caught across sessions and repos. */
12export type ZukanEntry = {
13  /** Its number in the book, given once and never reused. */
14  no: number
15  /** The normalised signature that tells kinds apart. */
16  key: string
17  /** The program and subcommand that raised it (`git push`). */
18  program: string
19  /** The error line as first seen, cut to a line. */
20  line: string
21  count: number
22  firstSeen: number
23  lastSeen: number
24  /** Where it was seen, most recent last, at most 5. */
25  repos: string[]
26  fix?: ZukanFix
27  note?: string
28}
29
30/** An error of this session waiting for the same program to succeed. */
31export type ZukanPending = { key: string; failed: string; calls: number }
32
33declare module 'claude-code' {
34  interface PluginState {
35    zukan: {
36      /** The book, mirrored from the store for the pane. */
37      entries: ZukanEntry[]
38      /** By program: the latest error not yet got past in this session. */
39      pending: Record<string, ZukanPending>
40    }
41  }
42}
43