@spardutti/claude-skills 2.10.0 → 2.11.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/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
@@ -236,18 +236,21 @@ if [ -n "$GAUNTLET_OFF" ]; then quit "skipped: GAUNTLET_OFF is set"; fi
236
236
  # ---------------------------------------------------------------- skip rule 1
237
237
  CHANGED=$( { git diff --name-only HEAD 2>/dev/null; \\
238
238
  git ls-files --others --exclude-standard 2>/dev/null; } | sort -u )
239
- if [ -z "$CHANGED" ]; then quit "skipped: no changed files"; fi
239
+ if [ -z "$CHANGED" ] && [ -z "\${GAUNTLET_DRYRUN:-}" ]; then quit "skipped: no changed files"; fi
240
240
 
241
241
  # ---------------------------------------------------------------- skip rule 2
242
242
  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
243
+ if [ -z "$FILES_NL" ] && [ -z "\${GAUNTLET_DRYRUN:-}" ]; then
244
+ quit "skipped: no changed code files (docs/config only)"
245
+ fi
244
246
 
245
247
  # ---------------------------------------------------------------- skip rule 3
246
248
  # The repo path is part of the hash: one session can touch several repos, and two
247
249
  # of them can legitimately hold an identical diff.
248
250
  DIFF_HASH=$( { printf '%s\\n' "$PROJECT_DIR"; git diff HEAD 2>/dev/null; printf '%s\\n' "$CHANGED"; } \\
249
251
  | git hash-object --stdin 2>/dev/null )
250
- if [ -f "$MARKER" ] && [ "$(cat "$MARKER")" = "$DIFF_HASH" ]; then
252
+ if [ -f "$MARKER" ] && [ "$(cat "$MARKER")" = "$DIFF_HASH" ] \\
253
+ && [ -z "\${GAUNTLET_DRYRUN:-}" ]; then
251
254
  quit "skipped: this diff already passed"
252
255
  fi
253
256
 
@@ -271,7 +274,50 @@ add_gate() { # kind (typecheck|tests), file regex, command
271
274
  fi
272
275
  }
273
276
 
274
- JS_EXT='\\.(ts|tsx|js|jsx|mjs|cjs)$'
277
+ JS_EXT='(ts|tsx|js|jsx|mjs|cjs)'
278
+
279
+ # Builds the gates for one directory. "" is the repo root; anything else is a
280
+ # nested project, and its gates match only files under that path. Commands run
281
+ # from inside the directory, which is why the file list is passed as absolute
282
+ # paths — a relative path would not survive the cd.
283
+ add_dir_gates() {
284
+ d="$1"
285
+ if [ -n "$d" ]; then pre="^$d/.*\\\\." ; run="cd $d && " ; else pre='\\.' ; run="" ; fi
286
+ have_tc=""; have_test=""
287
+ [ -n "$d" ] && [ -n "\${ROOT_TC:-}" ] && have_tc="skip"
288
+ [ -n "$d" ] && [ -n "\${ROOT_TEST:-}" ] && have_test="skip"
289
+
290
+ if [ -f "$d\${d:+/}package.json" ]; then
291
+ pkg="$d\${d:+/}package.json"
292
+ if [ -z "$have_tc" ]; then
293
+ if grep -q '"typecheck"[[:space:]]*:' "$pkg"; then
294
+ add_gate typecheck "$pre($JS_EXT)\\$" "\${run}npm run --silent typecheck"
295
+ elif [ -f "$d\${d:+/}tsconfig.json" ]; then
296
+ add_gate typecheck "$pre(ts|tsx)\\$" "\${run}npx --no-install tsc --noEmit"
297
+ fi
298
+ fi
299
+ if [ -z "$have_test" ]; then
300
+ if grep -q '"vitest"' "$pkg"; then
301
+ add_gate tests "$pre($JS_EXT)\\$" "\${run}npx --no-install vitest related --run \\$FILES"
302
+ elif grep -q '"jest"' "$pkg"; then
303
+ add_gate tests "$pre($JS_EXT)\\$" "\${run}npx --no-install jest --findRelatedTests \\$FILES --passWithNoTests"
304
+ elif grep -q '"test"[[:space:]]*:' "$pkg"; then
305
+ add_gate tests "$pre($JS_EXT)\\$" "\${run}CI=1 npm run --silent test"
306
+ fi
307
+ fi
308
+ fi
309
+
310
+ if [ -f "$d\${d:+/}pyproject.toml" ] || [ -f "$d\${d:+/}pytest.ini" ] || [ -f "$d\${d:+/}setup.cfg" ]; then
311
+ if [ -z "$have_tc" ]; then
312
+ if [ -f "$d\${d:+/}mypy.ini" ] || grep -qs 'tool.mypy' "$d\${d:+/}pyproject.toml"; then
313
+ add_gate typecheck "\${pre}py\\$" "\${run}python -m mypy ."
314
+ fi
315
+ fi
316
+ if [ -z "$have_test" ]; then
317
+ add_gate tests "\${pre}py\\$" "\${run}python -m pytest -q"
318
+ fi
319
+ fi
320
+ }
275
321
 
276
322
  detect() {
277
323
  # Explicit config wins outright: auto-detection is off for the whole repo.
@@ -285,37 +331,33 @@ detect() {
285
331
  return
286
332
  fi
287
333
 
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
334
+ add_dir_gates ""
335
+
336
+ # Many repos keep no manifest at the root — the code lives in web/, api/,
337
+ # backend/, packages/*. Look two levels down, but only for the kind of gate the
338
+ # root did not already provide, so a repo that works today keeps working.
339
+ ROOT_TC="$TC_GATES"
340
+ ROOT_TEST="$TEST_GATES"
341
+ while IFS= read -r manifest; do
342
+ [ -z "$manifest" ] && continue
343
+ d=$(dirname "$manifest")
344
+ d=\${d#./}
345
+ [ "$d" = "." ] && continue
346
+ add_dir_gates "$d"
347
+ done <<< "$(find . -mindepth 2 -maxdepth 3 \\
348
+ \\( -name package.json -o -name pyproject.toml -o -name pytest.ini \\) \\
349
+ -not -path '*/node_modules/*' -not -path '*/.git/*' \\
350
+ -not -path '*/dist/*' -not -path '*/build/*' \\
351
+ -not -path '*/.venv/*' -not -path '*/venv/*' 2>/dev/null | sort)"
314
352
  }
315
353
  detect
316
354
 
317
355
  # Typechecks before tests — cheapest first, so the slow gate is usually unpaid.
318
356
  GATES="$TC_GATES$TEST_GATES"
357
+ if [ -n "\${GAUNTLET_DRYRUN:-}" ]; then
358
+ if [ -z "$GATES" ]; then quit "would run: nothing — no gates detected"; fi
359
+ quit "would run: $(printf '%s' "$GATES" | cut -f1,3 | tr '\\t' ' ' | tr '\\n' '; ')"
360
+ fi
319
361
  if [ -z "$GATES" ]; then
320
362
  if [ -n "$SKIP_NOTE" ]; then quit "skipped: $SKIP_NOTE"; fi
321
363
  quit "skipped: no gates detected for this repo"
@@ -338,7 +380,9 @@ while IFS=$'\\t' read -r GKIND GEXT GCMD; do
338
380
  [ -z "$GFILES" ] && continue
339
381
  # $FILES is substituted as a real argument list, not a space-joined string —
340
382
  # word splitting would tear "src/my folder/a.ts" into two bogus paths.
341
- mapfile -t FILE_ARR <<< "$GFILES"
383
+ mapfile -t REL_ARR <<< "$GFILES"
384
+ FILE_ARR=()
385
+ for f in "\${REL_ARR[@]}"; do FILE_ARR+=("$PROJECT_DIR/$f"); done
342
386
  export FILES=$(printf '%s' "$GFILES" | tr '\\n' ' ')
343
387
  GCMD_RUN=\${GCMD//"$PAT_BRACE"/$FILES_REPL}
344
388
  GCMD_RUN=\${GCMD_RUN//"$PAT_PLAIN"/$FILES_REPL}
@@ -347,7 +391,7 @@ while IFS=$'\\t' read -r GKIND GEXT GCMD; do
347
391
  if [ $? -ne 0 ]; then
348
392
  # A gate whose tool is not installed is a missing gate, not a failing one.
349
393
  # 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
394
+ 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
395
  SKIP_NOTE="the $GKIND tool is not installed"
352
396
  RAN="\${RAN%"$GKIND "}"
353
397
  continue
@@ -443,6 +487,9 @@ export async function detectStack(targetDir = process.cwd()) {
443
487
  if (pkg) {
444
488
  if (pkg.includes('"vitest"')) test = "vitest";
445
489
  else if (pkg.includes('"jest"')) test = "jest";
490
+ // A delegating monorepo root has no runner as a direct dependency, but its
491
+ // own `test` script still runs the right thing.
492
+ else if (/"test"\s*:/.test(pkg)) test = "npm test";
446
493
  if (/"typecheck"\s*:/.test(pkg)) typecheck = "npm run typecheck";
447
494
  else if (await has("tsconfig.json")) typecheck = "tsc";
448
495
  }
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.0",
4
4
  "description": "CLI to install Claude Code skills from the claude-skills collection",
5
5
  "type": "module",
6
6
  "bin": {