SLOPSHOPPER

deps

Adds /deps: direct dependencies with versions from package.json, pyproject.toml, requirements.txt, go.mod and Cargo.toml at the repo root. No network…

newrowsguardcommandprocess
★ 1v0.5.1MITupdated 2026-10-06baselane-sh/mods/plugins/deps
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · deps
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /deps ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Direct dependencies │ │ │ │ package.json (2) │ │ hono ^4.7.0 │ │ zod ^3.24.0 │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ copied to clipboard ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Command output
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Direct dependencies │ │ │ │ package.json (2) │ │ hono ^4.7.0 │ │ zod ^3.24.0 │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ copied to clipboard
README

Baselane mods

Small mods for Claude Code. Pick the ones you want and combine them.

Baselane mods are Claude Code plugins made of function hooks. Each mod does one thing: a guard that asks before rm -rf, a band with the session cost, a side pane with your git status, a sound when tests pass, a pirate voice for Claude's prose. There are 100+ mods in families such as guards, panes, meters, sounds and stats. Install one, or stack ten. They do not need each other.

Quick start (30 seconds)

You need Claude Code 2.1.288 or later, with mods.

claude plugin marketplace add baselane-sh/mods-catalog
claude plugin install guard-essentials@baselane-mods
claude plugin install receipt@baselane-mods

Start Claude Code. Now it asks before harsh commands, and /receipt prints a receipt of the session.

Browse every mod at https://baselane-sh.github.io/mods-catalog/. The gallery (baselane-mods) pins each mod to a reviewed commit, so a new mod reaches the gallery a short time after it reaches this repo. For update, uninstall, options and starter sets, read docs/INSTALL.md.

This repo also has a development marketplace, baselane-mods-dev, that tracks main. Use it only to try changes that are not released yet:

claude plugin marketplace add baselane-sh/mods
claude plugin install <name>@baselane-mods-dev

Each mod is released with a git tag <name>--v<version>.

How packs work

Most mods have one rule. A pack is one mod with several rules from the same family. For example, guard-essentials holds several guards and band-pack puts several meters in one band.

  • Install a pack when you want the whole set with one command.
  • Install single mods when you want only some of the rules.
  • Install a pack or its single mods, not both. Each mod has its own copy of its engine, so a rule in two installed mods runs two times. For example, with guard-pack and infra-guard installed, the terraform destroy check runs two times, and you can see two questions.
  • Mods from different families combine with no conflict. guard-essentials, cost-meter and git-pane work well together.

The catalog below marks each pack with "pack".

Beads mods

Beads (bd) is an issue tracker that lives in your repo. 19 mods show your beads in Claude Code: bars above the prompt, panes, slash commands, a guard and reminders. They need bd 1.2 or later and a repo with a .beads folder. Without these, they do nothing.

The core set in one go (the bars, the panes, /ready and the guard):

claude plugin install beads-bars@baselane-mods
claude plugin install beads-pane@baselane-mods
claude plugin install epics-pane@baselane-mods
claude plugin install ready@baselane-mods
claude plugin install beads-guard@baselane-mods

Each beads mod is also in the catalog below, in its family.

Safety

  • Guards ask. They do not block. A guard stops a harsh command and asks you first. You decide. If a guard check fails, it asks you. It does not let the call through without a question.
  • Your data stays on your machine. No mod sends data out unless you set a destination yourself, such as an ntfy topic for ntfy-notify or long-run-notify, or a webhook URL for slack-notify or discord-notify. With a destination set, those mods send a short message only: a generic "needs input" text with the project folder name, or the project name and the seconds. One exception needs no setup: ci-band and ops-band ask GitHub, through your own gh login, for the latest Actions run of your branch every 2 minutes, sending the repository and branch name. Without gh, or with gh not logged in, they do nothing.
  • One mod can make a model call, and it is off by default. sports-narrator makes one small model call at the end of a turn that used tools. It sends tool names, file names and the first two words of each shell command, never file contents. It spends tokens, so you must turn it on.
  • Beads mods only read your beads. They run read commands of your own bd on your machine, such as bd status --json and bd ready --json. No beads mod creates, changes or closes a bead. beads-guard asks before a bd command that deletes or rewrites beads.
  • Display mods do not change what Claude reads. The mods in "Display and render" change only how rows and replies look on your screen. Other families can add text for Claude: prompt styles change how Claude writes, secret-output-guard tells Claude not to repeat a credential, auto-format and auto-lint tell Claude to read a file again, beads-prime adds the workflow text that bd prime prints from your repo's .beads files, and bead-claim-nudge and bead-commit-nudge add a one-line reminder at your next prompt.
  • Stats stay local. Streaks, achievements and /wrapped read records that the mods keep on your machine.
  • Mods that write files say so. /handoff writes .claude/handoff.md and never overwrites it. session-journal appends to ~/.claude/journal.log. auto-format and auto-lint run your project's own formatter or linter fix, which can rewrite files. The descriptions say what each mod writes.

Requirements

  • Claude Code 2.1.288 or later, with mods (the plugin function-hook API, in early access). Read the Claude Code mods docs.
  • Some mods use local tools: git for the git pane, the git band and most commands, lsof for port-watch, gh for ci-band, bd 1.2 or later for the beads mods, ps for process-pane, and on macOS pmset for battery-band and pgrep with osascript for now-playing.
  • To build from source: Node.js.

Build and check from source

plugins/ and .claude-plugin/marketplace.json are generated. Do not edit them by hand.

node scripts/build.mjs          # build one plugin folder per catalog entry
bash scripts/check.sh           # rebuild, then validate, test and type-check every mod
bash scripts/check.sh git-pane  # check only the mods you name
node scripts/gen-docs.mjs       # rewrite the catalog in this README
node --test 'scripts/*.test.mjs'  # test the docs generator

A change is done when check.sh prints check: 0 failure(s). Read AGENTS.md for the layout and the API facts.

Contributing

To add a mod:

  1. Add a rule to an engine: engines/<engine>/hooks/rules/<id>.ts, with a test in engines/<engine>/tests/<id>.test.ts.
  2. Add a catalog entry in catalog/<name>.json: { "name", "description", "rules": ["<id>"] }. Give the description the slash command, if the mod has one (for example "Open it with /git"). The catalog table reads it from there.
  3. Run node scripts/build.mjs, then bash scripts/check.sh <mod>, then node scripts/gen-docs.mjs.

A new engine needs a family in scripts/gen-docs.mjs. Keep files small, do not change inputs in place, and do not use the em-dash character.

Licence

MIT. Copyright (c) 2026 Baselane, LLC. Read LICENSE.

Catalog

<!-- catalog:start -->

199 mods in 10 families.

"needs setup" means the mod has options that you set with claude plugin configure <mod>. Its description tells you if it works before you set them. "pack" means one mod with several rules.

Guards (36)

Ask you before a harsh or risky command runs.

ModWhat it doesCommandNotes
beads-guardAsks before bd commands that delete data or rewrite history: delete, purge, prune, gc, sql, admin, import, rename, forget, restore, migrate and more. Reads only the command text, runs no bd.
secret-filename-guardAsks before a Bash command touches a secret-looking file (.env, private keys, credentials).
secret-guardAsks before a live API key, token or private key is written, edited or run.
env-exfil-guardAsks before a command prints your environment, echoes a secret variable or sends local data to a remote host.
infra-guardAsks before terraform destroy, kubectl delete, force-push, DROP TABLE or rm -rf.
secret-commit-guardAsks before a git commit that would record a credential or a secret-named file. Reads the staged diff with git.
protect-mainAsks before a commit, push or merge while you are on main or master. Reads the branch with git.
gitignore-checkAsks before git add or commit when secret-looking files are not ignored or are already tracked. Checks with git ls-files.
secret-output-guardTells Claude not to repeat a credential that showed up in command or file output, and names the source to rotate.
curl-pipe-guardAsks before a download is piped into a shell or interpreter (curl \sh, wget -O- \bash, bash <(curl ...)).
sudo-guardAsks before sudo, doas or su -c runs a command with elevated rights.
no-verify-guardAsks before git hooks are skipped (--no-verify, commit -n, HUSKY=0, core.hooksPath=/dev/null).
lockfile-guardAsks before a lockfile is written or edited by hand; the package manager should change it.
package-guardAsks before a new dependency is installed (npm, pnpm, yarn, bun, pip, uv, poetry, cargo, go, gem) and names the packages.
prod-db-guardAsks before destructive SQL runs in a command: TRUNCATE, DELETE or UPDATE with no WHERE.
path-jailAsks before Write, Edit or NotebookEdit changes a file outside the working directory, by real path.
docker-guardAsks before Docker commands that delete data: system, volume or image prune, rm -f, volume rm and compose down -v.
k8s-guardAsks before kubectl apply or replace with --force, kubectl drain, helm uninstall, and kubectl delete behind global flags (plain kubectl delete is infra-guard's).
migration-guardAsks before Write or Edit changes a migration that already exists; new migration files pass.
ci-config-guardAsks before Write or Edit changes CI config: .github/workflows, .gitlab-ci.yml or .circleci/config.yml.
chmod-guardAsks before chmod makes files world-writable (777, a+w, o+w) or chmod or chown runs recursively on a broad path (/, a system folder, a home folder).
git-history-guardAsks before git commands that throw away work or rewrite history: reset --hard, clean, rebase, filter-branch, filter-repo, push --delete, branch -D and stash clear.
big-file-guardAsks before a Write creates content over 1 MB, or git add names a file over 5 MB. Measures the files with find.
publish-guardAsks before a package is published: npm, pnpm, yarn or bun publish, cargo publish, twine upload, gem push, poetry or uv publish. Dry runs pass.
tag-guardAsks before release tags go to a remote: git push --tags, --follow-tags or --mirror, a push of a tag ref, and a push that deletes a remote tag. Dry runs pass. Reads tags with git.
deploy-guardAsks before a production deploy: vercel --prod, netlify deploy --prod, firebase deploy, fly deploy, gcloud app deploy, eb deploy, heroku rollback, serverless deploy to prod.
db-reset-guardAsks before a framework wipes a database: prisma migrate reset, rails or rake db:drop and db:reset, alembic downgrade, django flush, supabase db reset, knex rollback --all.
ssh-guardAsks before a private key in .ssh is read, authorized_keys or the SSH config is changed, or ssh-keygen would overwrite a key. Public keys and ssh -i pass. Reads HOME with printenv.
cron-guardAsks before scheduled jobs or services are wiped or stopped: crontab -r, crontab replaced from stdin or a file, launchctl unload or bootout, systemctl stop, disable or mask.
upload-guardAsks before local files go to a remote host: scp or rsync to host:path, piped input to nc, curl -T, sftp put. Local copies, downloads and localhost pass.
registry-push-guardAsks before an image or chart is pushed to a registry: docker push, docker buildx --push, podman push, helm push, gcloud artifacts docker push. Local registries pass.
guard-essentialsAsks only before harsh or disaster commands: destructive infra, git and SQL, piping downloads into a shell, sudo, leaking or committing secrets. Reads git. The quiet choice for daily work.pack
guard-packThe 15 core Baselane guards in one mod (secrets, git, infra, packages, path jail). Some read git, measure files with find, read HOME with printenv, or check where a path really lands.pack
guard-devopsAsks before harsh DevOps commands: destructive Docker, Kubernetes and Helm calls, CI config edits, broad chmod and chown, and git commands that lose work. Add infra-guard for plain kubectl delete.pack
release-packAsks before a release leaves your machine: package publish, tag push, production deploy and registry push guards in one mod. The tag guard reads tags with git.pack
ship-safe-packThe release-pack guards plus db-reset-guard and upload-guard: asks before publish, tag push, production deploy, registry push, database wipes and file uploads. Reads tags with git.pack

Reminders (19)

One quiet toast at turn end when something needs your attention.

ModWhat it doesCommandNotes
bead-claim-nudgeAt turn end, if Claude edited a file and no bead is in progress, shows a toast and reminds Claude at your next prompt, once per session. Reads with bd count; quiet without bd.
bead-commit-nudgeAt turn end, if a git commit named no bead id while a bead is in progress, shows a toast and reminds Claude at your next prompt. Reads with bd count and bd where; quiet without bd.
test-reminderReminds you at turn end when source files changed but no test command ran since.
ctx-nudgeReminds you to /clear or /compact when the context window passes 75 percent.
clippyOne helpful toast per session per trigger, in the classic paperclip voice: when you edit a migrations folder, a Dockerfile or a GitHub workflow, or run rm -rf. At most one toast per turn.
sports-narratorOne line of sports play-by-play at turn end. OFF by default: one haiku call per turn with tools. Turn on the enabled option. Sends tool names, file names and the first two words of each command.needs setup
commit-nudgeSuggests a commit at turn end after 8 or more file edits since the last git commit. One toast per batch of edits; a successful git commit resets the count.
todo-nudgeAt turn end, one toast with the count of TODO, FIXME and HACK lines your edits added this turn. Quiet when none were added.
break-nudgeSuggests a short break once the session has run 90 minutes, and again every 90 minutes after that.
docs-nudgeAt turn end, one toast when edits added exported code (an export statement in .ts or .js, or a public function signature) and no README or docs file was edited this session.
debug-print-nudgeAt turn end, one toast naming the count of console.log, print(, debugger and dbg! lines your edits added this turn. Only code files count.
typecheck-nudgeAt turn end, one toast when .ts or .tsx files were edited and no type check (tsc, vue-tsc, a typecheck script or a build) ran since the last edit.
lockfile-nudgeAt turn end, one toast when dependencies in package.json, pyproject.toml, Cargo.toml or go.mod changed and neither the matching lockfile was edited nor an install command ran.
big-diff-nudgeSuggests splitting the change once your edits added or removed more than 500 lines since the last git commit. A successful commit resets the count.
migration-nudgeAt turn end, one toast when a schema file changed (schema.prisma, models.py, SQL under schema/, a drizzle schema) and no migration file was created this session.
env-example-nudgeAt turn end, one toast when edits add a reference to an environment variable (process.env, os.environ, os.getenv, Deno.env) that .env.example does not list. Reads only .env.example names, never .env.
nudge-packTwo turn-end reminders in one mod: test-reminder and ctx-nudge.pack
focus-packThree quiet nudges in one mod: commit after 8 edits, TODO/FIXME/HACK lines added, and debug prints added.pack
quality-packThree quiet nudges in one mod: type check after TypeScript edits, lockfile after dependency edits, and a split suggestion past 500 changed lines.pack

Commands (31)

Slash commands that print a result and, where it helps, copy it.

ModWhat it doesCommandNotes
readyAdds /ready: the 20 top ready beads, one line each, and the total. Read with bd, the beads tracker. Read-only. Prints only, copies nothing./ready
beadAdds /bead <id>: one bead with status, priority, labels, description, blockers and children. Read with bd, the beads tracker. Read-only. Prints only, copies nothing./bead
beads-standupAdds /beads-standup: beads closed since yesterday, in progress, and the next 5 ready. Read with bd, the beads tracker. Read-only. Prints only, copies nothing./beads-standup
receiptAdds /receipt: a shareable receipt of the session (tools, files, commands, blocks, context, cost), printed and copied./receipt
standupAdds /standup: Yesterday, Today and Blockers from your git log and this session's record, printed and copied./standup
changelogAdds /changelog: commits since the last tag (or the last 30), read with git log and grouped by conventional-commit type, as Markdown. Copies the result to your clipboard./changelog
pr-descriptionAdds /pr-description: title, summary, diff totals and a test plan stub for the current branch against main or master, read with git. Copies the result to your clipboard./pr-description
handoffAdds /handoff: writes a session summary with the git status to .claude/handoff.md (never overwriting) and answers with the path. Copies the result to your clipboard./handoff
todosAdds /todos: TODO, FIXME and HACK lines in tracked files, found with git grep, grouped by file (50 lines at most). Read-only. Copies the result to your clipboard./todos
locAdds /loc: lines of tracked text files by language, read with git, sorted, with a total. Skips lockfiles and binaries. Read-only. Copies the result to your clipboard./loc
hotspotsAdds /hotspots: the 10 files changed most often in the last 90 days, from git log, with change counts. Read-only. Copies the result to your clipboard./hotspots
commit-msgAdds /commit-msg: a Conventional Commits message proposed from the staged diff, read with git. Heuristic, no model call, writes nothing. Copies the result to your clipboard./commit-msg
branchesAdds /branches: local branches merged into the default branch or idle for 30 days, as a cleanup list, read with git. Never deletes. Copies the result to your clipboard./branches
treeAdds /tree: tracked files (git ls-files) as a tree, 2 levels deep, folders with file counts (80 lines at most). Read-only. Copies the result to your clipboard./tree
depsAdds /deps: direct dependencies with versions from package.json, pyproject.toml, requirements.txt, go.mod and Cargo.toml at the repo root. No network. Read-only. Copies the result to your clipboard./deps
authorsAdds /authors: the top 15 contributors by commit count with their last commit date, from git log. Names only, never email addresses. Read-only. Copies the result to your clipboard./authors
scriptsAdds /scripts: runnable tasks from package.json scripts, Makefile targets, justfile recipes and pyproject scripts at the git repo root. Read-only. Copies the result to your clipboard./scripts
env-checkAdds /env-check: variable names in .env.example against .env at the git repo root, listing missing and extra names. Reads names only, never a value. Read-only. Copies the result to your clipboard./env-check
sizeAdds /size: the 15 largest tracked files and the total tracked size at HEAD, read with git. Read-only. Copies the result to your clipboard./size
licensesAdds /licenses: the licence of each direct dependency, from node_modules and Python dist-info at the git repo root. Shows unknown when it cannot tell. Read-only. Copies the result to your clipboard./licenses
conflictsAdds /conflicts: tracked files that still hold merge conflict markers, with line numbers, found with git grep. Read-only. Copies the result to your clipboard./conflicts
secret-scanAdds /secret-scan: tracked files and line numbers that hold secret-shaped text, found with git grep. Never prints a matched value. Read-only. Copies the result to your clipboard./secret-scan
envinfoAdds /envinfo: versions of git, node, npm, python3, go, rustc and docker if installed, by running each (2 s limit), and the OS. Read-only. Copies the result to your clipboard./envinfo
stashesAdds /stashes: git stashes with their age and the branch each was made on. Read-only, never applies or drops. Copies the result to your clipboard./stashes
recentAdds /recent: your last 15 commits across all local branches (author is git user.name), with branch and date. Read-only. Copies the result to your clipboard./recent
file-ownersAdds /owners <path>: the top 5 authors of a file or folder by lines, from git blame. Names only, never addresses. Read-only. Copies the result to your clipboard./owners
readme-checkAdds /readme-check: missing README sections (install, usage, licence, contributing) and broken relative links, at the git repo root. Read-only. Copies the result to your clipboard./readme-check
command-packThe five original slash commands in one mod: /receipt, /standup, /changelog, /pr-description and /handoff. They read git; /handoff also writes .claude/handoff.md. They copy results to the clipboard./receipt, /standup, /changelog, /pr-description, /handoffpack
repo-packFive read-only repo commands in one mod: /todos, /loc, /hotspots, /commit-msg and /branches. They read git. They copy results to the clipboard./todos, /loc, /hotspots, /commit-msg, /branchespack
explore-packFive read-only repo exploration commands in one mod: /tree, /deps, /authors, /scripts and /env-check. They read git and project files. They copy results to the clipboard./tree, /deps, /authors, /scripts, /env-checkpack
audit-packThree read-only audit commands in one mod: /secret-scan, /conflicts and /licenses. They read git, project files and dependency folders. They copy results to the clipboard./secret-scan, /conflicts, /licensespack

Band meters (30)

A one-line band above the prompt.

ModWhat it doesCommandNotes
cost-meterA band above the prompt with the session cost so far and what the last turn cost.
latte-meterA band above the prompt that counts the session cost in lattes. Set the latte price in the plugin options.needs setup
context-meterA band above the prompt with a 10 cell bar of the context window used, yellow from 60 percent, red from 80.
daily-spendA band above the prompt with today's total cost across all your sessions.

| pomodoro | A band above the prompt with a 25/5 focus timer (focus 18:42, break 03:10). Start and stop it with /pomodoro; a to

Source 11 files
hooks/register.ts 12 lines
1import type { Register } from 'claude-code'
2
3import { registerCommands } from './engine'
4import { answerCommandsWithRead } from './hosts/command-read'
5import { rule as deps } from './rules/deps'
6
7export const register: Register = on => {
8  const rules = [deps]
9  registerCommands(on, rules)
10  answerCommandsWithRead(on, [deps])
11}
12
hooks/engine.ts 187 lines
1import { atom, update } from 'claude-code'
2import type { On, ProcessRunResult, SessionUsage, UiCopyResult } from 'claude-code'
3
4import type { CommandRecord } from '../types'
5import { draw } from './render'
6import { EMPTY, observe } from './tracker'
7
8// What the engine knows about the session beside the record. A figure the
9// host does not have is left out, never zeroed, so a rule can say "n/a".
10export type Facts = {
11  turns?: number
12  elapsedMs?: number
13  contextPercent?: number
14  costUsd?: number
15  // The engine's clock now (epoch ms), for a rule that needs the date.
16  nowMs?: number
17}
18
19// What a rule may reach beyond the record, as closures over the host calls
20// (a rule never holds `$`). A rule that needs none of it ignores the argument.
21export type CommandTools = {
22  // The session's working directory.
23  cwd: () => Promise<string>
24  // `git -C <cwd> <args>`: stdout, or undefined when git exits non-zero (which
25  // is also what a directory outside any repo answers). Output past the host's
26  // 4 MiB cap, or a git that times out, throws.
27  git: (...args: string[]) => Promise<string | undefined>
28  exists: (path: string) => Promise<boolean>
29  // The text of a file (a relative path is under the cwd), or undefined when it
30  // is missing, unreadable or over the host's 4 MiB cap.
31  read: (path: string) => Promise<string | undefined>
32  // Creates the file and its directories.
33  write: (path: string, text: string) => Promise<void>
34  // `git -C <cwd> <args>` with the exit code kept, for a command that must tell
35  // "no match" (1) from a real failure. Never throws.
36  gitRun: (...args: string[]) => Promise<Ran>
37  // Any program, killed after `timeoutMs`. Never throws: a program that is not
38  // installed answers a non-zero code.
39  run: (argv: readonly string[], timeoutMs: number) => Promise<Ran>
40  // The entries of a directory, or undefined when it is missing or unreadable.
41  list: (path: string) => Promise<{ name: string; isDir: boolean }[] | undefined>
42}
43
44// What a program answered. `timedOut` and `truncated` (output past the 4 MiB
45// cap) are set when stdout cannot be trusted as complete.
46export type Ran = { code: number; stdout: string; stderr: string; timedOut: boolean; truncated: boolean }
47
48// What a rule answers: text to print and copy, or `{ text, copy: false }` for
49// a message that is not a result (an error must not replace the clipboard).
50export type Composed = string | { text: string; copy: false }
51
52// One slash command: its name and description, and how it turns the session
53// record into the text it prints. Pure unless it uses `tools`.
54export type CommandRule = {
55  name: string
56  description: string
57  // `args` is what the person typed after the command name, trimmed.
58  compose: (record: CommandRecord, facts: Facts, tools: CommandTools, args: string) => Composed | Promise<Composed>
59}
60
61export const GIT_TIMEOUT_MS = 15_000
62
63// A state value is written only by the plugin that owns it, and the owner is
64// the mod's name. The host wants the owner as a literal, so this source writes
65// the first mod's name and scripts/build.mjs swaps in each mod's own name
66// (engine.json, stateOwner). The same swap runs in types/index.d.ts.
67const record = atom({ plugin: 'deps', key: 'record' } as const, EMPTY)
68
69export const attempt = async <T>(read: () => Promise<T>): Promise<T | undefined> => {
70  try {
71    return await read()
72  } catch {
73    return undefined
74  }
75}
76
77// Takes a closure that starts the program, never `$` itself. The host rejects both
78// for a program that cannot start and for one still running at the timeout, and
79// the message does not say which, so a rejection that took nearly the whole
80// timeout counts as a timeout.
81export const exec = async (start: () => Promise<ProcessRunResult>, timeoutMs: number, now: () => Promise<number>): Promise<Ran> => {
82  const began = await now()
83  try {
84    const ran = await start()
85    return { code: ran.exitCode, stdout: ran.stdout, stderr: ran.stderr, timedOut: false, truncated: ran.isStdoutTruncated }
86  } catch (error) {
87    const message = error instanceof Error ? error.message : String(error)
88    const slow = (await now()) - began >= timeoutMs * 0.9
89    return { code: -1, stdout: '', stderr: '', timedOut: slow || /time/i.test(message), truncated: false }
90  }
91}
92
93// The tools a host does not give. A rule that calls one fails with a message,
94// and its tests fail: name what it uses in engine.json `needs`.
95const absent = (name: string) => (): Promise<never> =>
96  Promise.reject(new Error(`${name} is not given to this mod; name it in engine.json needs`))
97
98export const NO_TOOLS: CommandTools = {
99  cwd: absent('cwd'),
100  git: absent('git'),
101  exists: absent('exists'),
102  read: absent('read'),
103  write: absent('write'),
104  gitRun: absent('gitRun'),
105  run: absent('run'),
106  list: absent('list'),
107}
108
109// What every command reads besides its tools, as closures over the hook's `$`.
110export type CommandReads = {
111  usage: () => Promise<SessionUsage>
112  turns: () => Promise<number>
113  now: () => Promise<number>
114  kept: () => Promise<CommandRecord>
115  // Absent for a host that never copies: the answer is then the text alone, and
116  // the mod does not hold the clipboard call at all.
117  copy?: (text: string) => Promise<UiCopyResult>
118}
119
120// Answers one slash command: the rule composes its text from the record, the
121// facts and its tools, and a result is copied to the clipboard.
122export const answerCommand = async (rule: CommandRule, args: string, tools: CommandTools, reads: CommandReads): Promise<{ text: string }> => {
123  const usage = await attempt(reads.usage)
124  const turns = await attempt(reads.turns)
125  const now = await attempt(reads.now)
126  const kept = await attempt(reads.kept)
127
128  const facts: Facts = {
129    ...(turns === undefined ? {} : { turns }),
130    ...(now === undefined ? {} : { nowMs: now }),
131    ...(usage === undefined || now === undefined ? {} : { elapsedMs: Math.max(0, now - usage.startedAt) }),
132    ...(usage?.context.percent === undefined ? {} : { contextPercent: usage.context.percent }),
133    ...(usage?.cost === undefined ? {} : { costUsd: usage.cost.usd }),
134  }
135
136  let composed: Composed
137  try {
138    composed = await rule.compose(kept ?? EMPTY, facts, tools, args.trim())
139  } catch (error) {
140    return { text: `${rule.name}: failed, ${error instanceof Error ? error.message : String(error)}` }
141  }
142
143  if (typeof composed !== 'string') return { text: composed.text }
144  const text = composed
145  const copier = reads.copy
146  if (copier === undefined) return { text }
147
148  const copy = await attempt(() => copier(text))
149  const note = copy === undefined ? 'not copied (clipboard error)' : copy.isCopied ? 'copied to clipboard' : `not copied (${copy.reason})`
150  return { text: `${text}\n\n${note}` }
151}
152
153// The hooks every mod of the engine has. Each command's own answer is a host
154// (engine.json hosts), so a command gets only the tools its rule uses.
155export const registerCommands = (on: On, rules: readonly CommandRule[]): void => {
156  // The one tracker every rule reads. It lives in `$.state`, so a hot reload
157  // of the mod keeps the session so far.
158  on('tool.call', async ($, e, next) => {
159    const ran = await next(e)
160    try {
161      await update($, record, so_far => observe(so_far, e, ran))
162    } catch (error) {
163      await $.ui.log(`command: could not record ${e.tool}, ${error instanceof Error ? error.message : String(error)}`)
164    }
165    return ran
166  })
167
168  // /clear ends the conversation without a new session.start: start over.
169  on('session.end', async ($, e, next) => {
170    if (e.reason === 'clear') await update($, record, () => EMPTY)
171    return next(e)
172  })
173
174  on('session.start', async ($, e, next) => {
175    for (const rule of rules) await $.command.register({ name: rule.name, description: rule.description })
176    return next(e)
177  })
178
179  // A surface without Box or Text keeps the plain output row.
180  for (const rule of rules) {
181    on('ui.render', { component: 'CommandOutput', props: { command: rule.name } }, ($, e, next) => {
182      const { Box, Text } = $.ui.resolve(e)
183      return Box === undefined || Text === undefined ? next(e) : draw({ Box, Text }, e.props.text)
184    })
185  }
186}
187
hooks/hosts/command-read.ts 36 lines
1import { atom, read } from 'claude-code'
2import type { On } from 'claude-code'
3
4import { answerCommand, GIT_TIMEOUT_MS, attempt, NO_TOOLS } from '../engine'
5import type { CommandRule, CommandTools } from '../engine'
6import { EMPTY } from '../tracker'
7
8// The session record the engine keeps; the build swaps in the mod's name.
9const record = atom({ plugin: 'deps', key: 'record' } as const, EMPTY)
10
11// The commands whose rules read the repo with git and read project files.
12export const answerCommandsWithRead = (on: On, rules: readonly CommandRule[]): void => {
13  for (const rule of rules) {
14    on('command.run', { command: rule.name }, async ($, event) => {
15      const tools: CommandTools = {
16        ...NO_TOOLS,
17        cwd: () => $.session.cwd(),
18        git: async (...args) => {
19          const dir = await $.session.cwd()
20          const ran = await $.process.run(['git', '-C', dir, ...args], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS })
21          if (ran.isStdoutTruncated) throw new Error(`git ${args[0]} output passed the 4 MiB cap`)
22          return ran.exitCode === 0 ? ran.stdout : undefined
23        },
24        read: path => attempt(() => $.fs.read(path)),
25      }
26      return answerCommand(rule, event.args ?? '', tools, {
27        usage: () => $.session.usage(),
28        turns: () => $.session.turns(),
29        now: () => $.clock.now(),
30        kept: () => read($, record),
31        copy: text => $.ui.copy({ text }),
32      })
33    })
34  }
35}
36
hooks/rules/deps.ts 59 lines
1import type { CommandRule, CommandTools, Composed } from '../engine'
2import { finish, isRepo, message, notARepo, repoRoot } from '../helpers'
3import { cargoDeps, goDeps, npmDeps, pyprojectDeps, requirementsDeps, type Dep } from '../manifests'
4
5const MAX_ROWS = 100
6
7type Source = { file: string; parse: (text: string) => Dep[] | undefined }
8
9// The order the groups print in.
10const SOURCES: readonly Source[] = [
11  { file: 'package.json', parse: npmDeps },
12  { file: 'pyproject.toml', parse: pyprojectDeps },
13  { file: 'requirements.txt', parse: requirementsDeps },
14  { file: 'go.mod', parse: goDeps },
15  { file: 'Cargo.toml', parse: cargoDeps },
16]
17
18const row = (d: Dep): string => `  ${d.name}  ${d.version}${d.kind === undefined ? '' : `  (${d.kind})`}`
19
20// A group is its header and rows. The rows are what the cap counts.
21type Group = { header: string; rows: string[] }
22
23const group = (file: string, deps: readonly Dep[] | undefined): Group =>
24  deps === undefined
25    ? { header: file, rows: ['  could not be parsed'] }
26    : { header: `${file} (${deps.length})`, rows: deps.length === 0 ? ['  none'] : deps.map(row) }
27
28// Keeps the first MAX_ROWS rows across all groups, then one "+N more" line.
29// Groups are set apart by a blank line.
30const limited = (groups: readonly Group[]): string[] => {
31  const total = groups.reduce((sum, g) => sum + g.rows.length, 0)
32  let budget = MAX_ROWS
33  const shown = groups.flatMap(g => {
34    if (budget <= 0) return []
35    const rows = g.rows.slice(0, budget)
36    budget -= rows.length
37    return [['', g.header, ...rows]]
38  })
39  return [...shown.flat(), ...(total > MAX_ROWS ? ['', `+${total - MAX_ROWS} more`] : [])]
40}
41
42const compose = async (_record: unknown, _facts: unknown, tools: CommandTools): Promise<Composed> => {
43  if (!(await isRepo(tools))) return notARepo(await tools.cwd())
44  const root = (await repoRoot(tools)) ?? (await tools.cwd())
45  const found: Group[] = []
46  for (const { file, parse } of SOURCES) {
47    const text = await tools.read(`${root}/${file}`)
48    if (text !== undefined) found.push(group(file, parse(text)))
49  }
50  if (found.length === 0) return message('No package.json, pyproject.toml, requirements.txt, go.mod or Cargo.toml at the repo root.')
51  return finish(['Direct dependencies', ...limited(found)].join('\n'))
52}
53
54export const rule: CommandRule = {
55  name: 'deps',
56  description: 'List direct dependencies with versions from package.json, pyproject.toml, requirements.txt, go.mod and Cargo.toml, and copy it',
57  compose,
58}
59
hooks/render.ts 26 lines
1import type { RenderElement } from 'claude-code'
2
3type Tag = Parameters<typeof h>[0]
4export type Parts = { Box: Tag; Text: Tag }
5
6// Draws a command's output row as a bordered box, one Text per line so the
7// monospace layout holds on every surface. The engine ends each answer with
8// a blank line and a one-line note; the note sits outside the box.
9export const draw = ({ Box, Text }: Parts, full: string): RenderElement => {
10  const split = full.lastIndexOf('\n\n')
11  const body = split < 0 ? full : full.slice(0, split)
12  const note = split < 0 ? '' : full.slice(split + 2)
13
14  // h answers a node; two Boxes around Texts always make an element.
15  return h(
16    Box,
17    { flexDirection: 'column' },
18    h(
19      Box,
20      { flexDirection: 'column', borderStyle: 'round', paddingX: 1, alignSelf: 'flex-start' },
21      ...body.split('\n').map(line => h(Text, null, line === '' ? ' ' : line)),
22    ),
23    note === '' ? null : h(Text, { dimColor: true }, note),
24  ) as RenderElement
25}
26
hooks/tracker.ts 42 lines
1import type { ToolCallResult } from 'claude-code'
2
3import type { CommandRecord } from '../types'
4import { redact } from './patterns'
5
6export const EMPTY: CommandRecord = { calls: 0, tools: {}, files: [], commands: [], commandsRun: 0, blocked: 0, errored: 0 }
7
8const MAX_COMMANDS = 200
9const MAX_COMMAND_LENGTH = 200
10const FILE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
11
12type Call = { readonly tool: string; readonly [argument: string]: unknown }
13type Answer = Pick<ToolCallResult, 'deny' | 'isError'> | { readonly deny?: undefined; readonly isError?: boolean }
14
15const text = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined)
16
17const touchedFile = (e: Call): string | undefined =>
18  FILE_TOOLS.has(e.tool) ? (text(e.file_path) ?? text(e.notebook_path)) : undefined
19
20// Redacts before cutting, so a credential is never kept half-cut.
21const keep = (raw: string, limit: number): string => redact(raw).slice(0, limit)
22
23// The record after one more tool call. Pure: the record it is given is not
24// changed. A denied call counts as a call and as blocked, but it touched no
25// file and ran no command.
26export const observe = (record: CommandRecord, e: Call, ran: Answer): CommandRecord => {
27  const isDenied = ran.deny !== undefined
28  const file = isDenied ? undefined : touchedFile(e)
29  const command = isDenied || e.tool !== 'Bash' ? undefined : text(e.command)
30  const kept = file === undefined ? undefined : keep(file, 500)
31
32  return {
33    calls: record.calls + 1,
34    tools: { ...record.tools, [e.tool]: (record.tools[e.tool] ?? 0) + 1 },
35    files: kept === undefined || record.files.includes(kept) ? record.files : [...record.files, kept],
36    commands: command === undefined ? record.commands : [...record.commands, keep(command, MAX_COMMAND_LENGTH)].slice(-MAX_COMMANDS),
37    commandsRun: record.commandsRun + (command === undefined ? 0 : 1),
38    blocked: record.blocked + (isDenied ? 1 : 0),
39    errored: record.errored + (!isDenied && ran.isError === true ? 1 : 0),
40  }
41}
42
hooks/helpers.ts 51 lines
1import type { Composed, CommandTools } from './engine'
2import { redact } from './patterns'
3
4export const lines = (text: string | undefined): string[] =>
5  (text ?? '').split('\n').filter(line => line.length > 0)
6
7// True inside a git work tree.
8export const isRepo = async (tools: CommandTools): Promise<boolean> =>
9  (await tools.git('rev-parse', '--is-inside-work-tree')) !== undefined
10
11// An answer that is a message, not a result: printed, never copied.
12export const message = (text: string): Composed => ({ text, copy: false })
13
14export const notARepo = (dir: string): Composed =>
15  message(`Not a git repository: ${dir}\nRun this command from a folder inside a git repo.`)
16
17// Everything a command prints or writes passes here last: commit subjects and
18// paths are free text and can carry a credential.
19export const finish = (text: string): string => redact(text)
20
21// "1 tool call", "2 tool calls".
22export const count = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`
23
24// Bullets for the first `limit` items, then one line for the rest.
25export const bullets = (items: readonly string[], limit: number, indent = ''): string[] => [
26  ...items.slice(0, limit).map(item => `${indent}- ${item}`),
27  ...(items.length > limit ? [`${indent}- and ${items.length - limit} more`] : []),
28]
29
30// Items grouped by key, in first-seen order (Map.groupBy is past the es2023 lib).
31export const groupBy = <T>(items: readonly T[], key: (item: T) => string): [string, T[]][] => {
32  // Built in place: copying per item would be quadratic on a long git log.
33  const groups = new Map<string, T[]>()
34  for (const item of items) {
35    const mine = groups.get(key(item))
36    if (mine === undefined) groups.set(key(item), [item])
37    else mine.push(item)
38  }
39  return [...groups]
40}
41
42// The repo's top folder (not the cwd), or undefined outside a repo.
43export const repoRoot = async (tools: CommandTools): Promise<string | undefined> =>
44  (await tools.git('rev-parse', '--show-toplevel'))?.trim() || undefined
45
46// The first `limit` rows, then "+N more" for the rest (the todos wording).
47export const cap = (rows: readonly string[], limit: number): string[] => [
48  ...rows.slice(0, limit),
49  ...(rows.length > limit ? [`+${rows.length - limit} more`] : []),
50]
51
hooks/manifests.ts 99 lines
1import { pairs, sections, stringArray, versionOf } from './toml'
2
3// One direct dependency. `kind` is set for anything but a plain runtime one.
4export type Dep = { name: string; version: string; kind?: string }
5
6const dep = (name: string, version: string, kind?: string): Dep => ({ name, version, ...(kind === undefined ? {} : { kind }) })
7
8const NPM_KINDS: readonly [string, string | undefined][] = [
9  ['dependencies', undefined],
10  ['devDependencies', 'dev'],
11  ['peerDependencies', 'peer'],
12  ['optionalDependencies', 'optional'],
13]
14
15// Undefined when the text is not JSON (the message is not echoed: a parse error repeats the input).
16export const npmDeps = (text: string): Dep[] | undefined => {
17  let data: unknown
18  try {
19    data = JSON.parse(text)
20  } catch {
21    return undefined
22  }
23  if (typeof data !== 'object' || data === null) return undefined
24  const table = data as Record<string, unknown>
25  return NPM_KINDS.flatMap(([field, kind]) => {
26    const found = table[field]
27    if (typeof found !== 'object' || found === null) return []
28    return Object.entries(found).map(([name, version]) => dep(name, typeof version === 'string' ? version : '*', kind))
29  })
30}
31
32// "requests>=2.31 ; python_version < '3.11'" and "pydantic[email]>=2,<3" are
33// a name and the version spec; the extras and the marker are left out.
34const REQUIREMENT = /^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*([<>=!~][^;]*?)?\s*(?:;.*)?$/
35
36const requirement = (spec: string, kind?: string): Dep | undefined => {
37  const hit = REQUIREMENT.exec(spec.trim())
38  return hit === null ? undefined : dep(hit[1] ?? spec, (hit[2] ?? '').replace(/\s+/g, '') || '*', kind)
39}
40
41const present = (found: (Dep | undefined)[]): Dep[] => found.filter((d): d is Dep => d !== undefined)
42
43export const pyprojectDeps = (text: string): Dep[] => {
44  const all = sections(text)
45  const poetry = (name: string, kind?: string): Dep[] =>
46    pairs(all.get(name) ?? []).filter(([key]) => key !== 'python').map(([key, value]) => dep(key, versionOf(value), kind))
47  return [
48    ...present((stringArray(all.get('project') ?? [], 'dependencies') ?? []).map(spec => requirement(spec))),
49    ...pairs(all.get('project.optional-dependencies') ?? []).flatMap(([group]) =>
50      present((stringArray(all.get('project.optional-dependencies') ?? [], group) ?? []).map(spec => requirement(spec, 'optional'))),
51    ),
52    ...poetry('tool.poetry.dependencies'),
53    ...poetry('tool.poetry.dev-dependencies', 'dev'),
54    ...[...all.keys()]
55      .filter(name => /^tool\.poetry\.group\.[^.]+\.dependencies$/.test(name))
56      .flatMap(name => poetry(name, 'dev')),
57  ]
58}
59
60// Lines that set an option or pull in another file are not requirements.
61export const requirementsDeps = (text: string): Dep[] =>
62  text.split('\n').flatMap(row => {
63    const line = row.replace(/\s#.*$/, '').trim()
64    if (line === '' || line.startsWith('#') || line.startsWith('-')) return []
65    return [requirement(line) ?? dep(line, '*')]
66  })
67
68export const goDeps = (text: string): Dep[] => {
69  let inBlock = false
70  const found: Dep[] = []
71  for (const row of text.split('\n')) {
72    const line = row.trim()
73    if (inBlock && line === ')') inBlock = false
74    else if (/^require\s*\($/.test(line)) inBlock = true
75    else if (!line.startsWith('//')) {
76      const hit = (inBlock ? /^(\S+)\s+(\S+)(.*)$/ : /^require\s+(\S+)\s+(\S+)(.*)$/).exec(line)
77      if (hit !== null && !/\/\/\s*indirect/.test(hit[3] ?? '')) found.push(dep(hit[1] ?? '', hit[2] ?? '*'))
78    }
79  }
80  return found
81}
82
83const CARGO_KINDS: readonly [string, string | undefined][] = [
84  ['dependencies', undefined],
85  ['dev-dependencies', 'dev'],
86  ['build-dependencies', 'build'],
87]
88
89export const cargoDeps = (text: string): Dep[] => {
90  const all = sections(text)
91  return CARGO_KINDS.flatMap(([table, kind]) => [
92    ...pairs(all.get(table) ?? []).map(([name, value]) => dep(name, versionOf(value), kind)),
93    // [dependencies.tokio] with its own rows.
94    ...[...all]
95      .filter(([name]) => name.startsWith(`${table}.`))
96      .map(([name, body]) => dep(name.slice(table.length + 1), pairs(body).find(([key]) => key === 'version')?.[1].replace(/^["']|["']$/g, '') ?? '*', kind)),
97  ])
98}
99
hooks/patterns.ts 51 lines
1// Shared secret shapes, ported from claude-secret-guard-kit
2// (hooks/secret-patterns.sh). Every guard rule reads them from here so the
3// shapes stay in sync.
4
5// Shapes of live credential VALUES. Spliced where the source text would
6// otherwise match the shape it defines.
7const SECRET_VALUE_SOURCES = [
8  'AKIA[0-9A-Z]{16}',
9  '-----BEGIN [A-Z ]*PRIVATE KEY-----',
10  'gh[pousr]_[A-Za-z0-9]{30,}',
11  'github_pat_[A-Za-z0-9_]{30,}',
12  'sk-ant-[A-Za-z0-9_-]{20,}',
13  'sk-(proj-)?[A-Za-z0-9_-]{32,}',
14  'AIza[0-9A-Za-z_-]{30,}',
15  'xox[baprs]-[A-Za-z0-9-]{10,}',
16  'hooks\\.slack\\.com/services/T[A-Za-z0-9]+/B[A-Za-z0-9]+/[A-Za-z0-9]+',
17  '[sr]k_live_[0-9a-zA-Z]{20,}',
18  'eyJ[A-Za-z0-9_-]{10,}\\.eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}',
19  '(postgres(ql)?|mysql|mongodb(\\+srv)?|redis|amqp|mssql)://[^:/@\\s]+:[^@\\s]+@',
20  // Any other URL with a user and a password, such as git+https://user:token@host.
21  '[a-z][a-z0-9+.-]*://[^:/@\\s]+:[^/@\\s]+@',
22  'npm_[A-Za-z0-9]{36}',
23  'SG\\.[A-Za-z0-9_-]{22}\\.[A-Za-z0-9_-]{43}',
24  'SK[0-9a-fA-F]{32}',
25  'service_' + 'account.{0,120}private_' + 'key_id',
26  'AccountKey=[A-Za-z0-9+/=]{60,}',
27  'hf_[A-Za-z0-9]{30,}',
28]
29
30export const SECRET_VALUE = new RegExp(SECRET_VALUE_SOURCES.join('|'))
31
32// The same shapes for `git grep -E` (POSIX ERE has no \s, so the class is spelled out).
33export const SECRET_VALUE_ERE = SECRET_VALUE_SOURCES.join('|').replaceAll('\\s', '[:space:]')
34
35export const SECRET_VALUE_GLOBAL = new RegExp(SECRET_VALUE_SOURCES.join('|'), 'g')
36
37// Secret-looking FILE names. Whole-ish tokens to keep false positives low.
38export const SECRET_NAME =
39  /\.env(\.[A-Za-z0-9_-]+)?|\.pem|\.key|\.p12|\.pfx|\.keystore|\.jks|id_rsa|id_dsa|id_ecdsa|id_ed25519|secrets?\.(json|ya?ml|txt)|credentials|\.pgpass|\.htpasswd|\.npmrc|\.netrc|serviceaccount.*\.json|\.p8/i
40
41// Public env templates that must never trigger a prompt.
42export const SECRET_NAME_SAFE = /\.env\.(example|sample|template|dist|md)/g
43
44// Variable names whose values are secrets.
45export const SECRET_VAR_NAMES = '(KEY|SECRET|TOKEN|PASSWORD|PASSWD|PASS|CREDENTIAL|PRIVATE)'
46
47export const redact = (text: string): string => text.replace(SECRET_VALUE_GLOBAL, '[REDACTED]')
48
49// A path or command that names a secret-looking file, public templates aside.
50export const isSecretName = (text: string): boolean => SECRET_NAME.test(text.replace(SECRET_NAME_SAFE, ''))
51
hooks/toml.ts 71 lines
1// The little of TOML that dependency and script tables use: sections, plain
2// `key = value` rows and string arrays. Not a TOML parser.
3
4// Drops a trailing "# comment", leaving a "#" inside quotes alone.
5const stripComment = (line: string): string => {
6  let quote = ''
7  for (let i = 0; i < line.length; i += 1) {
8    const ch = line[i]
9    if (quote !== '') quote = ch === quote ? '' : quote
10    else if (ch === '"' || ch === "'") quote = ch
11    else if (ch === '#') return line.slice(0, i)
12  }
13  return line
14}
15
16// Body rows (comments dropped) by section name, "" for rows before any header.
17// An array-of-tables header ([[bin]]) starts a section nobody asks for.
18export const sections = (text: string): Map<string, string[]> => {
19  const found = new Map<string, string[]>()
20  let current = ''
21  for (const raw of text.split('\n')) {
22    const row = stripComment(raw).trimEnd()
23    const header = /^\s*\[([^\[\]]+)\]\s*$/.exec(row)
24    if (header !== null) current = (header[1] ?? '').trim()
25    else if (/^\s*\[\[/.test(row)) current = `[[${row.trim()}`
26    else if (row.trim() !== '') found.set(current, [...(found.get(current) ?? []), row])
27  }
28  return found
29}
30
31const STRING = /"((?:[^"\\]|\\.)*)"|'([^']*)'/g
32
33// `key = [ "a", "b" ]` across any number of rows. Undefined when absent.
34export const stringArray = (body: readonly string[], key: string): string[] | undefined => {
35  const text = body.join('\n')
36  const start = new RegExp(`^\\s*${key}\\s*=\\s*\\[`, 'm').exec(text)
37  if (start === null) return undefined
38  let quote = ''
39  let end = text.length
40  for (let i = start.index + start[0].length; i < text.length; i += 1) {
41    const ch = text[i]
42    if (quote !== '') quote = ch === quote ? '' : quote
43    else if (ch === '"' || ch === "'") quote = ch
44    else if (ch === ']') {
45      end = i
46      break
47    }
48  }
49  const inside = text.slice(start.index + start[0].length, end)
50  return [...inside.matchAll(STRING)].map(hit => hit[1] ?? hit[2] ?? '')
51}
52
53// One-row `name = value` pairs. A value that goes on over several rows keeps its first row.
54export const pairs = (body: readonly string[]): [string, string][] =>
55  body.flatMap(row => {
56    const hit = /^\s*("[^"]+"|[A-Za-z0-9_.-]+)\s*=\s*(.*?)\s*$/.exec(row)
57    return hit === null ? [] : [[(hit[1] ?? '').replace(/^"|"$/g, ''), hit[2] ?? '']]
58  })
59
60const unquote = (raw: string): string => raw.replace(/^["']|["']$/g, '')
61
62// The version a dependency value stands for: a string is the version; an inline
63// table gives its `version`, else what it points at ("path", "git", "workspace").
64export const versionOf = (raw: string): string => {
65  if (/^["']/.test(raw)) return unquote(raw)
66  const version = /\bversion\s*=\s*["']([^"']*)["']/.exec(raw)
67  if (version !== null) return version[1] ?? '*'
68  for (const kind of ['workspace', 'path', 'git']) if (new RegExp(`\\b${kind}\\s*=`).test(raw)) return `(${kind})`
69  return '*'
70}
71
types/index.d.ts 25 lines
1// What the command engine keeps in `$.state`, so a hot reload does not lose
2// the session so far. Everything here is already redacted.
3export type CommandRecord = {
4  /** Every tool call seen, denied and errored ones included. */
5  calls: number
6  /** Calls per tool name. */
7  tools: Record<string, number>
8  /** Unique paths a Write, Edit, MultiEdit or NotebookEdit call touched. */
9  files: string[]
10  /** Every Bash command that ran (not denied), uncapped. */
11  commandsRun: number
12  /** The latest of those commands, newest last, capped. */
13  commands: string[]
14  /** Calls a hook denied. */
15  blocked: number
16  /** Calls that ran and came back as an error. */
17  errored: number
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    deps: { record: CommandRecord }
23  }
24}
25