@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 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 more than one stack work.** Detection collects every gate it finds rather
231
- than stopping at the first, and each gate carries the file pattern it applies to — so a
232
- JS + Python repo gets four gates, and a turn that touched only `.py` files runs the Python
233
- two and skips the JS two entirely. Typechecks always run before tests, and the first red
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
- const { test, typecheck } = await detectStack(CWD);
162
- if (test) {
163
- const found = [typecheck, test].filter(Boolean).join(" + ");
164
- console.log(` ${chalk.dim(`Gauntlet detected ${found} — it will run those on changed files.`)}`);
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();
@@ -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" ]; then quit "skipped: no changed code files (docs/config only)"; fi
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" ]; then
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='\\.(ts|tsx|js|jsx|mjs|cjs)$'
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
- # Without node_modules every npm/npx gate fails on a missing binary, which
289
- # would block every turn in a fresh clone. That is a missing tool, not a red.
290
- if [ -f package.json ] && [ ! -d node_modules ]; then
291
- SKIP_NOTE="node_modules is missing — run your install first"
292
- elif [ -f package.json ]; then
293
- # Prefer the repo's own typecheck script. In a workspace repo the root
294
- # tsconfig is often solution-style (references only), where bare
295
- # \`tsc --noEmit\` does not mean what \`tsc -b\` means.
296
- if grep -q '"typecheck"[[:space:]]*:' package.json; then
297
- add_gate typecheck "$JS_EXT" 'npm run --silent typecheck'
298
- elif [ -f tsconfig.json ]; then
299
- add_gate typecheck '\\.(ts|tsx)$' 'npx --no-install tsc --noEmit'
300
- fi
301
- if grep -q '"vitest"' package.json; then
302
- add_gate tests "$JS_EXT" 'npx --no-install vitest related --run $FILES'
303
- elif grep -q '"jest"' package.json; then
304
- add_gate tests "$JS_EXT" 'npx --no-install jest --findRelatedTests $FILES --passWithNoTests'
305
- fi
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 FILE_ARR <<< "$GFILES"
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
- // Mirrors the detect() in scripts/gauntlet.sh. Kept in sync by hand: this one
429
- // only reports what the hook will find, it never decides anything.
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 has = async (f) => {
433
- try { await readFile(join(dir, f), "utf-8"); return true; } catch { return false; }
434
- };
435
- const read = async (f) => {
436
- try { return await readFile(join(dir, f), "utf-8"); } catch { return ""; }
437
- };
438
-
439
- let test = null;
440
- let typecheck = null;
441
-
442
- const pkg = await read("package.json");
443
- if (pkg) {
444
- if (pkg.includes('"vitest"')) test = "vitest";
445
- else if (pkg.includes('"jest"')) test = "jest";
446
- if (/"typecheck"\s*:/.test(pkg)) typecheck = "npm run typecheck";
447
- else if (await has("tsconfig.json")) typecheck = "tsc";
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.10.0",
3
+ "version": "2.11.1",
4
4
  "description": "CLI to install Claude Code skills from the claude-skills collection",
5
5
  "type": "module",
6
6
  "bin": {