@spardutti/claude-skills 2.2.0 → 2.3.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
  }
@@ -54,8 +54,78 @@ touch "/tmp/claude-skill-gate-$SESSION_ID"
54
54
  exit 0
55
55
  `;
56
56
 
57
+ // PreToolUse audit runner: runs each installed skill's audit.sh against the
58
+ // write target. Denies the write if any audit exits 2. Defers (exits 0) when
59
+ // the skill-gate marker isn't set yet — the gate handles that case.
60
+ //
61
+ // Honors .claude/skill-audit-ignore (one skill name per line, # comments OK).
62
+ const AUDIT_RUNNER_SCRIPT = `#!/bin/bash
63
+ # PreToolUse skill-audit runner.
64
+
65
+ INPUT=$(cat)
66
+
67
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
68
+ PROJECT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
69
+
70
+ SESSION_ID=$(printf '%s' "$INPUT" | grep -o '"session_id":"[^"]*"' | head -1 | sed 's/"session_id":"//; s/"$//')
71
+ if [ -z "$SESSION_ID" ]; then
72
+ exit 0
73
+ fi
74
+
75
+ # Defer until the skill-gate has been satisfied in this session.
76
+ if [ ! -f "/tmp/claude-skill-gate-$SESSION_ID" ]; then
77
+ exit 0
78
+ fi
79
+
80
+ FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path":"[^"]*"' | head -1 | sed 's/"file_path":"//; s/"$//')
81
+ if [ -z "$FILE_PATH" ]; then
82
+ exit 0
83
+ fi
84
+
85
+ SKILLS_DIR="$PROJECT_DIR/.claude/skills"
86
+ [ ! -d "$SKILLS_DIR" ] && exit 0
87
+
88
+ IGNORE_FILE="$PROJECT_DIR/.claude/skill-audit-ignore"
89
+ is_ignored() {
90
+ [ ! -f "$IGNORE_FILE" ] && return 1
91
+ grep -qE "^[[:space:]]*\${1}[[:space:]]*(#.*)?$" "$IGNORE_FILE"
92
+ }
93
+
94
+ # Portable JSON string escape for the deny reason.
95
+ json_escape() {
96
+ local s="$1"
97
+ s="\${s//\\\\/\\\\\\\\}"
98
+ s="\${s//\\"/\\\\\\"}"
99
+ s="\${s//$'\\n'/\\\\n}"
100
+ s="\${s//$'\\r'/\\\\r}"
101
+ s="\${s//$'\\t'/\\\\t}"
102
+ printf '"%s"' "$s"
103
+ }
104
+
105
+ for audit in "$SKILLS_DIR"/*/audit.sh; do
106
+ [ ! -f "$audit" ] && continue
107
+ [ ! -x "$audit" ] && continue
108
+ skill_name=$(basename "$(dirname "$audit")")
109
+ if is_ignored "$skill_name"; then
110
+ continue
111
+ fi
112
+
113
+ output=$("$audit" "$FILE_PATH" 2>&1 >/dev/null)
114
+ rc=$?
115
+
116
+ if [ $rc -eq 2 ]; then
117
+ reason=$(json_escape "$output")
118
+ printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\\n' "$reason"
119
+ exit 0
120
+ fi
121
+ done
122
+
123
+ exit 0
124
+ `;
125
+
57
126
  const GATE_FILENAME = "skill-gate.sh";
58
127
  const AUTO_MARK_FILENAME = "skill-gate-automark.sh";
128
+ const AUDIT_RUNNER_FILENAME = "skill-audit-runner.sh";
59
129
  const LEGACY_EVAL_FILENAME = "skill-forced-eval-hook.sh";
60
130
 
61
131
  export async function setupHook(targetDir = process.cwd()) {
@@ -63,6 +133,7 @@ export async function setupHook(targetDir = process.cwd()) {
63
133
  const hooksDir = join(resolved, ".claude", "hooks");
64
134
  const gatePath = join(hooksDir, GATE_FILENAME);
65
135
  const autoMarkPath = join(hooksDir, AUTO_MARK_FILENAME);
136
+ const auditRunnerPath = join(hooksDir, AUDIT_RUNNER_FILENAME);
66
137
  const settingsPath = join(resolved, ".claude", "settings.json");
67
138
 
68
139
  await mkdir(hooksDir, { recursive: true });
@@ -70,6 +141,8 @@ export async function setupHook(targetDir = process.cwd()) {
70
141
  await chmod(gatePath, 0o755);
71
142
  await writeFile(autoMarkPath, AUTO_MARK_SCRIPT, { mode: 0o755 });
72
143
  await chmod(autoMarkPath, 0o755);
144
+ await writeFile(auditRunnerPath, AUDIT_RUNNER_SCRIPT, { mode: 0o755 });
145
+ await chmod(auditRunnerPath, 0o755);
73
146
 
74
147
  let settings = {};
75
148
  try {
@@ -105,6 +178,17 @@ export async function setupHook(targetDir = process.cwd()) {
105
178
  settings.hooks.PreToolUse = [gateEntry];
106
179
  }
107
180
 
181
+ // Register PreToolUse audit runner (after the gate).
182
+ const auditCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${AUDIT_RUNNER_FILENAME}`;
183
+ const auditEntry = {
184
+ matcher: "Write|Edit|MultiEdit",
185
+ hooks: [{ type: "command", command: auditCommand }],
186
+ };
187
+ const auditAlreadyRegistered = settings.hooks.PreToolUse.some((entry) =>
188
+ entry.hooks?.some((h) => h.command?.endsWith(AUDIT_RUNNER_FILENAME))
189
+ );
190
+ if (!auditAlreadyRegistered) settings.hooks.PreToolUse.push(auditEntry);
191
+
108
192
  // Register PostToolUse auto-mark on Skill.
109
193
  const autoMarkCommand = `$CLAUDE_PROJECT_DIR/.claude/hooks/${AUTO_MARK_FILENAME}`;
110
194
  const autoMarkEntry = {
@@ -125,5 +209,6 @@ export async function setupHook(targetDir = process.cwd()) {
125
209
 
126
210
  console.log(` Hook installed: .claude/hooks/${GATE_FILENAME}`);
127
211
  console.log(` Hook installed: .claude/hooks/${AUTO_MARK_FILENAME}`);
212
+ console.log(` Hook installed: .claude/hooks/${AUDIT_RUNNER_FILENAME}`);
128
213
  console.log(` Settings updated: .claude/settings.json`);
129
214
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spardutti/claude-skills",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "CLI to install Claude Code skills from the claude-skills collection",
5
5
  "type": "module",
6
6
  "bin": {