@spardutti/claude-skills 1.25.0 → 1.27.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
@@ -101,24 +101,29 @@ The CLI will:
101
101
 
102
102
  After installing skills, the CLI asks if you want to set up automatic skill evaluation. If you say yes, it will:
103
103
 
104
- - **Create a hook** at `.claude/hooks/skill-forced-eval-hook.sh` that runs on every prompt
105
- - **Update your `CLAUDE.md`** with a `skill_evaluation` rule
104
+ - **Create a PreToolUse gate hook** at `.claude/hooks/skill-gate.sh`
105
+ - **Update your `CLAUDE.md`** with the skill-evaluation rule
106
106
 
107
- This forces Claude to explicitly evaluate every installed skill before writing code listing each skill as ACTIVATE or SKIP with a reason, then calling the relevant ones. Without this, Claude may silently ignore your skills.
107
+ The gate hard-blocks `Write`, `Edit`, and `MultiEdit` tool calls until Claude has evaluated the available skills and emitted the literal token `[skills-checked]` in its response. Once emitted, every subsequent edit in that turn passes through. The next user prompt resets the gate.
108
+
109
+ Unlike a soft reminder injected into context (which Claude can ignore), the gate denies the tool call outright — so the only path forward is to actually evaluate skills.
110
+
111
+ The gate auto-passes when the project has no skills installed, so it's safe to leave on globally.
108
112
 
109
113
  ### What gets created
110
114
 
111
- **`.claude/settings.json`** — Registers the hook:
115
+ **`.claude/settings.json`** — Registers the gate on file-writing tools:
112
116
 
113
117
  ```json
114
118
  {
115
119
  "hooks": {
116
- "UserPromptSubmit": [
120
+ "PreToolUse": [
117
121
  {
122
+ "matcher": "Write|Edit|MultiEdit",
118
123
  "hooks": [
119
124
  {
120
125
  "type": "command",
121
- "command": ".claude/hooks/skill-forced-eval-hook.sh"
126
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/skill-gate.sh"
122
127
  }
123
128
  ]
124
129
  }
@@ -127,19 +132,7 @@ This forces Claude to explicitly evaluate every installed skill before writing c
127
132
  }
128
133
  ```
129
134
 
130
- **`CLAUDE.md`** — Appends the evaluation rule:
131
-
132
- ```yaml
133
- skill_evaluation:
134
- mandatory: true
135
- rule: |
136
- BEFORE writing ANY code, you MUST:
137
- 1. List EVERY skill from the system-reminder's available skills section
138
- 2. For each skill, write: [skill-name] → ACTIVATE / SKIP — [one-line reason]
139
- 3. Call Skill(name) for every skill marked ACTIVATE
140
- 4. Only THEN proceed to implementation
141
- If you skip this evaluation, your response is INCOMPLETE and WRONG.
142
- ```
135
+ **`CLAUDE.md`** — Appends the evaluation rule, including the `[skills-checked]` sentinel that the gate looks for.
143
136
 
144
137
  ## Manual Install
145
138
 
@@ -1,65 +1,122 @@
1
1
  import { readFile, writeFile } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
 
4
- const SKILL_EVAL_BLOCK = `
5
- skill_evaluation:
6
- mandatory: true
7
- rule: |
8
- BEFORE writing ANY code, you MUST:
9
- 1. List EVERY skill from the system-reminder's available skills section
10
- 2. For each skill, write: [skill-name] ACTIVATE / SKIP — [one-line reason]
11
- 3. Call Skill(name) for every skill marked ACTIVATE
12
- 4. Only THEN proceed to implementation
13
- If you skip this evaluation, your response is INCOMPLETE and WRONG.`;
14
-
15
- const FILE_SIZE_BLOCK = `
16
- ## File Size Enforcement
4
+ const SKILL_BODY = `## Skills
5
+
6
+ BEFORE writing ANY code, you MUST:
7
+
8
+ 1. List EVERY skill available: check \`.claude/skills/\` (project) and \`~/.claude/skills/\` (global). The system-reminder's available-skills section is a hint, not the source of truth — if it's missing or empty, still check the directories.
9
+ 2. For each skill, write: [skill-name] ACTIVATE / SKIP — [one-line reason]
10
+ 3. Call Skill(name) for every skill marked ACTIVATE
11
+ 4. Emit the literal token \`[skills-checked]\` on its own line
12
+ 5. Only THEN proceed to implementation
13
+
14
+ A PreToolUse gate hook blocks Write/Edit/MultiEdit until the \`[skills-checked]\` token appears in your response since the most recent user prompt. The gate fires once per turn — the first blocked edit is the signal to evaluate skills, then retry. If you skip the evaluation, your response is INCOMPLETE and WRONG.`;
15
+
16
+ const FILE_SIZE_BODY = `## File Size Enforcement
17
17
 
18
18
  - **Never write a file longer than 200 lines of code.** If a file would exceed 200 lines, split it into smaller modules before writing.
19
19
  - This rule applies during skill evaluation: if the code you're about to write would exceed 200 lines in any single file, refactor into multiple files first.
20
20
  - Skill evaluation must check this limit as part of every ACTIVATE decision.`;
21
21
 
22
- const EVAL_MARKER = "skill_evaluation:";
23
- const FILE_SIZE_MARKER = "## File Size Enforcement";
22
+ const BLOCKS = [
23
+ {
24
+ id: "skill-evaluation",
25
+ body: SKILL_BODY,
26
+ legacyHeadings: ["## Skills"],
27
+ legacyYamlMarker: "skill_evaluation:",
28
+ },
29
+ {
30
+ id: "file-size",
31
+ body: FILE_SIZE_BODY,
32
+ legacyHeadings: ["## File Size Enforcement"],
33
+ },
34
+ ];
24
35
 
25
- export async function setupClaudeMd(targetDir = process.cwd()) {
26
- const resolved = resolve(targetDir);
27
- const claudeMdPath = join(resolved, "CLAUDE.md");
36
+ function wrap(id, body) {
37
+ return `<!-- claude-skills:${id}:start -->\n${body}\n<!-- claude-skills:${id}:end -->`;
38
+ }
28
39
 
29
- let existing = "";
30
- try {
31
- existing = await readFile(claudeMdPath, "utf-8");
32
- } catch {
33
- // File doesn't exist will create
34
- }
40
+ function spliceSentinels(content, id, replacement) {
41
+ const start = `<!-- claude-skills:${id}:start -->`;
42
+ const end = `<!-- claude-skills:${id}:end -->`;
43
+ const startIdx = content.indexOf(start);
44
+ if (startIdx === -1) return null;
45
+ const endIdx = content.indexOf(end, startIdx);
46
+ if (endIdx === -1) return null;
47
+ return content.slice(0, startIdx) + replacement + content.slice(endIdx + end.length);
48
+ }
35
49
 
36
- const hasEval = existing.includes(EVAL_MARKER);
37
- const hasFileSize = existing.includes(FILE_SIZE_MARKER);
50
+ function spliceLegacyHeading(content, heading, replacement) {
51
+ const lines = content.split("\n");
52
+ const startIdx = lines.findIndex((l) => l.trim() === heading);
53
+ if (startIdx === -1) return null;
54
+ let endIdx = lines.length;
55
+ for (let i = startIdx + 1; i < lines.length; i++) {
56
+ if (lines[i].startsWith("## ")) {
57
+ endIdx = i;
58
+ break;
59
+ }
60
+ }
61
+ return joinSplice(lines, startIdx, endIdx, replacement);
62
+ }
38
63
 
39
- if (hasEval && hasFileSize) {
40
- console.log(" CLAUDE.md already has skill_evaluation and file size rules — skipped.");
41
- return;
64
+ function spliceLegacyYaml(content, marker, replacement) {
65
+ const lines = content.split("\n");
66
+ const startIdx = lines.findIndex((l) => l.startsWith(marker));
67
+ if (startIdx === -1) return null;
68
+ let endIdx = lines.length;
69
+ for (let i = startIdx + 1; i < lines.length; i++) {
70
+ const line = lines[i];
71
+ if (line.length > 0 && !/^\s/.test(line)) {
72
+ endIdx = i;
73
+ break;
74
+ }
42
75
  }
76
+ return joinSplice(lines, startIdx, endIdx, replacement);
77
+ }
43
78
 
44
- let content = existing.trimEnd();
79
+ function joinSplice(lines, startIdx, endIdx, replacement) {
80
+ const before = lines.slice(0, startIdx).join("\n").replace(/\s+$/, "");
81
+ const after = lines.slice(endIdx).join("\n").replace(/^\s+/, "");
82
+ const parts = [before, replacement, after].filter((s) => s.length > 0);
83
+ return parts.join("\n\n");
84
+ }
45
85
 
46
- if (!hasEval) {
47
- content = content.length > 0
48
- ? content + "\n" + SKILL_EVAL_BLOCK
49
- : SKILL_EVAL_BLOCK.trimStart();
86
+ function applyBlock(content, block) {
87
+ const wrapped = wrap(block.id, block.body);
88
+ let next = spliceSentinels(content, block.id, wrapped);
89
+ if (next !== null) return { content: next, action: `replaced ${block.id}` };
90
+ for (const heading of block.legacyHeadings ?? []) {
91
+ next = spliceLegacyHeading(content, heading, wrapped);
92
+ if (next !== null) return { content: next, action: `migrated ${block.id}` };
50
93
  }
51
-
52
- if (!hasFileSize) {
53
- content = content + "\n" + FILE_SIZE_BLOCK;
94
+ if (block.legacyYamlMarker) {
95
+ next = spliceLegacyYaml(content, block.legacyYamlMarker, wrapped);
96
+ if (next !== null) return { content: next, action: `migrated ${block.id}` };
54
97
  }
98
+ const base = content.replace(/\s+$/, "");
99
+ const merged = base.length > 0 ? base + "\n\n" + wrapped : wrapped;
100
+ return { content: merged, action: `added ${block.id}` };
101
+ }
102
+
103
+ export async function setupClaudeMd(targetDir = process.cwd()) {
104
+ const claudeMdPath = join(resolve(targetDir), "CLAUDE.md");
55
105
 
56
- await writeFile(claudeMdPath, content + "\n", { mode: 0o644 });
106
+ let content = "";
107
+ try {
108
+ content = await readFile(claudeMdPath, "utf-8");
109
+ } catch {
110
+ // File doesn't exist — will create
111
+ }
57
112
 
58
- if (!hasEval && !hasFileSize) {
59
- console.log(" CLAUDE.md updated with skill_evaluation and file size rules.");
60
- } else if (!hasEval) {
61
- console.log(" CLAUDE.md updated with skill_evaluation block.");
62
- } else {
63
- console.log(" CLAUDE.md updated with file size enforcement rule.");
113
+ const actions = [];
114
+ for (const block of BLOCKS) {
115
+ const result = applyBlock(content, block);
116
+ content = result.content;
117
+ actions.push(result.action);
64
118
  }
119
+
120
+ await writeFile(claudeMdPath, content.replace(/\s+$/, "") + "\n", { mode: 0o644 });
121
+ console.log(` CLAUDE.md: ${actions.join(", ")}.`);
65
122
  }
@@ -1,95 +1,99 @@
1
1
  import { mkdir, writeFile, readFile, chmod } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
 
4
- const HOOK_SCRIPT = `#!/bin/bash
5
- # UserPromptSubmit hook that forces explicit skill evaluation
4
+ // PreToolUse gate on Write|Edit|MultiEdit. Blocks the tool call unless
5
+ // the assistant has emitted the literal sentinel [skills-checked] since
6
+ // the most recent user prompt. Resets every user turn, runs once per
7
+ // turn (subsequent edits in the same turn pass through).
8
+ //
9
+ // Pass-through cases:
10
+ // - project has no .claude/skills/*/SKILL.md files
11
+ // - transcript_path missing or unreadable
12
+ // - no user prompt found in transcript
13
+ //
14
+ // tool_result lines (which include the gate's own deny message) are
15
+ // filtered out of the sentinel scan to prevent self-satisfaction.
16
+ const GATE_SCRIPT = `#!/bin/bash
17
+ # PreToolUse gate: forces skill evaluation before file-writing tools run.
18
+
19
+ INPUT=$(cat)
6
20
 
7
- cat > /dev/null
8
-
9
- # Derive project root from the hook's own location
10
- # .claude/hooks/script.sh → go up two levels → project root
11
21
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
12
- DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
13
-
14
- # Build skill list from project skills
15
- SKILL_LIST=""
16
- while IFS= read -r skillfile; do
17
- name=$(grep -m1 '^name:' "$skillfile" 2>/dev/null | sed 's/^name: *//' | sed 's/^"//' | sed 's/"$//')
18
- desc=$(grep -m1 '^description:' "$skillfile" 2>/dev/null | sed 's/^description: *//' | sed 's/^"//' | sed 's/"$//')
19
- if [ -n "$name" ] && [ -n "$desc" ]; then
20
- SKILL_LIST="\${SKILL_LIST} - \${name}: \${desc}\\\\n"
21
- fi
22
- done < <(find "$DIR" -path '*/.claude/skills/*/SKILL.md' 2>/dev/null | sort -u)
23
-
24
- INSTRUCTION="INSTRUCTION: MANDATORY SKILL ACTIVATION SEQUENCE\\\\n\\\\n"
25
- INSTRUCTION+="<available_skills>\\\\n"
26
- INSTRUCTION+="System skills (from system-reminder):\\\\n - Check system-reminder for built-in skills\\\\n"
27
-
28
- if [ -n "$SKILL_LIST" ]; then
29
- INSTRUCTION+="Project skills:\\\\n\${SKILL_LIST}"
22
+ PROJECT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
23
+
24
+ if ! find "$PROJECT_DIR" -path '*/.claude/skills/*/SKILL.md' 2>/dev/null | grep -q .; then
25
+ exit 0
26
+ fi
27
+
28
+ TRANSCRIPT=$(printf '%s' "$INPUT" | grep -o '"transcript_path":"[^"]*"' | head -1 | sed 's/"transcript_path":"//; s/"$//')
29
+ if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then
30
+ exit 0
30
31
  fi
31
32
 
32
- INSTRUCTION+="</available_skills>\\\\n\\\\n"
33
- INSTRUCTION+="Step 1 - EVALUATE (do this in your response):\\\\n"
34
- INSTRUCTION+="For each skill in <available_skills>, state: [skill-name] - YES/NO - [reason]\\\\n\\\\n"
35
- INSTRUCTION+="Step 2 - ACTIVATE (do this immediately after Step 1):\\\\n"
36
- INSTRUCTION+="IF any skills are YES -> Use Skill(skill-name) tool for EACH relevant skill NOW\\\\n"
37
- INSTRUCTION+="IF no skills are YES -> State 'No skills needed' and proceed\\\\n\\\\n"
38
- INSTRUCTION+="Step 3 - IMPLEMENT:\\\\n"
39
- INSTRUCTION+="Only after Step 2 is complete, proceed with implementation.\\\\n\\\\n"
40
- INSTRUCTION+="CRITICAL: You MUST call Skill() tool in Step 2. Do NOT skip to implementation."
41
-
42
- printf '{"additionalContext": "%s"}\\n' "$INSTRUCTION"
33
+ LAST_PROMPT=$(grep -n '"type":"user"' "$TRANSCRIPT" 2>/dev/null | grep -v 'tool_use_id' | tail -1 | cut -d: -f1)
34
+ if [ -z "$LAST_PROMPT" ]; then
35
+ exit 0
36
+ fi
37
+
38
+ if tail -n +"$LAST_PROMPT" "$TRANSCRIPT" | grep -v 'tool_use_id' | grep -qF '[skills-checked]'; then
39
+ exit 0
40
+ fi
41
+
42
+ cat <<'EOF'
43
+ {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Skill evaluation required before writing or editing code. List each available skill as ACTIVATE or SKIP with a one-line reason, call Skill() for any ACTIVATE entries, then emit the literal token [skills-checked] (square brackets included) on its own line. Then retry the tool call."}}
44
+ EOF
43
45
  exit 0
44
46
  `;
45
47
 
46
- const HOOK_FILENAME = "skill-forced-eval-hook.sh";
48
+ const GATE_FILENAME = "skill-gate.sh";
49
+ const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
47
50
 
48
51
  export async function setupHook(targetDir = process.cwd()) {
49
52
  const resolved = resolve(targetDir);
50
53
  const hooksDir = join(resolved, ".claude", "hooks");
51
- const hookPath = join(hooksDir, HOOK_FILENAME);
54
+ const gatePath = join(hooksDir, GATE_FILENAME);
52
55
  const settingsPath = join(resolved, ".claude", "settings.json");
53
56
 
54
- // Write the UserPromptSubmit hook script
55
57
  await mkdir(hooksDir, { recursive: true });
56
- await writeFile(hookPath, HOOK_SCRIPT, { mode: 0o755 });
57
- await chmod(hookPath, 0o755);
58
+ await writeFile(gatePath, GATE_SCRIPT, { mode: 0o755 });
59
+ await chmod(gatePath, 0o755);
58
60
 
59
- // Merge into existing settings.json (don't clobber other config)
60
61
  let settings = {};
61
62
  try {
62
- const raw = await readFile(settingsPath, "utf-8");
63
- settings = JSON.parse(raw);
63
+ settings = JSON.parse(await readFile(settingsPath, "utf-8"));
64
64
  } catch {
65
- // File doesn't exist or is invalid — start fresh
65
+ // missing or invalid — start fresh
66
66
  }
67
+ if (!settings.hooks) settings.hooks = {};
67
68
 
68
- if (!settings.hooks) {
69
- settings.hooks = {};
69
+ // Clean up legacy UserPromptSubmit eval hook (replaced by the gate).
70
+ if (Array.isArray(settings.hooks.UserPromptSubmit)) {
71
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
72
+ (entry) => !entry.hooks?.some((h) => h.command?.endsWith(LEGACY_EVAL_FILENAME))
73
+ );
74
+ if (settings.hooks.UserPromptSubmit.length === 0) {
75
+ delete settings.hooks.UserPromptSubmit;
76
+ }
70
77
  }
71
78
 
72
- // --- UserPromptSubmit hook (forced eval via command) ---
73
- const hookCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${HOOK_FILENAME}`;
74
- const promptHookEntry = {
75
- hooks: [{ type: "command", command: hookCommand }],
79
+ // Register PreToolUse gate.
80
+ const gateCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${GATE_FILENAME}`;
81
+ const gateEntry = {
82
+ matcher: "Write|Edit|MultiEdit",
83
+ hooks: [{ type: "command", command: gateCommand }],
76
84
  };
77
85
 
78
- if (Array.isArray(settings.hooks.UserPromptSubmit)) {
79
- const alreadyInstalled = settings.hooks.UserPromptSubmit.some((entry) =>
80
- entry.hooks?.some((h) => h.command?.endsWith(HOOK_FILENAME))
86
+ if (Array.isArray(settings.hooks.PreToolUse)) {
87
+ const exists = settings.hooks.PreToolUse.some((entry) =>
88
+ entry.hooks?.some((h) => h.command?.endsWith(GATE_FILENAME))
81
89
  );
82
- if (!alreadyInstalled) {
83
- settings.hooks.UserPromptSubmit.push(promptHookEntry);
84
- }
90
+ if (!exists) settings.hooks.PreToolUse.push(gateEntry);
85
91
  } else {
86
- settings.hooks.UserPromptSubmit = [promptHookEntry];
92
+ settings.hooks.PreToolUse = [gateEntry];
87
93
  }
88
94
 
89
- await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", {
90
- mode: 0o644,
91
- });
95
+ await writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n", { mode: 0o644 });
92
96
 
93
- console.log(` Hook installed: .claude/hooks/${HOOK_FILENAME}`);
97
+ console.log(` Hook installed: .claude/hooks/${GATE_FILENAME}`);
94
98
  console.log(` Settings updated: .claude/settings.json`);
95
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "1.25.0",
3
+ "version": "1.27.0",
4
4
  "description": "CLI to install Claude Code skills from the claude-skills collection",
5
5
  "type": "module",
6
6
  "bin": {