skillrepo 1.0.0 → 1.2.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 ADDED
@@ -0,0 +1,216 @@
1
+ # skillrepo
2
+
3
+ Set up SkillRepo in any IDE -- one command.
4
+
5
+ ```
6
+ npx skillrepo init
7
+ ```
8
+
9
+ Auto-detects your IDEs, validates your access key, and writes the correct MCP
10
+ configuration for each one. Safe to run multiple times.
11
+
12
+ ## Quick Start
13
+
14
+ 1. **Create an access key** at [skillrepo.dev/app/settings](https://skillrepo.dev/app/settings) (Settings > Access Keys).
15
+ 2. **Run the CLI** in your project directory:
16
+ ```
17
+ npx skillrepo init
18
+ ```
19
+ 3. **Done.** Your IDE can now discover and activate skills from your SkillRepo library.
20
+
21
+ ## Usage
22
+
23
+ ```
24
+ npx skillrepo init [options]
25
+ ```
26
+
27
+ ### Options
28
+
29
+ | Flag | Short | Description |
30
+ |------|-------|-------------|
31
+ | `--key <key>` | `-k` | Access key. If omitted, the CLI reads `SKILLREPO_ACCESS_KEY` from the environment, then prompts interactively. |
32
+ | `--url <url>` | `-u` | SkillRepo server URL. Defaults to `https://skillrepo.dev`. Use this for self-hosted instances. |
33
+ | `--yes` | `-y` | Non-interactive mode. Skips confirmation prompts. Useful for CI or scripted setups. |
34
+
35
+ ### Examples
36
+
37
+ ```sh
38
+ # Interactive setup (prompts for key and confirms detected IDEs)
39
+ npx skillrepo init
40
+
41
+ # Pass the key directly
42
+ npx skillrepo init --key sk_live_abc123
43
+
44
+ # Self-hosted instance, non-interactive
45
+ npx skillrepo init --url https://skillrepo.internal.company.com --yes
46
+
47
+ # Key from environment variable
48
+ export SKILLREPO_ACCESS_KEY=sk_live_abc123
49
+ npx skillrepo init --yes
50
+ ```
51
+
52
+ ## What It Does
53
+
54
+ The CLI performs four steps:
55
+
56
+ 1. **Detects installed IDEs** by checking for IDE-specific directories in the
57
+ current project (`.claude/`, `.cursor/`, `.vscode/`) and global paths
58
+ (`~/.codeium/windsurf/`). If no IDE is detected, it defaults to Claude Code
59
+ and Cursor.
60
+
61
+ 2. **Validates the access key** against the SkillRepo API and fetches the
62
+ current skill mapping data for your library.
63
+
64
+ 3. **Writes MCP configuration files** for each detected IDE, using the correct
65
+ format and environment variable syntax for that IDE.
66
+
67
+ 4. **Writes skill mapping files** that let agents match user requests to the
68
+ right skill without a network round-trip. For Claude Code, it also installs a
69
+ SessionStart hook that auto-refreshes the mapping on each session start.
70
+
71
+ ### Files Created or Modified
72
+
73
+ | File | IDE | Purpose |
74
+ |------|-----|---------|
75
+ | `.mcp.json` | Claude Code | MCP server connection config |
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
+ | `.cursor/mcp.json` | Cursor | MCP server connection config |
80
+ | `.cursor/rules/skillrepo.mdc` | Cursor | Skill-to-tool mapping |
81
+ | `~/.codeium/windsurf/mcp_config.json` | Windsurf | MCP server connection config (global) |
82
+ | `.vscode/mcp.json` | VS Code + Copilot | MCP server connection config |
83
+ | `.env.local` | All | Stores `SKILLREPO_ACCESS_KEY` (gitignored) |
84
+
85
+ All writes are merge-safe. The CLI reads existing files, adds or updates only
86
+ the `skillrepo` entry, and leaves all other entries untouched. Running `init`
87
+ again with a new key updates the key in `.env.local` without affecting anything
88
+ else.
89
+
90
+ ## IDE-Specific Details
91
+
92
+ ### Claude Code
93
+
94
+ - Config file: `.mcp.json` with `mcpServers.skillrepo` entry
95
+ - Uses `${SKILLREPO_ACCESS_KEY}` syntax for environment variable expansion
96
+ - Includes `"type": "http"` in the server entry
97
+ - Installs a SessionStart hook that refreshes the skill mapping file
98
+ (`.claude/skillrepo.md`) automatically on every session start
99
+ - Skill mappings are always current without manual intervention
100
+
101
+ ### Cursor
102
+
103
+ - Config file: `.cursor/mcp.json` with `mcpServers.skillrepo` entry
104
+ - Uses `${env:SKILLREPO_ACCESS_KEY}` syntax for environment variable expansion
105
+ - Skill mapping file (`.cursor/rules/skillrepo.mdc`) is static; re-run
106
+ `npx skillrepo init` or ask your agent to run `update skills from skillrepo`
107
+ to refresh it after adding or removing skills
108
+
109
+ ### Windsurf
110
+
111
+ - Config file: `~/.codeium/windsurf/mcp_config.json` (global, not per-project)
112
+ - Uses `serverUrl` instead of `url` in the server entry
113
+ - Uses `${env:SKILLREPO_ACCESS_KEY}` syntax for environment variable expansion
114
+
115
+ ### VS Code + Copilot
116
+
117
+ - Config file: `.vscode/mcp.json` with `servers.skillrepo` entry (note:
118
+ `servers`, not `mcpServers`)
119
+ - VS Code does not support environment variable interpolation in MCP config;
120
+ instead, the CLI creates an `inputs` entry that prompts for the access key on
121
+ first use
122
+ - Uses `${input:skillrepo-api-key}` syntax to reference the input prompt
123
+
124
+ ## Team Setup
125
+
126
+ For teams sharing a repository, an admin runs the setup once and commits the
127
+ generated config files. Each developer then clones and adds their own key.
128
+
129
+ ### Admin (one-time)
130
+
131
+ ```sh
132
+ cd your-team-repo
133
+ npx skillrepo init
134
+ git add .mcp.json .claude/ .cursor/ .vscode/
135
+ git commit -m "chore: add SkillRepo integration"
136
+ git push
137
+ ```
138
+
139
+ The `.env.local` file is not committed -- it contains the access key and should
140
+ remain gitignored.
141
+
142
+ ### Each Developer
143
+
144
+ ```sh
145
+ git pull
146
+ npx skillrepo init
147
+ ```
148
+
149
+ The CLI detects the existing config files, merges in any updates, and writes the
150
+ developer's own access key to `.env.local`. Developers can also skip the CLI
151
+ and set the environment variable manually:
152
+
153
+ ```sh
154
+ echo 'SKILLREPO_ACCESS_KEY=sk_live_their_key' >> .env.local
155
+ ```
156
+
157
+ ## Self-Hosted Usage
158
+
159
+ If you run a self-hosted SkillRepo instance, pass the `--url` flag:
160
+
161
+ ```sh
162
+ npx skillrepo init --url https://skillrepo.internal.company.com
163
+ ```
164
+
165
+ The CLI writes the custom URL into all MCP config files. The access key is
166
+ validated against your self-hosted instance instead of skillrepo.dev.
167
+
168
+ You can also set the URL via the `SKILLREPO_URL` environment variable to avoid
169
+ passing `--url` every time.
170
+
171
+ ## Troubleshooting
172
+
173
+ ### "Invalid key format. Keys start with sk_live_"
174
+
175
+ Access keys always begin with `sk_live_`. Verify you copied the full key from
176
+ Settings > Access Keys. If the key was created with a different prefix, it may
177
+ be a test key that is not valid for production use.
178
+
179
+ ### "Cannot reach https://skillrepo.dev"
180
+
181
+ Check your network connection. If you are behind a corporate proxy or firewall,
182
+ ensure HTTPS traffic to `skillrepo.dev` (or your self-hosted URL) is allowed.
183
+ For self-hosted instances, verify the `--url` flag points to the correct address.
184
+
185
+ ### "Cannot parse .mcp.json -- invalid JSON"
186
+
187
+ The CLI cannot merge into a malformed JSON file. Open the file, fix the syntax
188
+ error (or delete it to start fresh), then run `npx skillrepo init` again.
189
+
190
+ ### "No IDEs detected"
191
+
192
+ The CLI looks for IDE-specific directories in the current working directory. If
193
+ you are not in a project root, `cd` into your project first. If no IDE
194
+ directories exist yet, the CLI defaults to configuring Claude Code and Cursor.
195
+
196
+ ### VS Code prompts for the access key every time
197
+
198
+ This is expected. VS Code does not support environment variable expansion in
199
+ MCP config files, so it uses an input prompt instead. The key is cached for the
200
+ duration of your VS Code session. Enter the same key from your `.env.local`
201
+ file.
202
+
203
+ ### Skill mappings are stale
204
+
205
+ For Claude Code, the SessionStart hook refreshes mappings automatically. For
206
+ Cursor and VS Code + Copilot, re-run `npx skillrepo init` or ask your agent to
207
+ run `update skills from skillrepo` to regenerate the mapping file.
208
+
209
+ ## Requirements
210
+
211
+ - Node.js 18 or later
212
+ - Zero runtime dependencies (uses built-in `fetch` and `fs`)
213
+
214
+ ## License
215
+
216
+ AGPL-3.0
package/bin/skillrepo.mjs CHANGED
@@ -25,7 +25,7 @@ if (!command || command === "init") {
25
25
  npx skillrepo init [options]
26
26
 
27
27
  Options:
28
- --key, -k <key> Access key (or set SKILLREPO_API_KEY env var)
28
+ --key, -k <key> Access key (or set SKILLREPO_ACCESS_KEY env var)
29
29
  --url, -u <url> SkillRepo URL (default: https://skillrepo.dev)
30
30
  --yes, -y Non-interactive mode
31
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Set up SkillRepo in any IDE — one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,7 +32,7 @@ const DEFAULT_URL = "https://skillrepo.dev";
32
32
  */
33
33
  function parseFlags(argv) {
34
34
  const flags = {
35
- key: process.env.SKILLREPO_API_KEY || null,
35
+ key: process.env.SKILLREPO_ACCESS_KEY || null,
36
36
  url: process.env.SKILLREPO_URL || DEFAULT_URL,
37
37
  yes: false,
38
38
  };
@@ -167,8 +167,8 @@ export async function runInit(argv) {
167
167
  printBlank();
168
168
  console.log(" Next steps:");
169
169
  console.log(" • Commit the generated config files to git");
170
- console.log(" • Each team member runs: npx skillrepo setup");
171
- console.log(" • SKILLREPO_API_KEY is in .env.local (gitignored)");
170
+ console.log(" • Each team member runs: npx skillrepo init");
171
+ console.log(" • SKILLREPO_ACCESS_KEY is in .env.local (gitignored)");
172
172
 
173
173
  if (detectedKeys.includes("vscode")) {
174
174
  printBlank();
@@ -17,7 +17,7 @@ export function mergeClaudeMcpConfig(mcpUrl) {
17
17
  type: "http",
18
18
  url: mcpUrl,
19
19
  headers: {
20
- Authorization: "Bearer ${SKILLREPO_API_KEY}",
20
+ Authorization: "Bearer ${SKILLREPO_ACCESS_KEY}",
21
21
  },
22
22
  };
23
23
 
@@ -16,7 +16,7 @@ export function mergeCursorMcpConfig(mcpUrl) {
16
16
  const serverEntry = {
17
17
  url: mcpUrl,
18
18
  headers: {
19
- Authorization: "Bearer ${env:SKILLREPO_API_KEY}",
19
+ Authorization: "Bearer ${env:SKILLREPO_ACCESS_KEY}",
20
20
  },
21
21
  };
22
22
 
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Merge SKILLREPO_API_KEY into .env.local
2
+ * Merge SKILLREPO_ACCESS_KEY into .env.local
3
3
  * Append-only — never overwrite existing keys unless the value differs.
4
4
  */
5
5
 
6
6
  import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
7
7
  import { envLocal } from "../paths.mjs";
8
8
 
9
- const KEY_NAME = "SKILLREPO_API_KEY";
9
+ const KEY_NAME = "SKILLREPO_ACCESS_KEY";
10
10
 
11
11
  /**
12
12
  * @param {string} apiKey
@@ -1,29 +1,56 @@
1
1
  /**
2
2
  * Merger for Claude Code .claude/hooks/hooks.json
3
- * Also writes the skillrepo-sync.mjs hook script.
3
+ * Writes hook scripts and merges hook entries into hooks.json.
4
4
  *
5
- * Merge strategy: add a SessionStart entry without destroying existing hooks.
6
- * Idempotent — if the hook is already installed, skip.
5
+ * Merge strategy: add SessionStart and UserPromptSubmit entries
6
+ * without destroying existing hooks. Idempotent — skips if already installed.
7
7
  */
8
8
 
9
9
  import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
10
- import { claudeHooksJson, claudeSyncHook } from "../paths.mjs";
10
+ import { claudeHooksJson, claudeSyncHook, claudePromptHook } from "../paths.mjs";
11
11
 
12
- const TARGET_COMMAND = "node .claude/hooks/skillrepo-sync.mjs";
12
+ const SYNC_COMMAND = "node .claude/hooks/skillrepo-sync.mjs";
13
+ const PROMPT_COMMAND = "node .claude/hooks/skillrepo-prompt-match.mjs";
13
14
 
14
15
  /**
15
- * Merge the SkillRepo SessionStart hook into hooks.json.
16
+ * Check if a command is already installed in a hook group array.
17
+ * @param {Array} groups
18
+ * @param {string} command
19
+ * @returns {boolean}
20
+ */
21
+ function hasCommand(groups, command) {
22
+ if (!Array.isArray(groups)) return false;
23
+ return groups.some(
24
+ (group) =>
25
+ Array.isArray(group.hooks) &&
26
+ group.hooks.some((h) => h.command === command)
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Merge SkillRepo hooks into hooks.json and write hook scripts.
16
32
  * @param {string} hooksJsonContent - The canonical hooks.json content from the API
17
- * @param {string} syncHookContent - The sync hook script content from the API
33
+ * @param {string} syncHookContent - The sync hook script content
34
+ * @param {string} promptHookContent - The prompt-match hook script content
18
35
  * @returns {{ results: { path: string; action: string }[] }}
19
36
  */
20
- export function mergeHooksConfig(hooksJsonContent, syncHookContent) {
37
+ export function mergeHooksConfig(hooksJsonContent, syncHookContent, promptHookContent) {
21
38
  const results = [];
22
39
 
23
- // Always write the sync hook script (latest version from server)
24
- const hookExisted = readFileSafe(claudeSyncHook()) !== null;
40
+ // Always write hook scripts (latest version from server)
41
+ const syncExisted = readFileSafe(claudeSyncHook()) !== null;
25
42
  writeFileSafe(claudeSyncHook(), syncHookContent);
26
- results.push({ path: ".claude/hooks/skillrepo-sync.mjs", action: hookExisted ? "updated" : "created" });
43
+ results.push({
44
+ path: ".claude/hooks/skillrepo-sync.mjs",
45
+ action: syncExisted ? "updated" : "created",
46
+ });
47
+
48
+ const promptExisted = readFileSafe(claudePromptHook()) !== null;
49
+ writeFileSafe(claudePromptHook(), promptHookContent);
50
+ results.push({
51
+ path: ".claude/hooks/skillrepo-prompt-match.mjs",
52
+ action: promptExisted ? "updated" : "created",
53
+ });
27
54
 
28
55
  // Merge hooks.json
29
56
  const filePath = claudeHooksJson();
@@ -39,31 +66,39 @@ export function mergeHooksConfig(hooksJsonContent, syncHookContent) {
39
66
  try {
40
67
  config = JSON.parse(existing);
41
68
  } catch {
42
- throw new Error(`Cannot parse .claude/hooks/hooks.json — invalid JSON. Fix or delete it, then run setup again.`);
69
+ throw new Error(
70
+ "Cannot parse .claude/hooks/hooks.json — invalid JSON. Fix or delete it, then run setup again."
71
+ );
43
72
  }
44
73
 
45
- // Navigate to hooks.SessionStart
46
74
  if (!config.hooks) config.hooks = {};
75
+ let changed = false;
76
+
77
+ // Merge SessionStart
47
78
  if (!Array.isArray(config.hooks.SessionStart)) config.hooks.SessionStart = [];
79
+ if (!hasCommand(config.hooks.SessionStart, SYNC_COMMAND)) {
80
+ config.hooks.SessionStart.push({
81
+ matcher: "startup|resume",
82
+ hooks: [{ type: "command", command: SYNC_COMMAND }],
83
+ });
84
+ changed = true;
85
+ }
48
86
 
49
- // Check if already installed
50
- const found = config.hooks.SessionStart.some((group) =>
51
- Array.isArray(group.hooks) &&
52
- group.hooks.some((h) => h.command === TARGET_COMMAND)
53
- );
87
+ // Merge UserPromptSubmit
88
+ if (!Array.isArray(config.hooks.UserPromptSubmit)) config.hooks.UserPromptSubmit = [];
89
+ if (!hasCommand(config.hooks.UserPromptSubmit, PROMPT_COMMAND)) {
90
+ config.hooks.UserPromptSubmit.push({
91
+ hooks: [{ type: "command", command: PROMPT_COMMAND }],
92
+ });
93
+ changed = true;
94
+ }
54
95
 
55
- if (found) {
96
+ if (changed) {
97
+ writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
98
+ results.push({ path: ".claude/hooks/hooks.json", action: "merged" });
99
+ } else {
56
100
  results.push({ path: ".claude/hooks/hooks.json", action: "skipped" });
57
- return { results };
58
101
  }
59
102
 
60
- // Append new entry
61
- config.hooks.SessionStart.push({
62
- matcher: "startup|resume",
63
- hooks: [{ type: "command", command: TARGET_COMMAND }],
64
- });
65
-
66
- writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
67
- results.push({ path: ".claude/hooks/hooks.json", action: "merged" });
68
103
  return { results };
69
104
  }
@@ -17,7 +17,7 @@ export function mergeWindsurfMcpConfig(mcpUrl) {
17
17
  const serverEntry = {
18
18
  serverUrl: mcpUrl,
19
19
  headers: {
20
- Authorization: "Bearer ${env:SKILLREPO_API_KEY}",
20
+ Authorization: "Bearer ${env:SKILLREPO_ACCESS_KEY}",
21
21
  },
22
22
  };
23
23
 
package/src/lib/paths.mjs CHANGED
@@ -14,7 +14,9 @@ export const claudeDir = () => join(cwd(), ".claude");
14
14
  export const claudeHooksDir = () => join(cwd(), ".claude", "hooks");
15
15
  export const claudeHooksJson = () => join(cwd(), ".claude", "hooks", "hooks.json");
16
16
  export const claudeSyncHook = () => join(cwd(), ".claude", "hooks", "skillrepo-sync.mjs");
17
+ export const claudePromptHook = () => join(cwd(), ".claude", "hooks", "skillrepo-prompt-match.mjs");
17
18
  export const claudeSkillrepoMd = () => join(cwd(), ".claude", "skillrepo.md");
19
+ export const claudeSkillrepoIndex = () => join(cwd(), ".claude", "skillrepo-index.json");
18
20
 
19
21
  // Cursor
20
22
  export const cursorDir = () => join(cwd(), ".cursor");
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { readFileSafe, writeFileSafe } from "./fs-utils.mjs";
7
- import { claudeSkillrepoMd, cursorSkillrepoMdc } from "./paths.mjs";
7
+ import { claudeSkillrepoMd, claudeSkillrepoIndex, cursorSkillrepoMdc } from "./paths.mjs";
8
8
  import { mergeClaudeMcpConfig } from "./mergers/claude-mcp.mjs";
9
9
  import { mergeCursorMcpConfig } from "./mergers/cursor-mcp.mjs";
10
10
  import { mergeWindsurfMcpConfig } from "./mergers/windsurf-mcp.mjs";
@@ -33,10 +33,16 @@ export function writeAllConfigs({ ides, mcpUrl, apiKey, payload }) {
33
33
  writeFileSafe(claudeSkillrepoMd(), payload.claudeCode.skillrepoMd.content);
34
34
  results.push({ path: ".claude/skillrepo.md", action: claudeMdExisted ? "updated" : "created" });
35
35
 
36
- // SessionStart hook
36
+ // JSON skill index for prompt matching
37
+ const indexExisted = readFileSafe(claudeSkillrepoIndex()) !== null;
38
+ writeFileSafe(claudeSkillrepoIndex(), payload.claudeCode.skillIndex.content);
39
+ results.push({ path: ".claude/skillrepo-index.json", action: indexExisted ? "updated" : "created" });
40
+
41
+ // Hooks (SessionStart sync + UserPromptSubmit prompt-match)
37
42
  const hookResults = mergeHooksConfig(
38
43
  payload.claudeCode.hooksJson.content,
39
- payload.claudeCode.syncHook.content
44
+ payload.claudeCode.syncHook.content,
45
+ payload.claudeCode.promptHook.content
40
46
  );
41
47
  results.push(...hookResults.results);
42
48
  }
@@ -25,12 +25,12 @@ describe(".env.local merger", () => {
25
25
 
26
26
  assert.equal(result.action, "created");
27
27
  const content = readFileSync(join(tempDir, ".env.local"), "utf-8");
28
- assert.equal(content, "SKILLREPO_API_KEY=sk_live_test123\n");
28
+ assert.equal(content, "SKILLREPO_ACCESS_KEY=sk_live_test123\n");
29
29
  });
30
30
 
31
31
  it("skips when key already exists with same value", async () => {
32
32
  const { mergeEnvLocal } = await import("../lib/mergers/env-local.mjs");
33
- writeFileSync(join(tempDir, ".env.local"), "SKILLREPO_API_KEY=sk_live_test123\n");
33
+ writeFileSync(join(tempDir, ".env.local"), "SKILLREPO_ACCESS_KEY=sk_live_test123\n");
34
34
 
35
35
  const result = mergeEnvLocal("sk_live_test123");
36
36
  assert.equal(result.action, "skipped");
@@ -38,13 +38,13 @@ describe(".env.local merger", () => {
38
38
 
39
39
  it("updates when key exists with different value", async () => {
40
40
  const { mergeEnvLocal } = await import("../lib/mergers/env-local.mjs");
41
- writeFileSync(join(tempDir, ".env.local"), "SKILLREPO_API_KEY=sk_live_old\n");
41
+ writeFileSync(join(tempDir, ".env.local"), "SKILLREPO_ACCESS_KEY=sk_live_old\n");
42
42
 
43
43
  const result = mergeEnvLocal("sk_live_new");
44
44
  assert.equal(result.action, "updated");
45
45
 
46
46
  const content = readFileSync(join(tempDir, ".env.local"), "utf-8");
47
- assert.ok(content.includes("SKILLREPO_API_KEY=sk_live_new"));
47
+ assert.ok(content.includes("SKILLREPO_ACCESS_KEY=sk_live_new"));
48
48
  assert.ok(!content.includes("sk_live_old"));
49
49
  });
50
50
 
@@ -57,7 +57,7 @@ describe(".env.local merger", () => {
57
57
 
58
58
  const content = readFileSync(join(tempDir, ".env.local"), "utf-8");
59
59
  assert.ok(content.includes("OTHER_VAR=value"));
60
- assert.ok(content.includes("SKILLREPO_API_KEY=sk_live_test123"));
60
+ assert.ok(content.includes("SKILLREPO_ACCESS_KEY=sk_live_test123"));
61
61
  });
62
62
 
63
63
  it("preserves CRLF line endings", async () => {
@@ -30,7 +30,7 @@ describe("Claude Code MCP config merger", () => {
30
30
  const content = JSON.parse(readFileSync(join(tempDir, ".mcp.json"), "utf-8"));
31
31
  assert.equal(content.mcpServers.skillrepo.type, "http");
32
32
  assert.equal(content.mcpServers.skillrepo.url, "https://skillrepo.dev/api/mcp");
33
- assert.equal(content.mcpServers.skillrepo.headers.Authorization, "Bearer ${SKILLREPO_API_KEY}");
33
+ assert.equal(content.mcpServers.skillrepo.headers.Authorization, "Bearer ${SKILLREPO_ACCESS_KEY}");
34
34
  });
35
35
 
36
36
  it("merges into existing config without destroying other servers", async () => {
@@ -0,0 +1,151 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+
7
+ let originalCwd;
8
+ let tempDir;
9
+
10
+ beforeEach(() => {
11
+ originalCwd = process.cwd;
12
+ tempDir = mkdtempSync(join(tmpdir(), "cli-hooks-test-"));
13
+ process.cwd = () => tempDir;
14
+ });
15
+
16
+ afterEach(() => {
17
+ process.cwd = originalCwd;
18
+ rmSync(tempDir, { recursive: true, force: true });
19
+ });
20
+
21
+ const SYNC_CONTENT = "// sync hook";
22
+ const PROMPT_CONTENT = "// prompt hook";
23
+ const HOOKS_JSON = JSON.stringify({
24
+ hooks: {
25
+ SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-sync.mjs" }] }],
26
+ UserPromptSubmit: [{ hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-prompt-match.mjs" }] }],
27
+ },
28
+ }, null, 2) + "\n";
29
+
30
+ describe("Hooks JSON merger", () => {
31
+ it("creates all files when nothing exists", async () => {
32
+ const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
33
+ const { results } = mergeHooksConfig(HOOKS_JSON, SYNC_CONTENT, PROMPT_CONTENT);
34
+
35
+ // Check sync hook written
36
+ const syncResult = results.find((r) => r.path.includes("sync"));
37
+ assert.equal(syncResult.action, "created");
38
+ assert.equal(readFileSync(join(tempDir, ".claude/hooks/skillrepo-sync.mjs"), "utf-8"), SYNC_CONTENT);
39
+
40
+ // Check prompt hook written
41
+ const promptResult = results.find((r) => r.path.includes("prompt"));
42
+ assert.equal(promptResult.action, "created");
43
+ assert.equal(readFileSync(join(tempDir, ".claude/hooks/skillrepo-prompt-match.mjs"), "utf-8"), PROMPT_CONTENT);
44
+
45
+ // Check hooks.json created
46
+ const jsonResult = results.find((r) => r.path.includes("hooks.json"));
47
+ assert.equal(jsonResult.action, "created");
48
+ });
49
+
50
+ it("merges into existing hooks.json without destroying other hooks", async () => {
51
+ const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
52
+
53
+ // Pre-existing hooks.json with a different hook
54
+ const existing = {
55
+ hooks: {
56
+ PreToolUse: [{ hooks: [{ type: "command", command: "node other-hook.mjs" }] }],
57
+ },
58
+ };
59
+ mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
60
+ writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), JSON.stringify(existing, null, 2));
61
+
62
+ const { results } = mergeHooksConfig(HOOKS_JSON, SYNC_CONTENT, PROMPT_CONTENT);
63
+
64
+ const jsonResult = results.find((r) => r.path.includes("hooks.json"));
65
+ assert.equal(jsonResult.action, "merged");
66
+
67
+ const config = JSON.parse(readFileSync(join(tempDir, ".claude/hooks/hooks.json"), "utf-8"));
68
+ // Original hook preserved
69
+ assert.equal(config.hooks.PreToolUse.length, 1);
70
+ // New hooks added
71
+ assert.equal(config.hooks.SessionStart.length, 1);
72
+ assert.equal(config.hooks.UserPromptSubmit.length, 1);
73
+ });
74
+
75
+ it("skips merge when hooks are already installed", async () => {
76
+ const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
77
+
78
+ // Pre-existing with both hooks already
79
+ const existing = {
80
+ hooks: {
81
+ SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-sync.mjs" }] }],
82
+ UserPromptSubmit: [{ hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-prompt-match.mjs" }] }],
83
+ },
84
+ };
85
+ mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
86
+ writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), JSON.stringify(existing, null, 2));
87
+
88
+ const { results } = mergeHooksConfig(HOOKS_JSON, SYNC_CONTENT, PROMPT_CONTENT);
89
+
90
+ const jsonResult = results.find((r) => r.path.includes("hooks.json"));
91
+ assert.equal(jsonResult.action, "skipped");
92
+ });
93
+
94
+ it("adds missing UserPromptSubmit when only SessionStart exists", async () => {
95
+ const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
96
+
97
+ // Only SessionStart installed (pre-existing v1.1 setup)
98
+ const existing = {
99
+ hooks: {
100
+ SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-sync.mjs" }] }],
101
+ },
102
+ };
103
+ mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
104
+ writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), JSON.stringify(existing, null, 2));
105
+
106
+ const { results } = mergeHooksConfig(HOOKS_JSON, SYNC_CONTENT, PROMPT_CONTENT);
107
+
108
+ const jsonResult = results.find((r) => r.path.includes("hooks.json"));
109
+ assert.equal(jsonResult.action, "merged");
110
+
111
+ const config = JSON.parse(readFileSync(join(tempDir, ".claude/hooks/hooks.json"), "utf-8"));
112
+ // SessionStart unchanged
113
+ assert.equal(config.hooks.SessionStart.length, 1);
114
+ // UserPromptSubmit added
115
+ assert.equal(config.hooks.UserPromptSubmit.length, 1);
116
+ assert.equal(config.hooks.UserPromptSubmit[0].hooks[0].command, "node .claude/hooks/skillrepo-prompt-match.mjs");
117
+ });
118
+
119
+ it("always updates hook scripts even when hooks.json is skipped", async () => {
120
+ const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
121
+
122
+ // Install once
123
+ mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
124
+ writeFileSync(join(tempDir, ".claude/hooks/skillrepo-sync.mjs"), "// old sync");
125
+ writeFileSync(join(tempDir, ".claude/hooks/skillrepo-prompt-match.mjs"), "// old prompt");
126
+ writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), HOOKS_JSON);
127
+
128
+ const { results } = mergeHooksConfig(HOOKS_JSON, "// new sync", "// new prompt");
129
+
130
+ // Scripts updated
131
+ const syncResult = results.find((r) => r.path.includes("sync.mjs"));
132
+ assert.equal(syncResult.action, "updated");
133
+ assert.equal(readFileSync(join(tempDir, ".claude/hooks/skillrepo-sync.mjs"), "utf-8"), "// new sync");
134
+
135
+ const promptResult = results.find((r) => r.path.includes("prompt-match"));
136
+ assert.equal(promptResult.action, "updated");
137
+ assert.equal(readFileSync(join(tempDir, ".claude/hooks/skillrepo-prompt-match.mjs"), "utf-8"), "// new prompt");
138
+ });
139
+
140
+ it("throws on invalid JSON in hooks.json", async () => {
141
+ const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
142
+
143
+ mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
144
+ writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), "not json{");
145
+
146
+ assert.throws(
147
+ () => mergeHooksConfig(HOOKS_JSON, SYNC_CONTENT, PROMPT_CONTENT),
148
+ /Cannot parse/
149
+ );
150
+ });
151
+ });