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 +0 -2
- package/package.json +1 -1
- package/src/commands/init.mjs +6 -19
- package/src/lib/paths.mjs +0 -5
- package/src/lib/write-configs.mjs +0 -5
- package/src/test/e2e/HANDOFF.md +0 -3
- package/src/test/e2e/cli-init.test.mjs +13 -125
- package/src/hooks/skillrepo-pretool-activation.mjs +0 -304
- package/src/hooks/skillrepo-prompt-match.mjs +0 -405
- package/src/hooks/skillrepo-sync.mjs +0 -772
- package/src/lib/first-sync.mjs +0 -65
- package/src/lib/mergers/hooks-json.mjs +0 -261
- package/src/test/hooks/detect-migration.test.mjs +0 -93
- package/src/test/hooks/skillrepo-pretool-activation.test.mjs +0 -376
- package/src/test/hooks/skillrepo-prompt-match.test.mjs +0 -530
- package/src/test/hooks/skillrepo-sync.test.mjs +0 -1043
- package/src/test/mergers/hooks-json.test.mjs +0 -419
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
package/src/commands/init.mjs
CHANGED
|
@@ -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,
|
|
9
|
-
* 5.
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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)) {
|
package/src/test/e2e/HANDOFF.md
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
203
|
+
// 5. No hook files generated (hooks removed in v1)
|
|
300
204
|
// =========================================================================
|
|
301
205
|
|
|
302
|
-
it("
|
|
206
|
+
it("does not generate hook files", async () => {
|
|
303
207
|
await runInit(tempDir, port);
|
|
304
208
|
|
|
305
|
-
|
|
306
|
-
assert.ok(
|
|
307
|
-
assert.ok(
|
|
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
|
-
}
|