skillrepo 1.7.0 → 1.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Set up SkillRepo in any IDE — one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SkillRepo PreToolUse hook — detects when skills are actually USED during a
4
+ * coding session by matching tool/command patterns against skill-declared
5
+ * `contextSignals.toolPatterns`. Telemetry-only: no content injection, no blocking.
6
+ *
7
+ * Standalone script: no npm dependencies, Node.js built-ins only.
8
+ * Installed by `npx skillrepo init` to `.claude/hooks/skillrepo-pretool-activation.mjs`.
9
+ *
10
+ * Part of #569 (PreToolUse hook for tool fingerprinting activation telemetry).
11
+ */
12
+
13
+ import { readFileSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { homedir, tmpdir } from "node:os";
17
+ import { createHash } from "node:crypto";
18
+ import { execSync } from "node:child_process";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Constants
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const DEFAULT_SERVER_URL = "https://skillrepo.dev";
25
+ const MAX_EVENTS_PER_BATCH = 50;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Config resolution (lightweight — only needs API key and server URL)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Read config from ~/.claude/skillrepo/config.json with fallbacks.
33
+ * Returns { apiKey, serverUrl } or null.
34
+ */
35
+ export function readConfig() {
36
+ const configPath = join(homedir(), ".claude", "skillrepo", "config.json");
37
+ try {
38
+ const cfg = JSON.parse(readFileSync(configPath, "utf-8"));
39
+ if (cfg.apiKey) {
40
+ return { apiKey: cfg.apiKey, serverUrl: cfg.serverUrl || DEFAULT_SERVER_URL };
41
+ }
42
+ } catch { /* not found */ }
43
+
44
+ const envKey = process.env.SKILLREPO_ACCESS_KEY;
45
+ if (envKey) {
46
+ return { apiKey: envKey, serverUrl: process.env.SKILLREPO_SERVER_URL || DEFAULT_SERVER_URL };
47
+ }
48
+
49
+ // .env.local fallback
50
+ for (const file of [".env.local", ".env"]) {
51
+ try {
52
+ const lines = readFileSync(join(process.cwd(), file), "utf-8").split("\n");
53
+ for (const line of lines) {
54
+ const trimmed = line.trim();
55
+ if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
56
+ const eqIdx = trimmed.indexOf("=");
57
+ if (trimmed.slice(0, eqIdx).trim() === "SKILLREPO_ACCESS_KEY") {
58
+ let val = trimmed.slice(eqIdx + 1).trim();
59
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
60
+ val = val.slice(1, -1);
61
+ }
62
+ val = val.replace(/\s+#.*$/, "");
63
+ if (val) return { apiKey: val, serverUrl: DEFAULT_SERVER_URL };
64
+ }
65
+ }
66
+ } catch { /* file doesn't exist */ }
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Tool pattern matching
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Match a tool invocation against all skills' contextSignals.toolPatterns.
78
+ *
79
+ * For Bash tools: prefix-match tool_input.command against each pattern.
80
+ * For non-Bash tools: match tool_name against patterns.
81
+ *
82
+ * Pattern matching rules:
83
+ * - Case-insensitive
84
+ * - Prefix match: pattern "gh issue" matches command "gh issue create --title ..."
85
+ * - Pattern must match at a word boundary (don't match "npm" against "npmx")
86
+ *
87
+ * Returns array of { skill, pattern } matches.
88
+ */
89
+ export function matchToolPatterns(toolName, toolInput, skills) {
90
+ if (!toolName || !skills?.length) return [];
91
+
92
+ const isBash = toolName.toLowerCase() === "bash";
93
+ const matches = [];
94
+
95
+ for (const skill of skills) {
96
+ const patterns = skill.contextSignals?.toolPatterns;
97
+ if (!patterns?.length) continue;
98
+
99
+ for (const pattern of patterns) {
100
+ if (!pattern) continue;
101
+ const patternLower = pattern.toLowerCase();
102
+
103
+ if (isBash) {
104
+ // Bash tool: prefix-match against command
105
+ const command = (toolInput?.command ?? "").trim();
106
+ if (!command) continue;
107
+ const commandLower = command.toLowerCase();
108
+
109
+ if (commandLower.startsWith(patternLower)) {
110
+ // Word boundary check: the character after the pattern must be
111
+ // end-of-string, whitespace, or a non-alphanumeric character
112
+ const afterIdx = patternLower.length;
113
+ if (afterIdx >= commandLower.length || /[^a-z0-9_]/i.test(commandLower[afterIdx])) {
114
+ matches.push({ skill, pattern });
115
+ break; // one match per skill is enough
116
+ }
117
+ }
118
+ } else {
119
+ // Non-Bash tool: match tool_name against patterns
120
+ const toolNameLower = toolName.toLowerCase();
121
+ if (toolNameLower === patternLower) {
122
+ matches.push({ skill, pattern });
123
+ break; // one match per skill is enough
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ return matches;
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Session dedup
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /**
137
+ * Read activation state for deduplication.
138
+ * State is stored in tmpdir keyed by session hash.
139
+ */
140
+ export function readActivationState(sessionId) {
141
+ const hash = createHash("sha256").update(sessionId || "default").digest("hex").slice(0, 16);
142
+ const statePath = join(tmpdir(), `skillrepo-activation-${hash}.json`);
143
+
144
+ try {
145
+ return { path: statePath, state: JSON.parse(readFileSync(statePath, "utf-8")) };
146
+ } catch {
147
+ return { path: statePath, state: { reported: {} } };
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Filter matches to exclude skills already reported in this session.
153
+ */
154
+ export function deduplicateActivations(matches, sessionState) {
155
+ return matches.filter(m => {
156
+ const key = `${m.skill.owner}/${m.skill.name}`;
157
+ return !sessionState.reported[key];
158
+ });
159
+ }
160
+
161
+ /**
162
+ * Mark skills as reported in session state.
163
+ */
164
+ export function updateActivationState(statePath, state, reportedMatches) {
165
+ for (const m of reportedMatches) {
166
+ const key = `${m.skill.owner}/${m.skill.name}`;
167
+ state.reported[key] = new Date().toISOString();
168
+ }
169
+ try { writeFileSync(statePath, JSON.stringify(state), "utf-8"); }
170
+ catch { /* non-critical */ }
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // GitHub username resolution
175
+ // ---------------------------------------------------------------------------
176
+
177
+ const GITHUB_USERNAME_CACHE_PATH = join(tmpdir(), "skillrepo-gh-user.json");
178
+
179
+ /**
180
+ * Resolve GitHub username via `gh api user`. Cached per session.
181
+ */
182
+ export function resolveGithubUsername() {
183
+ // Check cache first
184
+ try {
185
+ const cached = JSON.parse(readFileSync(GITHUB_USERNAME_CACHE_PATH, "utf-8"));
186
+ if (cached.username && cached.expiresAt > Date.now()) return cached.username;
187
+ } catch { /* no cache */ }
188
+
189
+ // Resolve via gh CLI
190
+ try {
191
+ const result = execSync("gh api user --jq .login", {
192
+ encoding: "utf-8",
193
+ timeout: 3_000,
194
+ stdio: ["pipe", "pipe", "pipe"],
195
+ }).trim();
196
+
197
+ if (result) {
198
+ // Cache for 1 hour
199
+ try {
200
+ writeFileSync(GITHUB_USERNAME_CACHE_PATH, JSON.stringify({
201
+ username: result,
202
+ expiresAt: Date.now() + 3_600_000,
203
+ }), "utf-8");
204
+ } catch { /* non-critical */ }
205
+ return result;
206
+ }
207
+ } catch { /* gh not installed or not authed */ }
208
+
209
+ return null;
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Telemetry payload
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /**
217
+ * Build telemetry payload for tool-pattern-activated skills.
218
+ */
219
+ export function buildTelemetryPayload(matches, sessionInfo) {
220
+ const events = matches.slice(0, MAX_EVENTS_PER_BATCH).map(m => ({
221
+ skillOwner: m.skill.owner,
222
+ skillName: m.skill.name,
223
+ skillVersion: m.skill.version || undefined,
224
+ activatedAt: new Date().toISOString(),
225
+ ide: sessionInfo.ide || "claude-code",
226
+ sessionHash: sessionInfo.sessionHash,
227
+ githubUsername: sessionInfo.githubUsername || undefined,
228
+ source: "pretool_hook",
229
+ toolPattern: m.pattern,
230
+ }));
231
+
232
+ return { events };
233
+ }
234
+
235
+ /**
236
+ * Fire-and-forget POST to telemetry endpoint.
237
+ *
238
+ * NOT awaited — the spec requires "do NOT block the agent" and Claude Code
239
+ * waits for hook process exit. The OS TCP stack flushes small payloads after
240
+ * process exit, so delivery reliability is ~99%+ for responsive servers.
241
+ * Occasional loss on slow/unreachable servers is acceptable for non-critical
242
+ * telemetry.
243
+ */
244
+ export function sendTelemetry(config, payload) {
245
+ const url = `${config.serverUrl}/api/v1/telemetry/activation`;
246
+
247
+ fetch(url, {
248
+ method: "POST",
249
+ headers: {
250
+ Authorization: `Bearer ${config.apiKey}`,
251
+ "Content-Type": "application/json",
252
+ },
253
+ body: JSON.stringify(payload),
254
+ }).catch(() => { /* telemetry errors are non-critical */ });
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Main entry point
259
+ // ---------------------------------------------------------------------------
260
+
261
+ export async function main(input) {
262
+ const indexPath = join(homedir(), ".claude", "skillrepo", "index.json");
263
+
264
+ // -- Read index --
265
+ let index;
266
+ try {
267
+ index = JSON.parse(readFileSync(indexPath, "utf-8"));
268
+ } catch {
269
+ // No index -> nothing to match. Exit cleanly.
270
+ process.stdout.write("{}");
271
+ return;
272
+ }
273
+
274
+ if (!index.skills?.length) {
275
+ process.stdout.write("{}");
276
+ return;
277
+ }
278
+
279
+ // -- Read config (needed for telemetry POST) --
280
+ const config = readConfig();
281
+ if (!config) {
282
+ process.stdout.write("{}");
283
+ return;
284
+ }
285
+
286
+ // -- Match tool against skills --
287
+ const toolName = input.tool_name ?? "";
288
+ const toolInput = input.tool_input ?? {};
289
+ const matches = matchToolPatterns(toolName, toolInput, index.skills);
290
+
291
+ if (matches.length === 0) {
292
+ process.stdout.write("{}");
293
+ return;
294
+ }
295
+
296
+ // -- Session dedup --
297
+ const sessionId = input.session_id ?? "default";
298
+ const { path: statePath, state } = readActivationState(sessionId);
299
+ const newMatches = deduplicateActivations(matches, state);
300
+
301
+ if (newMatches.length === 0) {
302
+ process.stdout.write("{}");
303
+ return;
304
+ }
305
+
306
+ // -- Build and send telemetry --
307
+ const sessionHash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
308
+
309
+ // Skip GitHub username resolution — it can block up to 3s on cold cache via
310
+ // `gh api user`. The prompt-match hook already identifies the session, and
311
+ // the server can correlate by API key if needed.
312
+ const payload = buildTelemetryPayload(newMatches, {
313
+ ide: "claude-code",
314
+ sessionHash,
315
+ githubUsername: "",
316
+ });
317
+
318
+ sendTelemetry(config, payload); // fire-and-forget -- do NOT await
319
+
320
+ // -- Update session state --
321
+ updateActivationState(statePath, state, newMatches);
322
+
323
+ // -- Output: NO additionalContext --
324
+ process.stdout.write("{}");
325
+ }
326
+
327
+ // -- Run --
328
+ const __filename = fileURLToPath(import.meta.url);
329
+ const isMainModule = process.argv[1] === __filename;
330
+
331
+ if (isMainModule) {
332
+ let inputBuf = "";
333
+ for await (const chunk of process.stdin) inputBuf += chunk;
334
+
335
+ let input;
336
+ try { input = JSON.parse(inputBuf); }
337
+ catch { process.stdout.write("{}"); process.exit(0); }
338
+
339
+ main(input).catch(() => {
340
+ process.stdout.write("{}");
341
+ process.exit(0);
342
+ });
343
+ }
@@ -441,7 +441,10 @@ export function writeRulesFiles(selectedSkills, projectDir) {
441
441
  written.add(baseName);
442
442
 
443
443
  // Claude Code: .claude/rules/skillrepo-{owner}-{name}.md
444
- atomicWrite(join(claudeRulesDir, `${baseName}.md`), skillMd.content);
444
+ // Append self-report instruction so the agent calls report_skill_activation
445
+ // when it follows this skill's instructions. Probabilistic but measurable.
446
+ const selfReportSuffix = `\n\n---\n_When you follow instructions from this skill, call \`report_skill_activation\` with owner "${skill.owner}" and name "${skill.name}" to report usage._`;
447
+ atomicWrite(join(claudeRulesDir, `${baseName}.md`), skillMd.content + selfReportSuffix);
445
448
 
446
449
  // Cursor: .cursor/rules/skillrepo-{owner}-{name}.mdc
447
450
  const mdcContent = buildCursorMdc(skill, skillMd.content);
@@ -9,6 +9,8 @@
9
9
  import { execFile } from "node:child_process";
10
10
  import { join, dirname } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
+ import { homedir } from "node:os";
13
+ import { unlinkSync } from "node:fs";
12
14
 
13
15
  const __filename = fileURLToPath(import.meta.url);
14
16
  const __dirname = dirname(__filename);
@@ -23,9 +25,21 @@ function syncHookPath() {
23
25
  /**
24
26
  * Run the sync hook as a subprocess.
25
27
  * Pipes '{}' as stdin (SessionStart hook input format).
28
+ *
29
+ * Deletes .last-sync before running so the hook performs a FULL sync
30
+ * instead of a delta. This is critical: if init is re-run, the previous
31
+ * .last-sync timestamp would cause a delta sync that returns 0 skills
32
+ * (nothing changed since that timestamp), leaving the index empty.
33
+ *
26
34
  * Returns a promise that resolves on success, rejects on failure.
27
35
  */
28
36
  export function runFirstSync() {
37
+ // Force full sync — delete the delta-sync marker so the hook fetches
38
+ // the complete library instead of only changes since the last sync.
39
+ try {
40
+ unlinkSync(join(homedir(), ".claude", "skillrepo", ".last-sync"));
41
+ } catch { /* doesn't exist yet — fine */ }
42
+
29
43
  return new Promise((resolve, reject) => {
30
44
  const child = execFile(
31
45
  process.execPath,
@@ -5,25 +5,26 @@
5
5
  * Phase C (#535): Hook scripts are bundled with the CLI package and copied
6
6
  * to .claude/hooks/ — no longer generated from server template strings.
7
7
  *
8
- * Merge strategy: add SessionStart and UserPromptSubmit entries
8
+ * Merge strategy: add SessionStart, UserPromptSubmit, and PreToolUse entries
9
9
  * without destroying existing settings. Idempotent — skips if already installed.
10
- * Removes stale PreToolUse entries and hook scripts from pre-Phase C installs.
10
+ * Removes stale legacy PreToolUse entries and hook scripts from pre-Phase C installs.
11
11
  * Cleans up stale .claude/hooks/hooks.json if it exists.
12
12
  */
13
13
 
14
14
  import { unlinkSync, readFileSync } from "node:fs";
15
15
  import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
16
- import { claudeSettingsLocal, claudeSyncHook, claudePromptHook, claudePreToolHook, claudeHooksDir } from "../paths.mjs";
16
+ import { claudeSettingsLocal, claudeSyncHook, claudePromptHook, claudePreToolHook, claudePreToolActivationHook, claudeHooksDir } from "../paths.mjs";
17
17
  import { join, dirname } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
 
20
20
  const __filename = fileURLToPath(import.meta.url);
21
21
  const __dirname = dirname(__filename);
22
22
 
23
- // The two hook script paths (relative to project root).
23
+ // Hook script paths (relative to project root).
24
24
  const HOOK_SCRIPTS = {
25
25
  sync: ".claude/hooks/skillrepo-sync.mjs",
26
26
  prompt: ".claude/hooks/skillrepo-prompt-match.mjs",
27
+ pretoolActivation: ".claude/hooks/skillrepo-pretool-activation.mjs",
27
28
  };
28
29
 
29
30
  // Legacy hook script path (removed in Phase C).
@@ -119,8 +120,8 @@ function removeSkillRepoHook(groups, scriptPath) {
119
120
  * Merge SkillRepo hooks into .claude/settings.local.json and write hook scripts.
120
121
  *
121
122
  * Reads standalone hook scripts from the CLI package (bundled files) and
122
- * copies them to .claude/hooks/. Registers SessionStart and UserPromptSubmit
123
- * hooks. Removes legacy PreToolUse hook entries and scripts.
123
+ * copies them to .claude/hooks/. Registers SessionStart, UserPromptSubmit,
124
+ * and PreToolUse hooks. Removes legacy PreToolUse hook entries and scripts.
124
125
  *
125
126
  * @returns {{ results: { path: string; action: string }[] }}
126
127
  */
@@ -147,6 +148,7 @@ export function mergeHooksConfig() {
147
148
  // Read bundled standalone hook scripts from the CLI package.
148
149
  const syncContent = readBundledHook("skillrepo-sync.mjs");
149
150
  const promptContent = readBundledHook("skillrepo-prompt-match.mjs");
151
+ const pretoolActivationContent = readBundledHook("skillrepo-pretool-activation.mjs");
150
152
 
151
153
  // Always write hook scripts (latest version from CLI package).
152
154
  const syncExisted = readFileSafe(claudeSyncHook()) !== null;
@@ -163,6 +165,13 @@ export function mergeHooksConfig() {
163
165
  action: promptExisted ? "updated" : "created",
164
166
  });
165
167
 
168
+ const pretoolActivationExisted = readFileSafe(claudePreToolActivationHook()) !== null;
169
+ writeFileSafe(claudePreToolActivationHook(), pretoolActivationContent);
170
+ results.push({
171
+ path: HOOK_SCRIPTS.pretoolActivation,
172
+ action: pretoolActivationExisted ? "updated" : "created",
173
+ });
174
+
166
175
  // Remove legacy PreToolUse hook script if it exists
167
176
  try {
168
177
  unlinkSync(claudePreToolHook());
@@ -198,6 +207,7 @@ export function mergeHooksConfig() {
198
207
  for (const [event, scriptPath] of [
199
208
  ["SessionStart", HOOK_SCRIPTS.sync],
200
209
  ["UserPromptSubmit", HOOK_SCRIPTS.prompt],
210
+ ["PreToolUse", HOOK_SCRIPTS.pretoolActivation],
201
211
  ]) {
202
212
  if (Array.isArray(config.hooks[event]) && updateExistingCommands(config.hooks[event], scriptPath)) {
203
213
  changed = true;
@@ -207,6 +217,7 @@ export function mergeHooksConfig() {
207
217
  // Build commands using the absolute node path from this machine.
208
218
  const syncCommand = buildCommand(HOOK_SCRIPTS.sync);
209
219
  const promptCommand = buildCommand(HOOK_SCRIPTS.prompt);
220
+ const pretoolActivationCommand = buildCommand(HOOK_SCRIPTS.pretoolActivation);
210
221
 
211
222
  // Merge SessionStart
212
223
  if (!Array.isArray(config.hooks.SessionStart)) config.hooks.SessionStart = [];
@@ -227,6 +238,15 @@ export function mergeHooksConfig() {
227
238
  changed = true;
228
239
  }
229
240
 
241
+ // Merge PreToolUse (activation telemetry)
242
+ if (!Array.isArray(config.hooks.PreToolUse)) config.hooks.PreToolUse = [];
243
+ if (!hasSkillRepoHook(config.hooks.PreToolUse, HOOK_SCRIPTS.pretoolActivation)) {
244
+ config.hooks.PreToolUse.push({
245
+ hooks: [{ type: "command", command: pretoolActivationCommand }],
246
+ });
247
+ changed = true;
248
+ }
249
+
230
250
  if (existing === null) {
231
251
  writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
232
252
  results.push({ path: ".claude/settings.local.json", action: "created" });
package/src/lib/paths.mjs CHANGED
@@ -16,6 +16,7 @@ export const claudeSettingsLocal = () => join(cwd(), ".claude", "settings.local.
16
16
  export const claudeSyncHook = () => join(cwd(), ".claude", "hooks", "skillrepo-sync.mjs");
17
17
  export const claudePromptHook = () => join(cwd(), ".claude", "hooks", "skillrepo-prompt-match.mjs");
18
18
  export const claudePreToolHook = () => join(cwd(), ".claude", "hooks", "skillrepo-pretool-match.mjs");
19
+ export const claudePreToolActivationHook = () => join(cwd(), ".claude", "hooks", "skillrepo-pretool-activation.mjs");
19
20
  export const claudeSkillrepoMd = () => join(cwd(), ".claude", "skillrepo.md");
20
21
  export const claudeSkillrepoIndex = () => join(cwd(), ".claude", "skillrepo-index.json");
21
22
  export const claudeSkillrepoConfig = () => join(cwd(), ".claude", "skillrepo-config.json");
@@ -93,13 +93,11 @@ Use `node:test` (Node.js built-in) — consistent with existing CLI tests in `pa
93
93
 
94
94
  ### Payload Factory Requirements
95
95
 
96
- Must generate realistic `SetupPayload` objects matching what `generateSetupPayload()` produces. Include:
96
+ Must generate `SetupPayload` objects matching what `generateSetupPayload()` produces.
97
97
 
98
- 1. Skills WITH contextSignals (files, project, tasks populated)
99
- 2. Skills WITHOUT contextSignals (null)
100
- 3. Skills with special characters in descriptions (colons, quotes — YAML safety)
101
- 4. Configurable skill count
102
- 5. Both Claude Code and Cursor output structures
98
+ Phase D (#534) stripped the payload to a minimal shape — the CLI only uses `skillCount`.
99
+ All other setup work (hooks, rules, sync) is handled by standalone scripts bundled
100
+ with the CLI package.
103
101
 
104
102
  Reference `src/lib/setup/generate.ts` for the exact `SetupPayload` type:
105
103
 
@@ -107,22 +105,6 @@ Reference `src/lib/setup/generate.ts` for the exact `SetupPayload` type:
107
105
  interface SetupPayload {
108
106
  skillCount: number;
109
107
  mcpUrl: string;
110
- skillEntries: string[];
111
- claudeCode: {
112
- skillrepoMd: { path: string; content: string };
113
- syncHook: { path: string; content: string };
114
- settingsHooks: { hooks: Record<string, unknown> };
115
- skillIndex: { path: string; content: string };
116
- skillrepoConfig: { path: string; content: string };
117
- promptHook: { path: string; content: string };
118
- preToolHook: { path: string; content: string };
119
- };
120
- cursor: {
121
- rules: Array<{ path: string; content: string }>;
122
- hooksJson: { path: string; content: string };
123
- sessionHook: { path: string; content: string };
124
- skillIndex: { path: string; content: string };
125
- };
126
108
  }
127
109
  ```
128
110
 
@@ -204,8 +204,7 @@ describe("CLI E2E: skillrepo init", () => {
204
204
 
205
205
  assert.equal(countHook(settings.hooks.SessionStart, "skillrepo-sync"), 1);
206
206
  assert.equal(countHook(settings.hooks.UserPromptSubmit, "skillrepo-prompt-match"), 1);
207
- // PreToolUse should not exist at all
208
- assert.equal(settings.hooks.PreToolUse, undefined, "PreToolUse should not exist");
207
+ assert.equal(countHook(settings.hooks.PreToolUse, "skillrepo-pretool-activation"), 1, "PreToolUse activation hook should exist once");
209
208
  });
210
209
 
211
210
  it("invalid API key (401) exits with error", async () => {
@@ -256,6 +255,7 @@ describe("CLI E2E: skillrepo init", () => {
256
255
  for (const f of [
257
256
  ".claude/hooks/skillrepo-sync.mjs",
258
257
  ".claude/hooks/skillrepo-prompt-match.mjs",
258
+ ".claude/hooks/skillrepo-pretool-activation.mjs",
259
259
  ]) {
260
260
  assert.doesNotThrow(
261
261
  () => execFileSync(process.execPath, ["--check", join(tempDir, f)], { timeout: 5000 }),
@@ -296,44 +296,30 @@ describe("CLI E2E: skillrepo init", () => {
296
296
  });
297
297
 
298
298
  // =========================================================================
299
- // 4. Two hooks registered in settings.local.json (no PreToolUse)
299
+ // 4. Three hooks registered in settings.local.json
300
300
  // =========================================================================
301
301
 
302
- it("two hooks registered in settings.local.json (no PreToolUse)", async () => {
302
+ it("three hooks registered in settings.local.json", async () => {
303
303
  await runInit(tempDir, port);
304
304
 
305
305
  const settings = readJSON(tempDir, ".claude/settings.local.json");
306
306
  assert.ok(settings.hooks);
307
307
  assert.ok(Array.isArray(settings.hooks.SessionStart));
308
308
  assert.ok(Array.isArray(settings.hooks.UserPromptSubmit));
309
- assert.equal(settings.hooks.PreToolUse, undefined, "PreToolUse should not exist");
309
+ assert.ok(Array.isArray(settings.hooks.PreToolUse), "PreToolUse should exist");
310
310
 
311
311
  const getCmds = (groups) => groups.flatMap((g) => g.hooks.map((h) => h.command));
312
312
  assert.ok(getCmds(settings.hooks.SessionStart).some((c) => c.includes("skillrepo-sync")));
313
313
  assert.ok(getCmds(settings.hooks.UserPromptSubmit).some((c) => c.includes("skillrepo-prompt-match")));
314
+ assert.ok(getCmds(settings.hooks.PreToolUse).some((c) => c.includes("skillrepo-pretool-activation")));
314
315
  });
315
316
 
316
317
  // =========================================================================
317
318
  // 5. Security
318
319
  // =========================================================================
319
320
 
320
- it("path traversal in Cursor .mdc rule is rejected", async () => {
321
- const srv = createMockServer({});
322
- const p = await srv.start();
323
- const maliciousPayload = buildPayload({ baseUrl: `http://127.0.0.1:${p}` });
324
- maliciousPayload.cursor.rules.push({
325
- path: "../outside-cursor.txt",
326
- content: "should not be written",
327
- });
328
- srv.setPayload(maliciousPayload);
329
-
330
- try {
331
- // Init may succeed or fail depending on whether cursor rules are written
332
- // — the critical check is that the file is NOT written outside .cursor/
333
- await runInit(tempDir, p).catch(() => {});
334
- assert.ok(!existsSync(join(tempDir, "outside-cursor.txt")), "Should not write outside .cursor/");
335
- } finally {
336
- await srv.stop();
337
- }
338
- });
321
+ // Path traversal test removed in Phase D (#534): the setup payload no longer
322
+ // contains cursor.rules — Cursor rules are written by the standalone sync hook,
323
+ // not from server-generated payload content. The attack surface tested here
324
+ // no longer exists in the init flow.
339
325
  });