@spardutti/claude-skills 2.8.1 → 2.9.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 +89 -3
- package/bin/cli.mjs +28 -5
- package/lib/setup-hook.mjs +214 -1
- package/package.json +1 -1
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
|
|
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
|
|
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,70 @@ 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
|
|
178
|
+
blocks the turn and hands the failure back to Claude to fix before you ever see it.
|
|
179
|
+
|
|
180
|
+
Only fast gates live here (target: under ~30s). Mutation testing and deep review stay
|
|
181
|
+
manual — they belong to `/test-review` and `/deep-review`, not to every turn.
|
|
182
|
+
|
|
183
|
+
At install time the CLI reports what it found, so a silent no-op is never a surprise:
|
|
184
|
+
|
|
185
|
+
```text
|
|
186
|
+
Gauntlet detected tsc + vitest — it will run those on changed files.
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
If no test runner is detected it says so, and offers to write a `.claude/gauntlet.conf`
|
|
190
|
+
pointing at your test command. Detection covers vitest, jest, pytest, `tsc`, and `mypy`;
|
|
191
|
+
everything else needs that one config line.
|
|
192
|
+
|
|
193
|
+
<details>
|
|
194
|
+
<summary>How the gauntlet hook works</summary>
|
|
195
|
+
|
|
196
|
+
It runs a skip ladder first, cheapest check first, and quits at the first "no":
|
|
197
|
+
|
|
198
|
+
1. no changed files (planning turns, questions)
|
|
199
|
+
2. no changed **code** files (docs/config only)
|
|
200
|
+
3. this exact diff already passed — the hash of a green run is cached per session
|
|
201
|
+
4. no test runner detected in the repo
|
|
202
|
+
|
|
203
|
+
Rule 3 is what makes repeated `/ship` runs free: the gates run once per distinct diff.
|
|
204
|
+
|
|
205
|
+
The stack is auto-detected (vitest / jest / pytest, `tsc` / `mypy`). Override anything in
|
|
206
|
+
a config file — two are read, in this order, and the project's wins key by key:
|
|
207
|
+
|
|
208
|
+
```text
|
|
209
|
+
~/.claude/gauntlet.conf your defaults for every project
|
|
210
|
+
<project>/.claude/gauntlet.conf overrides for this one
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
GAUNTLET_OFF=1 # disable entirely
|
|
215
|
+
GAUNTLET_TYPECHECK='npm run check' # "" to skip the gate
|
|
216
|
+
GAUNTLET_TEST='npm test -- $FILES' # $FILES = changed code files
|
|
217
|
+
GAUNTLET_CODE_EXT='ts|tsx|py' # what counts as code
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Set your house rules once in `~/.claude/gauntlet.conf`; only reach for the project file
|
|
221
|
+
where a repo does something unusual (or set `GAUNTLET_OFF=1` there to opt one out).
|
|
222
|
+
|
|
223
|
+
It registers in `.claude/settings.json`:
|
|
224
|
+
|
|
225
|
+
```json
|
|
226
|
+
{
|
|
227
|
+
"hooks": {
|
|
228
|
+
"Stop": [
|
|
229
|
+
{ "hooks": [
|
|
230
|
+
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/gauntlet.sh" } ] }
|
|
231
|
+
]
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
</details>
|
|
237
|
+
|
|
166
238
|
<details>
|
|
167
239
|
<summary>Manual install (without the CLI)</summary>
|
|
168
240
|
|
|
@@ -186,6 +258,7 @@ skills/ Skill playbooks — some are bundles (SKILL.md + on-demand refere
|
|
|
186
258
|
commands/ Slash commands installed to .claude/commands/
|
|
187
259
|
agents/ Subagent definitions — commands declare which they need via requires-agents
|
|
188
260
|
scripts/ validate-skills.mjs — checks skill length caps and reference integrity
|
|
261
|
+
gauntlet.sh — the Stop-hook verification gates, embedded by the CLI
|
|
189
262
|
cli/ The npm installer (npx @spardutti/claude-skills); version in cli/package.json
|
|
190
263
|
.husky/ pre-push hook running the skill validator
|
|
191
264
|
package.json Private dev-tooling package (claude-skills-dev) — not the published one
|
|
@@ -200,6 +273,19 @@ Skills live in `skills/<name>/SKILL.md`. Authoring conventions are in [CLAUDE.md
|
|
|
200
273
|
- References are one level deep — `SKILL.md` links them, they don't link each other.
|
|
201
274
|
- `npm run validate-skills` enforces this; it also runs on `pre-push`.
|
|
202
275
|
|
|
276
|
+
To get the validator on **every turn** instead of only on push, drop this into
|
|
277
|
+
this repo's `.claude/gauntlet.conf` — `.claude/` is gitignored, so each contributor adds it
|
|
278
|
+
locally. It has to be the project file, not `~/.claude/gauntlet.conf`: these settings are
|
|
279
|
+
only right for a skills repo:
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
GAUNTLET_CODE_EXT='md|mjs|js|json|sh' # markdown IS the product here
|
|
283
|
+
GAUNTLET_TYPECHECK=''
|
|
284
|
+
GAUNTLET_TEST='node scripts/validate-skills.mjs'
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Without the `CODE_EXT` line the hook treats a skill edit as a docs-only change and skips.
|
|
288
|
+
|
|
203
289
|
## License
|
|
204
290
|
|
|
205
291
|
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
|
|
147
|
-
|
|
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
|
|
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
|
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -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,215 @@ 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. Blocks the turn when a
|
|
165
|
+
# gate is red, handing the failure back to Claude to fix before the user sees it.
|
|
166
|
+
#
|
|
167
|
+
# Only cheap gates belong here (target: under ~30s). Mutation testing, deep
|
|
168
|
+
# review, and the spec checklist are the /gauntlet command's job, not this hook's.
|
|
169
|
+
#
|
|
170
|
+
# Skip ladder, cheapest first — quits at the first "no":
|
|
171
|
+
# 1. no changed files
|
|
172
|
+
# 2. no changed *code* files (docs/config only)
|
|
173
|
+
# 3. this exact diff already passed
|
|
174
|
+
# 4. no test runner detected
|
|
175
|
+
#
|
|
176
|
+
# Config, both optional and both KEY=value files that get sourced in this order:
|
|
177
|
+
# ~/.claude/gauntlet.conf your defaults for every project
|
|
178
|
+
# <project>/.claude/gauntlet.conf overrides for this one; wins key by key
|
|
179
|
+
# GAUNTLET_OFF=1 disable entirely
|
|
180
|
+
# GAUNTLET_TYPECHECK="cmd" override the typecheck command ("" to skip)
|
|
181
|
+
# GAUNTLET_TEST="cmd" override the test command; $FILES = changed code files
|
|
182
|
+
# GAUNTLET_CODE_EXT="ts|tsx|py" override which extensions count as code
|
|
183
|
+
|
|
184
|
+
set -uo pipefail
|
|
185
|
+
|
|
186
|
+
INPUT=$(cat 2>/dev/null || true)
|
|
187
|
+
|
|
188
|
+
# Never re-block a turn we already blocked — this would loop forever.
|
|
189
|
+
case "$INPUT" in
|
|
190
|
+
*'"stop_hook_active":true'*) exit 0 ;;
|
|
191
|
+
esac
|
|
192
|
+
|
|
193
|
+
PROJECT_DIR="\${CLAUDE_PROJECT_DIR:-}"
|
|
194
|
+
if [ -z "$PROJECT_DIR" ]; then PROJECT_DIR="$PWD"; fi
|
|
195
|
+
cd "$PROJECT_DIR" 2>/dev/null || exit 0
|
|
196
|
+
|
|
197
|
+
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
|
198
|
+
|
|
199
|
+
GAUNTLET_OFF=""
|
|
200
|
+
GAUNTLET_TYPECHECK="__auto__"
|
|
201
|
+
GAUNTLET_TEST="__auto__"
|
|
202
|
+
GAUNTLET_CODE_EXT="ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|sql"
|
|
203
|
+
# Global defaults first, then the project's on top — project wins, key by key.
|
|
204
|
+
if [ -n "\${HOME:-}" ] && [ -f "$HOME/.claude/gauntlet.conf" ]; then . "$HOME/.claude/gauntlet.conf"; fi
|
|
205
|
+
if [ -f "$PROJECT_DIR/.claude/gauntlet.conf" ]; then . "$PROJECT_DIR/.claude/gauntlet.conf"; fi
|
|
206
|
+
if [ -n "$GAUNTLET_OFF" ]; then exit 0; fi
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------- skip rule 1
|
|
209
|
+
CHANGED=$( { git diff --name-only HEAD 2>/dev/null; \\
|
|
210
|
+
git ls-files --others --exclude-standard 2>/dev/null; } | sort -u )
|
|
211
|
+
if [ -z "$CHANGED" ]; then exit 0; fi
|
|
212
|
+
|
|
213
|
+
# ---------------------------------------------------------------- skip rule 2
|
|
214
|
+
FILES=$(printf '%s\\n' "$CHANGED" | grep -E "\\.($GAUNTLET_CODE_EXT)$" || true)
|
|
215
|
+
if [ -z "$FILES" ]; then exit 0; fi
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------- skip rule 3
|
|
218
|
+
DIFF_HASH=$( { git diff HEAD 2>/dev/null; printf '%s\\n' "$CHANGED"; } \\
|
|
219
|
+
| git hash-object --stdin 2>/dev/null )
|
|
220
|
+
SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 \\
|
|
221
|
+
| sed 's/"session_id":"//; s/"$//')
|
|
222
|
+
if [ -z "$SESSION_ID" ]; then SESSION_ID="nosession"; fi
|
|
223
|
+
MARKER="/tmp/claude-gauntlet-$SESSION_ID"
|
|
224
|
+
if [ -f "$MARKER" ] && [ "$(cat "$MARKER")" = "$DIFF_HASH" ]; then exit 0; fi
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------- skip rule 4
|
|
227
|
+
# Detect the stack once. Auto-detection is a default, not a decision — the conf
|
|
228
|
+
# file wins whenever a repo does something unusual.
|
|
229
|
+
detect() {
|
|
230
|
+
if [ "$GAUNTLET_TEST" != "__auto__" ]; then return; fi
|
|
231
|
+
GAUNTLET_TEST=""
|
|
232
|
+
GAUNTLET_TYPECHECK=""
|
|
233
|
+
if [ -f package.json ]; then
|
|
234
|
+
if grep -q '"vitest"' package.json; then
|
|
235
|
+
GAUNTLET_TEST='npx --no-install vitest related --run $FILES'
|
|
236
|
+
elif grep -q '"jest"' package.json; then
|
|
237
|
+
GAUNTLET_TEST='npx --no-install jest --findRelatedTests $FILES --passWithNoTests'
|
|
238
|
+
fi
|
|
239
|
+
if [ -f tsconfig.json ]; then
|
|
240
|
+
GAUNTLET_TYPECHECK='npx --no-install tsc --noEmit'
|
|
241
|
+
fi
|
|
242
|
+
fi
|
|
243
|
+
if [ -z "$GAUNTLET_TEST" ] && { [ -f pyproject.toml ] || [ -f pytest.ini ] || [ -f setup.cfg ]; }; then
|
|
244
|
+
GAUNTLET_TEST='python -m pytest -q'
|
|
245
|
+
if [ -f mypy.ini ] || grep -qs 'tool.mypy' pyproject.toml; then
|
|
246
|
+
GAUNTLET_TYPECHECK='python -m mypy .'
|
|
247
|
+
fi
|
|
248
|
+
fi
|
|
249
|
+
}
|
|
250
|
+
detect
|
|
251
|
+
if [ -z "$GAUNTLET_TEST" ]; then exit 0; fi
|
|
252
|
+
|
|
253
|
+
# ------------------------------------------------------------------ the gates
|
|
254
|
+
# Cheapest first, stop at the first red. Most failures land in the first gate,
|
|
255
|
+
# so the expensive ones are usually never paid for.
|
|
256
|
+
export FILES=$(printf '%s' "$FILES" | tr '\\n' ' ')
|
|
257
|
+
FAILED=""
|
|
258
|
+
OUTPUT=""
|
|
259
|
+
|
|
260
|
+
run_gate() {
|
|
261
|
+
GATE_NAME="$1"
|
|
262
|
+
GATE_CMD="$2"
|
|
263
|
+
if [ -z "$GATE_CMD" ]; then return 0; fi
|
|
264
|
+
GATE_OUT=$(eval "$GATE_CMD" 2>&1)
|
|
265
|
+
if [ $? -ne 0 ]; then
|
|
266
|
+
FAILED="$GATE_NAME"
|
|
267
|
+
OUTPUT="$GATE_OUT"
|
|
268
|
+
return 1
|
|
269
|
+
fi
|
|
270
|
+
return 0
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
run_gate "typecheck" "$GAUNTLET_TYPECHECK" && run_gate "tests" "$GAUNTLET_TEST"
|
|
274
|
+
|
|
275
|
+
if [ -z "$FAILED" ]; then
|
|
276
|
+
printf '%s' "$DIFF_HASH" > "$MARKER"
|
|
277
|
+
exit 0
|
|
278
|
+
fi
|
|
279
|
+
|
|
280
|
+
# ------------------------------------------------------------------ block red
|
|
281
|
+
# Last 60 lines only — the hook's reason is read by a model, not archived.
|
|
282
|
+
TAIL=$(printf '%s\\n' "$OUTPUT" | tail -60)
|
|
283
|
+
MSG="GAUNTLET FAILED: the $FAILED gate is red on the changed files.
|
|
284
|
+
|
|
285
|
+
Fix it before ending the turn. Do not weaken or delete a test to make this pass.
|
|
286
|
+
If the failure is unrelated to your change, say so explicitly and stop.
|
|
287
|
+
|
|
288
|
+
Changed code files:
|
|
289
|
+
$FILES
|
|
290
|
+
|
|
291
|
+
--- $FAILED output (last 60 lines) ---
|
|
292
|
+
$TAIL"
|
|
293
|
+
|
|
294
|
+
json_escape() {
|
|
295
|
+
local s="$1"
|
|
296
|
+
s="\${s//\\\\/\\\\\\\\}"
|
|
297
|
+
s="\${s//\\"/\\\\\\"}"
|
|
298
|
+
s="\${s//$'\\n'/\\\\n}"
|
|
299
|
+
s="\${s//$'\\r'/\\\\r}"
|
|
300
|
+
s="\${s//$'\\t'/\\\\t}"
|
|
301
|
+
printf '"%s"' "$s"
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
printf '{"decision":"block","reason":%s}\\n' "$(json_escape "$MSG")"
|
|
305
|
+
exit 0
|
|
306
|
+
`;
|
|
307
|
+
|
|
158
308
|
const GATE_FILENAME = "skill-gate.sh";
|
|
159
309
|
const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
|
|
160
310
|
const APPLICATION_GATE_FILENAME = "skill-application-gate.sh";
|
|
311
|
+
const GAUNTLET_FILENAME = "gauntlet.sh";
|
|
161
312
|
const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
|
|
162
313
|
const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
|
|
163
314
|
|
|
315
|
+
// Mirrors the detect() in scripts/gauntlet.sh. Kept in sync by hand: this one
|
|
316
|
+
// only reports what the hook will find, it never decides anything.
|
|
317
|
+
export async function detectStack(targetDir = process.cwd()) {
|
|
318
|
+
const dir = resolve(targetDir);
|
|
319
|
+
const has = async (f) => {
|
|
320
|
+
try { await readFile(join(dir, f), "utf-8"); return true; } catch { return false; }
|
|
321
|
+
};
|
|
322
|
+
const read = async (f) => {
|
|
323
|
+
try { return await readFile(join(dir, f), "utf-8"); } catch { return ""; }
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
let test = null;
|
|
327
|
+
let typecheck = null;
|
|
328
|
+
|
|
329
|
+
const pkg = await read("package.json");
|
|
330
|
+
if (pkg) {
|
|
331
|
+
if (pkg.includes('"vitest"')) test = "vitest";
|
|
332
|
+
else if (pkg.includes('"jest"')) test = "jest";
|
|
333
|
+
if (await has("tsconfig.json")) typecheck = "tsc";
|
|
334
|
+
}
|
|
335
|
+
if (!test && (await has("pyproject.toml") || await has("pytest.ini") || await has("setup.cfg"))) {
|
|
336
|
+
test = "pytest";
|
|
337
|
+
if (await has("mypy.ini") || (await read("pyproject.toml")).includes("tool.mypy")) {
|
|
338
|
+
typecheck = "mypy";
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return { test, typecheck };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Written only when detection finds nothing — otherwise the hook auto-detects and
|
|
345
|
+
// no config file is needed.
|
|
346
|
+
export async function writeGauntletConf(targetDir, testCommand) {
|
|
347
|
+
const confPath = join(resolve(targetDir), ".claude", "gauntlet.conf");
|
|
348
|
+
await mkdir(dirname(confPath), { recursive: true });
|
|
349
|
+
await writeFile(confPath, [
|
|
350
|
+
"# Gauntlet config. Read after ~/.claude/gauntlet.conf, and wins over it.",
|
|
351
|
+
"# GAUNTLET_OFF=1 disables the hook in this project.",
|
|
352
|
+
"",
|
|
353
|
+
"GAUNTLET_TYPECHECK=''",
|
|
354
|
+
`GAUNTLET_TEST='${testCommand.replace(/'/g, "'\\''")}'`,
|
|
355
|
+
"",
|
|
356
|
+
].join("\n"), { mode: 0o644 });
|
|
357
|
+
return confPath;
|
|
358
|
+
}
|
|
359
|
+
|
|
164
360
|
export async function setupHook(targetDir = process.cwd()) {
|
|
165
361
|
const resolved = resolve(targetDir);
|
|
166
362
|
const hooksDir = join(resolved, ".claude", "hooks");
|
|
167
363
|
const gatePath = join(hooksDir, GATE_FILENAME);
|
|
168
364
|
const autoMarkPath = join(hooksDir, AUTO_MARK_FILENAME);
|
|
169
365
|
const applicationGatePath = join(hooksDir, APPLICATION_GATE_FILENAME);
|
|
366
|
+
const gauntletPath = join(hooksDir, GAUNTLET_FILENAME);
|
|
170
367
|
const settingsPath = join(resolved, ".claude", "settings.json");
|
|
171
368
|
|
|
172
369
|
await mkdir(hooksDir, { recursive: true });
|
|
@@ -176,6 +373,8 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
176
373
|
await chmod(autoMarkPath, 0o755);
|
|
177
374
|
await writeFile(applicationGatePath, APPLICATION_GATE_SCRIPT, { mode: 0o755 });
|
|
178
375
|
await chmod(applicationGatePath, 0o755);
|
|
376
|
+
await writeFile(gauntletPath, GAUNTLET_SCRIPT, { mode: 0o755 });
|
|
377
|
+
await chmod(gauntletPath, 0o755);
|
|
179
378
|
|
|
180
379
|
// Remove the legacy audit-runner hook file from prior installs (2.3.x).
|
|
181
380
|
try {
|
|
@@ -252,10 +451,24 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
252
451
|
settings.hooks.PostToolUse = [autoMarkEntry];
|
|
253
452
|
}
|
|
254
453
|
|
|
454
|
+
// Register Stop gauntlet hook.
|
|
455
|
+
const gauntletCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${GAUNTLET_FILENAME}`;
|
|
456
|
+
const gauntletEntry = { hooks: [{ type: "command", command: gauntletCommand }] };
|
|
457
|
+
|
|
458
|
+
if (Array.isArray(settings.hooks.Stop)) {
|
|
459
|
+
const exists = settings.hooks.Stop.some((entry) =>
|
|
460
|
+
entry.hooks?.some((h) => h.command?.endsWith(GAUNTLET_FILENAME))
|
|
461
|
+
);
|
|
462
|
+
if (!exists) settings.hooks.Stop.push(gauntletEntry);
|
|
463
|
+
} else {
|
|
464
|
+
settings.hooks.Stop = [gauntletEntry];
|
|
465
|
+
}
|
|
466
|
+
|
|
255
467
|
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o644 });
|
|
256
468
|
|
|
257
469
|
console.log(` Hook installed: .claude/hooks/${GATE_FILENAME}`);
|
|
258
470
|
console.log(` Hook installed: .claude/hooks/${AUTO_MARK_FILENAME}`);
|
|
259
471
|
console.log(` Hook installed: .claude/hooks/${APPLICATION_GATE_FILENAME}`);
|
|
472
|
+
console.log(` Hook installed: .claude/hooks/${GAUNTLET_FILENAME}`);
|
|
260
473
|
console.log(` Settings updated: .claude/settings.json`);
|
|
261
474
|
}
|