claude-toolkit 0.18.0-rc.0 → 0.18.1-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,25 @@
1
1
  # Changelog
2
2
 
3
- ## 0.18.0 (unreleased)
3
+ ## 0.18.1 (2026-11-07)
4
+
5
+ The edit hooks now check for their tool the way the package manager will look for it. Reported against `testRunner: "bun run --cwd apps/web vitest run"`: the guard read `apps/web` (the value of `--cwd`) as the tool, found nothing by that name and skipped the hook, silently and on every edit. Under `bash` (macOS, Windows Git Bash, Fedora) that was the whole story; under `dash` (Debian, Ubuntu, GitHub's `ubuntu-latest`) the same config happened to run, because `command -v apps/web` accepts any existing directory. This is not a regression from 0.17: those hooks tested a variable Claude Code never sets and never ran.
6
+
7
+ - fix(hooks): `parseHookCommand` replaces the "first word after the first `run`/`exec`/`x`/`npx`/`bunx`" rule. It reads only the `run`/`exec`/`x` forms of bun, bunx, npm, npx, pnpm and yarn, with a flag table per package manager, so the values of `--cwd`, `--filter`, `--env-file`, `-p`, `--prefix`, `-C` and the like are never taken for the tool, and `turbo run`, `nx run`, `vitest run --project`, `cargo run --bin` and `pnpm vitest run --project unit` are no longer misread. A form it does not recognise (a workspace selector, an unknown flag, `bun --cwd X run`) is guarded on its first word only: the command runs and a failure reaches Claude, instead of the hook skipping with no trace. `npx`, `npm exec`, `bunx` and `bun x` never run unchecked, because they would download a missing package: with an unrecognised flag the first word after the flags is checked (a value taken for the tool is no installed tool, so the hook skips), and a command with no tool word never runs. A file after `bun run` (`bun run check.ts`) is a file target and runs, with or without a directory.
8
+ - fix(hooks): `has-tool <name> [<dir> [<scope>]]` looks where the command runs. `--cwd`, `-C`, `--dir` and `--prefix` move the lookup to that directory, and bins are found in that directory and the ones above it, up to the checkout root (never above it), as bun, npm and pnpm resolve them. This is what pnpm and Bun 1.3 workspaces need: their dev tools live in the package's own `node_modules/.bin`, not the root's. `<scope>` (`any`, `script`, `bin`, `local`) follows what each command can run: `npm run` and `pnpm run` run scripts only; `npx` and `npm exec` run bins only. Only new trailing arguments were added, so a 0.18.0 helper (for example the one in a main checkout, seen from a worktree) still works with the new hook text. A word with a `/` that is not `@scope/name` is a file, not a package.
9
+ - fix(hooks): the `PATH` fallback behaves the same in every shell. `command -v` also accepts builtins (a script named `test`) and, in `dash`, any existing path, so it now counts only when it answers with an absolute path. `npx` and `npm exec` never use it. Yarn commands are satisfied by `.pnp.cjs`, since Plug'n'Play has no `node_modules/.bin`.
10
+ - fix(hooks): a `bun`/`npm`/`pnpm`/`yarn` command that runs with no tool check (for example `bun run --filter web test`) skips a checkout with no `node_modules` or Plug'n'Play, so a fresh worktree stays quiet. The install hook is exempt.
11
+ - fix(cli): the `prepare` hint names the package manager the project uses (the `packageManager` field of its `package.json`, else its one lockfile) instead of the `bun` that the first run writes to the config. A project on pnpm or npm no longer gets `&& bun run claude-toolkit:refresh`, which fails an install on a machine without bun.
12
+ - fix(skills): `ct-verification-before-completion` no longer prescribes a bare `tsc --noEmit`, which checks no files and reports 0 errors when the root `tsconfig.json` only lists `references`.
13
+ - docs: README documents how the tool is looked up per command, a "Monorepos and other package managers" section (forms that work, forms that never can, and what a hook run from the repo root changes for Vitest, `tsc`, Biome, Cargo and Playwright), and that the default hooks need bun on every machine. CLAUDE.md describes `parseHookCommand`, the `has-tool` arguments and the fail-open rule, and no longer claims stack hook overrides that the generator never applied.
14
+ - test: the hook tests run under every shell found among `sh`, `bash` and `dash`. CI's `sh` is `dash`, which passed the 0.18.0 code whenever the misread word named an existing directory.
15
+
16
+ Behaviour changes to know about:
17
+
18
+ - Commands the old rule skipped silently now run, so failures that were hidden now reach Claude: the monorepo forms above, and commands for other programs such as `turbo run test` or `cargo run --bin x`, as long as their first word is on the `PATH`.
19
+ - Commands that ran by accident now skip: `npm run <bin>` and `pnpm run <bin>` when the name is not a script, a script named after a shell builtin (`bun run test` with no `test` script), and an `npx` tool installed only globally (add it as a devDependency).
20
+ - Not changed: every hook still runs from the repo root, so Vitest ignores a package's `vitest.config.ts`, and `tsc --noEmit` against a root `tsconfig.json` with only `references` checks nothing. README now says so and shows how to target a package. The protected-branch guard still blocks writes outside the repo while the session is on a protected branch, which also blocks Claude's own scratch and memory files; that is the next patch.
21
+
22
+ ## 0.18.0 (2026-09-29)
4
23
 
5
24
  The edit hooks finally run, and safely. Every config value that reaches a hook command is checked against an allowlist first. The format and test hooks read the edited file from Claude Code's stdin JSON, skip tools that are not installed and files git ignores, and report failures to Claude as context without ever blocking. `.claude/settings.json` is merged instead of overwritten, the skill hook's output is advisory, and the shipped commands, agents and skills lose their broadest pre-approvals.
6
25
 
package/README.md CHANGED
@@ -175,7 +175,7 @@ The generated `.claude/settings.json` registers these hooks. Each command is pla
175
175
  | Install | edits `package.json` | `hooks.installCommand` + `hooks.autoInstall: true` | off |
176
176
 
177
177
  - **They never block.** The format, test, type-check, extra-check and install hooks always exit 0. When a command fails, Claude gets the last lines of its output as context next to the edit and decides what to do. The install hook also writes the last 20 lines of its output to Claude Code's debug log (`claude --debug-file <path>`).
178
- - **They skip missing tools and ignored files.** A hook does nothing unless its tool is installed: the word after `run`, `exec`, `x`, `npx` or `bunx` must be a `node_modules/.bin` entry, a `package.json` script or a command on the `PATH`; a command without such a prefix (e.g. `tsc --noEmit`) must itself be on the `PATH` — use `bun run tsc --noEmit`. Tools are looked up in the edited file's checkout. In a Claude Code worktree (under `.claude/worktrees/`), Claude Code reads the worktree's own `.claude/`. With `.claude/` gitignored, a fresh worktree has none, so the toolkit's hooks, skills, commands and permission rules only apply there after you install dependencies in the worktree. That install provides the tools the hooks look for, and the `prepare` line then runs `claude-toolkit refresh` to generate the worktree's `.claude/`. The default formatter stays silent until Biome is installed. Files that git ignores (build output, dependencies) are skipped too. If your `biome.json` excludes files with `files.includes`, add `--no-errors-on-unmatched` to the formatter so edits to excluded files are not reported as failures.
178
+ - **They skip missing tools and ignored files.** A hook does nothing, and prints nothing, unless its command can run: its first word must be on Claude Code's `PATH` (the defaults need `bun`), and a `bun`, `bunx`, `npm`, `npx`, `pnpm` or `yarn` command must find its tool where that command looks for it, as [Monorepos and other package managers](#monorepos-and-other-package-managers) spells out. A bare command (e.g. `tsc --noEmit`) is looked up on the `PATH` only — use `bun run tsc --noEmit`. The lookup starts in the edited file's checkout. In a Claude Code worktree (under `.claude/worktrees/`), Claude Code reads the worktree's own `.claude/`. With `.claude/` gitignored, a fresh worktree has none, so the toolkit's hooks, skills, commands and permission rules only apply there after you install dependencies in the worktree. That install provides the tools the hooks look for, and the `prepare` line then runs `claude-toolkit refresh` to generate the worktree's `.claude/`. The default formatter stays silent until Biome is installed. Files that git ignores (build output, dependencies) are skipped too. If your `biome.json` excludes files with `files.includes`, add `--no-errors-on-unmatched` to the formatter so edits to excluded files are not reported as failures.
179
179
  - **Commands are allowlisted.** A hook command may only contain letters, digits, spaces and `. _ / @ : = + -`, and must start with the program to run (not `eval`, `exec`, `command`, `.`, `source`, a shell with `-c`, or an option). Anything else — quotes, `$`, `;`, `|`, `&&`, redirects, newlines — stops `bunx claude-toolkit` and `claude-toolkit refresh` with an error naming the field and the value. Put such a command in a `package.json` script and reference the script:
180
180
 
181
181
  ```json
@@ -188,6 +188,56 @@ The generated `.claude/settings.json` registers these hooks. Each command is pla
188
188
 
189
189
  - **`settings.json` is merged, not overwritten.** The toolkit owns only hook entries whose command starts with `# claude-toolkit:` (and the unmarked entries that 0.17 and earlier wrote), and it sets `includeCoAuthoredBy` and its `env` defaults only when they are missing. Your `permissions` (including `deny` rules), your own hooks, `model` and every other key survive regeneration. A `settings.json` that is not a JSON object is saved as `settings.json.bak` before a new one is written.
190
190
 
191
+ ### Monorepos and other package managers
192
+
193
+ Every hook runs from the root of the edited file's checkout, and the format and test commands get the edited file's path appended. A `bun`, `bunx`, `npm`, `npx`, `pnpm` or `yarn` command is checked for its tool before it runs, where that command would look for it:
194
+
195
+ | Command | The tool must be… |
196
+ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
197
+ | `bun run <tool>` | a `package.json` script, a `node_modules/.bin` entry, or a command on the `PATH` |
198
+ | `bun x <tool>`, `bunx <tool>`, `pnpm exec <tool>`, `yarn exec <tool>` | a `node_modules/.bin` entry, or a command on the `PATH` |
199
+ | `npm run <script>`, `pnpm run <script>` | a `package.json` script |
200
+ | `npx <tool>`, `npm exec <tool>` | a `node_modules/.bin` entry. A tool installed only globally is skipped: `npx` would download a missing package, so add the tool as a devDependency |
201
+ | `yarn run <tool>` | a script or a `node_modules/.bin` entry |
202
+
203
+ Yarn Plug'n'Play has no `node_modules`, so a `yarn run` or `yarn exec` check is satisfied by the presence of `.pnp.cjs`. Scripts are read from the directory the command runs in; `.bin` entries are looked up there and in the directories above it, up to the checkout root. `--cwd` (bun, Yarn), `-C` or `--dir` (pnpm) and `--prefix` or `-C` (npm) move the command to another directory, and the lookup follows. In a pnpm workspace, and in a new Bun 1.3 workspace, a package's dev tools live in the package's own `node_modules/.bin`, not the root's, so target the package:
204
+
205
+ ```ts
206
+ hooks: {
207
+ testRunner: "bun run --cwd apps/web vitest run",
208
+ formatter: "pnpm -C apps/web exec biome format --write",
209
+ typeCheck: "npm --prefix apps/web run typecheck",
210
+ },
211
+ ```
212
+
213
+ Any other command runs with only its first word checked: `cargo check`, `turbo run test`, `nx run web:test`, and the forms that select workspaces (`--filter`, `-F`, `-w`, `--workspace`, `pnpm -r`, `yarn workspace`). Such a hook runs and reports failures to Claude instead of skipping. It still waits until the checkout has a `node_modules` folder (or Plug'n'Play), so a fresh worktree stays quiet. A file after `bun run` (`bun run check.ts`, `bun run scripts/fmt.mjs`) is run the same way. `npx`, `npm exec`, `bunx` and `bun x` are the exception, because they download a missing package: with a flag the toolkit does not know, the hook checks the first word after the flags and skips when that is not an installed tool, and with no tool word at all it never runs.
214
+
215
+ To run a package's tests for every test file in the repo, point the hook at a script in the root `package.json`, called with your package manager. `--passWithNoTests` keeps edits to other packages' tests quiet:
216
+
217
+ ```json
218
+ { "scripts": { "test:file": "bun run --cwd apps/web vitest run --passWithNoTests" } }
219
+ ```
220
+
221
+ ```ts
222
+ hooks: { testRunner: "bun run test:file" },
223
+ ```
224
+
225
+ Forms that do not work, whatever the toolkit does:
226
+
227
+ - `bun --cwd apps/web run …` prints bun's usage and exits 0 without running anything. Put the flag after `run`.
228
+ - `bun run --filter web vitest run` fails: bun's `--filter` runs scripts only, so use `bun run --filter web test`.
229
+ - `pnpm -C apps/web vitest run` and `pnpm --filter web vitest run` need `exec` (`pnpm -C apps/web exec vitest run`).
230
+ - A script that does `cd apps/web && vitest run` fails where the tool is installed only in the package.
231
+
232
+ Running from the root also changes what the tools see:
233
+
234
+ - **Vitest** ignores a package's `vitest.config.ts` when run from the root, so passing tests can be reported as failures. Target the package as above, or define `test.projects` at the root.
235
+ - **`tsc --noEmit`** checks nothing, and exits 0, when the root `tsconfig.json` only lists `references` (most Vite templates). Use `tsc -b --noEmit` and gitignore the `.tsbuildinfo` files it writes.
236
+ - **Biome** reads the nearest `biome.json`; a nested one needs `"root": false` or every file under it fails.
237
+ - **Cargo** looks for `Cargo.toml` at the root: use `cargo check --manifest-path worker/Cargo.toml` when the crate is in a subfolder.
238
+ - **Playwright** `*.spec.ts` files match the test hook, which runs the test command on them. Keep the test command away from them or exclude them in its config.
239
+ - **Machines without bun** skip every default hook, and cannot run `bunx claude-toolkit`. Everything that must work for the whole team needs bun, or hook commands written for your package manager.
240
+
191
241
  ## Project Setup
192
242
 
193
243
  Add `.claude/` to your `.gitignore` (it's generated, not tracked):
package/bin/cli.ts CHANGED
@@ -24,7 +24,7 @@ import { basename, join, resolve } from "node:path";
24
24
  import { detectStacks } from "../src/detect.js";
25
25
  import { generate, readMarker, readToolkitVersion } from "../src/generator.js";
26
26
  import { DEFAULT_HOOKS } from "../src/hook-commands.js";
27
- import { buildRefreshHint } from "../src/refresh-hint.js";
27
+ import { buildRefreshHint, inferPackageManager, LOCKFILE_NAMES } from "../src/refresh-hint.js";
28
28
  import type { ClaudeToolkitConfig } from "../src/types.js";
29
29
 
30
30
  const CONFIG_FILENAME = "claude-toolkit.config.ts";
@@ -199,15 +199,17 @@ async function printRefreshHint(
199
199
  ): Promise<void> {
200
200
  const pkgPath = join(projectDir, "package.json");
201
201
  if (!existsSync(pkgPath)) return;
202
- let scripts: Record<string, unknown>;
202
+ let pkg: { scripts?: Record<string, unknown>; packageManager?: unknown };
203
203
  try {
204
- scripts = JSON.parse(await readFile(pkgPath, "utf-8")).scripts ?? {};
204
+ pkg = JSON.parse(await readFile(pkgPath, "utf-8"));
205
205
  } catch {
206
206
  return; // an unparsable package.json is not worth failing the run over
207
207
  }
208
+ // A first run writes "bun" to the config whatever the project uses.
209
+ const lockfiles = LOCKFILE_NAMES.filter((name) => existsSync(join(projectDir, name)));
208
210
  const lines = buildRefreshHint({
209
- scripts,
210
- packageManager,
211
+ scripts: pkg.scripts ?? {},
212
+ packageManager: inferPackageManager(pkg.packageManager, lockfiles) ?? packageManager,
211
213
  yarnBerry: existsSync(join(projectDir, ".yarnrc.yml")),
212
214
  previousVersion,
213
215
  });
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Input helper for the generated PostToolUse hooks. Filesystem only: it reads the
3
- * hook's JSON from stdin and, for has-tool, the package.json and node_modules/.bin of the
4
- * current directory (the hook first cds to the edited file's checkout). It never starts
5
- * a process and never reads environment variables.
3
+ * hook's JSON from stdin and, for has-tool, the package.json and node_modules/.bin files
4
+ * around the current directory (the hook first cds to the edited file's checkout). It
5
+ * never starts a process and never reads environment variables.
6
6
  *
7
7
  * node hook-input.cjs path Print tool_input.file_path; nothing on bad input.
8
- * node hook-input.cjs has-tool <name> Exit 0 when <name> is a node_modules/.bin entry
9
- * or a package.json script in the current
10
- * directory; exit 1 otherwise.
8
+ * node hook-input.cjs has-tool <name> [<dir> [<scope>]]
9
+ * Exit 0 when <name> is something a command run in
10
+ * <dir> (default ".") can find, 1 otherwise. <scope>
11
+ * is any (default), script, bin or local; see hasTool.
11
12
  * node hook-input.cjs context <title> Read command output from stdin and print it as
12
13
  * PostToolUse additionalContext JSON.
13
14
  */
@@ -17,6 +18,8 @@ const path = require("node:path");
17
18
 
18
19
  // npm, pnpm and yarn create bare, .cmd and .ps1 shims; bun on Windows creates .exe and .bunx.
19
20
  const BIN_SUFFIXES = ["", ".cmd", ".ps1", ".exe", ".bunx"];
21
+ // Where has-tool looks; an unknown scope counts as "any", so a newer hook works with this helper.
22
+ const SCOPES = new Set(["any", "script", "bin", "local"]);
20
23
  // Code points below SPACE, and DEL, are control characters (newlines included).
21
24
  const SPACE = 0x20;
22
25
  const DEL = 0x7f;
@@ -67,19 +70,53 @@ function readScripts(root) {
67
70
  }
68
71
  }
69
72
 
73
+ /** Nearest ancestor-or-self holding .git (a worktree has a .git file). */
74
+ function checkoutTop(cwd) {
75
+ for (let dir = cwd; ; dir = path.dirname(dir)) {
76
+ if (fs.existsSync(path.join(dir, ".git"))) return dir;
77
+ if (path.dirname(dir) === dir) return null;
78
+ }
79
+ }
80
+
81
+ /** `start` and its parents up to the checkout top, never above it. */
82
+ function lookupDirs(start, cwd) {
83
+ const top = checkoutTop(cwd) ?? cwd;
84
+ const rel = path.relative(top, start);
85
+ if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return [start];
86
+ const dirs = [];
87
+ for (let dir = start; ; dir = path.dirname(dir)) {
88
+ dirs.push(dir);
89
+ if (dir === top || path.dirname(dir) === dir) return dirs;
90
+ }
91
+ }
92
+
93
+ /** "prettier@3" is prettier, "@biomejs/biome" is biome, a path is "". */
94
+ function binName(name) {
95
+ const bare = name.replace(/(.)@[^/]*$/, "$1");
96
+ const scoped = /^@[^/]+\/([^/]+)$/.exec(bare);
97
+ if (scoped) return scoped[1];
98
+ return bare.includes("/") || bare === "." || bare === ".." ? "" : bare;
99
+ }
100
+
70
101
  /**
71
- * True when `name` is a package.json script or a node_modules/.bin entry in `root` (the
72
- * current directory unless given). A version suffix is ignored ("prettier@3" counts as "prettier"), and a scoped package is looked
73
- * up by its last segment ("@biomejs/biome" as "biome").
102
+ * scope: any = a script of `dir`, else a .bin entry in `dir` or a parent; script; bin = .bin
103
+ * in `dir` or a parent; local = .bin in `dir` only.
74
104
  */
75
- function hasTool(name, root = process.cwd()) {
105
+ function hasTool(name, dir = ".", scope = "any", cwd = process.cwd()) {
76
106
  if (typeof name !== "string" || name === "") return false;
77
- if (typeof readScripts(root)[name] === "string") return true;
78
- const bare = name.replace(/(.)@[^/]*$/, "$1");
79
- const binName = bare.split("/").pop() ?? "";
80
- if (binName === "") return false;
81
- const binDir = path.join(root, "node_modules", ".bin");
82
- return BIN_SUFFIXES.some((suffix) => fs.existsSync(path.join(binDir, binName + suffix)));
107
+ const start = path.resolve(cwd, dir);
108
+ if ((scope === "any" || scope === "script") && typeof readScripts(start)[name] === "string") {
109
+ return true;
110
+ }
111
+ if (scope === "script") return false;
112
+ const bin = binName(name);
113
+ if (bin === "") return false;
114
+ const dirs = scope === "local" ? [start] : lookupDirs(start, cwd);
115
+ return dirs.some((d) =>
116
+ BIN_SUFFIXES.some((suffix) =>
117
+ fs.existsSync(path.join(d, "node_modules", ".bin", bin + suffix)),
118
+ ),
119
+ );
83
120
  }
84
121
 
85
122
  /** PostToolUse JSON that hands `title` and the tail of `output` to Claude as context. */
@@ -94,13 +131,14 @@ function contextJson(title, output) {
94
131
 
95
132
  /** Run one subcommand; returns the exit code. */
96
133
  function main(argv) {
97
- const [command, arg] = argv;
134
+ const [command, arg, dir, scope] = argv;
98
135
  if (command === "path") {
99
136
  const filePath = filePathFrom(readStdin());
100
137
  if (filePath !== "") process.stdout.write(`${filePath}\n`);
101
138
  return 0;
102
139
  }
103
- if (command === "has-tool") return hasTool(arg) ? 0 : 1;
140
+ if (command === "has-tool")
141
+ return hasTool(arg, dir || ".", SCOPES.has(scope) ? scope : "any") ? 0 : 1;
104
142
  if (command === "context") {
105
143
  process.stdout.write(`${contextJson(arg ?? "", readStdin())}\n`);
106
144
  return 0;
@@ -12,7 +12,7 @@ description: Evidence-based completion claims -- never say "done" without proof
12
12
  Before claiming complete, run and show output for each:
13
13
 
14
14
  1. **Tests pass** -- Full suite or related tests. Show actual output, not just "tests pass".
15
- 2. **Types check** -- `tsc --noEmit` with zero errors on changed files.
15
+ 2. **Types check** -- The project's type-check script (or `tsc --noEmit`) with zero errors on changed files. When the root `tsconfig` only lists `references`, use `tsc -b` or the script: a plain `tsc --noEmit` there checks nothing and reports 0 errors.
16
16
  3. **Lint passes** -- Fix violations introduced by your changes.
17
17
  4. **Feature works** -- Show actual output (API response, CLI output, screenshot) for user-facing changes.
18
18
 
@@ -45,7 +45,7 @@ Before completing work that touches dependencies, inputs, or external data:
45
45
  ```
46
46
  ## Completed: [task name]
47
47
  - Tests: X passing (Y new, Z existing) -- [command output]
48
- - Types: tsc --noEmit: 0 errors
48
+ - Types: [type-check command]: 0 errors
49
49
  - Lint: 0 violations
50
50
  - Security: audit clean, no exposed secrets
51
51
  - Manual: [endpoint/action]: [status/result]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-toolkit",
3
- "version": "0.18.0-rc.0",
3
+ "version": "0.18.1-rc.0",
4
4
  "description": "Reusable Claude Code configuration toolkit with stack-specific connectors",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,11 +46,11 @@
46
46
  "url": "git+https://github.com/william-queant/claude-toolkit.git"
47
47
  },
48
48
  "devDependencies": {
49
- "@biomejs/biome": "^2.4.10",
50
- "@types/bun": "^1.3.11",
49
+ "@biomejs/biome": "^2.6.6",
50
+ "@types/bun": "^1.4.4",
51
51
  "husky": "^9.1.7",
52
- "lint-staged": "^16.4.0",
53
- "typescript": "^6.0.2"
52
+ "lint-staged": "^17.6.0",
53
+ "typescript": "^7.0.2"
54
54
  },
55
55
  "engines": {
56
56
  "bun": ">=1.3.0"
@@ -120,37 +120,362 @@ const TYPECHECK_PATTERNS = ["*.ts", "*.tsx", "*.mts", "*.cts"];
120
120
  const EXTRA_CHECK_PATTERNS = ["*.rs"];
121
121
  const INSTALL_PATTERNS = ["package.json", "*/package.json"];
122
122
 
123
- /** Words after which the next non-flag word names the tool: `bun run x`, `pnpm exec x`, `npx x`. */
124
- const TOOL_PREFIXES = new Set(["run", "exec", "x", "npx", "bunx"]);
123
+ export type Scope = "any" | "script" | "bin" | "local";
124
+
125
+ export interface ToolCheck {
126
+ tool: string;
127
+ /** Relative to the checkout root. */
128
+ dir: string;
129
+ scope: Scope;
130
+ /** Also accept an executable on the PATH. */
131
+ path: boolean;
132
+ /** Yarn Plug'n'Play has no .bin: a .pnp.cjs satisfies the check. */
133
+ pnp: boolean;
134
+ }
135
+
136
+ /** A null `check` guards the runner only: the command runs and failures reach Claude. */
137
+ export interface ParsedCommand {
138
+ runner: string;
139
+ check: ToolCheck | null;
140
+ /** Never run: the tool is unknown and the runner would download it. */
141
+ blocked?: boolean;
142
+ }
143
+
144
+ interface Flags {
145
+ dir?: string[];
146
+ /** The directory is the only place the runner looks (npm's `--prefix`). */
147
+ localDir?: boolean;
148
+ workspace?: string[];
149
+ workspaceBool?: string[];
150
+ values?: string[];
151
+ booleans?: string[];
152
+ }
153
+
154
+ const flagTable = (f: Flags) => ({
155
+ dir: new Set(f.dir ?? []),
156
+ localDir: f.localDir ?? false,
157
+ workspace: new Set(f.workspace ?? []),
158
+ workspaceBool: new Set(f.workspaceBool ?? []),
159
+ values: new Set(f.values ?? []),
160
+ booleans: new Set(f.booleans ?? []),
161
+ });
162
+ type FlagTable = ReturnType<typeof flagTable>;
163
+
164
+ // Checked against bun 1.3, npm 10-11, pnpm 8-12, Yarn 1 and 4. An unlisted flag means no tool check.
165
+ const BUN = flagTable({
166
+ dir: ["--cwd"],
167
+ workspace: ["--filter", "-F"],
168
+ values: [
169
+ "--env-file",
170
+ "--conditions",
171
+ "--preload",
172
+ "-r",
173
+ "--config",
174
+ "-c",
175
+ "--shell",
176
+ "--elide-lines",
177
+ ],
178
+ booleans: ["--bun", "-b", "--silent", "--if-present", "--no-install"],
179
+ });
180
+ const BUNX = flagTable({
181
+ values: ["-p", "--package", "--cache-dir", "--registry", "--config"],
182
+ booleans: ["--bun", "-b", "--silent", "--no-install", "--verbose"],
183
+ });
184
+ const NPM = flagTable({
185
+ dir: ["--prefix", "-C"],
186
+ localDir: true,
187
+ workspace: ["-w", "--workspace"],
188
+ workspaceBool: ["--workspaces", "-ws", "--include-workspace-root"],
189
+ values: [
190
+ "-p",
191
+ "--package",
192
+ "-c",
193
+ "--call",
194
+ "--script-shell",
195
+ "--loglevel",
196
+ "--registry",
197
+ "--cache",
198
+ "--userconfig",
199
+ "--globalconfig",
200
+ "--tag",
201
+ "--otp",
202
+ "--before",
203
+ ],
204
+ booleans: [
205
+ "--silent",
206
+ "-s",
207
+ "--if-present",
208
+ "--ignore-scripts",
209
+ "--yes",
210
+ "-y",
211
+ "--no",
212
+ "--quiet",
213
+ "-q",
214
+ "--foreground-scripts",
215
+ ],
216
+ });
217
+ const PNPM = flagTable({
218
+ dir: ["-C", "--dir"],
219
+ workspace: ["--filter", "-F", "--filter-prod"],
220
+ workspaceBool: ["-r", "--recursive"],
221
+ values: ["--reporter", "--loglevel", "--workspace-concurrency"],
222
+ booleans: [
223
+ "--silent",
224
+ "-s",
225
+ "--parallel",
226
+ "--stream",
227
+ "--aggregate-output",
228
+ "--if-present",
229
+ "-w",
230
+ "--workspace-root",
231
+ ],
232
+ });
233
+ const YARN = flagTable({
234
+ dir: ["--cwd"],
235
+ booleans: ["--silent", "-s", "-T", "--top-level", "-B", "--binaries-only"],
236
+ });
237
+
238
+ interface FlagState {
239
+ dir: string | null;
240
+ /** Before bun's `run`, `--cwd DIR` prints usage and runs nothing. */
241
+ spaceDir: boolean;
242
+ localDir: boolean;
243
+ workspace: boolean;
244
+ unknown: boolean;
245
+ }
246
+ const freshState = (): FlagState => ({
247
+ dir: null,
248
+ spaceDir: false,
249
+ localDir: false,
250
+ workspace: false,
251
+ unknown: false,
252
+ });
253
+
254
+ /** Returns the index of the first non-flag word (or of `--`). */
255
+ function readFlags(words: string[], start: number, table: FlagTable, state: FlagState): number {
256
+ let i = start;
257
+ while (i < words.length) {
258
+ const word = words[i] ?? "";
259
+ if (word === "--" || !word.startsWith("-")) return i;
260
+ const eq = word.indexOf("=");
261
+ const name = eq === -1 ? word : word.slice(0, eq);
262
+ const inline = eq === -1 ? undefined : word.slice(eq + 1);
263
+ const takesValue = table.dir.has(name) || table.workspace.has(name) || table.values.has(name);
264
+ let value = inline;
265
+ if (takesValue && value === undefined) {
266
+ i += 1;
267
+ value = words[i];
268
+ }
269
+ if (table.dir.has(name)) {
270
+ if (value === undefined || value === "" || value.startsWith("-")) state.unknown = true;
271
+ else
272
+ Object.assign(state, {
273
+ dir: value,
274
+ spaceDir: inline === undefined,
275
+ localDir: table.localDir,
276
+ });
277
+ } else if (table.workspace.has(name) || table.workspaceBool.has(name)) {
278
+ state.workspace = true;
279
+ } else if (table.values.has(name)) {
280
+ if (value === undefined) state.unknown = true;
281
+ } else if (!table.booleans.has(name) && inline === undefined) {
282
+ state.unknown = true;
283
+ }
284
+ i += 1;
285
+ }
286
+ return i;
287
+ }
288
+
289
+ const unversioned = (word: string) => word.replace(/(.)@[^/]*$/, "$1");
290
+ const isFileTarget = (word: string) =>
291
+ (word.includes("/") && !/^@[^/]+\/[^/]+$/.test(unversioned(word))) ||
292
+ /\.[cm]?[jt]sx?$/.test(word);
293
+
294
+ interface Resolution {
295
+ scope: Scope;
296
+ path: boolean;
297
+ /** npx, npm exec, bunx and bun x fetch missing packages. */
298
+ downloads: boolean;
299
+ pnp?: boolean;
300
+ }
301
+
302
+ function checkTool(
303
+ runner: string,
304
+ words: string[],
305
+ from: number,
306
+ state: FlagState,
307
+ how: Resolution,
308
+ ): ParsedCommand {
309
+ const tool = words[words[from] === "--" ? from + 1 : from];
310
+ const dir = state.dir ?? ".";
311
+ if (how.downloads) {
312
+ // Never run unchecked: an unknown flag's value is checked as the tool, fails, and the hook skips.
313
+ if (tool === undefined || tool.startsWith("-")) return { runner, check: null, blocked: true };
314
+ const scope = state.localDir && state.dir !== null ? "local" : "bin";
315
+ return { runner, check: { tool, dir, scope, path: how.path, pnp: false } };
316
+ }
317
+ const unchecked: ParsedCommand = { runner, check: null };
318
+ if (tool === undefined || tool.startsWith("-") || isFileTarget(tool)) return unchecked;
319
+ if (state.unknown || state.workspace) return unchecked;
320
+ return { runner, check: { tool, dir, scope: how.scope, path: how.path, pnp: how.pnp ?? false } };
321
+ }
322
+
323
+ const NPM_RUN = new Set(["run", "run-script", "rum", "urn"]);
324
+
325
+ function parseBun(runner: string, words: string[]): ParsedCommand {
326
+ const state = freshState();
327
+ const at = readFlags(words, 0, BUN, state);
328
+ const sub = words[at];
329
+ if ((sub === "run" || sub === "x") && state.spaceDir) {
330
+ return { runner, check: null }; // bun prints usage and runs nothing
331
+ }
332
+ if (sub === "run") {
333
+ const from = readFlags(words, at + 1, BUN, state);
334
+ return checkTool(runner, words, from, state, { scope: "any", path: true, downloads: false });
335
+ }
336
+ if (sub === "x") {
337
+ const from = readFlags(words, at + 1, BUNX, state);
338
+ return checkTool(runner, words, from, state, { scope: "bin", path: true, downloads: true });
339
+ }
340
+ return { runner, check: null };
341
+ }
342
+
343
+ function parseBunx(runner: string, words: string[]): ParsedCommand {
344
+ const state = freshState();
345
+ const from = readFlags(words, 0, BUNX, state);
346
+ return checkTool(runner, words, from, state, { scope: "bin", path: true, downloads: true });
347
+ }
348
+
349
+ function parseNpm(runner: string, words: string[]): ParsedCommand {
350
+ const state = freshState();
351
+ const at = readFlags(words, 0, NPM, state);
352
+ const sub = words[at];
353
+ if (sub !== undefined && NPM_RUN.has(sub)) {
354
+ const from = readFlags(words, at + 1, NPM, state);
355
+ // npm reads its own flags anywhere before "--": `npm run test --workspace web`.
356
+ const end = words.indexOf("--", from + 1);
357
+ const after = words.slice(from + 1, end === -1 ? undefined : end);
358
+ for (const [k, word] of after.entries()) {
359
+ if (word.startsWith("-")) readFlags(after, k, NPM, state);
360
+ }
361
+ return checkTool(runner, words, from, state, {
362
+ scope: "script",
363
+ path: false,
364
+ downloads: false,
365
+ });
366
+ }
367
+ if (sub === "exec" || sub === "x") {
368
+ const from = readFlags(words, at + 1, NPM, state);
369
+ return checkTool(runner, words, from, state, { scope: "bin", path: false, downloads: true });
370
+ }
371
+ return { runner, check: null };
372
+ }
373
+
374
+ function parseNpx(runner: string, words: string[]): ParsedCommand {
375
+ const state = freshState();
376
+ const from = readFlags(words, 0, NPM, state);
377
+ return checkTool(runner, words, from, state, { scope: "bin", path: false, downloads: true });
378
+ }
379
+
380
+ function parsePnpm(runner: string, words: string[]): ParsedCommand {
381
+ const state = freshState();
382
+ const at = readFlags(words, 0, PNPM, state);
383
+ const sub = words[at];
384
+ if (sub === "run") {
385
+ const from = readFlags(words, at + 1, PNPM, state);
386
+ return checkTool(runner, words, from, state, {
387
+ scope: "script",
388
+ path: false,
389
+ downloads: false,
390
+ });
391
+ }
392
+ if (sub === "exec") {
393
+ const from = readFlags(words, at + 1, PNPM, state);
394
+ return checkTool(runner, words, from, state, { scope: "bin", path: true, downloads: false });
395
+ }
396
+ return { runner, check: null };
397
+ }
398
+
399
+ function parseYarn(runner: string, words: string[]): ParsedCommand {
400
+ const state = freshState();
401
+ const at = readFlags(words, 0, YARN, state);
402
+ const sub = words[at];
403
+ if (sub === "run") {
404
+ const from = readFlags(words, at + 1, YARN, state);
405
+ return checkTool(runner, words, from, state, {
406
+ scope: "any",
407
+ path: false,
408
+ downloads: false,
409
+ pnp: true,
410
+ });
411
+ }
412
+ if (sub === "exec") {
413
+ const from = readFlags(words, at + 1, YARN, state);
414
+ return checkTool(runner, words, from, state, {
415
+ scope: "bin",
416
+ path: true,
417
+ downloads: false,
418
+ pnp: true,
419
+ });
420
+ }
421
+ return { runner, check: null };
422
+ }
423
+
424
+ const PARSERS: Record<string, (runner: string, words: string[]) => ParsedCommand> = {
425
+ bun: parseBun,
426
+ bunx: parseBunx,
427
+ npm: parseNpm,
428
+ npx: parseNpx,
429
+ pnpm: parsePnpm,
430
+ yarn: parseYarn,
431
+ };
432
+
433
+ const JS_RUNNERS = new Set(Object.keys(PARSERS));
125
434
 
126
435
  /**
127
- * The executables an allowlisted command needs. `runner` is its first word (after any
128
- * VAR=value assignments); `tool` is the first non-flag word after run, exec, x, npx or
129
- * bunx, or the runner itself when there is none.
436
+ * Unrecognised forms are guarded on their runner only: a failure then reaches Claude, while a
437
+ * wrong guess would skip a working hook with no trace.
130
438
  */
439
+ export function parseHookCommand(command: string): ParsedCommand {
440
+ const words = command.trim().split(/\s+/);
441
+ const at = words.findIndex((word) => !word.includes("="));
442
+ const runner = at === -1 ? "" : (words[at] ?? "");
443
+ const parse = PARSERS[runner];
444
+ return parse ? parse(runner, words.slice(at + 1)) : { runner, check: null };
445
+ }
446
+
131
447
  export function commandTools(command: string): { runner: string; tool: string } {
132
- const words = command
133
- .trim()
134
- .split(/\s+/)
135
- .filter((word) => !word.includes("="));
136
- const runner = words[0] ?? "";
137
- const at = words.findIndex((word) => TOOL_PREFIXES.has(word));
138
- const tool = at === -1 ? undefined : words.slice(at + 1).find((word) => !word.startsWith("-"));
139
- return { runner, tool: tool ?? runner };
448
+ const { runner, check } = parseHookCommand(command);
449
+ return { runner, tool: check?.tool ?? runner };
140
450
  }
141
451
 
142
- /** sh lines that exit 0 silently unless the command's runner and tool are installed. */
143
- function toolGuard(command: string): string[] {
144
- // runner and tool are words of an allowlisted command (HOOK_COMMAND_PATTERN): no shell syntax.
145
- const { runner, tool } = commandTools(command);
146
- const runnerLine = `command -v ${runner} >/dev/null 2>&1 || exit 0`;
147
- // A bare command (no run/exec/x/npx/bunx) is found by sh through PATH only: a
148
- // node_modules/.bin entry or package.json script of that name cannot run it.
149
- if (runner === tool) return [runnerLine];
150
- return [
151
- runnerLine,
152
- `${HOOK_INPUT} has-tool ${tool} || command -v ${tool} >/dev/null 2>&1 || exit 0`,
153
- ];
452
+ // `command -v` also accepts builtins and, in dash, any existing path.
453
+ const ON_PATH = (tool: string) =>
454
+ `{ found="$(command -v ${tool} 2>/dev/null)" && case "$found" in /*) ;; *) false;; esac; }`;
455
+
456
+ const DEPENDENCIES_LINE =
457
+ '[ -d node_modules ] || [ -d "$top/node_modules" ] || [ -f .pnp.cjs ] || [ -f "$top/.pnp.cjs" ] || exit 0';
458
+
459
+ /**
460
+ * sh lines that exit 0 unless the command can run. Interpolated words come from an allowlisted
461
+ * command (HOOK_COMMAND_PATTERN). Not `needsDependencies` for install: it runs in fresh checkouts.
462
+ */
463
+ function toolGuard(command: string, needsDependencies = false): string[] {
464
+ const { runner, check, blocked } = parseHookCommand(command);
465
+ const lines = [`command -v ${runner} >/dev/null 2>&1 || exit 0`];
466
+ if (blocked) return [...lines, "exit 0"];
467
+ if (check === null) {
468
+ if (needsDependencies && JS_RUNNERS.has(runner)) lines.push(DEPENDENCIES_LINE);
469
+ return lines;
470
+ }
471
+ const args = [check.tool];
472
+ if (check.dir !== "." || check.scope !== "any") args.push(check.dir);
473
+ if (check.scope !== "any") args.push(check.scope);
474
+ const found = [`${HOOK_INPUT} has-tool ${args.join(" ")}`];
475
+ if (check.pnp) found.unshift("[ -f .pnp.cjs ]", "[ -f .pnp.js ]");
476
+ if (check.path && !check.tool.includes("/")) found.push(ON_PATH(check.tool));
477
+ lines.push(`${found.join(" || ")} || exit 0`);
478
+ return lines;
154
479
  }
155
480
 
156
481
  /** A PostToolUse hook that runs one allowlisted command after matching edits. */
@@ -181,7 +506,7 @@ function editHookCommand(hook: EditHook): string {
181
506
  ...PREAMBLE,
182
507
  `case "$f" in ${hook.patterns.join("|")}) ;; *) exit 0;; esac`,
183
508
  ...PROJECT_LINES,
184
- ...toolGuard(hook.command),
509
+ ...toolGuard(hook.command, true),
185
510
  `out="$(${run} 2>&1)" && exit 0`,
186
511
  `printf '%s\\n' "$out" | ${HOOK_INPUT} context "claude-toolkit ${hook.id} hook: ${hook.command} failed for $f"`,
187
512
  "exit 0",
@@ -42,6 +42,29 @@ const CAVEATS: Record<PackageManager | "yarn-berry", string | null> = {
42
42
  'Yarn 2+ never runs "prepare" for your own project; "postinstall" runs whenever your dependencies change.',
43
43
  };
44
44
 
45
+ const LOCKFILES: Record<string, PackageManager> = {
46
+ "bun.lock": "bun",
47
+ "bun.lockb": "bun",
48
+ "pnpm-lock.yaml": "pnpm",
49
+ "package-lock.json": "npm",
50
+ "yarn.lock": "yarn",
51
+ };
52
+ export const LOCKFILE_NAMES = Object.keys(LOCKFILES);
53
+
54
+ /** The package.json `packageManager` field, else the one lockfile; null when unsure. */
55
+ export function inferPackageManager(
56
+ packageManagerField: unknown,
57
+ lockfiles: string[],
58
+ ): PackageManager | null {
59
+ if (typeof packageManagerField === "string") {
60
+ const name = packageManagerField.split("@")[0] ?? "";
61
+ if (name === "bun" || name === "npm" || name === "pnpm" || name === "yarn") return name;
62
+ return null;
63
+ }
64
+ const found = new Set(lockfiles.map((file) => LOCKFILES[file]).filter(Boolean));
65
+ return found.size === 1 ? ([...found][0] ?? null) : null;
66
+ }
67
+
45
68
  /** Compare x.y.z numerically. A prerelease counts as its release: 0.17.0-rc.0 is not before 0.17.0. */
46
69
  export function isBefore(version: string, floor: string): boolean {
47
70
  const parts = (v: string) =>
@@ -7,16 +7,17 @@ export default defineConfig({
7
7
  // Package manager used for install/run commands
8
8
  packageManager: "bun",
9
9
 
10
- // Hook automation — commands run after Claude edits a matching file. A command may only
11
- // use letters, digits, spaces and . _ / @ : = + -; for anything else, add a package.json
12
- // script and reference it as "bun run <script>".
10
+ // Hook automation — commands run exactly as written after Claude edits a matching file, from
11
+ // the repo root. A command may only use letters, digits, spaces and . _ / @ : = + -; for
12
+ // anything else, add a package.json script and reference it with your package manager
13
+ // (e.g. "bun run <script>"). In a monorepo, target a package: "bun run --cwd apps/web vitest run".
13
14
  hooks: {
14
15
  formatter: "bun run biome format --write",
15
16
  testRunner: "bun run vitest run",
16
17
  typeCheck: "bun run tsc --noEmit",
17
18
  // typeCheckOnEdit: true, // also run typeCheck after every .ts/.tsx edit (slow on large projects)
18
19
  // autoInstall: true, // run the install command after every package.json edit
19
- // extraChecks: ['cargo check --target wasm32-unknown-unknown'],
20
+ // extraChecks: ['cargo check --target wasm32-unknown-unknown'], // runs at the repo root: add --manifest-path DIR/Cargo.toml for a crate in a subfolder
20
21
  },
21
22
 
22
23
  // Directory → skill mappings (merged with stack defaults)
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
2
+ "$schema": "https://biomejs.dev/schemas/2.6.6/schema.json",
3
3
  "vcs": {
4
4
  "enabled": true,
5
5
  "clientKind": "git",