@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 +18 -5
- package/lib/setup-hook.mjs +79 -32
- 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/lib/setup-hook.mjs
CHANGED
|
@@ -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" ]
|
|
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" ]
|
|
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='
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|