@spardutti/claude-skills 2.2.0 → 2.4.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/lib/github.mjs CHANGED
@@ -67,17 +67,57 @@ async function fetchListing({ apiUrl, label, entryFilter, buildRawUrl, mapEntry,
67
67
  return results.filter(Boolean);
68
68
  }
69
69
 
70
- export function fetchSkills() {
71
- return fetchListing({
72
- apiUrl: CONTENTS_API,
73
- label: "skills",
74
- entryFilter: (e) => e.type === "dir",
75
- buildRawUrl: (dir) => `${RAW_BASE}/${dir.name}/SKILL.md`,
76
- mapEntry: (dir, content) => {
77
- const { name, description, category } = parseFrontmatter(content, dir.name);
78
- return { dirName: dir.name, name, description, category, content };
79
- },
80
- });
70
+ export async function fetchSkills() {
71
+ const headers = getAuthHeaders();
72
+ const res = await fetch(CONTENTS_API, { headers });
73
+
74
+ if (!res.ok) {
75
+ if (res.status === 403 || res.status === 429) {
76
+ throw new Error("GitHub API rate limit exceeded. Try again later or install gh CLI (https://cli.github.com).");
77
+ }
78
+ throw new Error(`Failed to list skills: ${res.status} ${res.statusText}`);
79
+ }
80
+
81
+ const dirs = (await res.json()).filter((e) => e.type === "dir");
82
+
83
+ const skills = await Promise.all(
84
+ dirs.map(async (dir) => {
85
+ try {
86
+ const listRes = await fetch(`${CONTENTS_API}/${dir.name}`, { headers });
87
+ if (!listRes.ok) {
88
+ console.warn(` Warning: Failed to list skill ${dir.name}, skipping`);
89
+ return null;
90
+ }
91
+ const files = (await listRes.json()).filter((e) => e.type === "file");
92
+
93
+ const fetched = await Promise.all(
94
+ files.map(async (f) => {
95
+ const r = await fetch(`${RAW_BASE}/${dir.name}/${f.name}`, { headers });
96
+ if (!r.ok) {
97
+ console.warn(` Warning: Failed to fetch ${dir.name}/${f.name}, skipping`);
98
+ return null;
99
+ }
100
+ return { name: f.name, content: await r.text(), executable: f.name.endsWith(".sh") };
101
+ })
102
+ );
103
+
104
+ const valid = fetched.filter(Boolean);
105
+ const skillMd = valid.find((f) => f.name === "SKILL.md");
106
+ if (!skillMd) {
107
+ console.warn(` Warning: ${dir.name}/SKILL.md missing, skipping`);
108
+ return null;
109
+ }
110
+ const peerFiles = valid.filter((f) => f.name !== "SKILL.md");
111
+ const { name, description, category } = parseFrontmatter(skillMd.content, dir.name);
112
+ return { dirName: dir.name, name, description, category, content: skillMd.content, peerFiles };
113
+ } catch {
114
+ console.warn(` Warning: Failed to fetch ${dir.name}, skipping`);
115
+ return null;
116
+ }
117
+ })
118
+ );
119
+
120
+ return skills.filter(Boolean);
81
121
  }
82
122
 
83
123
  export function fetchCommands() {
package/lib/install.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
1
+ import { mkdir, writeFile, chmod } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import chalk from "chalk";
4
4
 
@@ -24,6 +24,13 @@ export async function installSkills(skills, targetDir = process.cwd()) {
24
24
  const skillDir = join(baseDir, skill.dirName);
25
25
  await mkdir(skillDir, { recursive: true });
26
26
  await writeFile(join(skillDir, "SKILL.md"), skill.content);
27
+
28
+ for (const peer of skill.peerFiles ?? []) {
29
+ const peerPath = join(skillDir, peer.name);
30
+ await writeFile(peerPath, peer.content);
31
+ if (peer.executable) await chmod(peerPath, 0o755);
32
+ }
33
+
27
34
  console.log(` ${chalk.green("✔")} ${chalk.bold(humanName(skill))} ${chalk.dim(`→ .claude/skills/${skill.dirName}/`)}`);
28
35
  }
29
36
  }
@@ -1,4 +1,4 @@
1
- import { mkdir, writeFile, readFile, chmod } from "node:fs/promises";
1
+ import { mkdir, writeFile, readFile, chmod, unlink } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
 
4
4
  // PreToolUse gate on Write|Edit|MultiEdit. Blocks the tool call unless
@@ -39,9 +39,12 @@ EOF
39
39
  exit 0
40
40
  `;
41
41
 
42
- // PostToolUse on Skill: auto-creates the per-session gate marker.
42
+ // PostToolUse on Skill: auto-creates the per-session gate marker AND a
43
+ // per-skill "loaded" marker. The application gate uses the loaded marker
44
+ // to require the model to explicitly apply each loaded skill before the
45
+ // first Write/Edit in this session.
43
46
  const AUTO_MARK_SCRIPT = `#!/bin/bash
44
- # PostToolUse on Skill: auto-marks the skill-gate as satisfied for the session.
47
+ # PostToolUse on Skill: marks gate satisfied + records loaded skill.
45
48
 
46
49
  INPUT=$(cat)
47
50
 
@@ -51,18 +54,108 @@ if [ -z "$SESSION_ID" ]; then
51
54
  fi
52
55
 
53
56
  touch "/tmp/claude-skill-gate-$SESSION_ID"
57
+
58
+ SKILL_NAME=$(printf '%s' "$INPUT" | grep -o '"skill":"[^"]*"' | head -1 | sed 's/"skill":"//; s/"$//')
59
+ if [ -n "$SKILL_NAME" ]; then
60
+ # Sanitize: only allow [A-Za-z0-9_-] in the marker filename.
61
+ SAFE_NAME=$(printf '%s' "$SKILL_NAME" | tr -cd 'A-Za-z0-9_-')
62
+ if [ -n "$SAFE_NAME" ]; then
63
+ touch "/tmp/claude-skill-loaded-$SESSION_ID-$SAFE_NAME"
64
+ fi
65
+ fi
66
+
67
+ exit 0
68
+ `;
69
+
70
+ // PreToolUse application gate: requires the model to explicitly apply each
71
+ // loaded skill before the first Write/Edit. Reads SKILL.md's Rules section
72
+ // and inlines it in the deny message. One ack per skill per session.
73
+ const APPLICATION_GATE_SCRIPT = `#!/bin/bash
74
+ # PreToolUse application gate: blocks file edits until each loaded skill
75
+ # has been explicitly applied (acked) for this session.
76
+
77
+ INPUT=$(cat)
78
+
79
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
80
+ PROJECT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
81
+
82
+ SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | sed 's/"session_id":"//; s/"$//')
83
+ if [ -z "$SESSION_ID" ]; then
84
+ exit 0
85
+ fi
86
+
87
+ # Defer to the loading gate until it's been satisfied this session.
88
+ if [ ! -f "/tmp/claude-skill-gate-$SESSION_ID" ]; then
89
+ exit 0
90
+ fi
91
+
92
+ # Find the first loaded-but-unacked skill (handle one at a time; the next
93
+ # blocked write will surface the next skill).
94
+ UNACKED=""
95
+ for marker in /tmp/claude-skill-loaded-$SESSION_ID-*; do
96
+ [ ! -f "$marker" ] && continue
97
+ skill_name="\${marker##/tmp/claude-skill-loaded-$SESSION_ID-}"
98
+ if [ ! -f "/tmp/claude-skill-acked-$SESSION_ID-$skill_name" ]; then
99
+ UNACKED="$skill_name"
100
+ break
101
+ fi
102
+ done
103
+
104
+ if [ -z "$UNACKED" ]; then
105
+ exit 0
106
+ fi
107
+
108
+ SKILL_MD="$PROJECT_DIR/.claude/skills/$UNACKED/SKILL.md"
109
+ RULES=""
110
+ if [ -f "$SKILL_MD" ]; then
111
+ RULES=$(awk '/^## Rules/{flag=1} /^## /{if(flag && !/^## Rules/)exit} flag' "$SKILL_MD")
112
+ fi
113
+ if [ -z "$RULES" ]; then
114
+ RULES="(Rules section not found in $SKILL_MD — refer to the loaded skill content already in context.)"
115
+ fi
116
+
117
+ MSG="BLOCKED: skill '$UNACKED' was loaded but not yet applied to your work.
118
+
119
+ Before this Write/Edit, you must:
120
+
121
+ 1. State the specific rules from '$UNACKED' that apply to the file you're about to write.
122
+ 2. State how your next write respects each rule.
123
+ 3. Then ack the application by running this Bash tool call:
124
+ touch /tmp/claude-skill-acked-$SESSION_ID-$UNACKED
125
+
126
+ One ack per skill per session. After acking, retry the Write/Edit.
127
+
128
+ Rules from $UNACKED/SKILL.md:
129
+
130
+ $RULES"
131
+
132
+ json_escape() {
133
+ local s="$1"
134
+ s="\${s//\\\\/\\\\\\\\}"
135
+ s="\${s//\\"/\\\\\\"}"
136
+ s="\${s//$'\\n'/\\\\n}"
137
+ s="\${s//$'\\r'/\\\\r}"
138
+ s="\${s//$'\\t'/\\\\t}"
139
+ printf '"%s"' "$s"
140
+ }
141
+
142
+ REASON=$(json_escape "$MSG")
143
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\\n' "$REASON"
54
144
  exit 0
55
145
  `;
56
146
 
57
147
  const GATE_FILENAME = "skill-gate.sh";
58
148
  const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
149
+ const APPLICATION_GATE_FILENAME = "skill-application-gate.sh";
59
150
  const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
151
+ const LEGACY_AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
60
152
 
61
153
  export async function setupHook(targetDir = process.cwd()) {
62
154
  const resolved = resolve(targetDir);
63
155
  const hooksDir = join(resolved, ".claude", "hooks");
64
156
  const gatePath = join(hooksDir, GATE_FILENAME);
65
157
  const autoMarkPath = join(hooksDir, AUTO_MARK_FILENAME);
158
+ const applicationGatePath = join(hooksDir, APPLICATION_GATE_FILENAME);
66
159
  const settingsPath = join(resolved, ".claude", "settings.json");
67
160
 
68
161
  await mkdir(hooksDir, { recursive: true });
@@ -70,6 +163,15 @@ export async function setupHook(targetDir = process.cwd()) {
70
163
  await chmod(gatePath, 0o755);
71
164
  await writeFile(autoMarkPath, AUTO_MARK_SCRIPT, { mode: 0o755 });
72
165
  await chmod(autoMarkPath, 0o755);
166
+ await writeFile(applicationGatePath, APPLICATION_GATE_SCRIPT, { mode: 0o755 });
167
+ await chmod(applicationGatePath, 0o755);
168
+
169
+ // Remove the legacy audit-runner hook file from prior installs (2.3.x).
170
+ try {
171
+ await unlink(join(hooksDir, LEGACY_AUDIT_RUNNER_FILENAME));
172
+ } catch {
173
+ // not present — fine
174
+ }
73
175
 
74
176
  let settings = {};
75
177
  try {
@@ -89,6 +191,13 @@ export async function setupHook(targetDir = process.cwd()) {
89
191
  }
90
192
  }
91
193
 
194
+ // Clean up legacy PreToolUse audit-runner hook (2.3.x — removed in 2.4.0).
195
+ if (Array.isArray(settings.hooks.PreToolUse)) {
196
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(
197
+ (entry) => !entry.hooks?.some((h) => h.command?.endsWith(LEGACY_AUDIT_RUNNER_FILENAME))
198
+ );
199
+ }
200
+
92
201
  // Register PreToolUse gate.
93
202
  const gateCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${GATE_FILENAME}`;
94
203
  const gateEntry = {
@@ -105,6 +214,17 @@ export async function setupHook(targetDir = process.cwd()) {
105
214
  settings.hooks.PreToolUse = [gateEntry];
106
215
  }
107
216
 
217
+ // Register PreToolUse application gate (after the loading gate).
218
+ const applicationCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${APPLICATION_GATE_FILENAME}`;
219
+ const applicationEntry = {
220
+ matcher: "Write|Edit|MultiEdit",
221
+ hooks: [{ type: "command", command: applicationCommand }],
222
+ };
223
+ const applicationAlreadyRegistered = settings.hooks.PreToolUse.some((entry) =>
224
+ entry.hooks?.some((h) => h.command?.endsWith(APPLICATION_GATE_FILENAME))
225
+ );
226
+ if (!applicationAlreadyRegistered) settings.hooks.PreToolUse.push(applicationEntry);
227
+
108
228
  // Register PostToolUse auto-mark on Skill.
109
229
  const autoMarkCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${AUTO_MARK_FILENAME}`;
110
230
  const autoMarkEntry = {
@@ -125,5 +245,6 @@ export async function setupHook(targetDir = process.cwd()) {
125
245
 
126
246
  console.log(` Hook installed: .claude/hooks/${GATE_FILENAME}`);
127
247
  console.log(` Hook installed: .claude/hooks/${AUTO_MARK_FILENAME}`);
248
+ console.log(` Hook installed: .claude/hooks/${APPLICATION_GATE_FILENAME}`);
128
249
  console.log(` Settings updated: .claude/settings.json`);
129
250
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "CLI to install Claude Code skills from the claude-skills collection",
5
5
  "type": "module",
6
6
  "bin": {