@spardutti/claude-skills 2.8.1 → 2.10.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
@@ -47,7 +47,7 @@ The CLI will:
47
47
  2. Let you pick skills to install → `.claude/skills/`
48
48
  3. Let you pick commands to install → `.claude/commands/`
49
49
  4. Auto-install any subagents the selected commands declare → `.claude/agents/`
50
- 5. Optionally set up the **skill-evaluation hook** (recommended — see [How It Works](#how-it-works))
50
+ 5. Optionally set up the **hooks** — skill evaluation before edits, verification after (recommended — see [How It Works](#how-it-works))
51
51
 
52
52
  ## Skill Catalog
53
53
 
@@ -132,11 +132,17 @@ npx @spardutti/claude-skills --sync
132
132
 
133
133
  After installing skills, the CLI offers to set up a hook that **guarantees** Claude evaluates your skills before writing code — instead of a soft reminder it can ignore.
134
134
 
135
- It installs two hooks and appends a rule to your `CLAUDE.md`:
135
+ It installs four hooks and appends a rule to your `CLAUDE.md`. Three guard the
136
+ **front** — nothing is written until your skills are considered:
136
137
 
137
138
  - `skill-gate.sh` — a `PreToolUse` gate on `Write|Edit|MultiEdit`
139
+ - `skill-application-gate.sh` — a second `PreToolUse` gate requiring each loaded skill to be applied, not just read
138
140
  - `skill-gate-automark.sh` — a `PostToolUse` hook on `Skill` that clears the gate
139
141
 
142
+ The fourth guards the **back** — nothing finishes until the code passes:
143
+
144
+ - `gauntlet.sh` — a `Stop` hook running the fast gates on your changed files (see [Automatic Verification](#automatic-verification-gauntlet))
145
+
140
146
  <details>
141
147
  <summary>How the gate works</summary>
142
148
 
@@ -151,7 +157,9 @@ It registers in `.claude/settings.json`:
151
157
  "hooks": {
152
158
  "PreToolUse": [
153
159
  { "matcher": "Write|Edit|MultiEdit", "hooks": [
154
- { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-gate.sh" } ] }
160
+ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-gate.sh" } ] },
161
+ { "matcher": "Write|Edit|MultiEdit", "hooks": [
162
+ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-application-gate.sh" } ] }
155
163
  ],
156
164
  "PostToolUse": [
157
165
  { "matcher": "Skill", "hooks": [
@@ -163,6 +171,109 @@ It registers in `.claude/settings.json`:
163
171
 
164
172
  </details>
165
173
 
174
+ ### Automatic Verification (Gauntlet)
175
+
176
+ The same install adds `gauntlet.sh` — a `Stop` hook that runs the cheap, deterministic
177
+ gates on your changed files every time Claude finishes a turn. Green is silent; red returns
178
+ `{"decision":"continue"}` so the turn doesn't end, handing the failure back to Claude to
179
+ fix before you ever see it.
180
+
181
+ Keep the gates fast for a reason beyond patience: a `command` hook that reaches its timeout
182
+ (600s by default) is cancelled and its **output discarded**, so the turn ends as if the hook
183
+ never ran. A stalled gate is a silent pass, not a block.
184
+
185
+ Only fast gates live here (target: under ~30s). Mutation testing and deep review stay
186
+ manual — they belong to `/test-review` and `/deep-review`, not to every turn.
187
+
188
+ At install time the CLI reports what it found, so a silent no-op is never a surprise:
189
+
190
+ ```text
191
+ Gauntlet detected tsc + vitest — it will run those on changed files.
192
+ ```
193
+
194
+ If no test runner is detected it says so, and offers to write a `.claude/gauntlet.conf`
195
+ pointing at your test command. Detection covers vitest, jest, pytest, `tsc`, and `mypy`;
196
+ everything else needs that one config line.
197
+
198
+ <details>
199
+ <summary>How the gauntlet hook works</summary>
200
+
201
+ It runs a skip ladder first, cheapest check first, and quits at the first "no":
202
+
203
+ 1. no changed files (planning turns, questions)
204
+ 2. no changed **code** files (docs/config only)
205
+ 3. this exact diff already passed — the hash of a green run is cached per session and repo
206
+ 4. no gates detected for this repo
207
+
208
+ Rule 3 is what makes repeated `/ship` runs free: the gates run once per distinct diff.
209
+
210
+ Every run records why it ended in `/tmp/claude-gauntlet-<session>.why`, so a green run
211
+ and a skipped one are told apart without re-running the hook. `GAUNTLET_DEBUG=1` prints
212
+ the same line to stderr:
213
+
214
+ ```text
215
+ gauntlet: green: gates passed
216
+ gauntlet: skipped: no changed code files (docs/config only)
217
+ gauntlet: green: gates passed, but the runner matched 0 test files — the changed code was never executed
218
+ ```
219
+
220
+ That last one is the false green worth knowing about. A test runner given files it has no
221
+ tests for exits 0 and looks exactly like a pass — in a monorepo whose vitest workspace
222
+ excludes a package, **no** change in that package can ever be covered. The hook names it
223
+ instead of reporting green. Set `GAUNTLET_REQUIRE_TESTS=1` to make it block instead.
224
+
225
+ The stack is auto-detected: vitest / jest / pytest for tests, and for typechecking the
226
+ repo's own `typecheck` script if it has one, else bare `tsc --noEmit` or `mypy`. The script
227
+ is preferred because a workspace root `tsconfig.json` is often solution-style, where
228
+ `tsc --noEmit` does not mean what the repo's `tsc -b` means.
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.
235
+
236
+ Setting `GAUNTLET_TEST` or `GAUNTLET_TYPECHECK` switches the repo to explicit mode:
237
+ auto-detection is off and only what you set runs.
238
+
239
+ **A missing tool is a skip, not a red.** A fresh clone with no `node_modules`, or a venv
240
+ without pytest, would otherwise fail every gate and block every turn — so the hook reports
241
+ `skipped: node_modules is missing — run your install first` and gets out of the way. Only
242
+ a gate that actually ran and actually failed blocks. Override anything in
243
+ a config file — two are read, in this order, and the project's wins key by key:
244
+
245
+ ```text
246
+ ~/.claude/gauntlet.conf your defaults for every project
247
+ <project>/.claude/gauntlet.conf overrides for this one
248
+ ```
249
+
250
+ ```bash
251
+ GAUNTLET_OFF=1 # disable entirely
252
+ GAUNTLET_TYPECHECK='npm run check' # "" to skip the gate
253
+ GAUNTLET_TEST='npm test -- $FILES' # $FILES = changed code files
254
+ GAUNTLET_CODE_EXT='ts|tsx|py' # what counts as code
255
+ GAUNTLET_DEBUG=1 # print the outcome to stderr every run
256
+ GAUNTLET_REQUIRE_TESTS=1 # block when the runner matched 0 test files
257
+ ```
258
+
259
+ Set your house rules once in `~/.claude/gauntlet.conf`; only reach for the project file
260
+ where a repo does something unusual (or set `GAUNTLET_OFF=1` there to opt one out).
261
+
262
+ It registers in `.claude/settings.json`:
263
+
264
+ ```json
265
+ {
266
+ "hooks": {
267
+ "Stop": [
268
+ { "hooks": [
269
+ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/gauntlet.sh" } ] }
270
+ ]
271
+ }
272
+ }
273
+ ```
274
+
275
+ </details>
276
+
166
277
  <details>
167
278
  <summary>Manual install (without the CLI)</summary>
168
279
 
@@ -186,6 +297,7 @@ skills/ Skill playbooks — some are bundles (SKILL.md + on-demand refere
186
297
  commands/ Slash commands installed to .claude/commands/
187
298
  agents/ Subagent definitions — commands declare which they need via requires-agents
188
299
  scripts/ validate-skills.mjs — checks skill length caps and reference integrity
300
+ gauntlet.sh — the Stop-hook verification gates, embedded by the CLI
189
301
  cli/ The npm installer (npx @spardutti/claude-skills); version in cli/package.json
190
302
  .husky/ pre-push hook running the skill validator
191
303
  package.json Private dev-tooling package (claude-skills-dev) — not the published one
@@ -200,6 +312,19 @@ Skills live in `skills/<name>/SKILL.md`. Authoring conventions are in [CLAUDE.md
200
312
  - References are one level deep — `SKILL.md` links them, they don't link each other.
201
313
  - `npm run validate-skills` enforces this; it also runs on `pre-push`.
202
314
 
315
+ To get the validator on **every turn** instead of only on push, drop this into
316
+ this repo's `.claude/gauntlet.conf` — `.claude/` is gitignored, so each contributor adds it
317
+ locally. It has to be the project file, not `~/.claude/gauntlet.conf`: these settings are
318
+ only right for a skills repo:
319
+
320
+ ```bash
321
+ GAUNTLET_CODE_EXT='md|mjs|js|json|sh' # markdown IS the product here
322
+ GAUNTLET_TYPECHECK=''
323
+ GAUNTLET_TEST='node scripts/validate-skills.mjs'
324
+ ```
325
+
326
+ Without the `CODE_EXT` line the hook treats a skill edit as a docs-only change and skips.
327
+
203
328
  ## License
204
329
 
205
330
  MIT
package/bin/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { confirm } from "@inquirer/prompts";
3
+ import { confirm, input } from "@inquirer/prompts";
4
4
  import chalk from "chalk";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
@@ -8,7 +8,7 @@ import { dirname, join } from "node:path";
8
8
  import { fetchSkills, fetchCommands, fetchAgents } from "../lib/github.mjs";
9
9
  import { promptSkillSelection, promptCommandSelection, promptRemoval } from "../lib/prompt.mjs";
10
10
  import { installSkills, installCommands, installRequiredAgents } from "../lib/install.mjs";
11
- import { setupHook } from "../lib/setup-hook.mjs";
11
+ import { setupHook, detectStack, writeGauntletConf } from "../lib/setup-hook.mjs";
12
12
  import { setupClaudeMd } from "../lib/setup-claude-md.mjs";
13
13
  import {
14
14
  readManifest, writeManifest, computeOrphans, computeRemovals, scanInstalled, removeArtifacts,
@@ -143,17 +143,40 @@ async function main() {
143
143
  process.exit(0);
144
144
  }
145
145
 
146
- // --- Hook setup (only if skills were installed) ---
147
- if (selectedSkills.length > 0) {
146
+ // --- Hook setup ---
147
+ // Offered whenever anything was installed: the skill gates need skills, but the
148
+ // gauntlet guards any repo with a test runner, commands-only installs included.
149
+ if (selectedSkills.length > 0 || selectedCommands.length > 0) {
148
150
  console.log();
149
151
  const shouldSetup = await confirm({
150
- message: "Set up skill evaluation hook + CLAUDE.md rule? (Recommended)",
152
+ message: "Set up hooks — skill evaluation before edits, verification after? (Recommended)",
151
153
  default: true,
152
154
  });
153
155
  if (shouldSetup) {
154
156
  console.log();
155
157
  await setupHook();
156
158
  await setupClaudeMd();
159
+
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.`)}`);
165
+ } else {
166
+ console.log(` ${chalk.yellow("!")} ${chalk.dim("Gauntlet found no test runner here — it will stay asleep.")}`);
167
+ console.log();
168
+ const wantConf = await confirm({
169
+ message: "Point it at your test command now?",
170
+ default: false,
171
+ });
172
+ if (wantConf) {
173
+ const cmd = (await input({ message: "Test command:" })).trim();
174
+ if (cmd) {
175
+ const written = await writeGauntletConf(CWD, cmd);
176
+ console.log(` Config written: ${written.replace(CWD + "/", "")}`);
177
+ }
178
+ }
179
+ }
157
180
  }
158
181
  }
159
182
 
@@ -1,5 +1,5 @@
1
1
  import { mkdir, writeFile, readFile, chmod, unlink } from "node:fs/promises";
2
- import { join, resolve } from "node:path";
2
+ import { join, resolve, dirname } from "node:path";
3
3
 
4
4
  // PreToolUse gate on Write|Edit|MultiEdit. Blocks the tool call unless
5
5
  // a per-session marker file exists at /tmp/claude-skill-gate-<SESSION_ID>.
@@ -155,18 +155,329 @@ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision"
155
155
  exit 0
156
156
  `;
157
157
 
158
+ // Stop hook: fast deterministic gates on the changed diff. Silent when green,
159
+ // blocks the turn when a gate is red so the model fixes it before the user sees
160
+ // it. Canonical source: scripts/gauntlet.sh in the claude-skills repo.
161
+ const GAUNTLET_SCRIPT = `#!/usr/bin/env bash
162
+ # gauntlet.sh — Stop hook: fast deterministic gates on the changed diff.
163
+ #
164
+ # Runs when Claude finishes a turn. Silent when green. On a red gate it returns
165
+ # {"decision":"continue"} so the turn does not end, handing the failure back to
166
+ # Claude to fix before the user sees it.
167
+ #
168
+ # Two things the hook contract makes non-negotiable:
169
+ # - stop_hook_active must short-circuit, or a red gate loops forever.
170
+ # - A hook that hits its timeout (600s default for command hooks) is cancelled
171
+ # and its output discarded, so the turn ends as if the hook never ran. Keep
172
+ # the gates fast; that is a silent pass, not a block.
173
+ #
174
+ # Only cheap gates belong here (target: under ~30s). Mutation testing, deep
175
+ # review, and the spec checklist are the /gauntlet command's job, not this hook's.
176
+ #
177
+ # Skip ladder, cheapest first — quits at the first "no":
178
+ # 1. no changed files
179
+ # 2. no changed *code* files (docs/config only)
180
+ # 3. this exact diff already passed
181
+ # 4. no gates detected for this repo
182
+ #
183
+ # Config, both optional and both KEY=value files that get sourced in this order:
184
+ # ~/.claude/gauntlet.conf your defaults for every project
185
+ # <project>/.claude/gauntlet.conf overrides for this one; wins key by key
186
+ # GAUNTLET_OFF=1 disable entirely
187
+ # GAUNTLET_TYPECHECK="cmd" explicit typecheck command ("" to skip)
188
+ # GAUNTLET_TEST="cmd" explicit test command; $FILES = changed code files
189
+ # GAUNTLET_CODE_EXT="ts|tsx|py" override which extensions count as code
190
+ # GAUNTLET_DEBUG=1 print the outcome to stderr on every run
191
+ # GAUNTLET_REQUIRE_TESTS=1 block when a runner matched 0 test files
192
+ #
193
+ # Setting either command switches the whole repo to explicit mode — auto-detection
194
+ # is off, and only what you set runs.
195
+ #
196
+ # Every run records why it ended in /tmp/claude-gauntlet-<session>.why, so a green
197
+ # run and a skipped one are told apart without re-running the hook.
198
+
199
+ set -uo pipefail
200
+
201
+ INPUT=$(cat 2>/dev/null || true)
202
+
203
+ # Never re-block a turn we already blocked — this would loop forever.
204
+ case "$INPUT" in
205
+ *'"stop_hook_active":true'*) exit 0 ;;
206
+ esac
207
+
208
+ SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 \\
209
+ | sed 's/"session_id":"//; s/"$//')
210
+ if [ -z "$SESSION_ID" ]; then SESSION_ID="nosession"; fi
211
+ MARKER="/tmp/claude-gauntlet-$SESSION_ID"
212
+ WHY="$MARKER.why"
213
+
214
+ # Records why this run ended, so "green" and "never ran" stop looking identical.
215
+ quit() {
216
+ printf '%s\\n' "$1" > "$WHY" 2>/dev/null
217
+ if [ -n "\${GAUNTLET_DEBUG:-}" ]; then printf 'gauntlet: %s\\n' "$1" >&2; fi
218
+ exit 0
219
+ }
220
+
221
+ PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-}"
222
+ if [ -z "$PROJECT_DIR" ]; then PROJECT_DIR="$PWD"; fi
223
+ cd "$PROJECT_DIR" 2>/dev/null || quit "skipped: project dir unreadable"
224
+
225
+ git rev-parse --git-dir >/dev/null 2>&1 || quit "skipped: not a git repo"
226
+
227
+ GAUNTLET_OFF=""
228
+ GAUNTLET_TYPECHECK="__auto__"
229
+ GAUNTLET_TEST="__auto__"
230
+ GAUNTLET_CODE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|sql"
231
+ # Global defaults first, then the project's on top — project wins, key by key.
232
+ if [ -n "\${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ]; then . "$HOME/.claude/gauntlet.conf"; fi
233
+ if [ -f "$PROJECT_DIR/.claude/gauntlet.conf" ]; then . "$PROJECT_DIR/.claude/gauntlet.conf"; fi
234
+ if [ -n "$GAUNTLET_OFF" ]; then quit "skipped: GAUNTLET_OFF is set"; fi
235
+
236
+ # ---------------------------------------------------------------- skip rule 1
237
+ CHANGED=$( { git diff --name-only HEAD 2>/dev/null; \\
238
+ git ls-files --others --exclude-standard 2>/dev/null; } | sort -u )
239
+ if [ -z "$CHANGED" ]; then quit "skipped: no changed files"; fi
240
+
241
+ # ---------------------------------------------------------------- skip rule 2
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
244
+
245
+ # ---------------------------------------------------------------- skip rule 3
246
+ # The repo path is part of the hash: one session can touch several repos, and two
247
+ # of them can legitimately hold an identical diff.
248
+ DIFF_HASH=$( { printf '%s\\n' "$PROJECT_DIR"; git diff HEAD 2>/dev/null; printf '%s\\n' "$CHANGED"; } \\
249
+ | git hash-object --stdin 2>/dev/null )
250
+ if [ -f "$MARKER" ] && [ "$(cat "$MARKER")" = "$DIFF_HASH" ]; then
251
+ quit "skipped: this diff already passed"
252
+ fi
253
+
254
+ # ---------------------------------------------------------------- skip rule 4
255
+ # A monorepo can hold more than one stack, so detection collects every gate it
256
+ # finds rather than stopping at the first. Each gate carries the file pattern it
257
+ # applies to, and is skipped entirely when the diff touched none of those files —
258
+ # so a Python-only change never runs the JS suite, and vice versa.
259
+ TC_GATES=""
260
+ TEST_GATES=""
261
+ SKIP_NOTE=""
262
+
263
+ add_gate() { # kind (typecheck|tests), file regex, command
264
+ LINE=$(printf '%s\\t%s\\t%s' "$1" "$2" "$3")
265
+ if [ "$1" = "typecheck" ]; then
266
+ TC_GATES="$TC_GATES$LINE
267
+ "
268
+ else
269
+ TEST_GATES="$TEST_GATES$LINE
270
+ "
271
+ fi
272
+ }
273
+
274
+ JS_EXT='\\.(ts|tsx|js|jsx|mjs|cjs)$'
275
+
276
+ detect() {
277
+ # Explicit config wins outright: auto-detection is off for the whole repo.
278
+ if [ "$GAUNTLET_TEST" != "__auto__" ] || [ "$GAUNTLET_TYPECHECK" != "__auto__" ]; then
279
+ if [ "$GAUNTLET_TYPECHECK" != "__auto__" ] && [ -n "$GAUNTLET_TYPECHECK" ]; then
280
+ add_gate typecheck '.' "$GAUNTLET_TYPECHECK"
281
+ fi
282
+ if [ "$GAUNTLET_TEST" != "__auto__" ] && [ -n "$GAUNTLET_TEST" ]; then
283
+ add_gate tests '.' "$GAUNTLET_TEST"
284
+ fi
285
+ return
286
+ fi
287
+
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
314
+ }
315
+ detect
316
+
317
+ # Typechecks before tests — cheapest first, so the slow gate is usually unpaid.
318
+ GATES="$TC_GATES$TEST_GATES"
319
+ if [ -z "$GATES" ]; then
320
+ if [ -n "$SKIP_NOTE" ]; then quit "skipped: $SKIP_NOTE"; fi
321
+ quit "skipped: no gates detected for this repo"
322
+ fi
323
+
324
+ # ------------------------------------------------------------------ the gates
325
+ FAILED=""
326
+ OUTPUT=""
327
+ NO_TEST_MATCH=""
328
+ RAN=""
329
+ # Kept in variables on purpose: a bare \`}\` inside a \${var//pat/repl} replacement
330
+ # closes the expansion early and silently mangles the command.
331
+ FILES_REPL='"\${FILE_ARR[@]}"'
332
+ PAT_BRACE='\${FILES}'
333
+ PAT_PLAIN='$FILES'
334
+
335
+ while IFS=$'\\t' read -r GKIND GEXT GCMD; do
336
+ [ -z "$GKIND" ] && continue
337
+ GFILES=$(printf '%s\\n' "$FILES_NL" | grep -E "$GEXT" || true)
338
+ [ -z "$GFILES" ] && continue
339
+ # $FILES is substituted as a real argument list, not a space-joined string —
340
+ # word splitting would tear "src/my folder/a.ts" into two bogus paths.
341
+ mapfile -t FILE_ARR <<< "$GFILES"
342
+ export FILES=$(printf '%s' "$GFILES" | tr '\\n' ' ')
343
+ GCMD_RUN=\${GCMD//"$PAT_BRACE"/$FILES_REPL}
344
+ GCMD_RUN=\${GCMD_RUN//"$PAT_PLAIN"/$FILES_REPL}
345
+ RAN="$RAN$GKIND "
346
+ GATE_OUT=$(eval "$GCMD_RUN" 2>&1)
347
+ if [ $? -ne 0 ]; then
348
+ # A gate whose tool is not installed is a missing gate, not a failing one.
349
+ # 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
351
+ SKIP_NOTE="the $GKIND tool is not installed"
352
+ RAN="\${RAN%"$GKIND "}"
353
+ continue
354
+ fi
355
+ FAILED="$GKIND"
356
+ OUTPUT="$GATE_OUT"
357
+ break
358
+ fi
359
+ # A runner handed files it has no tests for exits 0 and looks exactly like a
360
+ # pass. It is the loudest false green: the changed code was never executed.
361
+ if [ "$GKIND" = "tests" ] \\
362
+ && printf '%s' "$GATE_OUT" | grep -qiE 'no test files found|no tests found|no tests ran'; then
363
+ NO_TEST_MATCH="$GCMD"
364
+ OUTPUT="$GATE_OUT"
365
+ fi
366
+ done <<< "$GATES"
367
+
368
+ if [ -z "$FAILED" ] && [ -n "$NO_TEST_MATCH" ] && [ -n "\${GAUNTLET_REQUIRE_TESTS:-}" ]; then
369
+ FAILED="tests"
370
+ OUTPUT="$OUTPUT
371
+
372
+ The runner matched 0 test files for the changed code, so nothing was executed.
373
+ Write a test that covers the change, or unset GAUNTLET_REQUIRE_TESTS."
374
+ fi
375
+
376
+ if [ -z "$FAILED" ]; then
377
+ printf '%s' "$DIFF_HASH" > "$MARKER"
378
+ if [ -z "$RAN" ]; then
379
+ if [ -n "$SKIP_NOTE" ]; then quit "skipped: $SKIP_NOTE"; fi
380
+ quit "skipped: no gate applied to the changed files"
381
+ elif [ -n "$SKIP_NOTE" ]; then
382
+ quit "green: gates passed ($RAN) — but $SKIP_NOTE"
383
+ elif [ -n "$NO_TEST_MATCH" ]; then
384
+ quit "green: gates passed, but a runner matched 0 test files — that code was never executed"
385
+ fi
386
+ quit "green: gates passed ($RAN)"
387
+ fi
388
+
389
+ # ------------------------------------------------------------------ block red
390
+ # Last 60 lines only — the hook's reason is read by a model, not archived.
391
+ TAIL=$(printf '%s\\n' "$OUTPUT" | tail -60)
392
+ MSG="GAUNTLET FAILED: the $FAILED gate is red on the changed files.
393
+
394
+ Fix it before ending the turn. Do not weaken or delete a test to make this pass.
395
+ If the failure is unrelated to your change, say so explicitly and stop.
396
+
397
+ Changed code files:
398
+ $FILES
399
+
400
+ --- $FAILED output (last 60 lines) ---
401
+ $TAIL"
402
+
403
+ json_escape() {
404
+ local s="$1"
405
+ s="\${s//\\\\/\\\\\\\\}"
406
+ s="\${s//\\"/\\\\\\"}"
407
+ s="\${s//$'\\n'/\\\\n}"
408
+ s="\${s//$'\\r'/\\\\r}"
409
+ s="\${s//$'\\t'/\\\\t}"
410
+ printf '"%s"' "$s"
411
+ }
412
+
413
+ printf '%s\\n' "red: the $FAILED gate failed" > "$WHY" 2>/dev/null
414
+ if [ -n "\${GAUNTLET_DEBUG:-}" ]; then printf 'gauntlet: red: the %s gate failed\\n' "$FAILED" >&2; fi
415
+ # A Stop hook's decision is "continue" / "stop" / "escalate" — "block" is not a
416
+ # valid value and would be ignored, letting the turn end on a red gate.
417
+ printf '{"decision":"continue","reason":%s}\\n' "$(json_escape "$MSG")"
418
+ exit 0
419
+ `;
420
+
158
421
  const GATE_FILENAME = "skill-gate.sh";
159
422
  const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
160
423
  const APPLICATION_GATE_FILENAME = "skill-application-gate.sh";
424
+ const GAUNTLET_FILENAME = "gauntlet.sh";
161
425
  const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
162
426
  const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
163
427
 
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.
430
+ export async function detectStack(targetDir = process.cwd()) {
431
+ 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 };
456
+ }
457
+
458
+ // Written only when detection finds nothing — otherwise the hook auto-detects and
459
+ // no config file is needed.
460
+ export async function writeGauntletConf(targetDir, testCommand) {
461
+ const confPath = join(resolve(targetDir), ".claude", "gauntlet.conf");
462
+ await mkdir(dirname(confPath), { recursive: true });
463
+ await writeFile(confPath, [
464
+ "# Gauntlet config. Read after ~/.claude/gauntlet.conf, and wins over it.",
465
+ "# GAUNTLET_OFF=1 disables the hook in this project.",
466
+ "",
467
+ "GAUNTLET_TYPECHECK=''",
468
+ `GAUNTLET_TEST='${testCommand.replace(/'/g, "'\\''")}'`,
469
+ "",
470
+ ].join("\n"), { mode: 0o644 });
471
+ return confPath;
472
+ }
473
+
164
474
  export async function setupHook(targetDir = process.cwd()) {
165
475
  const resolved = resolve(targetDir);
166
476
  const hooksDir = join(resolved, ".claude", "hooks");
167
477
  const gatePath = join(hooksDir, GATE_FILENAME);
168
478
  const autoMarkPath = join(hooksDir, AUTO_MARK_FILENAME);
169
479
  const applicationGatePath = join(hooksDir, APPLICATION_GATE_FILENAME);
480
+ const gauntletPath = join(hooksDir, GAUNTLET_FILENAME);
170
481
  const settingsPath = join(resolved, ".claude", "settings.json");
171
482
 
172
483
  await mkdir(hooksDir, { recursive: true });
@@ -176,6 +487,8 @@ export async function setupHook(targetDir = process.cwd()) {
176
487
  await chmod(autoMarkPath, 0o755);
177
488
  await writeFile(applicationGatePath, APPLICATION_GATE_SCRIPT, { mode: 0o755 });
178
489
  await chmod(applicationGatePath, 0o755);
490
+ await writeFile(gauntletPath, GAUNTLET_SCRIPT, { mode: 0o755 });
491
+ await chmod(gauntletPath, 0o755);
179
492
 
180
493
  // Remove the legacy audit-runner hook file from prior installs (2.3.x).
181
494
  try {
@@ -252,10 +565,24 @@ export async function setupHook(targetDir = process.cwd()) {
252
565
  settings.hooks.PostToolUse = [autoMarkEntry];
253
566
  }
254
567
 
568
+ // Register Stop gauntlet hook.
569
+ const gauntletCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${GAUNTLET_FILENAME}`;
570
+ const gauntletEntry = { hooks: [{ type: "command", command: gauntletCommand }] };
571
+
572
+ if (Array.isArray(settings.hooks.Stop)) {
573
+ const exists = settings.hooks.Stop.some((entry) =>
574
+ entry.hooks?.some((h) => h.command?.endsWith(GAUNTLET_FILENAME))
575
+ );
576
+ if (!exists) settings.hooks.Stop.push(gauntletEntry);
577
+ } else {
578
+ settings.hooks.Stop = [gauntletEntry];
579
+ }
580
+
255
581
  await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o644 });
256
582
 
257
583
  console.log(` Hook installed: .claude/hooks/${GATE_FILENAME}`);
258
584
  console.log(` Hook installed: .claude/hooks/${AUTO_MARK_FILENAME}`);
259
585
  console.log(` Hook installed: .claude/hooks/${APPLICATION_GATE_FILENAME}`);
586
+ console.log(` Hook installed: .claude/hooks/${GAUNTLET_FILENAME}`);
260
587
  console.log(` Settings updated: .claude/settings.json`);
261
588
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.8.1",
3
+ "version": "2.10.0",
4
4
  "description": "CLI to install Claude Code skills from the claude-skills collection",
5
5
  "type": "module",
6
6
  "bin": {