@spardutti/claude-skills 2.10.0 → 2.11.1
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/README.md +18 -5
- package/bin/cli.mjs +6 -4
- package/lib/setup-hook.mjs +97 -58
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -227,11 +227,17 @@ repo's own `typecheck` script if it has one, else bare `tsc --noEmit` or `mypy`.
|
|
|
227
227
|
is preferred because a workspace root `tsconfig.json` is often solution-style, where
|
|
228
228
|
`tsc --noEmit` does not mean what the repo's `tsc -b` means.
|
|
229
229
|
|
|
230
|
-
**Monorepos with
|
|
231
|
-
than stopping at the first, and each gate carries the file pattern it
|
|
232
|
-
JS + Python repo gets four gates, and a turn that touched only `.py` files
|
|
233
|
-
two and skips the JS two entirely. Typechecks always run before tests, and
|
|
234
|
-
stops the rest.
|
|
230
|
+
**Monorepos work, including the ones with nothing at the root.** Detection collects every
|
|
231
|
+
gate it finds rather than stopping at the first, and each gate carries the file pattern it
|
|
232
|
+
applies to — so a JS + Python repo gets four gates, and a turn that touched only `.py` files
|
|
233
|
+
runs the Python two and skips the JS two entirely. Typechecks always run before tests, and
|
|
234
|
+
the first red stops the rest.
|
|
235
|
+
|
|
236
|
+
When the root has no manifest, detection looks two levels down for `package.json` /
|
|
237
|
+
`pyproject.toml` / `pytest.ini` — the `web/` + `api/`, `apps/*`, `packages/*` layouts — and
|
|
238
|
+
scopes each gate to that folder's files, running the command from inside it. Nested gates
|
|
239
|
+
are only added for the kind the root did not already provide, so a repo that works today
|
|
240
|
+
keeps working.
|
|
235
241
|
|
|
236
242
|
Setting `GAUNTLET_TEST` or `GAUNTLET_TYPECHECK` switches the repo to explicit mode:
|
|
237
243
|
auto-detection is off and only what you set runs.
|
|
@@ -298,6 +304,8 @@ commands/ Slash commands installed to .claude/commands/
|
|
|
298
304
|
agents/ Subagent definitions — commands declare which they need via requires-agents
|
|
299
305
|
scripts/ validate-skills.mjs — checks skill length caps and reference integrity
|
|
300
306
|
gauntlet.sh — the Stop-hook verification gates, embedded by the CLI
|
|
307
|
+
gauntlet-selftest.sh — 15 behavioural tests for the hook (runs on pre-push)
|
|
308
|
+
gauntlet-survey.sh — what the hook would do in every repo under a directory
|
|
301
309
|
cli/ The npm installer (npx @spardutti/claude-skills); version in cli/package.json
|
|
302
310
|
.husky/ pre-push hook running the skill validator
|
|
303
311
|
package.json Private dev-tooling package (claude-skills-dev) — not the published one
|
|
@@ -311,6 +319,11 @@ Skills live in `skills/<name>/SKILL.md`. Authoring conventions are in [CLAUDE.md
|
|
|
311
319
|
- `SKILL.md` ≤ 350 lines; reference files ≤ 500 and need a `## Contents` TOC past 100 lines.
|
|
312
320
|
- References are one level deep — `SKILL.md` links them, they don't link each other.
|
|
313
321
|
- `npm run validate-skills` enforces this; it also runs on `pre-push`.
|
|
322
|
+
- Changing `scripts/gauntlet.sh`? Run `bash scripts/gauntlet-selftest.sh` (also on `pre-push`),
|
|
323
|
+
and run `bash scripts/gauntlet-survey.sh ~/projects` before releasing. Detection is where
|
|
324
|
+
this hook keeps breaking, because real repos are shaped in ways invented test repos aren't —
|
|
325
|
+
the survey is a dry run that reports what each of your repos would get without executing
|
|
326
|
+
anything. Read the rows that say `nothing`.
|
|
314
327
|
|
|
315
328
|
To get the validator on **every turn** instead of only on push, drop this into
|
|
316
329
|
this repo's `.claude/gauntlet.conf` — `.claude/` is gitignored, so each contributor adds it
|
package/bin/cli.mjs
CHANGED
|
@@ -158,10 +158,12 @@ async function main() {
|
|
|
158
158
|
await setupClaudeMd();
|
|
159
159
|
|
|
160
160
|
// Report what the gauntlet will actually run, so a silent no-op is visible.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
161
|
+
// This asks the installed hook itself — never a second copy of its logic.
|
|
162
|
+
const gates = await detectStack(CWD);
|
|
163
|
+
if (gates) {
|
|
164
|
+
for (const gate of gates.split(";").map((g) => g.trim()).filter(Boolean)) {
|
|
165
|
+
console.log(` ${chalk.dim(`Gauntlet gate: ${gate}`)}`);
|
|
166
|
+
}
|
|
165
167
|
} else {
|
|
166
168
|
console.log(` ${chalk.yellow("!")} ${chalk.dim("Gauntlet found no test runner here — it will stay asleep.")}`);
|
|
167
169
|
console.log();
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, writeFile, readFile, chmod, unlink } from "node:fs/promises";
|
|
2
2
|
import { join, resolve, dirname } from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
3
4
|
|
|
4
5
|
// PreToolUse gate on Write|Edit|MultiEdit. Blocks the tool call unless
|
|
5
6
|
// a per-session marker file exists at /tmp/claude-skill-gate-<SESSION_ID>.
|
|
@@ -236,18 +237,21 @@ if [ -n "$GAUNTLET_OFF" ]; then quit "skipped: GAUNTLET_OFF is set"; fi
|
|
|
236
237
|
# ---------------------------------------------------------------- skip rule 1
|
|
237
238
|
CHANGED=$( { git diff --name-only HEAD 2>/dev/null; \\
|
|
238
239
|
git ls-files --others --exclude-standard 2>/dev/null; } | sort -u )
|
|
239
|
-
if [ -z "$CHANGED" ]; then quit "skipped: no changed files"; fi
|
|
240
|
+
if [ -z "$CHANGED" ] && [ -z "\${GAUNTLET_DRYRUN:-}" ]; then quit "skipped: no changed files"; fi
|
|
240
241
|
|
|
241
242
|
# ---------------------------------------------------------------- skip rule 2
|
|
242
243
|
FILES_NL=$(printf '%s\\n' "$CHANGED" | grep -E "\\.($GAUNTLET_CODE_EXT)$" || true)
|
|
243
|
-
if [ -z "$FILES_NL" ]
|
|
244
|
+
if [ -z "$FILES_NL" ] && [ -z "\${GAUNTLET_DRYRUN:-}" ]; then
|
|
245
|
+
quit "skipped: no changed code files (docs/config only)"
|
|
246
|
+
fi
|
|
244
247
|
|
|
245
248
|
# ---------------------------------------------------------------- skip rule 3
|
|
246
249
|
# The repo path is part of the hash: one session can touch several repos, and two
|
|
247
250
|
# of them can legitimately hold an identical diff.
|
|
248
251
|
DIFF_HASH=$( { printf '%s\\n' "$PROJECT_DIR"; git diff HEAD 2>/dev/null; printf '%s\\n' "$CHANGED"; } \\
|
|
249
252
|
| git hash-object --stdin 2>/dev/null )
|
|
250
|
-
if [ -f "$MARKER" ] && [ "$(cat "$MARKER")" = "$DIFF_HASH" ]
|
|
253
|
+
if [ -f "$MARKER" ] && [ "$(cat "$MARKER")" = "$DIFF_HASH" ] \\
|
|
254
|
+
&& [ -z "\${GAUNTLET_DRYRUN:-}" ]; then
|
|
251
255
|
quit "skipped: this diff already passed"
|
|
252
256
|
fi
|
|
253
257
|
|
|
@@ -271,7 +275,50 @@ add_gate() { # kind (typecheck|tests), file regex, command
|
|
|
271
275
|
fi
|
|
272
276
|
}
|
|
273
277
|
|
|
274
|
-
JS_EXT='
|
|
278
|
+
JS_EXT='(ts|tsx|js|jsx|mjs|cjs)'
|
|
279
|
+
|
|
280
|
+
# Builds the gates for one directory. "" is the repo root; anything else is a
|
|
281
|
+
# nested project, and its gates match only files under that path. Commands run
|
|
282
|
+
# from inside the directory, which is why the file list is passed as absolute
|
|
283
|
+
# paths — a relative path would not survive the cd.
|
|
284
|
+
add_dir_gates() {
|
|
285
|
+
d="$1"
|
|
286
|
+
if [ -n "$d" ]; then pre="^$d/.*\\\\." ; run="cd $d && " ; else pre='\\.' ; run="" ; fi
|
|
287
|
+
have_tc=""; have_test=""
|
|
288
|
+
[ -n "$d" ] && [ -n "\${ROOT_TC:-}" ] && have_tc="skip"
|
|
289
|
+
[ -n "$d" ] && [ -n "\${ROOT_TEST:-}" ] && have_test="skip"
|
|
290
|
+
|
|
291
|
+
if [ -f "$d\${d:+/}package.json" ]; then
|
|
292
|
+
pkg="$d\${d:+/}package.json"
|
|
293
|
+
if [ -z "$have_tc" ]; then
|
|
294
|
+
if grep -q '"typecheck"[[:space:]]*:' "$pkg"; then
|
|
295
|
+
add_gate typecheck "$pre($JS_EXT)\\$" "\${run}npm run --silent typecheck"
|
|
296
|
+
elif [ -f "$d\${d:+/}tsconfig.json" ]; then
|
|
297
|
+
add_gate typecheck "$pre(ts|tsx)\\$" "\${run}npx --no-install tsc --noEmit"
|
|
298
|
+
fi
|
|
299
|
+
fi
|
|
300
|
+
if [ -z "$have_test" ]; then
|
|
301
|
+
if grep -q '"vitest"' "$pkg"; then
|
|
302
|
+
add_gate tests "$pre($JS_EXT)\\$" "\${run}npx --no-install vitest related --run \\$FILES"
|
|
303
|
+
elif grep -q '"jest"' "$pkg"; then
|
|
304
|
+
add_gate tests "$pre($JS_EXT)\\$" "\${run}npx --no-install jest --findRelatedTests \\$FILES --passWithNoTests"
|
|
305
|
+
elif grep -q '"test"[[:space:]]*:' "$pkg"; then
|
|
306
|
+
add_gate tests "$pre($JS_EXT)\\$" "\${run}CI=1 npm run --silent test"
|
|
307
|
+
fi
|
|
308
|
+
fi
|
|
309
|
+
fi
|
|
310
|
+
|
|
311
|
+
if [ -f "$d\${d:+/}pyproject.toml" ] || [ -f "$d\${d:+/}pytest.ini" ] || [ -f "$d\${d:+/}setup.cfg" ]; then
|
|
312
|
+
if [ -z "$have_tc" ]; then
|
|
313
|
+
if [ -f "$d\${d:+/}mypy.ini" ] || grep -qs 'tool.mypy' "$d\${d:+/}pyproject.toml"; then
|
|
314
|
+
add_gate typecheck "\${pre}py\\$" "\${run}python -m mypy ."
|
|
315
|
+
fi
|
|
316
|
+
fi
|
|
317
|
+
if [ -z "$have_test" ]; then
|
|
318
|
+
add_gate tests "\${pre}py\\$" "\${run}python -m pytest -q"
|
|
319
|
+
fi
|
|
320
|
+
fi
|
|
321
|
+
}
|
|
275
322
|
|
|
276
323
|
detect() {
|
|
277
324
|
# Explicit config wins outright: auto-detection is off for the whole repo.
|
|
@@ -285,37 +332,33 @@ detect() {
|
|
|
285
332
|
return
|
|
286
333
|
fi
|
|
287
334
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
fi
|
|
307
|
-
|
|
308
|
-
if [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f setup.cfg ]; then
|
|
309
|
-
if [ -f mypy.ini ] || grep -qs 'tool.mypy' pyproject.toml; then
|
|
310
|
-
add_gate typecheck '\\.py$' 'python -m mypy .'
|
|
311
|
-
fi
|
|
312
|
-
add_gate tests '\\.py$' 'python -m pytest -q'
|
|
313
|
-
fi
|
|
335
|
+
add_dir_gates ""
|
|
336
|
+
|
|
337
|
+
# Many repos keep no manifest at the root — the code lives in web/, api/,
|
|
338
|
+
# backend/, packages/*. Look two levels down, but only for the kind of gate the
|
|
339
|
+
# root did not already provide, so a repo that works today keeps working.
|
|
340
|
+
ROOT_TC="$TC_GATES"
|
|
341
|
+
ROOT_TEST="$TEST_GATES"
|
|
342
|
+
while IFS= read -r manifest; do
|
|
343
|
+
[ -z "$manifest" ] && continue
|
|
344
|
+
d=$(dirname "$manifest")
|
|
345
|
+
d=\${d#./}
|
|
346
|
+
[ "$d" = "." ] && continue
|
|
347
|
+
add_dir_gates "$d"
|
|
348
|
+
done <<< "$(find . -mindepth 2 -maxdepth 3 \\
|
|
349
|
+
\\( -name package.json -o -name pyproject.toml -o -name pytest.ini \\) \\
|
|
350
|
+
-not -path '*/node_modules/*' -not -path '*/.git/*' \\
|
|
351
|
+
-not -path '*/dist/*' -not -path '*/build/*' \\
|
|
352
|
+
-not -path '*/.venv/*' -not -path '*/venv/*' 2>/dev/null | sort)"
|
|
314
353
|
}
|
|
315
354
|
detect
|
|
316
355
|
|
|
317
356
|
# Typechecks before tests — cheapest first, so the slow gate is usually unpaid.
|
|
318
357
|
GATES="$TC_GATES$TEST_GATES"
|
|
358
|
+
if [ -n "\${GAUNTLET_DRYRUN:-}" ]; then
|
|
359
|
+
if [ -z "$GATES" ]; then quit "would run: nothing — no gates detected"; fi
|
|
360
|
+
quit "would run: $(printf '%s' "$GATES" | cut -f1,3 | tr '\\t' ' ' | tr '\\n' '; ')"
|
|
361
|
+
fi
|
|
319
362
|
if [ -z "$GATES" ]; then
|
|
320
363
|
if [ -n "$SKIP_NOTE" ]; then quit "skipped: $SKIP_NOTE"; fi
|
|
321
364
|
quit "skipped: no gates detected for this repo"
|
|
@@ -338,7 +381,9 @@ while IFS=$'\\t' read -r GKIND GEXT GCMD; do
|
|
|
338
381
|
[ -z "$GFILES" ] && continue
|
|
339
382
|
# $FILES is substituted as a real argument list, not a space-joined string —
|
|
340
383
|
# word splitting would tear "src/my folder/a.ts" into two bogus paths.
|
|
341
|
-
mapfile -t
|
|
384
|
+
mapfile -t REL_ARR <<< "$GFILES"
|
|
385
|
+
FILE_ARR=()
|
|
386
|
+
for f in "\${REL_ARR[@]}"; do FILE_ARR+=("$PROJECT_DIR/$f"); done
|
|
342
387
|
export FILES=$(printf '%s' "$GFILES" | tr '\\n' ' ')
|
|
343
388
|
GCMD_RUN=\${GCMD//"$PAT_BRACE"/$FILES_REPL}
|
|
344
389
|
GCMD_RUN=\${GCMD_RUN//"$PAT_PLAIN"/$FILES_REPL}
|
|
@@ -347,7 +392,7 @@ while IFS=$'\\t' read -r GKIND GEXT GCMD; do
|
|
|
347
392
|
if [ $? -ne 0 ]; then
|
|
348
393
|
# A gate whose tool is not installed is a missing gate, not a failing one.
|
|
349
394
|
# Blocking on it would wall off every turn until the user installs something.
|
|
350
|
-
if printf '%s' "$GATE_OUT" | grep -qiE 'command not found|no module named|cannot find module|could not determine executable|canceled due to missing packages|is not recognized'; then
|
|
395
|
+
if printf '%s' "$GATE_OUT" | grep -qiE 'command not found|no module named|cannot find module|could not determine executable|canceled due to missing packages|missing script|is not recognized'; then
|
|
351
396
|
SKIP_NOTE="the $GKIND tool is not installed"
|
|
352
397
|
RAN="\${RAN%"$GKIND "}"
|
|
353
398
|
continue
|
|
@@ -425,34 +470,28 @@ const GAUNTLET_FILENAME = "gauntlet.sh";
|
|
|
425
470
|
const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
|
|
426
471
|
const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
|
|
427
472
|
|
|
428
|
-
//
|
|
429
|
-
//
|
|
473
|
+
// Asks the installed hook what it would run, rather than reimplementing its
|
|
474
|
+
// detection here. A second copy drifts: the JS one reported "no test runner"
|
|
475
|
+
// for repos the shell script had already learned to handle.
|
|
430
476
|
export async function detectStack(targetDir = process.cwd()) {
|
|
431
477
|
const dir = resolve(targetDir);
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
449
|
-
if (!test && (await has("pyproject.toml") || await has("pytest.ini") || await has("setup.cfg"))) {
|
|
450
|
-
test = "pytest";
|
|
451
|
-
if (await has("mypy.ini") || (await read("pyproject.toml")).includes("tool.mypy")) {
|
|
452
|
-
typecheck = "mypy";
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
return { test, typecheck };
|
|
478
|
+
const hook = join(dir, ".claude", "hooks", GAUNTLET_FILENAME);
|
|
479
|
+
return new Promise((done) => {
|
|
480
|
+
const child = spawn("bash", [hook], {
|
|
481
|
+
cwd: dir,
|
|
482
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: dir, GAUNTLET_DEBUG: "1", GAUNTLET_DRYRUN: "1" },
|
|
483
|
+
});
|
|
484
|
+
let err = "";
|
|
485
|
+
child.stderr.on("data", (c) => { err += c; });
|
|
486
|
+
child.on("error", () => done(null));
|
|
487
|
+
child.on("close", () => {
|
|
488
|
+
const line = err.split("\n").find((l) => l.startsWith("gauntlet: would run:"));
|
|
489
|
+
if (!line) return done(null);
|
|
490
|
+
const gates = line.slice("gauntlet: would run:".length).trim();
|
|
491
|
+
done(gates && !gates.startsWith("nothing") ? gates : null);
|
|
492
|
+
});
|
|
493
|
+
child.stdin.end('{"session_id":"install"}');
|
|
494
|
+
});
|
|
456
495
|
}
|
|
457
496
|
|
|
458
497
|
// Written only when detection finds nothing — otherwise the hook auto-detects and
|