@spardutti/claude-skills 2.8.0 → 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 +245 -21
- 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>.
|
|
@@ -89,45 +89,56 @@ if [ ! -f "/tmp/claude-skill-gate-$SESSION_ID" ]; then
|
|
|
89
89
|
exit 0
|
|
90
90
|
fi
|
|
91
91
|
|
|
92
|
-
#
|
|
93
|
-
# blocked write will surface the next skill).
|
|
92
|
+
# Collect every loaded-but-unacked skill so a single ack clears them all.
|
|
94
93
|
UNACKED=""
|
|
95
94
|
for marker in /tmp/claude-skill-loaded-$SESSION_ID-*; do
|
|
96
95
|
[ ! -f "$marker" ] && continue
|
|
97
96
|
skill_name="\${marker##/tmp/claude-skill-loaded-$SESSION_ID-}"
|
|
98
97
|
if [ ! -f "/tmp/claude-skill-acked-$SESSION_ID-$skill_name" ]; then
|
|
99
|
-
UNACKED="$skill_name"
|
|
100
|
-
break
|
|
98
|
+
UNACKED="$UNACKED $skill_name"
|
|
101
99
|
fi
|
|
102
100
|
done
|
|
101
|
+
UNACKED="\${UNACKED# }"
|
|
103
102
|
|
|
104
103
|
if [ -z "$UNACKED" ]; then
|
|
105
104
|
exit 0
|
|
106
105
|
fi
|
|
107
106
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
107
|
+
# One touch call with explicit paths — brace expansion is not used because
|
|
108
|
+
# bash leaves a single-element {name} literal, creating a garbage marker.
|
|
109
|
+
ACK_CMD="touch"
|
|
110
|
+
NAMES=""
|
|
111
|
+
RULES_BLOCKS=""
|
|
112
|
+
for skill in $UNACKED; do
|
|
113
|
+
ACK_CMD="$ACK_CMD /tmp/claude-skill-acked-$SESSION_ID-$skill"
|
|
114
|
+
NAMES="$NAMES, '$skill'"
|
|
115
|
+
SKILL_MD="$PROJECT_DIR/.claude/skills/$skill/SKILL.md"
|
|
116
|
+
RULES=""
|
|
117
|
+
if [ -f "$SKILL_MD" ]; then
|
|
118
|
+
RULES=$(awk '/^## Rules/{flag=1} /^## /{if(flag && !/^## Rules/)exit} flag' "$SKILL_MD")
|
|
119
|
+
fi
|
|
120
|
+
if [ -z "$RULES" ]; then
|
|
121
|
+
RULES="(Rules section not found in $SKILL_MD — refer to the loaded skill content already in context.)"
|
|
122
|
+
fi
|
|
123
|
+
RULES_BLOCKS="$RULES_BLOCKS
|
|
124
|
+
### Rules from $skill/SKILL.md
|
|
116
125
|
|
|
117
|
-
|
|
126
|
+
$RULES
|
|
127
|
+
"
|
|
128
|
+
done
|
|
129
|
+
NAMES="\${NAMES#, }"
|
|
130
|
+
|
|
131
|
+
MSG="BLOCKED: loaded skills not yet applied to your work: $NAMES
|
|
118
132
|
|
|
119
133
|
Before this Write/Edit, you must:
|
|
120
134
|
|
|
121
|
-
1.
|
|
135
|
+
1. For each skill listed above, state the specific rules that apply to the file you're about to write.
|
|
122
136
|
2. State how your next write respects each rule.
|
|
123
|
-
3. Then ack
|
|
124
|
-
|
|
137
|
+
3. Then ack all of them in a single Bash tool call:
|
|
138
|
+
$ACK_CMD
|
|
125
139
|
|
|
126
140
|
One ack per skill per session. After acking, retry the Write/Edit.
|
|
127
|
-
|
|
128
|
-
Rules from $UNACKED/SKILL.md:
|
|
129
|
-
|
|
130
|
-
$RULES"
|
|
141
|
+
$RULES_BLOCKS"
|
|
131
142
|
|
|
132
143
|
json_escape() {
|
|
133
144
|
local s="$1"
|
|
@@ -144,18 +155,215 @@ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision"
|
|
|
144
155
|
exit 0
|
|
145
156
|
`;
|
|
146
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
|
+
|
|
147
308
|
const GATE_FILENAME = "skill-gate.sh";
|
|
148
309
|
const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
|
|
149
310
|
const APPLICATION_GATE_FILENAME = "skill-application-gate.sh";
|
|
311
|
+
const GAUNTLET_FILENAME = "gauntlet.sh";
|
|
150
312
|
const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
|
|
151
313
|
const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
|
|
152
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
|
+
|
|
153
360
|
export async function setupHook(targetDir = process.cwd()) {
|
|
154
361
|
const resolved = resolve(targetDir);
|
|
155
362
|
const hooksDir = join(resolved, ".claude", "hooks");
|
|
156
363
|
const gatePath = join(hooksDir, GATE_FILENAME);
|
|
157
364
|
const autoMarkPath = join(hooksDir, AUTO_MARK_FILENAME);
|
|
158
365
|
const applicationGatePath = join(hooksDir, APPLICATION_GATE_FILENAME);
|
|
366
|
+
const gauntletPath = join(hooksDir, GAUNTLET_FILENAME);
|
|
159
367
|
const settingsPath = join(resolved, ".claude", "settings.json");
|
|
160
368
|
|
|
161
369
|
await mkdir(hooksDir, { recursive: true });
|
|
@@ -165,6 +373,8 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
165
373
|
await chmod(autoMarkPath, 0o755);
|
|
166
374
|
await writeFile(applicationGatePath, APPLICATION_GATE_SCRIPT, { mode: 0o755 });
|
|
167
375
|
await chmod(applicationGatePath, 0o755);
|
|
376
|
+
await writeFile(gauntletPath, GAUNTLET_SCRIPT, { mode: 0o755 });
|
|
377
|
+
await chmod(gauntletPath, 0o755);
|
|
168
378
|
|
|
169
379
|
// Remove the legacy audit-runner hook file from prior installs (2.3.x).
|
|
170
380
|
try {
|
|
@@ -241,10 +451,24 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
241
451
|
settings.hooks.PostToolUse = [autoMarkEntry];
|
|
242
452
|
}
|
|
243
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
|
+
|
|
244
467
|
await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o644 });
|
|
245
468
|
|
|
246
469
|
console.log(` Hook installed: .claude/hooks/${GATE_FILENAME}`);
|
|
247
470
|
console.log(` Hook installed: .claude/hooks/${AUTO_MARK_FILENAME}`);
|
|
248
471
|
console.log(` Hook installed: .claude/hooks/${APPLICATION_GATE_FILENAME}`);
|
|
472
|
+
console.log(` Hook installed: .claude/hooks/${GAUNTLET_FILENAME}`);
|
|
249
473
|
console.log(` Settings updated: .claude/settings.json`);
|
|
250
474
|
}
|