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 +20 -1
- package/README.md +51 -1
- package/bin/cli.ts +7 -5
- package/core/hooks/hook-input.cjs +56 -18
- package/core/skills/ct-verification-before-completion/SKILL.md +2 -2
- package/package.json +5 -5
- package/src/hook-commands.ts +351 -26
- package/src/refresh-hint.ts +23 -0
- package/templates/claude-toolkit.config.ts +5 -4
- package/templates/configs/biome.base.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.18.
|
|
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
|
|
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
|
|
202
|
+
let pkg: { scripts?: Record<string, unknown>; packageManager?: unknown };
|
|
203
203
|
try {
|
|
204
|
-
|
|
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
|
|
4
|
-
* current directory (the hook first cds to the edited file's checkout). It
|
|
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>
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
72
|
-
*
|
|
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,
|
|
105
|
+
function hasTool(name, dir = ".", scope = "any", cwd = process.cwd()) {
|
|
76
106
|
if (typeof name !== "string" || name === "") return false;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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")
|
|
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:
|
|
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.
|
|
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.
|
|
50
|
-
"@types/bun": "^1.
|
|
49
|
+
"@biomejs/biome": "^2.6.6",
|
|
50
|
+
"@types/bun": "^1.4.4",
|
|
51
51
|
"husky": "^9.1.7",
|
|
52
|
-
"lint-staged": "^
|
|
53
|
-
"typescript": "^
|
|
52
|
+
"lint-staged": "^17.6.0",
|
|
53
|
+
"typescript": "^7.0.2"
|
|
54
54
|
},
|
|
55
55
|
"engines": {
|
|
56
56
|
"bun": ">=1.3.0"
|
package/src/hook-commands.ts
CHANGED
|
@@ -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
|
-
|
|
124
|
-
|
|
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
|
-
*
|
|
128
|
-
*
|
|
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
|
|
133
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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",
|
package/src/refresh-hint.ts
CHANGED
|
@@ -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
|
|
11
|
-
// use letters, digits, spaces and . _ / @ : = + -; for
|
|
12
|
-
// script and reference it
|
|
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)
|