@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 +51 -11
- package/lib/install.mjs +8 -1
- package/lib/setup-hook.mjs +124 -3
- package/package.json +1 -1
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
}
|
package/lib/setup-hook.mjs
CHANGED
|
@@ -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:
|
|
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
|
}
|