skillrepo 1.3.1 → 1.4.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.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Set up SkillRepo in any IDE — one command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,6 +24,8 @@ import {
24
24
  confirm,
25
25
  } from "../lib/prompt.mjs";
26
26
 
27
+ import { resolveKeyFromEnvFiles } from "../lib/resolve-key.mjs";
28
+
27
29
  const DEFAULT_URL = "https://skillrepo.dev";
28
30
 
29
31
  /**
@@ -32,7 +34,7 @@ const DEFAULT_URL = "https://skillrepo.dev";
32
34
  */
33
35
  function parseFlags(argv) {
34
36
  const flags = {
35
- key: process.env.SKILLREPO_ACCESS_KEY || null,
37
+ key: resolveKeyFromEnvFiles(),
36
38
  url: process.env.SKILLREPO_URL || DEFAULT_URL,
37
39
  yes: false,
38
40
  };
@@ -39,7 +39,7 @@ export function mergeClaudeMcpConfig(mcpUrl) {
39
39
  config.mcpServers = {};
40
40
  }
41
41
 
42
- const action = config.mcpServers.skillrepo ? "merged" : "created";
42
+ const action = config.mcpServers.skillrepo ? "updated" : "merged";
43
43
  config.mcpServers.skillrepo = serverEntry;
44
44
 
45
45
  writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
@@ -1,16 +1,34 @@
1
1
  /**
2
- * Merger for Claude Code .claude/hooks/hooks.json
3
- * Writes hook scripts and merges hook entries into hooks.json.
2
+ * Merger for Claude Code hooks into .claude/settings.local.json
3
+ * Writes hook scripts and merges hook entries into settings.local.json.
4
4
  *
5
5
  * Merge strategy: add SessionStart and UserPromptSubmit entries
6
- * without destroying existing hooks. Idempotent — skips if already installed.
6
+ * without destroying existing settings. Idempotent — skips if already installed.
7
+ * Cleans up stale .claude/hooks/hooks.json if it exists.
7
8
  */
8
9
 
10
+ import { unlinkSync } from "node:fs";
9
11
  import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
10
- import { claudeHooksJson, claudeSyncHook, claudePromptHook } from "../paths.mjs";
12
+ import { claudeSettingsLocal, claudeSyncHook, claudePromptHook, claudeHooksDir } from "../paths.mjs";
13
+ import { join } from "node:path";
11
14
 
12
- const SYNC_COMMAND = "node .claude/hooks/skillrepo-sync.mjs";
13
- const PROMPT_COMMAND = "node .claude/hooks/skillrepo-prompt-match.mjs";
15
+ const DEFAULT_SYNC_COMMAND = "node .claude/hooks/skillrepo-sync.mjs";
16
+ const DEFAULT_PROMPT_COMMAND = "node .claude/hooks/skillrepo-prompt-match.mjs";
17
+
18
+ /**
19
+ * Extract a hook command from a hooks config group, falling back to a default.
20
+ * @param {object} hooksConfig - The hooks config object from the server payload
21
+ * @param {string} eventName - e.g. "SessionStart" or "UserPromptSubmit"
22
+ * @param {string} fallback - default command if extraction fails
23
+ * @returns {string}
24
+ */
25
+ function extractCommand(hooksConfig, eventName, fallback) {
26
+ try {
27
+ return hooksConfig[eventName][0].hooks[0].command || fallback;
28
+ } catch {
29
+ return fallback;
30
+ }
31
+ }
14
32
 
15
33
  /**
16
34
  * Check if a command is already installed in a hook group array.
@@ -28,13 +46,15 @@ function hasCommand(groups, command) {
28
46
  }
29
47
 
30
48
  /**
31
- * Merge SkillRepo hooks into hooks.json and write hook scripts.
32
- * @param {string} hooksJsonContent - The canonical hooks.json content from the API
49
+ * Merge SkillRepo hooks into .claude/settings.local.json and write hook scripts.
50
+ * @param {object} hooksConfig - The hooks config object from the server payload (settingsHooks.hooks)
33
51
  * @param {string} syncHookContent - The sync hook script content
34
52
  * @param {string} promptHookContent - The prompt-match hook script content
35
53
  * @returns {{ results: { path: string; action: string }[] }}
36
54
  */
37
- export function mergeHooksConfig(hooksJsonContent, syncHookContent, promptHookContent) {
55
+ export function mergeHooksConfig(hooksConfig, syncHookContent, promptHookContent) {
56
+ const syncCommand = extractCommand(hooksConfig, "SessionStart", DEFAULT_SYNC_COMMAND);
57
+ const promptCommand = extractCommand(hooksConfig, "UserPromptSubmit", DEFAULT_PROMPT_COMMAND);
38
58
  const results = [];
39
59
 
40
60
  // Always write hook scripts (latest version from server)
@@ -52,23 +72,28 @@ export function mergeHooksConfig(hooksJsonContent, syncHookContent, promptHookCo
52
72
  action: promptExisted ? "updated" : "created",
53
73
  });
54
74
 
55
- // Merge hooks.json
56
- const filePath = claudeHooksJson();
57
- const existing = readFileSafe(filePath);
58
-
59
- if (existing === null) {
60
- writeFileSafe(filePath, hooksJsonContent);
61
- results.push({ path: ".claude/hooks/hooks.json", action: "created" });
62
- return { results };
63
- }
64
-
65
- let config;
75
+ // Clean up stale .claude/hooks/hooks.json if it exists
76
+ const staleHooksJson = join(claudeHooksDir(), "hooks.json");
66
77
  try {
67
- config = JSON.parse(existing);
78
+ unlinkSync(staleHooksJson);
79
+ results.push({ path: ".claude/hooks/hooks.json", action: "removed" });
68
80
  } catch {
69
- throw new Error(
70
- "Cannot parse .claude/hooks/hooks.json — invalid JSON. Fix or delete it, then run setup again."
71
- );
81
+ // File doesn't exist — nothing to clean up
82
+ }
83
+
84
+ // Merge hooks into .claude/settings.local.json
85
+ const filePath = claudeSettingsLocal();
86
+ const existing = readFileSafe(filePath);
87
+
88
+ let config = {};
89
+ if (existing !== null) {
90
+ try {
91
+ config = JSON.parse(existing);
92
+ } catch {
93
+ throw new Error(
94
+ "Cannot parse .claude/settings.local.json — invalid JSON. Fix or delete it, then run setup again."
95
+ );
96
+ }
72
97
  }
73
98
 
74
99
  if (!config.hooks) config.hooks = {};
@@ -76,28 +101,31 @@ export function mergeHooksConfig(hooksJsonContent, syncHookContent, promptHookCo
76
101
 
77
102
  // Merge SessionStart
78
103
  if (!Array.isArray(config.hooks.SessionStart)) config.hooks.SessionStart = [];
79
- if (!hasCommand(config.hooks.SessionStart, SYNC_COMMAND)) {
104
+ if (!hasCommand(config.hooks.SessionStart, syncCommand)) {
80
105
  config.hooks.SessionStart.push({
81
106
  matcher: "startup|resume",
82
- hooks: [{ type: "command", command: SYNC_COMMAND }],
107
+ hooks: [{ type: "command", command: syncCommand }],
83
108
  });
84
109
  changed = true;
85
110
  }
86
111
 
87
112
  // Merge UserPromptSubmit
88
113
  if (!Array.isArray(config.hooks.UserPromptSubmit)) config.hooks.UserPromptSubmit = [];
89
- if (!hasCommand(config.hooks.UserPromptSubmit, PROMPT_COMMAND)) {
114
+ if (!hasCommand(config.hooks.UserPromptSubmit, promptCommand)) {
90
115
  config.hooks.UserPromptSubmit.push({
91
- hooks: [{ type: "command", command: PROMPT_COMMAND }],
116
+ hooks: [{ type: "command", command: promptCommand }],
92
117
  });
93
118
  changed = true;
94
119
  }
95
120
 
96
- if (changed) {
121
+ if (existing === null) {
122
+ writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
123
+ results.push({ path: ".claude/settings.local.json", action: "created" });
124
+ } else if (changed) {
97
125
  writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
98
- results.push({ path: ".claude/hooks/hooks.json", action: "merged" });
126
+ results.push({ path: ".claude/settings.local.json", action: "merged" });
99
127
  } else {
100
- results.push({ path: ".claude/hooks/hooks.json", action: "skipped" });
128
+ results.push({ path: ".claude/settings.local.json", action: "skipped" });
101
129
  }
102
130
 
103
131
  return { results };
package/src/lib/paths.mjs CHANGED
@@ -12,7 +12,7 @@ const cwd = () => process.cwd();
12
12
  export const claudeMcpJson = () => join(cwd(), ".mcp.json");
13
13
  export const claudeDir = () => join(cwd(), ".claude");
14
14
  export const claudeHooksDir = () => join(cwd(), ".claude", "hooks");
15
- export const claudeHooksJson = () => join(cwd(), ".claude", "hooks", "hooks.json");
15
+ export const claudeSettingsLocal = () => join(cwd(), ".claude", "settings.local.json");
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 claudeSkillrepoMd = () => join(cwd(), ".claude", "skillrepo.md");
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Resolve SKILLREPO_ACCESS_KEY from process.env → .env.local → .env.
3
+ * Extracted as a shared module so it can be tested independently.
4
+ */
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ /**
10
+ * Resolve SKILLREPO_ACCESS_KEY from .env.local → .env → process.env.
11
+ * @returns {string|null}
12
+ */
13
+ export function resolveKeyFromEnvFiles() {
14
+ if (process.env.SKILLREPO_ACCESS_KEY) return process.env.SKILLREPO_ACCESS_KEY;
15
+ const cwd = process.cwd();
16
+ for (const file of [".env.local", ".env"]) {
17
+ try {
18
+ const lines = readFileSync(join(cwd, file), "utf-8").split("\n");
19
+ for (const line of lines) {
20
+ const trimmed = line.trim();
21
+ if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
22
+ const eqIdx = trimmed.indexOf("=");
23
+ const key = trimmed.slice(0, eqIdx).trim();
24
+ if (key === "SKILLREPO_ACCESS_KEY") {
25
+ let val = trimmed.slice(eqIdx + 1).trim();
26
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
27
+ val = val.slice(1, -1);
28
+ }
29
+ val = val.replace(/\s+#.*$/, "");
30
+ if (val) return val;
31
+ }
32
+ }
33
+ } catch { /* file not found, continue */ }
34
+ }
35
+ return null;
36
+ }
@@ -40,7 +40,7 @@ export function writeAllConfigs({ ides, mcpUrl, apiKey, payload }) {
40
40
 
41
41
  // Hooks (SessionStart sync + UserPromptSubmit prompt-match)
42
42
  const hookResults = mergeHooksConfig(
43
- payload.claudeCode.hooksJson.content,
43
+ payload.claudeCode.settingsHooks.hooks,
44
44
  payload.claudeCode.syncHook.content,
45
45
  payload.claudeCode.promptHook.content
46
46
  );
@@ -1,6 +1,6 @@
1
1
  import { describe, it, beforeEach, afterEach } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
3
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
 
@@ -46,13 +46,13 @@ describe("Claude Code MCP config merger", () => {
46
46
 
47
47
  const result = mergeClaudeMcpConfig("https://skillrepo.dev/api/mcp");
48
48
 
49
- assert.equal(result.action, "created"); // new entry, not merge
49
+ assert.equal(result.action, "merged"); // adding to existing file
50
50
  const content = JSON.parse(readFileSync(join(tempDir, ".mcp.json"), "utf-8"));
51
51
  assert.ok(content.mcpServers.other, "other server preserved");
52
52
  assert.ok(content.mcpServers.skillrepo, "skillrepo added");
53
53
  });
54
54
 
55
- it("updates existing skillrepo entry (merged)", async () => {
55
+ it("updates existing skillrepo entry", async () => {
56
56
  const { mergeClaudeMcpConfig } = await import("../../lib/mergers/claude-mcp.mjs");
57
57
 
58
58
  const existing = {
@@ -64,7 +64,7 @@ describe("Claude Code MCP config merger", () => {
64
64
 
65
65
  const result = mergeClaudeMcpConfig("https://new-url.com/api/mcp");
66
66
 
67
- assert.equal(result.action, "merged");
67
+ assert.equal(result.action, "updated");
68
68
  const content = JSON.parse(readFileSync(join(tempDir, ".mcp.json"), "utf-8"));
69
69
  assert.equal(content.mcpServers.skillrepo.url, "https://new-url.com/api/mcp");
70
70
  });
@@ -20,17 +20,16 @@ afterEach(() => {
20
20
 
21
21
  const SYNC_CONTENT = "// sync hook";
22
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 () => {
23
+
24
+ const HOOKS_CONFIG = {
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
+
29
+ describe("Hooks settings.local.json merger", () => {
30
+ it("creates settings.local.json and scripts when nothing exists", async () => {
32
31
  const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
33
- const { results } = mergeHooksConfig(HOOKS_JSON, SYNC_CONTENT, PROMPT_CONTENT);
32
+ const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
34
33
 
35
34
  // Check sync hook written
36
35
  const syncResult = results.find((r) => r.path.includes("sync"));
@@ -42,30 +41,37 @@ describe("Hooks JSON merger", () => {
42
41
  assert.equal(promptResult.action, "created");
43
42
  assert.equal(readFileSync(join(tempDir, ".claude/hooks/skillrepo-prompt-match.mjs"), "utf-8"), PROMPT_CONTENT);
44
43
 
45
- // Check hooks.json created
46
- const jsonResult = results.find((r) => r.path.includes("hooks.json"));
47
- assert.equal(jsonResult.action, "created");
44
+ // Check settings.local.json created
45
+ const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
46
+ assert.equal(settingsResult.action, "created");
47
+
48
+ const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
49
+ assert.equal(config.hooks.SessionStart.length, 1);
50
+ assert.equal(config.hooks.UserPromptSubmit.length, 1);
48
51
  });
49
52
 
50
- it("merges into existing hooks.json without destroying other hooks", async () => {
53
+ it("merges into existing settings without destroying other keys", async () => {
51
54
  const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
52
55
 
53
- // Pre-existing hooks.json with a different hook
56
+ // Pre-existing settings.local.json with permissions and other hooks
54
57
  const existing = {
58
+ permissions: { allow: ["Bash(*)", "Read(*)"] },
55
59
  hooks: {
56
60
  PreToolUse: [{ hooks: [{ type: "command", command: "node other-hook.mjs" }] }],
57
61
  },
58
62
  };
59
- mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
60
- writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), JSON.stringify(existing, null, 2));
63
+ mkdirSync(join(tempDir, ".claude"), { recursive: true });
64
+ writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
61
65
 
62
- const { results } = mergeHooksConfig(HOOKS_JSON, SYNC_CONTENT, PROMPT_CONTENT);
66
+ const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
63
67
 
64
- const jsonResult = results.find((r) => r.path.includes("hooks.json"));
65
- assert.equal(jsonResult.action, "merged");
68
+ const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
69
+ assert.equal(settingsResult.action, "merged");
66
70
 
67
- const config = JSON.parse(readFileSync(join(tempDir, ".claude/hooks/hooks.json"), "utf-8"));
68
- // Original hook preserved
71
+ const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
72
+ // Original permissions preserved
73
+ assert.deepEqual(config.permissions, { allow: ["Bash(*)", "Read(*)"] });
74
+ // Original hooks preserved
69
75
  assert.equal(config.hooks.PreToolUse.length, 1);
70
76
  // New hooks added
71
77
  assert.equal(config.hooks.SessionStart.length, 1);
@@ -82,50 +88,55 @@ describe("Hooks JSON merger", () => {
82
88
  UserPromptSubmit: [{ hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-prompt-match.mjs" }] }],
83
89
  },
84
90
  };
85
- mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
86
- writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), JSON.stringify(existing, null, 2));
91
+ mkdirSync(join(tempDir, ".claude"), { recursive: true });
92
+ writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
87
93
 
88
- const { results } = mergeHooksConfig(HOOKS_JSON, SYNC_CONTENT, PROMPT_CONTENT);
94
+ const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
89
95
 
90
- const jsonResult = results.find((r) => r.path.includes("hooks.json"));
91
- assert.equal(jsonResult.action, "skipped");
96
+ const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
97
+ assert.equal(settingsResult.action, "skipped");
92
98
  });
93
99
 
94
100
  it("adds missing UserPromptSubmit when only SessionStart exists", async () => {
95
101
  const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
96
102
 
97
- // Only SessionStart installed (pre-existing v1.1 setup)
103
+ // Only SessionStart installed
98
104
  const existing = {
99
105
  hooks: {
100
106
  SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-sync.mjs" }] }],
101
107
  },
102
108
  };
103
- mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
104
- writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), JSON.stringify(existing, null, 2));
109
+ mkdirSync(join(tempDir, ".claude"), { recursive: true });
110
+ writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
105
111
 
106
- const { results } = mergeHooksConfig(HOOKS_JSON, SYNC_CONTENT, PROMPT_CONTENT);
112
+ const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
107
113
 
108
- const jsonResult = results.find((r) => r.path.includes("hooks.json"));
109
- assert.equal(jsonResult.action, "merged");
114
+ const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
115
+ assert.equal(settingsResult.action, "merged");
110
116
 
111
- const config = JSON.parse(readFileSync(join(tempDir, ".claude/hooks/hooks.json"), "utf-8"));
112
- // SessionStart unchanged
117
+ const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
113
118
  assert.equal(config.hooks.SessionStart.length, 1);
114
- // UserPromptSubmit added
115
119
  assert.equal(config.hooks.UserPromptSubmit.length, 1);
116
120
  assert.equal(config.hooks.UserPromptSubmit[0].hooks[0].command, "node .claude/hooks/skillrepo-prompt-match.mjs");
117
121
  });
118
122
 
119
- it("always updates hook scripts even when hooks.json is skipped", async () => {
123
+ it("always updates hook scripts even when settings merge is skipped", async () => {
120
124
  const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
121
125
 
122
- // Install once
126
+ // Install once with old scripts
123
127
  mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
124
128
  writeFileSync(join(tempDir, ".claude/hooks/skillrepo-sync.mjs"), "// old sync");
125
129
  writeFileSync(join(tempDir, ".claude/hooks/skillrepo-prompt-match.mjs"), "// old prompt");
126
- writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), HOOKS_JSON);
130
+ mkdirSync(join(tempDir, ".claude"), { recursive: true });
131
+ const existing = {
132
+ hooks: {
133
+ SessionStart: [{ matcher: "startup|resume", hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-sync.mjs" }] }],
134
+ UserPromptSubmit: [{ hooks: [{ type: "command", command: "node .claude/hooks/skillrepo-prompt-match.mjs" }] }],
135
+ },
136
+ };
137
+ writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
127
138
 
128
- const { results } = mergeHooksConfig(HOOKS_JSON, "// new sync", "// new prompt");
139
+ const { results } = mergeHooksConfig(HOOKS_CONFIG, "// new sync", "// new prompt");
129
140
 
130
141
  // Scripts updated
131
142
  const syncResult = results.find((r) => r.path.includes("sync.mjs"));
@@ -135,17 +146,83 @@ describe("Hooks JSON merger", () => {
135
146
  const promptResult = results.find((r) => r.path.includes("prompt-match"));
136
147
  assert.equal(promptResult.action, "updated");
137
148
  assert.equal(readFileSync(join(tempDir, ".claude/hooks/skillrepo-prompt-match.mjs"), "utf-8"), "// new prompt");
149
+
150
+ // Settings skipped
151
+ const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
152
+ assert.equal(settingsResult.action, "skipped");
138
153
  });
139
154
 
140
- it("throws on invalid JSON in hooks.json", async () => {
155
+ it("cleans up stale .claude/hooks/hooks.json", async () => {
141
156
  const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
142
157
 
158
+ // Create stale hooks.json
143
159
  mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
144
- writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), "not json{");
160
+ writeFileSync(join(tempDir, ".claude/hooks/hooks.json"), '{"hooks":{}}');
161
+
162
+ const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
163
+
164
+ // Stale file removed
165
+ assert.equal(existsSync(join(tempDir, ".claude/hooks/hooks.json")), false);
166
+ const removedResult = results.find((r) => r.path === ".claude/hooks/hooks.json");
167
+ assert.equal(removedResult.action, "removed");
168
+
169
+ // settings.local.json created
170
+ const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
171
+ assert.equal(settingsResult.action, "created");
172
+ });
173
+
174
+ it("throws on invalid JSON in settings.local.json", async () => {
175
+ const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
176
+
177
+ mkdirSync(join(tempDir, ".claude"), { recursive: true });
178
+ writeFileSync(join(tempDir, ".claude/settings.local.json"), "not json{");
145
179
 
146
180
  assert.throws(
147
- () => mergeHooksConfig(HOOKS_JSON, SYNC_CONTENT, PROMPT_CONTENT),
181
+ () => mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT),
148
182
  /Cannot parse/
149
183
  );
150
184
  });
185
+
186
+ it("merges SessionStart when existing value is null", async () => {
187
+ const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
188
+
189
+ // Pre-populate settings.local.json with SessionStart: null
190
+ const existing = {
191
+ hooks: { SessionStart: null },
192
+ };
193
+ mkdirSync(join(tempDir, ".claude"), { recursive: true });
194
+ writeFileSync(join(tempDir, ".claude/settings.local.json"), JSON.stringify(existing, null, 2));
195
+
196
+ const { results } = mergeHooksConfig(HOOKS_CONFIG, SYNC_CONTENT, PROMPT_CONTENT);
197
+
198
+ const settingsResult = results.find((r) => r.path.includes("settings.local.json"));
199
+ assert.equal(settingsResult.action, "merged");
200
+
201
+ const config = JSON.parse(readFileSync(join(tempDir, ".claude/settings.local.json"), "utf-8"));
202
+ assert.ok(Array.isArray(config.hooks.SessionStart), "SessionStart should be an array");
203
+ assert.equal(config.hooks.SessionStart.length, 1);
204
+ assert.equal(config.hooks.SessionStart[0].hooks[0].command, "node .claude/hooks/skillrepo-sync.mjs");
205
+ });
206
+
207
+ it("documents partial write when JSON parse fails (hook scripts written before throw)", async () => {
208
+ const { mergeHooksConfig } = await import("../../lib/mergers/hooks-json.mjs");
209
+
210
+ // Create old sync script on disk
211
+ mkdirSync(join(tempDir, ".claude/hooks"), { recursive: true });
212
+ writeFileSync(join(tempDir, ".claude/hooks/skillrepo-sync.mjs"), "// old content");
213
+
214
+ // Create invalid settings.local.json
215
+ mkdirSync(join(tempDir, ".claude"), { recursive: true });
216
+ writeFileSync(join(tempDir, ".claude/settings.local.json"), "not valid json{{{");
217
+
218
+ // Should throw on invalid JSON
219
+ assert.throws(
220
+ () => mergeHooksConfig(HOOKS_CONFIG, "// new sync content", PROMPT_CONTENT),
221
+ /Cannot parse/
222
+ );
223
+
224
+ // Document the partial-write behavior: sync script was updated before the throw
225
+ const syncOnDisk = readFileSync(join(tempDir, ".claude/hooks/skillrepo-sync.mjs"), "utf-8");
226
+ assert.equal(syncOnDisk, "// new sync content", "sync script should have new content (partial write)");
227
+ });
151
228
  });
@@ -0,0 +1,85 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+
7
+ let originalCwd;
8
+ let tempDir;
9
+ let originalEnv;
10
+
11
+ beforeEach(() => {
12
+ originalCwd = process.cwd;
13
+ originalEnv = process.env.SKILLREPO_ACCESS_KEY;
14
+ delete process.env.SKILLREPO_ACCESS_KEY;
15
+ tempDir = mkdtempSync(join(tmpdir(), "cli-resolve-key-test-"));
16
+ process.cwd = () => tempDir;
17
+ });
18
+
19
+ afterEach(() => {
20
+ process.cwd = originalCwd;
21
+ if (originalEnv !== undefined) {
22
+ process.env.SKILLREPO_ACCESS_KEY = originalEnv;
23
+ } else {
24
+ delete process.env.SKILLREPO_ACCESS_KEY;
25
+ }
26
+ rmSync(tempDir, { recursive: true, force: true });
27
+ });
28
+
29
+ describe("resolveKeyFromEnvFiles", () => {
30
+ it("short-circuits when key is in process.env", async () => {
31
+ process.env.SKILLREPO_ACCESS_KEY = "sk_live_from_env";
32
+ const { resolveKeyFromEnvFiles } = await import("../lib/resolve-key.mjs");
33
+ const result = resolveKeyFromEnvFiles();
34
+ assert.equal(result, "sk_live_from_env");
35
+ });
36
+
37
+ it("reads plain value from .env.local", async () => {
38
+ writeFileSync(join(tempDir, ".env.local"), "SKILLREPO_ACCESS_KEY=sk_live_abc123\n");
39
+ const { resolveKeyFromEnvFiles } = await import("../lib/resolve-key.mjs");
40
+ const result = resolveKeyFromEnvFiles();
41
+ assert.equal(result, "sk_live_abc123");
42
+ });
43
+
44
+ it("reads double-quoted value from .env.local", async () => {
45
+ writeFileSync(join(tempDir, ".env.local"), 'SKILLREPO_ACCESS_KEY="sk_live_quoted"\n');
46
+ const { resolveKeyFromEnvFiles } = await import("../lib/resolve-key.mjs");
47
+ const result = resolveKeyFromEnvFiles();
48
+ assert.equal(result, "sk_live_quoted");
49
+ });
50
+
51
+ it("strips inline comment from value", async () => {
52
+ writeFileSync(join(tempDir, ".env.local"), "SKILLREPO_ACCESS_KEY=sk_live_abc123 # my key\n");
53
+ const { resolveKeyFromEnvFiles } = await import("../lib/resolve-key.mjs");
54
+ const result = resolveKeyFromEnvFiles();
55
+ assert.equal(result, "sk_live_abc123");
56
+ });
57
+
58
+ it("falls back to .env when .env.local is absent", async () => {
59
+ writeFileSync(join(tempDir, ".env"), "SKILLREPO_ACCESS_KEY=sk_live_fallback\n");
60
+ const { resolveKeyFromEnvFiles } = await import("../lib/resolve-key.mjs");
61
+ const result = resolveKeyFromEnvFiles();
62
+ assert.equal(result, "sk_live_fallback");
63
+ });
64
+
65
+ it("returns null when key is absent in both files", async () => {
66
+ writeFileSync(join(tempDir, ".env.local"), "OTHER_VAR=hello\n");
67
+ writeFileSync(join(tempDir, ".env"), "ANOTHER_VAR=world\n");
68
+ const { resolveKeyFromEnvFiles } = await import("../lib/resolve-key.mjs");
69
+ const result = resolveKeyFromEnvFiles();
70
+ assert.equal(result, null);
71
+ });
72
+
73
+ it("returns null when both files are missing", async () => {
74
+ const { resolveKeyFromEnvFiles } = await import("../lib/resolve-key.mjs");
75
+ const result = resolveKeyFromEnvFiles();
76
+ assert.equal(result, null);
77
+ });
78
+
79
+ it("ignores commented-out key line", async () => {
80
+ writeFileSync(join(tempDir, ".env.local"), "# SKILLREPO_ACCESS_KEY=sk_live_old\n");
81
+ const { resolveKeyFromEnvFiles } = await import("../lib/resolve-key.mjs");
82
+ const result = resolveKeyFromEnvFiles();
83
+ assert.equal(result, null);
84
+ });
85
+ });