skillrepo 1.10.1 → 2.0.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
@@ -74,8 +74,6 @@ The CLI performs four steps:
74
74
  |------|-----|---------|
75
75
  | `.mcp.json` | Claude Code | MCP server connection config |
76
76
  | `.claude/skillrepo.md` | Claude Code | Skill-to-tool mapping |
77
- | `.claude/hooks/hooks.json` | Claude Code | SessionStart hook registration |
78
- | `.claude/hooks/skillrepo-sync.mjs` | Claude Code | Auto-refresh script for skill mappings |
79
77
  | `.cursor/mcp.json` | Cursor | MCP server connection config |
80
78
  | `.cursor/rules/skillrepo.mdc` | Cursor | Skill-to-tool mapping |
81
79
  | `~/.codeium/windsurf/mcp_config.json` | Windsurf | MCP server connection config (global) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "1.10.1",
3
+ "version": "2.0.0",
4
4
  "description": "Set up SkillRepo in any IDE — one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,15 +5,13 @@
5
5
  * 1. Detect IDEs
6
6
  * 2. Prompt for access key
7
7
  * 3. Validate key + fetch skill count
8
- * 4. Write configs (global config, hooks, MCP, .gitignore)
9
- * 5. Run first sync (writes rules files + local cache)
10
- * 6. Print summary
8
+ * 4. Write configs (global config, MCP, .gitignore)
9
+ * 5. Print summary
11
10
  */
12
11
 
13
12
  import { detectIdes, formatDetectedIdes, getDetectedIdeKeys } from "../lib/detect-ides.mjs";
14
13
  import { fetchSetupPayload, AuthError, SuspendedError, NetworkError } from "../lib/http.mjs";
15
14
  import { writeAllConfigs } from "../lib/write-configs.mjs";
16
- import { runFirstSync } from "../lib/first-sync.mjs";
17
15
  import {
18
16
  printHeader,
19
17
  printStep,
@@ -68,7 +66,7 @@ export async function runInit(argv) {
68
66
  printHeader("SkillRepo Setup");
69
67
 
70
68
  // ── Step 1: Detect IDEs ───────────────────────────────────────────────
71
- printStep(1, 5, "Detecting IDEs...");
69
+ printStep(1, 4, "Detecting IDEs...");
72
70
 
73
71
  const detected = detectIdes();
74
72
  const ideList = formatDetectedIdes(detected);
@@ -97,7 +95,7 @@ export async function runInit(argv) {
97
95
  printBlank();
98
96
 
99
97
  // ── Step 2: Access Key ────────────────────────────────────────────────
100
- printStep(2, 5, "Access Key");
98
+ printStep(2, 4, "Access Key");
101
99
 
102
100
  let apiKey = flags.key;
103
101
  if (!apiKey) {
@@ -113,7 +111,7 @@ export async function runInit(argv) {
113
111
  printBlank();
114
112
 
115
113
  // ── Step 3: Validate + Fetch ──────────────────────────────────────────
116
- printStep(3, 5, "Validating key...");
114
+ printStep(3, 4, "Validating key...");
117
115
 
118
116
  let payload;
119
117
  try {
@@ -143,7 +141,7 @@ export async function runInit(argv) {
143
141
  printBlank();
144
142
 
145
143
  // ── Step 4: Write configs ─────────────────────────────────────────────
146
- printStep(4, 5, "Writing configuration...");
144
+ printStep(4, 4, "Writing configuration...");
147
145
  printBlank();
148
146
 
149
147
  const mcpUrl = `${flags.url}/api/mcp`;
@@ -168,17 +166,6 @@ export async function runInit(argv) {
168
166
 
169
167
  printBlank();
170
168
 
171
- // ── Step 5: First sync ────────────────────────────────────────────────
172
- printStep(5, 5, "Running first sync...");
173
-
174
- try {
175
- await runFirstSync();
176
- printSuccess("Skills synced and rules files written.");
177
- } catch (err) {
178
- printWarning(`First sync failed: ${err.message}`);
179
- printWarning("Skills will sync automatically on your next session start.");
180
- }
181
-
182
169
  // ── Summary ───────────────────────────────────────────────────────────
183
170
  printBlank();
184
171
  printSuccess("SkillRepo is ready.");
package/src/lib/paths.mjs CHANGED
@@ -11,12 +11,7 @@ const cwd = () => process.cwd();
11
11
  // Claude Code
12
12
  export const claudeMcpJson = () => join(cwd(), ".mcp.json");
13
13
  export const claudeDir = () => join(cwd(), ".claude");
14
- export const claudeHooksDir = () => join(cwd(), ".claude", "hooks");
15
14
  export const claudeSettingsLocal = () => join(cwd(), ".claude", "settings.local.json");
16
- export const claudeSyncHook = () => join(cwd(), ".claude", "hooks", "skillrepo-sync.mjs");
17
- export const claudePromptHook = () => join(cwd(), ".claude", "hooks", "skillrepo-prompt-match.mjs");
18
- export const claudePreToolHook = () => join(cwd(), ".claude", "hooks", "skillrepo-pretool-match.mjs");
19
- export const claudePreToolActivationHook = () => join(cwd(), ".claude", "hooks", "skillrepo-pretool-activation.mjs");
20
15
  export const claudeSkillrepoMd = () => join(cwd(), ".claude", "skillrepo.md");
21
16
  export const claudeSkillrepoIndex = () => join(cwd(), ".claude", "skillrepo-index.json");
22
17
  export const claudeSkillrepoConfig = () => join(cwd(), ".claude", "skillrepo-config.json");
@@ -17,7 +17,6 @@ import { mergeClaudeMcpConfig } from "./mergers/claude-mcp.mjs";
17
17
  import { mergeCursorMcpConfig } from "./mergers/cursor-mcp.mjs";
18
18
  import { mergeWindsurfMcpConfig } from "./mergers/windsurf-mcp.mjs";
19
19
  import { mergeVscodeMcpConfig } from "./mergers/vscode-mcp.mjs";
20
- import { mergeHooksConfig } from "./mergers/hooks-json.mjs";
21
20
  import { mergeEnvLocal } from "./mergers/env-local.mjs";
22
21
  import { mergeGitignore } from "./mergers/gitignore.mjs";
23
22
 
@@ -42,10 +41,6 @@ export function writeAllConfigs({ ides, mcpUrl, apiKey, serverUrl, userId }) {
42
41
  if (ides.includes("claudeCode")) {
43
42
  results.push(mergeClaudeMcpConfig(mcpUrl));
44
43
 
45
- // Install standalone hooks (SessionStart + UserPromptSubmit)
46
- const hookResults = mergeHooksConfig();
47
- results.push(...hookResults.results);
48
-
49
44
  // Create .claude/rules/ directory for skill delivery
50
45
  const rulesDir = claudeRulesDir();
51
46
  if (!existsSync(rulesDir)) {
@@ -20,8 +20,6 @@ The CLI (`npx skillrepo init`) now generates:
20
20
  - `.claude/skillrepo.md` — skill reference
21
21
  - `.claude/skillrepo-index.json` — skill index with `contextSignals` (files, project, tasks)
22
22
  - `.claude/skillrepo-config.json` — version 2 config with `signals` array and `companions` map
23
- - `.claude/hooks/skillrepo-sync.mjs` — SessionStart hook (refreshes configs + repo profiling)
24
- - `.claude/hooks/skillrepo-prompt-match.mjs` — UserPromptSubmit hook (weighted scoring + profile multiplier)
25
23
  - `.claude/hooks/skillrepo-pretool-match.mjs` — PreToolUse hook (dynamic signal map + glob matching)
26
24
  - `.claude/settings.local.json` — hook registration (merged with existing)
27
25
  - `.mcp.json` — MCP server config
@@ -181,7 +179,6 @@ assert.strictEqual(matchGlob('src/utils/helper.ts', '**/*.tsx'), false);
181
179
 
182
180
  Look at `packages/cli/src/test/` for the established patterns:
183
181
  - `detect-ides.test.mjs` — temp directory creation, `process.cwd()` override
184
- - `mergers/hooks-json.test.mjs` — file merge testing (19 test cases)
185
182
  - `env-local.test.mjs` — `.env.local` merge behavior
186
183
 
187
184
  All use `node:test`, `node:assert`, `mkdtempSync`, `rmSync` for cleanup.
@@ -19,7 +19,7 @@ import {
19
19
  } from "node:fs";
20
20
  import { join, resolve, dirname } from "node:path";
21
21
  import { tmpdir } from "node:os";
22
- import { execFile, execFileSync, spawn } from "node:child_process";
22
+ import { execFile } from "node:child_process";
23
23
  import { fileURLToPath } from "node:url";
24
24
 
25
25
  import { createMockServer } from "./mock-server.mjs";
@@ -69,30 +69,6 @@ function runInitExpectFail(cwd, port, { key = API_KEY } = {}) {
69
69
  });
70
70
  }
71
71
 
72
- /**
73
- * Run a hook script as a subprocess, piping stdinPayload via stdin.
74
- * Returns the stdout string.
75
- */
76
- function runHookSubprocess(hookPath, stdinPayload, cwd) {
77
- return new Promise((resolve, reject) => {
78
- const child = spawn(process.execPath, [hookPath], {
79
- cwd,
80
- env: { ...process.env, NODE_NO_WARNINGS: "1" },
81
- timeout: 10_000,
82
- });
83
- let stdout = "";
84
- let stderr = "";
85
- child.stdout.on("data", (d) => { stdout += d; });
86
- child.stderr.on("data", (d) => { stderr += d; });
87
- child.on("close", (code) => {
88
- if (code !== 0) return reject(new Error(`Hook exited ${code}: ${stderr}\nstdout: ${stdout}`));
89
- resolve(stdout);
90
- });
91
- child.stdin.write(stdinPayload);
92
- child.stdin.end();
93
- });
94
- }
95
-
96
72
  /** Read a file from the temp dir. */
97
73
  function readFile(dir, relPath) {
98
74
  return readFileSync(join(dir, relPath), "utf-8");
@@ -145,39 +121,9 @@ describe("CLI E2E: skillrepo init", () => {
145
121
  });
146
122
 
147
123
  // =========================================================================
148
- // 1. Full Init Flow
124
+ // 1. MCP Config
149
125
  // =========================================================================
150
126
 
151
- it("produces all expected Claude Code files", async () => {
152
- await runInit(tempDir, port);
153
-
154
- const expected = [
155
- ".claude/settings.local.json",
156
- ".claude/hooks/skillrepo-sync.mjs",
157
- ".claude/hooks/skillrepo-prompt-match.mjs",
158
- ];
159
-
160
- for (const f of expected) {
161
- assert.ok(fileExists(tempDir, f), `Missing file: ${f}`);
162
- }
163
-
164
- // .claude/rules/ directory should be created
165
- assert.ok(existsSync(join(tempDir, ".claude/rules")), "Missing directory: .claude/rules/");
166
-
167
- // Old format files should NOT exist
168
- assert.ok(!fileExists(tempDir, ".claude/skillrepo.md"), "Old format file should not exist: .claude/skillrepo.md");
169
- assert.ok(!fileExists(tempDir, ".claude/skillrepo-index.json"), "Old format file should not exist: .claude/skillrepo-index.json");
170
- assert.ok(!fileExists(tempDir, ".claude/skillrepo-config.json"), "Old format file should not exist: .claude/skillrepo-config.json");
171
- assert.ok(!fileExists(tempDir, ".claude/hooks/skillrepo-pretool-match.mjs"), "PreToolUse hook should not exist");
172
- });
173
-
174
- it("produces all expected Cursor files", async () => {
175
- await runInit(tempDir, port);
176
-
177
- // Cursor only gets MCP config now — rules, hooks, and index are handled by sync hook
178
- assert.ok(fileExists(tempDir, ".mcp.json"), "Should have .mcp.json for Cursor MCP config");
179
- });
180
-
181
127
  it(".mcp.json is created with MCP server config", async () => {
182
128
  await runInit(tempDir, port);
183
129
 
@@ -192,20 +138,9 @@ describe("CLI E2E: skillrepo init", () => {
192
138
  assert.ok(envContent.includes(`SKILLREPO_ACCESS_KEY=${API_KEY}`));
193
139
  });
194
140
 
195
- it("running init twice does not duplicate hooks", async () => {
196
- await runInit(tempDir, port);
197
-
198
- await runInit(tempDir, port);
199
-
200
- // Hooks not duplicated in settings
201
- const settings = readJSON(tempDir, ".claude/settings.local.json");
202
- const countHook = (groups, needle) =>
203
- groups.filter((g) => g.hooks?.some((h) => h.command?.includes(needle))).length;
204
-
205
- assert.equal(countHook(settings.hooks.SessionStart, "skillrepo-sync"), 1);
206
- assert.equal(countHook(settings.hooks.UserPromptSubmit, "skillrepo-prompt-match"), 1);
207
- assert.equal(countHook(settings.hooks.PreToolUse, "skillrepo-pretool-activation"), 1, "PreToolUse activation hook should exist once");
208
- });
141
+ // =========================================================================
142
+ // 2. Error Handling
143
+ // =========================================================================
209
144
 
210
145
  it("invalid API key (401) exits with error", async () => {
211
146
  const srv = createMockServer({}, { validKey: "sk_live_onlythisone" });
@@ -235,52 +170,21 @@ describe("CLI E2E: skillrepo init", () => {
235
170
  });
236
171
 
237
172
  // =========================================================================
238
- // 2. Generated Files Are Structurally Valid
173
+ // 3. Generated Files Are Structurally Valid
239
174
  // =========================================================================
240
175
 
241
176
  it("all JSON files parse without error", async () => {
242
177
  await runInit(tempDir, port);
243
178
 
244
- for (const f of [
245
- ".claude/settings.local.json",
246
- ".mcp.json",
247
- ]) {
179
+ for (const f of [".mcp.json"]) {
248
180
  assert.doesNotThrow(() => readJSON(tempDir, f), `Invalid JSON: ${f}`);
249
181
  }
250
182
  });
251
183
 
252
- it("all hook scripts are valid ES modules", async () => {
253
- await runInit(tempDir, port);
254
-
255
- for (const f of [
256
- ".claude/hooks/skillrepo-sync.mjs",
257
- ".claude/hooks/skillrepo-prompt-match.mjs",
258
- ".claude/hooks/skillrepo-pretool-activation.mjs",
259
- ]) {
260
- assert.doesNotThrow(
261
- () => execFileSync(process.execPath, ["--check", join(tempDir, f)], { timeout: 5000 }),
262
- `Script is not a valid ES module: ${f}`,
263
- );
264
- }
265
- });
266
-
267
184
  // =========================================================================
268
- // 3. .gitignore, .claude/rules/ directory, and old format cleanup
185
+ // 4. .gitignore and old format cleanup
269
186
  // =========================================================================
270
187
 
271
- it(".gitignore contains skillrepo rules entry", async () => {
272
- await runInit(tempDir, port);
273
-
274
- const gitignore = readFile(tempDir, ".gitignore");
275
- assert.ok(gitignore.includes(".claude/rules/skillrepo-"), "gitignore should contain skillrepo rules pattern");
276
- });
277
-
278
- it(".claude/rules/ directory is created", async () => {
279
- await runInit(tempDir, port);
280
-
281
- assert.ok(existsSync(join(tempDir, ".claude/rules")), ".claude/rules/ directory should exist");
282
- });
283
-
284
188
  it("old format files are cleaned up during migration", async () => {
285
189
  // Create old format files before init
286
190
  writeFileSync(join(tempDir, ".claude/skillrepo.md"), "# Old skillrepo md");
@@ -296,30 +200,14 @@ describe("CLI E2E: skillrepo init", () => {
296
200
  });
297
201
 
298
202
  // =========================================================================
299
- // 4. Three hooks registered in settings.local.json
203
+ // 5. No hook files generated (hooks removed in v1)
300
204
  // =========================================================================
301
205
 
302
- it("three hooks registered in settings.local.json", async () => {
206
+ it("does not generate hook files", async () => {
303
207
  await runInit(tempDir, port);
304
208
 
305
- const settings = readJSON(tempDir, ".claude/settings.local.json");
306
- assert.ok(settings.hooks);
307
- assert.ok(Array.isArray(settings.hooks.SessionStart));
308
- assert.ok(Array.isArray(settings.hooks.UserPromptSubmit));
309
- assert.ok(Array.isArray(settings.hooks.PreToolUse), "PreToolUse should exist");
310
-
311
- const getCmds = (groups) => groups.flatMap((g) => g.hooks.map((h) => h.command));
312
- assert.ok(getCmds(settings.hooks.SessionStart).some((c) => c.includes("skillrepo-sync")));
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")));
209
+ assert.ok(!fileExists(tempDir, ".claude/hooks/skillrepo-sync.mjs"), "Sync hook should not exist");
210
+ assert.ok(!fileExists(tempDir, ".claude/hooks/skillrepo-prompt-match.mjs"), "Prompt-match hook should not exist");
211
+ assert.ok(!fileExists(tempDir, ".claude/hooks/skillrepo-pretool-activation.mjs"), "PreToolUse hook should not exist");
315
212
  });
316
-
317
- // =========================================================================
318
- // 5. Security
319
- // =========================================================================
320
-
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.
325
213
  });
@@ -1,304 +0,0 @@
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
-
19
- // ---------------------------------------------------------------------------
20
- // Constants
21
- // ---------------------------------------------------------------------------
22
-
23
- const DEFAULT_SERVER_URL = "https://skillrepo.dev";
24
- const MAX_EVENTS_PER_BATCH = 50;
25
-
26
- // ---------------------------------------------------------------------------
27
- // Config resolution (lightweight — only needs API key and server URL)
28
- // ---------------------------------------------------------------------------
29
-
30
- /**
31
- * Read config from ~/.claude/skillrepo/config.json with fallbacks.
32
- * Returns { apiKey, serverUrl, userId } or null.
33
- */
34
- export function readConfig() {
35
- const configPath = join(homedir(), ".claude", "skillrepo", "config.json");
36
- try {
37
- const cfg = JSON.parse(readFileSync(configPath, "utf-8"));
38
- if (cfg.apiKey) {
39
- return {
40
- apiKey: cfg.apiKey,
41
- serverUrl: cfg.serverUrl || DEFAULT_SERVER_URL,
42
- userId: cfg.userId || null,
43
- };
44
- }
45
- } catch { /* not found */ }
46
-
47
- const envKey = process.env.SKILLREPO_ACCESS_KEY;
48
- if (envKey) {
49
- return { apiKey: envKey, serverUrl: process.env.SKILLREPO_SERVER_URL || DEFAULT_SERVER_URL, userId: null };
50
- }
51
-
52
- // .env.local fallback
53
- for (const file of [".env.local", ".env"]) {
54
- try {
55
- const lines = readFileSync(join(process.cwd(), file), "utf-8").split("\n");
56
- for (const line of lines) {
57
- const trimmed = line.trim();
58
- if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
59
- const eqIdx = trimmed.indexOf("=");
60
- if (trimmed.slice(0, eqIdx).trim() === "SKILLREPO_ACCESS_KEY") {
61
- let val = trimmed.slice(eqIdx + 1).trim();
62
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
63
- val = val.slice(1, -1);
64
- }
65
- val = val.replace(/\s+#.*$/, "");
66
- if (val) return { apiKey: val, serverUrl: DEFAULT_SERVER_URL, userId: null };
67
- }
68
- }
69
- } catch { /* file doesn't exist */ }
70
- }
71
-
72
- return null;
73
- }
74
-
75
- // ---------------------------------------------------------------------------
76
- // Tool pattern matching
77
- // ---------------------------------------------------------------------------
78
-
79
- /**
80
- * Match a tool invocation against all skills' contextSignals.toolPatterns.
81
- *
82
- * For Bash tools: prefix-match tool_input.command against each pattern.
83
- * For non-Bash tools: match tool_name against patterns.
84
- *
85
- * Pattern matching rules:
86
- * - Case-insensitive
87
- * - Prefix match: pattern "gh issue" matches command "gh issue create --title ..."
88
- * - Pattern must match at a word boundary (don't match "npm" against "npmx")
89
- *
90
- * Returns array of { skill, pattern } matches.
91
- */
92
- export function matchToolPatterns(toolName, toolInput, skills) {
93
- if (!toolName || !skills?.length) return [];
94
-
95
- const isBash = toolName.toLowerCase() === "bash";
96
- const matches = [];
97
-
98
- for (const skill of skills) {
99
- const patterns = skill.contextSignals?.toolPatterns;
100
- if (!patterns?.length) continue;
101
-
102
- for (const pattern of patterns) {
103
- if (!pattern) continue;
104
- const patternLower = pattern.toLowerCase();
105
-
106
- if (isBash) {
107
- // Bash tool: prefix-match against command
108
- const command = (toolInput?.command ?? "").trim();
109
- if (!command) continue;
110
- const commandLower = command.toLowerCase();
111
-
112
- if (commandLower.startsWith(patternLower)) {
113
- // Word boundary check: the character after the pattern must be
114
- // end-of-string, whitespace, or a non-alphanumeric character
115
- const afterIdx = patternLower.length;
116
- if (afterIdx >= commandLower.length || /[^a-z0-9_]/i.test(commandLower[afterIdx])) {
117
- matches.push({ skill, pattern });
118
- break; // one match per skill is enough
119
- }
120
- }
121
- } else {
122
- // Non-Bash tool: match tool_name against patterns
123
- const toolNameLower = toolName.toLowerCase();
124
- if (toolNameLower === patternLower) {
125
- matches.push({ skill, pattern });
126
- break; // one match per skill is enough
127
- }
128
- }
129
- }
130
- }
131
-
132
- return matches;
133
- }
134
-
135
- // ---------------------------------------------------------------------------
136
- // Session dedup
137
- // ---------------------------------------------------------------------------
138
-
139
- /**
140
- * Read activation state for deduplication.
141
- * State is stored in tmpdir keyed by session hash.
142
- */
143
- export function readActivationState(sessionId) {
144
- const hash = createHash("sha256").update(sessionId || "default").digest("hex").slice(0, 16);
145
- const statePath = join(tmpdir(), `skillrepo-activation-${hash}.json`);
146
-
147
- try {
148
- return { path: statePath, state: JSON.parse(readFileSync(statePath, "utf-8")) };
149
- } catch {
150
- return { path: statePath, state: { reported: {} } };
151
- }
152
- }
153
-
154
- /**
155
- * Filter matches to exclude skills already reported in this session.
156
- */
157
- export function deduplicateActivations(matches, sessionState) {
158
- return matches.filter(m => {
159
- const key = `${m.skill.owner}/${m.skill.name}`;
160
- return !sessionState.reported[key];
161
- });
162
- }
163
-
164
- /**
165
- * Mark skills as reported in session state.
166
- */
167
- export function updateActivationState(statePath, state, reportedMatches) {
168
- for (const m of reportedMatches) {
169
- const key = `${m.skill.owner}/${m.skill.name}`;
170
- state.reported[key] = new Date().toISOString();
171
- }
172
- try { writeFileSync(statePath, JSON.stringify(state), "utf-8"); }
173
- catch { /* non-critical */ }
174
- }
175
-
176
- // ---------------------------------------------------------------------------
177
- // Telemetry payload
178
- // ---------------------------------------------------------------------------
179
-
180
- /**
181
- * Build telemetry payload for tool-pattern-activated skills.
182
- */
183
- export function buildTelemetryPayload(matches, sessionInfo) {
184
- const events = matches.slice(0, MAX_EVENTS_PER_BATCH).map(m => ({
185
- skillOwner: m.skill.owner,
186
- skillName: m.skill.name,
187
- skillVersion: m.skill.version || undefined,
188
- activatedAt: new Date().toISOString(),
189
- ide: sessionInfo.ide || "claude-code",
190
- sessionHash: sessionInfo.sessionHash,
191
- userId: sessionInfo.userId || undefined,
192
- source: "pretool_hook",
193
- toolPattern: m.pattern,
194
- }));
195
-
196
- return { events };
197
- }
198
-
199
- /**
200
- * Fire-and-forget POST to telemetry endpoint.
201
- *
202
- * NOT awaited — the spec requires "do NOT block the agent" and Claude Code
203
- * waits for hook process exit. The OS TCP stack flushes small payloads after
204
- * process exit, so delivery reliability is ~99%+ for responsive servers.
205
- * Occasional loss on slow/unreachable servers is acceptable for non-critical
206
- * telemetry.
207
- */
208
- export function sendTelemetry(config, payload) {
209
- const url = `${config.serverUrl}/api/v1/telemetry/activation`;
210
-
211
- fetch(url, {
212
- method: "POST",
213
- headers: {
214
- Authorization: `Bearer ${config.apiKey}`,
215
- "Content-Type": "application/json",
216
- },
217
- body: JSON.stringify(payload),
218
- }).catch(() => { /* telemetry errors are non-critical */ });
219
- }
220
-
221
- // ---------------------------------------------------------------------------
222
- // Main entry point
223
- // ---------------------------------------------------------------------------
224
-
225
- export async function main(input) {
226
- const indexPath = join(homedir(), ".claude", "skillrepo", "index.json");
227
-
228
- // -- Read index --
229
- let index;
230
- try {
231
- index = JSON.parse(readFileSync(indexPath, "utf-8"));
232
- } catch {
233
- // No index -> nothing to match. Exit cleanly.
234
- process.stdout.write("{}");
235
- return;
236
- }
237
-
238
- if (!index.skills?.length) {
239
- process.stdout.write("{}");
240
- return;
241
- }
242
-
243
- // -- Read config (needed for telemetry POST) --
244
- const config = readConfig();
245
- if (!config) {
246
- process.stdout.write("{}");
247
- return;
248
- }
249
-
250
- // -- Match tool against skills --
251
- const toolName = input.tool_name ?? "";
252
- const toolInput = input.tool_input ?? {};
253
- const matches = matchToolPatterns(toolName, toolInput, index.skills);
254
-
255
- if (matches.length === 0) {
256
- process.stdout.write("{}");
257
- return;
258
- }
259
-
260
- // -- Session dedup --
261
- const sessionId = input.session_id ?? "default";
262
- const { path: statePath, state } = readActivationState(sessionId);
263
- const newMatches = deduplicateActivations(matches, state);
264
-
265
- if (newMatches.length === 0) {
266
- process.stdout.write("{}");
267
- return;
268
- }
269
-
270
- // -- Build and send telemetry --
271
- const sessionHash = createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
272
-
273
- const payload = buildTelemetryPayload(newMatches, {
274
- ide: "claude-code",
275
- sessionHash,
276
- userId: config.userId,
277
- });
278
-
279
- sendTelemetry(config, payload); // fire-and-forget -- do NOT await
280
-
281
- // -- Update session state --
282
- updateActivationState(statePath, state, newMatches);
283
-
284
- // -- Output: NO additionalContext --
285
- process.stdout.write("{}");
286
- }
287
-
288
- // -- Run --
289
- const __filename = fileURLToPath(import.meta.url);
290
- const isMainModule = process.argv[1] === __filename;
291
-
292
- if (isMainModule) {
293
- let inputBuf = "";
294
- for await (const chunk of process.stdin) inputBuf += chunk;
295
-
296
- let input;
297
- try { input = JSON.parse(inputBuf); }
298
- catch { process.stdout.write("{}"); process.exit(0); }
299
-
300
- main(input).catch(() => {
301
- process.stdout.write("{}");
302
- process.exit(0);
303
- });
304
- }