@spardutti/claude-skills 2.12.1 → 2.13.1
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 +19 -3
- package/lib/setup-hook.mjs +115 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -142,8 +142,20 @@ After installing skills, the CLI offers to set up a hook that **guarantees** Cla
|
|
|
142
142
|
It installs four hooks and appends a rule to your `CLAUDE.md`. Three guard the
|
|
143
143
|
**front** — nothing is written until your skills are considered:
|
|
144
144
|
|
|
145
|
-
- `skill-gate.sh` — a `PreToolUse` gate on `Write|Edit|MultiEdit`
|
|
145
|
+
- `skill-gate.sh` — a `PreToolUse` gate on `Write|Edit|MultiEdit|Bash`
|
|
146
146
|
- `skill-application-gate.sh` — a second `PreToolUse` gate requiring each loaded skill to be applied, not just read
|
|
147
|
+
|
|
148
|
+
Both cover **Bash**, not only the file-writing tools. A session that edits through
|
|
149
|
+
`python3 - <<'PY'` in Bash walked past the old matcher entirely — twelve source files, not
|
|
150
|
+
one prompt — and some harnesses actively tell the model to prefer Bash for edits. Only
|
|
151
|
+
commands that can write are gated (a redirect, `tee`, `sed -i`, `cp`/`mv`, or an
|
|
152
|
+
interpreter given inline code or a heredoc); `git status` and a redirect to `/dev/null`
|
|
153
|
+
pass untouched, and the command that clears the gate is never gated against itself.
|
|
154
|
+
|
|
155
|
+
Neither gate fires on **prose** — `.md`, `.mdx`, `.txt`, `.rst`, `.adoc`, images. Skills
|
|
156
|
+
are about code, and a repo full of `PLAN_*.md` otherwise hits the gate on every write.
|
|
157
|
+
Config files (`.json`, `.yml`, `.toml`) stay gated, because skills do have rules about
|
|
158
|
+
`tsconfig`, `package.json`, and Compose.
|
|
147
159
|
- `skill-gate-automark.sh` — a `PostToolUse` hook on `Skill` that clears the gate
|
|
148
160
|
|
|
149
161
|
The last two guard the **back** — nothing finishes or ships until the code passes:
|
|
@@ -164,9 +176,9 @@ It registers in `.claude/settings.json`:
|
|
|
164
176
|
{
|
|
165
177
|
"hooks": {
|
|
166
178
|
"PreToolUse": [
|
|
167
|
-
{ "matcher": "Write|Edit|MultiEdit", "hooks": [
|
|
179
|
+
{ "matcher": "Write|Edit|MultiEdit|Bash", "hooks": [
|
|
168
180
|
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-gate.sh" } ] },
|
|
169
|
-
{ "matcher": "Write|Edit|MultiEdit", "hooks": [
|
|
181
|
+
{ "matcher": "Write|Edit|MultiEdit|Bash", "hooks": [
|
|
170
182
|
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-application-gate.sh" } ] }
|
|
171
183
|
],
|
|
172
184
|
"PostToolUse": [
|
|
@@ -334,6 +346,7 @@ scripts/ validate-skills.mjs — checks skill length caps and reference in
|
|
|
334
346
|
ship-gate-hook.sh — refuses git commit/push without a ship-gate receipt
|
|
335
347
|
gauntlet-selftest.sh — 15 behavioural tests for the hook (runs on pre-push)
|
|
336
348
|
gauntlet-survey.sh — what the hook would do in every repo under a directory
|
|
349
|
+
preflight.sh — everything that must pass before a release
|
|
337
350
|
cli/ The npm installer (npx @spardutti/claude-skills); version in cli/package.json
|
|
338
351
|
.husky/ pre-push hook running the skill validator
|
|
339
352
|
package.json Private dev-tooling package (claude-skills-dev) — not the published one
|
|
@@ -347,6 +360,9 @@ Skills live in `skills/<name>/SKILL.md`. Authoring conventions are in [CLAUDE.md
|
|
|
347
360
|
- `SKILL.md` ≤ 350 lines; reference files ≤ 500 and need a `## Contents` TOC past 100 lines.
|
|
348
361
|
- References are one level deep — `SKILL.md` links them, they don't link each other.
|
|
349
362
|
- `npm run validate-skills` enforces this; it also runs on `pre-push`.
|
|
363
|
+
- Before any release, run `bash scripts/preflight.sh` — validator, self-tests, a check that the
|
|
364
|
+
hook scripts embedded in the CLI still match `scripts/`, detection against every repo under
|
|
365
|
+
`~/projects`, and that the version is ahead of the registry. Do not publish while it is red.
|
|
350
366
|
- Changing `scripts/gauntlet.sh`? Run `bash scripts/gauntlet-selftest.sh` (also on `pre-push`),
|
|
351
367
|
and run `bash scripts/gauntlet-survey.sh ~/projects` before releasing. Detection is where
|
|
352
368
|
this hook keeps breaking, because real repos are shaped in ways invented test repos aren't —
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -34,6 +34,56 @@ if [ -f "$MARKER" ]; then
|
|
|
34
34
|
exit 0
|
|
35
35
|
fi
|
|
36
36
|
|
|
37
|
+
# Write|Edit|MultiEdit is not the only way to change a file. A session that edits
|
|
38
|
+
# through \`python3 - <<'PY'\` in Bash walked past this gate entirely — twelve
|
|
39
|
+
# source files, not one prompt — and some harnesses actively tell the model to
|
|
40
|
+
# prefer Bash for edits. So Bash is gated too, but only for commands that can
|
|
41
|
+
# write, and only until the gate is cleared once for the session.
|
|
42
|
+
|
|
43
|
+
# Skills are about code. Blocking a plan document to ask how the React rules
|
|
44
|
+
# apply is pure noise, and a repo full of PLAN_*.md hits it on every write.
|
|
45
|
+
# Config files stay gated — skills do have rules about tsconfig and compose.
|
|
46
|
+
PROSE_EXT='md|mdx|txt|rst|adoc|png|jpg|jpeg|gif|svg|webp|ico'
|
|
47
|
+
CODE_EXT='ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|sql|sh|json|ya?ml|toml'
|
|
48
|
+
|
|
49
|
+
TOOL=$(printf '%s' "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | sed 's/.*:"//; s/"$//')
|
|
50
|
+
|
|
51
|
+
if [ "$TOOL" != "Bash" ]; then
|
|
52
|
+
# The structured tools name their target outright.
|
|
53
|
+
TARGET=$(printf '%s' "$INPUT" | grep -o '"file_path":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
54
|
+
case "$TARGET" in
|
|
55
|
+
*.*) printf '%s' "$TARGET" | grep -qiE "\.($PROSE_EXT)$" && exit 0 ;;
|
|
56
|
+
esac
|
|
57
|
+
fi
|
|
58
|
+
if [ "$TOOL" = "Bash" ]; then
|
|
59
|
+
CMD=$(printf '%s' "$INPUT" | grep -o '"command":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
60
|
+
|
|
61
|
+
# Never gate the command that clears the gate, or this deadlocks.
|
|
62
|
+
case "$CMD" in
|
|
63
|
+
*claude-skill-gate-*|*claude-skill-acked-*|*claude-skill-loaded-*) exit 0 ;;
|
|
64
|
+
esac
|
|
65
|
+
|
|
66
|
+
# /dev/null redirects are not file writes; drop them before looking for one.
|
|
67
|
+
|
|
68
|
+
# A command that names a prose file and no code file is writing prose.
|
|
69
|
+
if printf '%s' "$CMD" | grep -qiE "\.($PROSE_EXT)([^A-Za-z0-9]|$)" \
|
|
70
|
+
&& ! printf '%s' "$CMD" | grep -qiE "\.($CODE_EXT)([^A-Za-z0-9]|$)"; then
|
|
71
|
+
exit 0
|
|
72
|
+
fi
|
|
73
|
+
STRIPPED=$(printf '%s' "$CMD" | sed 's![12]*>>*[[:space:]]*/dev/null!!g')
|
|
74
|
+
WRITES=""
|
|
75
|
+
case "$STRIPPED" in
|
|
76
|
+
*">"*|*"tee "*|*"sed -i"*|*"cp "*|*"mv "*|*"truncate "*|*"dd "*) WRITES=1 ;;
|
|
77
|
+
esac
|
|
78
|
+
# An interpreter given inline code or a heredoc can write anything, and the
|
|
79
|
+
# shell shows no redirect at all — this is the shape that got past the gate.
|
|
80
|
+
case "$CMD" in
|
|
81
|
+
*"<<"*|*python*" -c"*|*node*" -e"*|*perl*" -e"*|*ruby*" -e"*) WRITES=1 ;;
|
|
82
|
+
esac
|
|
83
|
+
[ -z "$WRITES" ] && exit 0
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
|
|
37
87
|
cat <<EOF
|
|
38
88
|
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"BLOCKED: skill evaluation required before file edits in this session.\\n\\nStep 1 — evaluate every available skill as ACTIVATE or SKIP with a one-line reason.\\n\\nStep 2 — you MUST take EXACTLY ONE of these tool actions to clear the gate. Listing skills in text is NOT enough; retrying the edit without doing one of these will be denied again:\\n (a) If any skill is ACTIVATE → call Skill(name) for it. This auto-clears the gate.\\n (b) If ALL skills are SKIP → run this Bash tool call: touch /tmp/claude-skill-gate-$SESSION_ID\\n\\nStep 3 — only after Step 2 completes, retry the file edit."}}
|
|
39
89
|
EOF
|
|
@@ -90,6 +140,47 @@ if [ ! -f "/tmp/claude-skill-gate-$SESSION_ID" ]; then
|
|
|
90
140
|
exit 0
|
|
91
141
|
fi
|
|
92
142
|
|
|
143
|
+
# Same reason as the loading gate: Bash can write files, and a heredoc into an
|
|
144
|
+
# interpreter shows no redirect at all.
|
|
145
|
+
|
|
146
|
+
# Skills are about code. Blocking a plan document to ask how the React rules
|
|
147
|
+
# apply is pure noise, and a repo full of PLAN_*.md hits it on every write.
|
|
148
|
+
# Config files stay gated — skills do have rules about tsconfig and compose.
|
|
149
|
+
PROSE_EXT='md|mdx|txt|rst|adoc|png|jpg|jpeg|gif|svg|webp|ico'
|
|
150
|
+
CODE_EXT='ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|rb|php|c|h|cpp|hpp|cs|swift|sql|sh|json|ya?ml|toml'
|
|
151
|
+
|
|
152
|
+
TOOL=$(printf '%s' "$INPUT" | grep -o '"tool_name":"[^"]*"' | head -1 | sed 's/.*:"//; s/"$//')
|
|
153
|
+
|
|
154
|
+
if [ "$TOOL" != "Bash" ]; then
|
|
155
|
+
# The structured tools name their target outright.
|
|
156
|
+
TARGET=$(printf '%s' "$INPUT" | grep -o '"file_path":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
157
|
+
case "$TARGET" in
|
|
158
|
+
*.*) printf '%s' "$TARGET" | grep -qiE "\.($PROSE_EXT)$" && exit 0 ;;
|
|
159
|
+
esac
|
|
160
|
+
fi
|
|
161
|
+
if [ "$TOOL" = "Bash" ]; then
|
|
162
|
+
CMD=$(printf '%s' "$INPUT" | grep -o '"command":[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//; s/"$//')
|
|
163
|
+
case "$CMD" in
|
|
164
|
+
*claude-skill-gate-*|*claude-skill-acked-*|*claude-skill-loaded-*) exit 0 ;;
|
|
165
|
+
esac
|
|
166
|
+
|
|
167
|
+
# A command that names a prose file and no code file is writing prose.
|
|
168
|
+
if printf '%s' "$CMD" | grep -qiE "\.($PROSE_EXT)([^A-Za-z0-9]|$)" \
|
|
169
|
+
&& ! printf '%s' "$CMD" | grep -qiE "\.($CODE_EXT)([^A-Za-z0-9]|$)"; then
|
|
170
|
+
exit 0
|
|
171
|
+
fi
|
|
172
|
+
STRIPPED=$(printf '%s' "$CMD" | sed 's![12]*>>*[[:space:]]*/dev/null!!g')
|
|
173
|
+
WRITES=""
|
|
174
|
+
case "$STRIPPED" in
|
|
175
|
+
*">"*|*"tee "*|*"sed -i"*|*"cp "*|*"mv "*|*"truncate "*|*"dd "*) WRITES=1 ;;
|
|
176
|
+
esac
|
|
177
|
+
case "$CMD" in
|
|
178
|
+
*"<<"*|*python*" -c"*|*node*" -e"*|*perl*" -e"*|*ruby*" -e"*) WRITES=1 ;;
|
|
179
|
+
esac
|
|
180
|
+
[ -z "$WRITES" ] && exit 0
|
|
181
|
+
fi
|
|
182
|
+
|
|
183
|
+
|
|
93
184
|
# Collect every loaded-but-unacked skill so a single ack clears them all.
|
|
94
185
|
UNACKED=""
|
|
95
186
|
for marker in /tmp/claude-skill-loaded-$SESSION_ID-*; do
|
|
@@ -133,8 +224,11 @@ MSG="BLOCKED: loaded skills not yet applied to your work: $NAMES
|
|
|
133
224
|
|
|
134
225
|
Before this Write/Edit, you must:
|
|
135
226
|
|
|
136
|
-
1. For each skill listed above,
|
|
137
|
-
|
|
227
|
+
1. For each skill listed above, say whether it applies to the file you're about to
|
|
228
|
+
write. If it does not — a Python skill and a TypeScript file, say — write
|
|
229
|
+
"does not apply" and move on. Do not invent a connection.
|
|
230
|
+
2. For the ones that do apply: state the specific rules that bear on this file, and
|
|
231
|
+
how your next write respects each.
|
|
138
232
|
3. Then ack all of them in a single Bash tool call:
|
|
139
233
|
$ACK_CMD
|
|
140
234
|
|
|
@@ -847,6 +941,18 @@ export async function writeGauntletConf(targetDir, testCommand) {
|
|
|
847
941
|
return confPath;
|
|
848
942
|
}
|
|
849
943
|
|
|
944
|
+
// Register the entry, or CORRECT the one already there. Skipping when a hook of
|
|
945
|
+
// this name exists leaves an existing install pinned to whatever matcher it was
|
|
946
|
+
// first written with — which is how the skill gates kept watching only
|
|
947
|
+
// Write|Edit|MultiEdit after they learned to cover Bash.
|
|
948
|
+
function upsert(list, filename, entry) {
|
|
949
|
+
const existing = list.find((e) => e.hooks?.some((h) => h.command?.endsWith(filename)));
|
|
950
|
+
if (!existing) return list.push(entry);
|
|
951
|
+
if (entry.matcher === undefined) delete existing.matcher;
|
|
952
|
+
else existing.matcher = entry.matcher;
|
|
953
|
+
existing.hooks = entry.hooks;
|
|
954
|
+
}
|
|
955
|
+
|
|
850
956
|
export async function setupHook(targetDir = process.cwd()) {
|
|
851
957
|
const resolved = resolve(targetDir);
|
|
852
958
|
const hooksDir = join(resolved, ".claude", "hooks");
|
|
@@ -907,15 +1013,12 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
907
1013
|
// Register PreToolUse gate.
|
|
908
1014
|
const gateCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${GATE_FILENAME}`;
|
|
909
1015
|
const gateEntry = {
|
|
910
|
-
matcher: "Write|Edit|MultiEdit",
|
|
1016
|
+
matcher: "Write|Edit|MultiEdit|Bash",
|
|
911
1017
|
hooks: [{ type: "command", command: gateCommand }],
|
|
912
1018
|
};
|
|
913
1019
|
|
|
914
1020
|
if (Array.isArray(settings.hooks.PreToolUse)) {
|
|
915
|
-
|
|
916
|
-
entry.hooks?.some((h) => h.command?.endsWith(GATE_FILENAME))
|
|
917
|
-
);
|
|
918
|
-
if (!exists) settings.hooks.PreToolUse.push(gateEntry);
|
|
1021
|
+
upsert(settings.hooks.PreToolUse, GATE_FILENAME, gateEntry);
|
|
919
1022
|
} else {
|
|
920
1023
|
settings.hooks.PreToolUse = [gateEntry];
|
|
921
1024
|
}
|
|
@@ -923,13 +1026,10 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
923
1026
|
// Register PreToolUse application gate (after the loading gate).
|
|
924
1027
|
const applicationCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${APPLICATION_GATE_FILENAME}`;
|
|
925
1028
|
const applicationEntry = {
|
|
926
|
-
matcher: "Write|Edit|MultiEdit",
|
|
1029
|
+
matcher: "Write|Edit|MultiEdit|Bash",
|
|
927
1030
|
hooks: [{ type: "command", command: applicationCommand }],
|
|
928
1031
|
};
|
|
929
|
-
|
|
930
|
-
entry.hooks?.some((h) => h.command?.endsWith(APPLICATION_GATE_FILENAME))
|
|
931
|
-
);
|
|
932
|
-
if (!applicationAlreadyRegistered) settings.hooks.PreToolUse.push(applicationEntry);
|
|
1032
|
+
upsert(settings.hooks.PreToolUse, APPLICATION_GATE_FILENAME, applicationEntry);
|
|
933
1033
|
|
|
934
1034
|
// Register PreToolUse ship-gate receipt check on Bash.
|
|
935
1035
|
const shipGateCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${SHIP_GATE_HOOK_FILENAME}`;
|
|
@@ -937,10 +1037,7 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
937
1037
|
matcher: "Bash",
|
|
938
1038
|
hooks: [{ type: "command", command: shipGateCommand }],
|
|
939
1039
|
};
|
|
940
|
-
|
|
941
|
-
entry.hooks?.some((h) => h.command?.endsWith(SHIP_GATE_HOOK_FILENAME))
|
|
942
|
-
);
|
|
943
|
-
if (!shipGateRegistered) settings.hooks.PreToolUse.push(shipGateEntry);
|
|
1040
|
+
upsert(settings.hooks.PreToolUse, SHIP_GATE_HOOK_FILENAME, shipGateEntry);
|
|
944
1041
|
|
|
945
1042
|
// Register PostToolUse auto-mark on Skill.
|
|
946
1043
|
const autoMarkCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${AUTO_MARK_FILENAME}`;
|
|
@@ -950,10 +1047,7 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
950
1047
|
};
|
|
951
1048
|
|
|
952
1049
|
if (Array.isArray(settings.hooks.PostToolUse)) {
|
|
953
|
-
|
|
954
|
-
entry.hooks?.some((h) => h.command?.endsWith(AUTO_MARK_FILENAME))
|
|
955
|
-
);
|
|
956
|
-
if (!exists) settings.hooks.PostToolUse.push(autoMarkEntry);
|
|
1050
|
+
upsert(settings.hooks.PostToolUse, AUTO_MARK_FILENAME, autoMarkEntry);
|
|
957
1051
|
} else {
|
|
958
1052
|
settings.hooks.PostToolUse = [autoMarkEntry];
|
|
959
1053
|
}
|
|
@@ -963,10 +1057,7 @@ export async function setupHook(targetDir = process.cwd()) {
|
|
|
963
1057
|
const gauntletEntry = { hooks: [{ type: "command", command: gauntletCommand }] };
|
|
964
1058
|
|
|
965
1059
|
if (Array.isArray(settings.hooks.Stop)) {
|
|
966
|
-
|
|
967
|
-
entry.hooks?.some((h) => h.command?.endsWith(GAUNTLET_FILENAME))
|
|
968
|
-
);
|
|
969
|
-
if (!exists) settings.hooks.Stop.push(gauntletEntry);
|
|
1060
|
+
upsert(settings.hooks.Stop, GAUNTLET_FILENAME, gauntletEntry);
|
|
970
1061
|
} else {
|
|
971
1062
|
settings.hooks.Stop = [gauntletEntry];
|
|
972
1063
|
}
|