skillrepo 1.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.
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * SkillRepo CLI — Set up SkillRepo in any IDE, one command.
5
+ *
6
+ * Usage:
7
+ * npx skillrepo init [--key <key>] [--url <url>] [--yes]
8
+ */
9
+
10
+ import { runInit } from "../src/commands/init.mjs";
11
+
12
+ const args = process.argv.slice(2);
13
+ const command = args[0];
14
+
15
+ if (!command || command === "init") {
16
+ runInit(args.slice(command === "init" ? 1 : 0)).catch((err) => {
17
+ console.error(`\n Error: ${err.message}\n`);
18
+ process.exit(1);
19
+ });
20
+ } else if (command === "--help" || command === "-h") {
21
+ console.log(`
22
+ SkillRepo CLI
23
+
24
+ Usage:
25
+ npx skillrepo init [options]
26
+
27
+ Options:
28
+ --key, -k <key> Access key (or set SKILLREPO_API_KEY env var)
29
+ --url, -u <url> SkillRepo URL (default: https://skillrepo.dev)
30
+ --yes, -y Non-interactive mode
31
+
32
+ Examples:
33
+ npx skillrepo init
34
+ npx skillrepo init --key sk_live_abc123
35
+ npx skillrepo init --url https://my-skillrepo.com --yes
36
+ `);
37
+ } else if (command === "--version" || command === "-v") {
38
+ // Read version from package.json
39
+ import("../package.json", { with: { type: "json" } })
40
+ .then((pkg) => console.log(pkg.default.version))
41
+ .catch(() => console.log("1.0.0"));
42
+ } else {
43
+ console.error(` Unknown command: ${command}`);
44
+ console.error(` Run "npx skillrepo --help" for usage.`);
45
+ process.exit(1);
46
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "skillrepo",
3
+ "version": "1.0.0",
4
+ "description": "Set up SkillRepo in any IDE — one command",
5
+ "type": "module",
6
+ "bin": {
7
+ "skillrepo": "./bin/skillrepo.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18.0.0"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "keywords": [
20
+ "mcp",
21
+ "skills",
22
+ "ai",
23
+ "ide",
24
+ "cursor",
25
+ "claude",
26
+ "vscode",
27
+ "windsurf",
28
+ "setup"
29
+ ],
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/atxpace/skill-repo.git",
33
+ "directory": "packages/cli"
34
+ },
35
+ "license": "AGPL-3.0",
36
+ "scripts": {
37
+ "test": "find src/test -name '*.test.mjs' | xargs node --test"
38
+ }
39
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `skillrepo init` — main command orchestrator.
3
+ *
4
+ * Flow:
5
+ * 1. Detect IDEs
6
+ * 2. Prompt for access key
7
+ * 3. Validate key + fetch skill mappings
8
+ * 4. Write all configs
9
+ * 5. Print summary
10
+ */
11
+
12
+ import { detectIdes, formatDetectedIdes, getDetectedIdeKeys } from "../lib/detect-ides.mjs";
13
+ import { fetchSetupPayload, AuthError, SuspendedError, NetworkError } from "../lib/http.mjs";
14
+ import { writeAllConfigs } from "../lib/write-configs.mjs";
15
+ import {
16
+ printHeader,
17
+ printStep,
18
+ printSuccess,
19
+ printWarning,
20
+ printError,
21
+ printResult,
22
+ printBlank,
23
+ promptSecret,
24
+ confirm,
25
+ } from "../lib/prompt.mjs";
26
+
27
+ const DEFAULT_URL = "https://skillrepo.dev";
28
+
29
+ /**
30
+ * Parse CLI flags from argv.
31
+ * @param {string[]} argv
32
+ */
33
+ function parseFlags(argv) {
34
+ const flags = {
35
+ key: process.env.SKILLREPO_API_KEY || null,
36
+ url: process.env.SKILLREPO_URL || DEFAULT_URL,
37
+ yes: false,
38
+ };
39
+
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const arg = argv[i];
42
+ if ((arg === "--key" || arg === "-k") && argv[i + 1]) {
43
+ flags.key = argv[++i];
44
+ } else if ((arg === "--url" || arg === "-u") && argv[i + 1]) {
45
+ flags.url = argv[++i];
46
+ } else if (arg === "--yes" || arg === "-y") {
47
+ flags.yes = true;
48
+ }
49
+ }
50
+
51
+ // Normalize URL
52
+ flags.url = flags.url.replace(/\/+$/, "");
53
+
54
+ return flags;
55
+ }
56
+
57
+ /**
58
+ * Run the setup command.
59
+ * @param {string[]} argv - CLI arguments after the "setup" subcommand
60
+ */
61
+ export async function runInit(argv) {
62
+ const flags = parseFlags(argv);
63
+
64
+ printHeader("SkillRepo Setup");
65
+
66
+ // ── Step 1: Detect IDEs ───────────────────────────────────────────────
67
+ printStep(1, 4, "Detecting IDEs...");
68
+
69
+ const detected = detectIdes();
70
+ const ideList = formatDetectedIdes(detected);
71
+ const detectedKeys = getDetectedIdeKeys(detected);
72
+
73
+ for (const ide of ideList) {
74
+ if (ide.detected) {
75
+ printSuccess(`${ide.name}`);
76
+ }
77
+ }
78
+
79
+ if (detectedKeys.length === 0 || !Object.values(detected).some(Boolean)) {
80
+ printWarning("No IDEs detected — defaulting to Claude Code + Cursor");
81
+ }
82
+
83
+ // Confirm IDE selection
84
+ if (!flags.yes) {
85
+ const names = detectedKeys.map((k) => ideList.find((i) => i.key === k)?.name).filter(Boolean);
86
+ const ok = await confirm(`Configure for: ${names.join(", ")}?`);
87
+ if (!ok) {
88
+ console.log(" Setup cancelled.");
89
+ process.exit(0);
90
+ }
91
+ }
92
+
93
+ printBlank();
94
+
95
+ // ── Step 2: Access Key ────────────────────────────────────────────────
96
+ printStep(2, 4, "Access Key");
97
+
98
+ let apiKey = flags.key;
99
+ if (!apiKey) {
100
+ apiKey = await promptSecret("Enter your SkillRepo access key (sk_live_...)");
101
+ }
102
+
103
+ if (!apiKey || !apiKey.startsWith("sk_live_")) {
104
+ printError("Invalid key format. Keys start with sk_live_");
105
+ printError(`Get your key at ${flags.url}/app/integrations`);
106
+ process.exit(1);
107
+ }
108
+
109
+ printBlank();
110
+
111
+ // ── Step 3: Validate + Fetch ──────────────────────────────────────────
112
+ printStep(3, 4, "Validating key...");
113
+
114
+ let payload;
115
+ try {
116
+ payload = await fetchSetupPayload(apiKey, flags.url);
117
+ } catch (err) {
118
+ if (err instanceof AuthError) {
119
+ printError(`Invalid access key. Get your key at ${flags.url}/app/integrations`);
120
+ process.exit(1);
121
+ }
122
+ if (err instanceof SuspendedError) {
123
+ printError(`Account suspended: ${err.reason || "Contact your admin"}`);
124
+ process.exit(1);
125
+ }
126
+ if (err instanceof NetworkError) {
127
+ printError(`Cannot reach ${flags.url}. Check your network and the --url flag.`);
128
+ process.exit(1);
129
+ }
130
+ throw err;
131
+ }
132
+
133
+ printSuccess(`Valid. ${payload.skillCount} skills in your library.`);
134
+
135
+ if (payload.skillCount === 0) {
136
+ printWarning("No skills in your library yet. MCP config will be written — skills will activate when you add them.");
137
+ }
138
+
139
+ printBlank();
140
+
141
+ // ── Step 4: Write configs ─────────────────────────────────────────────
142
+ printStep(4, 4, "Writing configuration...");
143
+ printBlank();
144
+
145
+ const mcpUrl = `${flags.url}/api/mcp`;
146
+
147
+ let results;
148
+ try {
149
+ results = writeAllConfigs({
150
+ ides: detectedKeys,
151
+ mcpUrl,
152
+ apiKey,
153
+ payload,
154
+ });
155
+ } catch (err) {
156
+ printError(err.message);
157
+ process.exit(2);
158
+ }
159
+
160
+ for (const r of results) {
161
+ printResult(r.path, r.action);
162
+ }
163
+
164
+ // ── Summary ───────────────────────────────────────────────────────────
165
+ printBlank();
166
+ printSuccess("SkillRepo is ready.");
167
+ printBlank();
168
+ console.log(" Next steps:");
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)");
172
+
173
+ if (detectedKeys.includes("vscode")) {
174
+ printBlank();
175
+ console.log(" Note: VS Code will prompt for your access key on first use.");
176
+ }
177
+
178
+ if (detectedKeys.includes("claudeCode")) {
179
+ printBlank();
180
+ console.log(" Claude Code: Skills refresh automatically on each session start.");
181
+ }
182
+
183
+ printBlank();
184
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * IDE auto-detection.
3
+ * Checks for IDE-specific directories/files in the project and home dir.
4
+ */
5
+
6
+ import { pathExists } from "./fs-utils.mjs";
7
+ import * as paths from "./paths.mjs";
8
+
9
+ /**
10
+ * @typedef {Object} DetectedIdes
11
+ * @property {boolean} claudeCode
12
+ * @property {boolean} cursor
13
+ * @property {boolean} windsurf
14
+ * @property {boolean} vscode
15
+ */
16
+
17
+ /**
18
+ * Detect which IDEs are configured in the current project.
19
+ * @returns {DetectedIdes}
20
+ */
21
+ export function detectIdes() {
22
+ return {
23
+ claudeCode:
24
+ pathExists(paths.claudeMcpJson()) ||
25
+ pathExists(paths.claudeDir()),
26
+ cursor: pathExists(paths.cursorDir()),
27
+ windsurf: pathExists(paths.windsurfDir()),
28
+ vscode: pathExists(paths.vscodeDir()),
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Format detected IDEs as a human-readable list.
34
+ * @param {DetectedIdes} detected
35
+ * @returns {{ name: string; detected: boolean }[]}
36
+ */
37
+ export function formatDetectedIdes(detected) {
38
+ return [
39
+ { name: "Claude Code", key: "claudeCode", detected: detected.claudeCode },
40
+ { name: "Cursor", key: "cursor", detected: detected.cursor },
41
+ { name: "Windsurf", key: "windsurf", detected: detected.windsurf },
42
+ { name: "VS Code + Copilot", key: "vscode", detected: detected.vscode },
43
+ ];
44
+ }
45
+
46
+ /**
47
+ * Get the list of IDE keys that were detected.
48
+ * If none detected, defaults to Claude Code + Cursor (most common pair).
49
+ * @param {DetectedIdes} detected
50
+ * @returns {string[]}
51
+ */
52
+ export function getDetectedIdeKeys(detected) {
53
+ const keys = Object.entries(detected)
54
+ .filter(([, v]) => v)
55
+ .map(([k]) => k);
56
+
57
+ // Default to Claude Code + Cursor if nothing detected
58
+ if (keys.length === 0) {
59
+ return ["claudeCode", "cursor"];
60
+ }
61
+
62
+ return keys;
63
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Safe filesystem utilities.
3
+ * Creates directories as needed, handles errors cleanly.
4
+ */
5
+
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+
9
+ /**
10
+ * Read a file as UTF-8, returning null if it doesn't exist.
11
+ * Throws on other errors (permission denied, etc.)
12
+ */
13
+ export function readFileSafe(filePath) {
14
+ try {
15
+ return readFileSync(filePath, "utf-8");
16
+ } catch (err) {
17
+ if (err.code === "ENOENT") return null;
18
+ throw err;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Write a file, creating parent directories as needed.
24
+ * Throws on permission errors.
25
+ */
26
+ export function writeFileSafe(filePath, content) {
27
+ const dir = dirname(filePath);
28
+ if (!existsSync(dir)) {
29
+ mkdirSync(dir, { recursive: true });
30
+ }
31
+ // Guard against writing to a directory
32
+ if (existsSync(filePath) && statSync(filePath).isDirectory()) {
33
+ throw new Error(`${filePath} is a directory, expected a file`);
34
+ }
35
+ writeFileSync(filePath, content, "utf-8");
36
+ }
37
+
38
+ /**
39
+ * Check if a path exists (file or directory).
40
+ */
41
+ export function pathExists(p) {
42
+ return existsSync(p);
43
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * HTTP client for the SkillRepo API.
3
+ * Uses Node 18+ built-in fetch. Zero dependencies.
4
+ */
5
+
6
+ const VERSION = "1.0.0";
7
+ const DEFAULT_URL = "https://skillrepo.dev";
8
+
9
+ /**
10
+ * Fetch the setup payload from the SkillRepo API.
11
+ * @param {string} apiKey - Access key (sk_live_...)
12
+ * @param {string} [baseUrl] - SkillRepo base URL (defaults to https://skillrepo.dev)
13
+ * @returns {Promise<object>} The setup payload
14
+ */
15
+ export async function fetchSetupPayload(apiKey, baseUrl) {
16
+ const url = `${(baseUrl || DEFAULT_URL).replace(/\/+$/, "")}/api/v1/setup`;
17
+
18
+ const res = await fetch(url, {
19
+ method: "GET",
20
+ headers: {
21
+ Authorization: `Bearer ${apiKey}`,
22
+ Accept: "application/json",
23
+ "User-Agent": `skillrepo-cli/${VERSION}`,
24
+ },
25
+ });
26
+
27
+ if (res.status === 401) {
28
+ const body = await res.json().catch(() => ({}));
29
+ throw new AuthError(body.error || "Invalid access key");
30
+ }
31
+
32
+ if (res.status === 403) {
33
+ const body = await res.json().catch(() => ({}));
34
+ throw new SuspendedError(body.error || "Account suspended", body.reason);
35
+ }
36
+
37
+ if (!res.ok) {
38
+ throw new NetworkError(`Server returned ${res.status}: ${res.statusText}`);
39
+ }
40
+
41
+ return res.json();
42
+ }
43
+
44
+ // ── Error types ─────────────────────────────────────────────────────────
45
+
46
+ export class AuthError extends Error {
47
+ constructor(message) {
48
+ super(message);
49
+ this.name = "AuthError";
50
+ }
51
+ }
52
+
53
+ export class SuspendedError extends Error {
54
+ constructor(message, reason) {
55
+ super(message);
56
+ this.name = "SuspendedError";
57
+ this.reason = reason;
58
+ }
59
+ }
60
+
61
+ export class NetworkError extends Error {
62
+ constructor(message) {
63
+ super(message);
64
+ this.name = "NetworkError";
65
+ }
66
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Merger for Claude Code .mcp.json
3
+ * Format: { mcpServers: { skillrepo: { type: "http", url, headers } } }
4
+ * Env vars: ${VAR} syntax
5
+ */
6
+
7
+ import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
8
+ import { claudeMcpJson } from "../paths.mjs";
9
+
10
+ /**
11
+ * @param {string} mcpUrl
12
+ * @returns {{ path: string; action: string }}
13
+ */
14
+ export function mergeClaudeMcpConfig(mcpUrl) {
15
+ const filePath = claudeMcpJson();
16
+ const serverEntry = {
17
+ type: "http",
18
+ url: mcpUrl,
19
+ headers: {
20
+ Authorization: "Bearer ${SKILLREPO_API_KEY}",
21
+ },
22
+ };
23
+
24
+ const existing = readFileSafe(filePath);
25
+
26
+ if (existing === null) {
27
+ writeFileSafe(filePath, JSON.stringify({ mcpServers: { skillrepo: serverEntry } }, null, 2) + "\n");
28
+ return { path: ".mcp.json", action: "created" };
29
+ }
30
+
31
+ let config;
32
+ try {
33
+ config = JSON.parse(existing);
34
+ } catch {
35
+ throw new Error(`Cannot parse .mcp.json — invalid JSON. Fix or delete it, then run setup again.`);
36
+ }
37
+
38
+ if (!config.mcpServers || typeof config.mcpServers !== "object") {
39
+ config.mcpServers = {};
40
+ }
41
+
42
+ const action = config.mcpServers.skillrepo ? "merged" : "created";
43
+ config.mcpServers.skillrepo = serverEntry;
44
+
45
+ writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
46
+ return { path: ".mcp.json", action };
47
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Merger for Cursor .cursor/mcp.json
3
+ * Format: { mcpServers: { skillrepo: { url, headers } } }
4
+ * Env vars: ${env:VAR} syntax (different from Claude Code)
5
+ */
6
+
7
+ import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
8
+ import { cursorMcpJson } from "../paths.mjs";
9
+
10
+ /**
11
+ * @param {string} mcpUrl
12
+ * @returns {{ path: string; action: string }}
13
+ */
14
+ export function mergeCursorMcpConfig(mcpUrl) {
15
+ const filePath = cursorMcpJson();
16
+ const serverEntry = {
17
+ url: mcpUrl,
18
+ headers: {
19
+ Authorization: "Bearer ${env:SKILLREPO_API_KEY}",
20
+ },
21
+ };
22
+
23
+ const existing = readFileSafe(filePath);
24
+
25
+ if (existing === null) {
26
+ writeFileSafe(filePath, JSON.stringify({ mcpServers: { skillrepo: serverEntry } }, null, 2) + "\n");
27
+ return { path: ".cursor/mcp.json", action: "created" };
28
+ }
29
+
30
+ let config;
31
+ try {
32
+ config = JSON.parse(existing);
33
+ } catch {
34
+ throw new Error(`Cannot parse .cursor/mcp.json — invalid JSON. Fix or delete it, then run setup again.`);
35
+ }
36
+
37
+ if (!config.mcpServers || typeof config.mcpServers !== "object") {
38
+ config.mcpServers = {};
39
+ }
40
+
41
+ const action = config.mcpServers.skillrepo ? "merged" : "created";
42
+ config.mcpServers.skillrepo = serverEntry;
43
+
44
+ writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
45
+ return { path: ".cursor/mcp.json", action };
46
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Merge SKILLREPO_API_KEY into .env.local
3
+ * Append-only — never overwrite existing keys unless the value differs.
4
+ */
5
+
6
+ import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
7
+ import { envLocal } from "../paths.mjs";
8
+
9
+ const KEY_NAME = "SKILLREPO_API_KEY";
10
+
11
+ /**
12
+ * @param {string} apiKey
13
+ * @returns {{ path: string; action: string }}
14
+ */
15
+ export function mergeEnvLocal(apiKey) {
16
+ const filePath = envLocal();
17
+ const prefix = `${KEY_NAME}=`;
18
+ const existing = readFileSafe(filePath);
19
+
20
+ if (existing === null) {
21
+ writeFileSafe(filePath, `${prefix}${apiKey}\n`);
22
+ return { path: ".env.local", action: "created" };
23
+ }
24
+
25
+ const lines = existing.split(/\r?\n/);
26
+ const lineEnding = existing.includes("\r\n") ? "\r\n" : "\n";
27
+ const idx = lines.findIndex((l) => l.startsWith(prefix));
28
+
29
+ if (idx >= 0) {
30
+ const currentValue = lines[idx].slice(prefix.length);
31
+ if (currentValue === apiKey) {
32
+ return { path: ".env.local", action: "skipped" };
33
+ }
34
+ // Different value — update in place
35
+ lines[idx] = `${prefix}${apiKey}`;
36
+ writeFileSafe(filePath, lines.join(lineEnding));
37
+ return { path: ".env.local", action: "updated" };
38
+ }
39
+
40
+ // Key not found — append
41
+ let content = existing;
42
+ if (content && !content.endsWith("\n") && !content.endsWith("\r\n")) {
43
+ content += lineEnding;
44
+ }
45
+ content += `${prefix}${apiKey}${lineEnding}`;
46
+ writeFileSafe(filePath, content);
47
+ return { path: ".env.local", action: "added" };
48
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Merger for Claude Code .claude/hooks/hooks.json
3
+ * Also writes the skillrepo-sync.mjs hook script.
4
+ *
5
+ * Merge strategy: add a SessionStart entry without destroying existing hooks.
6
+ * Idempotent — if the hook is already installed, skip.
7
+ */
8
+
9
+ import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
10
+ import { claudeHooksJson, claudeSyncHook } from "../paths.mjs";
11
+
12
+ const TARGET_COMMAND = "node .claude/hooks/skillrepo-sync.mjs";
13
+
14
+ /**
15
+ * Merge the SkillRepo SessionStart hook into hooks.json.
16
+ * @param {string} hooksJsonContent - The canonical hooks.json content from the API
17
+ * @param {string} syncHookContent - The sync hook script content from the API
18
+ * @returns {{ results: { path: string; action: string }[] }}
19
+ */
20
+ export function mergeHooksConfig(hooksJsonContent, syncHookContent) {
21
+ const results = [];
22
+
23
+ // Always write the sync hook script (latest version from server)
24
+ const hookExisted = readFileSafe(claudeSyncHook()) !== null;
25
+ writeFileSafe(claudeSyncHook(), syncHookContent);
26
+ results.push({ path: ".claude/hooks/skillrepo-sync.mjs", action: hookExisted ? "updated" : "created" });
27
+
28
+ // Merge hooks.json
29
+ const filePath = claudeHooksJson();
30
+ const existing = readFileSafe(filePath);
31
+
32
+ if (existing === null) {
33
+ writeFileSafe(filePath, hooksJsonContent);
34
+ results.push({ path: ".claude/hooks/hooks.json", action: "created" });
35
+ return { results };
36
+ }
37
+
38
+ let config;
39
+ try {
40
+ config = JSON.parse(existing);
41
+ } catch {
42
+ throw new Error(`Cannot parse .claude/hooks/hooks.json — invalid JSON. Fix or delete it, then run setup again.`);
43
+ }
44
+
45
+ // Navigate to hooks.SessionStart
46
+ if (!config.hooks) config.hooks = {};
47
+ if (!Array.isArray(config.hooks.SessionStart)) config.hooks.SessionStart = [];
48
+
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
+ );
54
+
55
+ if (found) {
56
+ results.push({ path: ".claude/hooks/hooks.json", action: "skipped" });
57
+ return { results };
58
+ }
59
+
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
+ return { results };
69
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Merger for VS Code + Copilot .vscode/mcp.json
3
+ * Format: { inputs: [...], servers: { skillrepo: { type: "http", url, headers } } }
4
+ * Note: uses "servers" not "mcpServers", and "${input:id}" for access key
5
+ * VS Code does NOT support env var interpolation in mcp.json
6
+ */
7
+
8
+ import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
9
+ import { vscodeMcpJson } from "../paths.mjs";
10
+
11
+ const INPUT_ENTRY = {
12
+ id: "skillrepo-api-key",
13
+ type: "promptString",
14
+ description: "SkillRepo access key (sk_live_...)",
15
+ password: true,
16
+ };
17
+
18
+ /**
19
+ * @param {string} mcpUrl
20
+ * @returns {{ path: string; action: string }}
21
+ */
22
+ export function mergeVscodeMcpConfig(mcpUrl) {
23
+ const filePath = vscodeMcpJson();
24
+ const serverEntry = {
25
+ type: "http",
26
+ url: mcpUrl,
27
+ headers: {
28
+ Authorization: "Bearer ${input:skillrepo-api-key}",
29
+ },
30
+ };
31
+
32
+ const existing = readFileSafe(filePath);
33
+
34
+ if (existing === null) {
35
+ const config = {
36
+ inputs: [INPUT_ENTRY],
37
+ servers: { skillrepo: serverEntry },
38
+ };
39
+ writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
40
+ return { path: ".vscode/mcp.json", action: "created" };
41
+ }
42
+
43
+ let config;
44
+ try {
45
+ config = JSON.parse(existing);
46
+ } catch {
47
+ throw new Error(`Cannot parse .vscode/mcp.json — invalid JSON. Fix or delete it, then run setup again.`);
48
+ }
49
+
50
+ // Ensure inputs array exists and has our entry
51
+ if (!Array.isArray(config.inputs)) {
52
+ config.inputs = [];
53
+ }
54
+ const hasInput = config.inputs.some((i) => i.id === INPUT_ENTRY.id);
55
+ if (!hasInput) {
56
+ config.inputs.push(INPUT_ENTRY);
57
+ }
58
+
59
+ // Ensure servers object exists
60
+ if (!config.servers || typeof config.servers !== "object") {
61
+ config.servers = {};
62
+ }
63
+
64
+ const action = config.servers.skillrepo ? "merged" : "created";
65
+ config.servers.skillrepo = serverEntry;
66
+
67
+ writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
68
+ return { path: ".vscode/mcp.json", action };
69
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Merger for Windsurf mcp_config.json (GLOBAL — always at ~/.codeium/windsurf/)
3
+ * Format: { mcpServers: { skillrepo: { serverUrl, headers } } }
4
+ * Note: uses "serverUrl" not "url"
5
+ * Env vars: ${env:VAR} syntax
6
+ */
7
+
8
+ import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
9
+ import { windsurfMcpJson } from "../paths.mjs";
10
+
11
+ /**
12
+ * @param {string} mcpUrl
13
+ * @returns {{ path: string; action: string }}
14
+ */
15
+ export function mergeWindsurfMcpConfig(mcpUrl) {
16
+ const filePath = windsurfMcpJson();
17
+ const serverEntry = {
18
+ serverUrl: mcpUrl,
19
+ headers: {
20
+ Authorization: "Bearer ${env:SKILLREPO_API_KEY}",
21
+ },
22
+ };
23
+
24
+ const existing = readFileSafe(filePath);
25
+
26
+ if (existing === null) {
27
+ writeFileSafe(filePath, JSON.stringify({ mcpServers: { skillrepo: serverEntry } }, null, 2) + "\n");
28
+ return { path: "~/.codeium/windsurf/mcp_config.json", action: "created" };
29
+ }
30
+
31
+ let config;
32
+ try {
33
+ config = JSON.parse(existing);
34
+ } catch {
35
+ throw new Error(`Cannot parse ~/.codeium/windsurf/mcp_config.json — invalid JSON. Fix or delete it, then run setup again.`);
36
+ }
37
+
38
+ if (!config.mcpServers || typeof config.mcpServers !== "object") {
39
+ config.mcpServers = {};
40
+ }
41
+
42
+ const action = config.mcpServers.skillrepo ? "merged" : "created";
43
+ config.mcpServers.skillrepo = serverEntry;
44
+
45
+ writeFileSafe(filePath, JSON.stringify(config, null, 2) + "\n");
46
+ return { path: "~/.codeium/windsurf/mcp_config.json", action };
47
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Cross-platform path resolution for all config files the CLI writes.
3
+ * Uses Node built-ins only — no dependencies.
4
+ */
5
+
6
+ import { join, resolve } from "node:path";
7
+ import { homedir } from "node:os";
8
+
9
+ const cwd = () => process.cwd();
10
+
11
+ // Claude Code
12
+ export const claudeMcpJson = () => join(cwd(), ".mcp.json");
13
+ export const claudeDir = () => join(cwd(), ".claude");
14
+ export const claudeHooksDir = () => join(cwd(), ".claude", "hooks");
15
+ export const claudeHooksJson = () => join(cwd(), ".claude", "hooks", "hooks.json");
16
+ export const claudeSyncHook = () => join(cwd(), ".claude", "hooks", "skillrepo-sync.mjs");
17
+ export const claudeSkillrepoMd = () => join(cwd(), ".claude", "skillrepo.md");
18
+
19
+ // Cursor
20
+ export const cursorDir = () => join(cwd(), ".cursor");
21
+ export const cursorMcpJson = () => join(cwd(), ".cursor", "mcp.json");
22
+ export const cursorRulesDir = () => join(cwd(), ".cursor", "rules");
23
+ export const cursorSkillrepoMdc = () => join(cwd(), ".cursor", "rules", "skillrepo.mdc");
24
+
25
+ // Windsurf (always global — no project-level config)
26
+ export const windsurfDir = () => join(homedir(), ".codeium", "windsurf");
27
+ export const windsurfMcpJson = () => join(homedir(), ".codeium", "windsurf", "mcp_config.json");
28
+
29
+ // VS Code + Copilot
30
+ export const vscodeDir = () => join(cwd(), ".vscode");
31
+ export const vscodeMcpJson = () => join(cwd(), ".vscode", "mcp.json");
32
+
33
+ // Shared
34
+ export const envLocal = () => join(cwd(), ".env.local");
35
+ export const projectRoot = () => resolve(cwd());
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Interactive prompts using Node's built-in readline.
3
+ * Zero dependencies. Supports TTY detection and NO_COLOR.
4
+ */
5
+
6
+ import { createInterface } from "node:readline";
7
+
8
+ const isTTY = process.stdout.isTTY && !process.env.NO_COLOR;
9
+
10
+ // ── Colors ──────────────────────────────────────────────────────────────
11
+
12
+ const green = (s) => (isTTY ? `\x1b[32m${s}\x1b[0m` : s);
13
+ const yellow = (s) => (isTTY ? `\x1b[33m${s}\x1b[0m` : s);
14
+ const red = (s) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s);
15
+ const dim = (s) => (isTTY ? `\x1b[2m${s}\x1b[0m` : s);
16
+ const bold = (s) => (isTTY ? `\x1b[1m${s}\x1b[0m` : s);
17
+
18
+ // ── Output helpers ──────────────────────────────────────────────────────
19
+
20
+ export function printHeader(title) {
21
+ console.log("");
22
+ console.log(` ${bold(title)}`);
23
+ console.log("");
24
+ }
25
+
26
+ export function printStep(n, total, message) {
27
+ console.log(` ${dim(`Step ${n}/${total}:`)} ${message}`);
28
+ }
29
+
30
+ export function printSuccess(message) {
31
+ console.log(` ${green("✓")} ${message}`);
32
+ }
33
+
34
+ export function printWarning(message) {
35
+ console.log(` ${yellow("⚠")} ${message}`);
36
+ }
37
+
38
+ export function printError(message) {
39
+ console.error(` ${red("✗")} ${message}`);
40
+ }
41
+
42
+ export function printResult(path, action) {
43
+ const label =
44
+ action === "created" ? green("created") :
45
+ action === "merged" ? yellow("merged") :
46
+ action === "updated" ? yellow("updated") :
47
+ dim("skipped");
48
+ console.log(` ${path.padEnd(45)} ${label}`);
49
+ }
50
+
51
+ export function printBlank() {
52
+ console.log("");
53
+ }
54
+
55
+ // ── Prompts ─────────────────────────────────────────────────────────────
56
+
57
+ function createRl() {
58
+ return createInterface({
59
+ input: process.stdin,
60
+ output: process.stdout,
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Prompt for text input.
66
+ */
67
+ export function promptText(question, defaultValue) {
68
+ return new Promise((resolve) => {
69
+ const rl = createRl();
70
+ const suffix = defaultValue ? ` ${dim(`(${defaultValue})`)} ` : " ";
71
+ rl.question(` ${question}${suffix}> `, (answer) => {
72
+ rl.close();
73
+ resolve(answer.trim() || defaultValue || "");
74
+ });
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Prompt for a secret (access key). Disables echo.
80
+ */
81
+ export function promptSecret(question) {
82
+ return new Promise((resolve) => {
83
+ const rl = createRl();
84
+ // Disable echo for the key input
85
+ if (process.stdin.isTTY) {
86
+ process.stdout.write(` ${question} > `);
87
+ process.stdin.setRawMode(true);
88
+ let input = "";
89
+ const onData = (ch) => {
90
+ const c = ch.toString();
91
+ if (c === "\n" || c === "\r") {
92
+ process.stdin.setRawMode(false);
93
+ process.stdin.removeListener("data", onData);
94
+ process.stdout.write("\n");
95
+ rl.close();
96
+ resolve(input.trim());
97
+ } else if (c === "\x03") {
98
+ // Ctrl-C
99
+ process.stdin.setRawMode(false);
100
+ process.stdout.write("\n");
101
+ rl.close();
102
+ process.exit(0);
103
+ } else if (c === "\x7f" || c === "\b") {
104
+ // Backspace
105
+ if (input.length > 0) {
106
+ input = input.slice(0, -1);
107
+ process.stdout.write("\b \b");
108
+ }
109
+ } else {
110
+ input += c;
111
+ process.stdout.write("*");
112
+ }
113
+ };
114
+ process.stdin.on("data", onData);
115
+ } else {
116
+ // Non-TTY: just read normally
117
+ rl.question(` ${question} > `, (answer) => {
118
+ rl.close();
119
+ resolve(answer.trim());
120
+ });
121
+ }
122
+ });
123
+ }
124
+
125
+ /**
126
+ * y/n confirmation.
127
+ */
128
+ export function confirm(question, defaultYes = true) {
129
+ return new Promise((resolve) => {
130
+ const rl = createRl();
131
+ const hint = defaultYes ? "Y/n" : "y/N";
132
+ rl.question(` ${question} (${hint}) `, (answer) => {
133
+ rl.close();
134
+ const a = answer.trim().toLowerCase();
135
+ if (a === "") resolve(defaultYes);
136
+ else resolve(a === "y" || a === "yes");
137
+ });
138
+ });
139
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Orchestrates writing all config files based on detected IDEs.
3
+ * Calls individual mergers and collects results.
4
+ */
5
+
6
+ import { readFileSafe, writeFileSafe } from "./fs-utils.mjs";
7
+ import { claudeSkillrepoMd, cursorSkillrepoMdc } from "./paths.mjs";
8
+ import { mergeClaudeMcpConfig } from "./mergers/claude-mcp.mjs";
9
+ import { mergeCursorMcpConfig } from "./mergers/cursor-mcp.mjs";
10
+ import { mergeWindsurfMcpConfig } from "./mergers/windsurf-mcp.mjs";
11
+ import { mergeVscodeMcpConfig } from "./mergers/vscode-mcp.mjs";
12
+ import { mergeHooksConfig } from "./mergers/hooks-json.mjs";
13
+ import { mergeEnvLocal } from "./mergers/env-local.mjs";
14
+
15
+ /**
16
+ * Write all configuration files.
17
+ * @param {object} options
18
+ * @param {string[]} options.ides - IDE keys to configure (claudeCode, cursor, windsurf, vscode)
19
+ * @param {string} options.mcpUrl - The MCP endpoint URL
20
+ * @param {string} options.apiKey - The access key
21
+ * @param {object} options.payload - The setup payload from the API
22
+ * @returns {{ path: string; action: string }[]}
23
+ */
24
+ export function writeAllConfigs({ ides, mcpUrl, apiKey, payload }) {
25
+ const results = [];
26
+
27
+ // Claude Code
28
+ if (ides.includes("claudeCode")) {
29
+ results.push(mergeClaudeMcpConfig(mcpUrl));
30
+
31
+ // Skill mapping file (always refresh with latest)
32
+ const claudeMdExisted = readFileSafe(claudeSkillrepoMd()) !== null;
33
+ writeFileSafe(claudeSkillrepoMd(), payload.claudeCode.skillrepoMd.content);
34
+ results.push({ path: ".claude/skillrepo.md", action: claudeMdExisted ? "updated" : "created" });
35
+
36
+ // SessionStart hook
37
+ const hookResults = mergeHooksConfig(
38
+ payload.claudeCode.hooksJson.content,
39
+ payload.claudeCode.syncHook.content
40
+ );
41
+ results.push(...hookResults.results);
42
+ }
43
+
44
+ // Cursor
45
+ if (ides.includes("cursor")) {
46
+ results.push(mergeCursorMcpConfig(mcpUrl));
47
+
48
+ // Skill mapping file (always refresh with latest)
49
+ const cursorMdcExisted = readFileSafe(cursorSkillrepoMdc()) !== null;
50
+ writeFileSafe(cursorSkillrepoMdc(), payload.cursor.skillrepoMdc.content);
51
+ results.push({ path: ".cursor/rules/skillrepo.mdc", action: cursorMdcExisted ? "updated" : "created" });
52
+ }
53
+
54
+ // Windsurf
55
+ if (ides.includes("windsurf")) {
56
+ results.push(mergeWindsurfMcpConfig(mcpUrl));
57
+ }
58
+
59
+ // VS Code + Copilot
60
+ if (ides.includes("vscode")) {
61
+ results.push(mergeVscodeMcpConfig(mcpUrl));
62
+ }
63
+
64
+ // .env.local (always — every IDE needs the key)
65
+ results.push(mergeEnvLocal(apiKey));
66
+
67
+ return results;
68
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, rmSync } 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-test-"));
13
+ process.cwd = () => tempDir;
14
+ });
15
+
16
+ afterEach(() => {
17
+ process.cwd = originalCwd;
18
+ rmSync(tempDir, { recursive: true, force: true });
19
+ });
20
+
21
+ describe("IDE detection", () => {
22
+ it("detects Claude Code from .claude/ directory", async () => {
23
+ const { detectIdes } = await import("../lib/detect-ides.mjs");
24
+ mkdirSync(join(tempDir, ".claude"));
25
+
26
+ const result = detectIdes();
27
+ assert.equal(result.claudeCode, true);
28
+ });
29
+
30
+ it("detects Cursor from .cursor/ directory", async () => {
31
+ const { detectIdes } = await import("../lib/detect-ides.mjs");
32
+ mkdirSync(join(tempDir, ".cursor"));
33
+
34
+ const result = detectIdes();
35
+ assert.equal(result.cursor, true);
36
+ });
37
+
38
+ it("detects VS Code from .vscode/ directory", async () => {
39
+ const { detectIdes } = await import("../lib/detect-ides.mjs");
40
+ mkdirSync(join(tempDir, ".vscode"));
41
+
42
+ const result = detectIdes();
43
+ assert.equal(result.vscode, true);
44
+ });
45
+
46
+ it("returns all false when no IDE directories exist", async () => {
47
+ const { detectIdes } = await import("../lib/detect-ides.mjs");
48
+
49
+ const result = detectIdes();
50
+ assert.equal(result.claudeCode, false);
51
+ assert.equal(result.cursor, false);
52
+ assert.equal(result.vscode, false);
53
+ // windsurf depends on home dir, skip assertion
54
+ });
55
+
56
+ it("defaults to claudeCode + cursor when nothing detected", async () => {
57
+ const { getDetectedIdeKeys, detectIdes } = await import("../lib/detect-ides.mjs");
58
+
59
+ // Override windsurf detection too
60
+ const keys = getDetectedIdeKeys({ claudeCode: false, cursor: false, windsurf: false, vscode: false });
61
+ assert.deepEqual(keys, ["claudeCode", "cursor"]);
62
+ });
63
+
64
+ it("returns only detected IDEs", async () => {
65
+ const { getDetectedIdeKeys } = await import("../lib/detect-ides.mjs");
66
+
67
+ const keys = getDetectedIdeKeys({ claudeCode: true, cursor: false, windsurf: false, vscode: true });
68
+ assert.deepEqual(keys, ["claudeCode", "vscode"]);
69
+ });
70
+ });
@@ -0,0 +1,71 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync } 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-test-"));
13
+ process.cwd = () => tempDir;
14
+ });
15
+
16
+ afterEach(() => {
17
+ process.cwd = originalCwd;
18
+ rmSync(tempDir, { recursive: true, force: true });
19
+ });
20
+
21
+ describe(".env.local merger", () => {
22
+ it("creates .env.local when file does not exist", async () => {
23
+ const { mergeEnvLocal } = await import("../lib/mergers/env-local.mjs");
24
+ const result = mergeEnvLocal("sk_live_test123");
25
+
26
+ assert.equal(result.action, "created");
27
+ const content = readFileSync(join(tempDir, ".env.local"), "utf-8");
28
+ assert.equal(content, "SKILLREPO_API_KEY=sk_live_test123\n");
29
+ });
30
+
31
+ it("skips when key already exists with same value", async () => {
32
+ const { mergeEnvLocal } = await import("../lib/mergers/env-local.mjs");
33
+ writeFileSync(join(tempDir, ".env.local"), "SKILLREPO_API_KEY=sk_live_test123\n");
34
+
35
+ const result = mergeEnvLocal("sk_live_test123");
36
+ assert.equal(result.action, "skipped");
37
+ });
38
+
39
+ it("updates when key exists with different value", async () => {
40
+ const { mergeEnvLocal } = await import("../lib/mergers/env-local.mjs");
41
+ writeFileSync(join(tempDir, ".env.local"), "SKILLREPO_API_KEY=sk_live_old\n");
42
+
43
+ const result = mergeEnvLocal("sk_live_new");
44
+ assert.equal(result.action, "updated");
45
+
46
+ const content = readFileSync(join(tempDir, ".env.local"), "utf-8");
47
+ assert.ok(content.includes("SKILLREPO_API_KEY=sk_live_new"));
48
+ assert.ok(!content.includes("sk_live_old"));
49
+ });
50
+
51
+ it("appends when key does not exist in file", async () => {
52
+ const { mergeEnvLocal } = await import("../lib/mergers/env-local.mjs");
53
+ writeFileSync(join(tempDir, ".env.local"), "OTHER_VAR=value\n");
54
+
55
+ const result = mergeEnvLocal("sk_live_test123");
56
+ assert.equal(result.action, "added");
57
+
58
+ const content = readFileSync(join(tempDir, ".env.local"), "utf-8");
59
+ assert.ok(content.includes("OTHER_VAR=value"));
60
+ assert.ok(content.includes("SKILLREPO_API_KEY=sk_live_test123"));
61
+ });
62
+
63
+ it("preserves CRLF line endings", async () => {
64
+ const { mergeEnvLocal } = await import("../lib/mergers/env-local.mjs");
65
+ writeFileSync(join(tempDir, ".env.local"), "OTHER=val\r\n");
66
+
67
+ mergeEnvLocal("sk_live_test123");
68
+ const content = readFileSync(join(tempDir, ".env.local"), "utf-8");
69
+ assert.ok(content.includes("\r\n"));
70
+ });
71
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+
7
+ // We need to override the cwd for testing
8
+ let originalCwd;
9
+ let tempDir;
10
+
11
+ beforeEach(() => {
12
+ originalCwd = process.cwd;
13
+ tempDir = mkdtempSync(join(tmpdir(), "cli-test-"));
14
+ process.cwd = () => tempDir;
15
+ });
16
+
17
+ afterEach(() => {
18
+ process.cwd = originalCwd;
19
+ rmSync(tempDir, { recursive: true, force: true });
20
+ });
21
+
22
+ describe("Claude Code MCP config merger", () => {
23
+ it("creates .mcp.json when file does not exist", async () => {
24
+ const { mergeClaudeMcpConfig } = await import("../../lib/mergers/claude-mcp.mjs");
25
+ const result = mergeClaudeMcpConfig("https://skillrepo.dev/api/mcp");
26
+
27
+ assert.equal(result.action, "created");
28
+ assert.equal(result.path, ".mcp.json");
29
+
30
+ const content = JSON.parse(readFileSync(join(tempDir, ".mcp.json"), "utf-8"));
31
+ assert.equal(content.mcpServers.skillrepo.type, "http");
32
+ assert.equal(content.mcpServers.skillrepo.url, "https://skillrepo.dev/api/mcp");
33
+ assert.equal(content.mcpServers.skillrepo.headers.Authorization, "Bearer ${SKILLREPO_API_KEY}");
34
+ });
35
+
36
+ it("merges into existing config without destroying other servers", async () => {
37
+ const { mergeClaudeMcpConfig } = await import("../../lib/mergers/claude-mcp.mjs");
38
+
39
+ // Pre-existing config with another server
40
+ const existing = {
41
+ mcpServers: {
42
+ other: { type: "stdio", command: "some-server" },
43
+ },
44
+ };
45
+ writeFileSync(join(tempDir, ".mcp.json"), JSON.stringify(existing));
46
+
47
+ const result = mergeClaudeMcpConfig("https://skillrepo.dev/api/mcp");
48
+
49
+ assert.equal(result.action, "created"); // new entry, not merge
50
+ const content = JSON.parse(readFileSync(join(tempDir, ".mcp.json"), "utf-8"));
51
+ assert.ok(content.mcpServers.other, "other server preserved");
52
+ assert.ok(content.mcpServers.skillrepo, "skillrepo added");
53
+ });
54
+
55
+ it("updates existing skillrepo entry (merged)", async () => {
56
+ const { mergeClaudeMcpConfig } = await import("../../lib/mergers/claude-mcp.mjs");
57
+
58
+ const existing = {
59
+ mcpServers: {
60
+ skillrepo: { type: "http", url: "https://old-url.com/api/mcp", headers: {} },
61
+ },
62
+ };
63
+ writeFileSync(join(tempDir, ".mcp.json"), JSON.stringify(existing));
64
+
65
+ const result = mergeClaudeMcpConfig("https://new-url.com/api/mcp");
66
+
67
+ assert.equal(result.action, "merged");
68
+ const content = JSON.parse(readFileSync(join(tempDir, ".mcp.json"), "utf-8"));
69
+ assert.equal(content.mcpServers.skillrepo.url, "https://new-url.com/api/mcp");
70
+ });
71
+
72
+ it("throws on invalid JSON", async () => {
73
+ const { mergeClaudeMcpConfig } = await import("../../lib/mergers/claude-mcp.mjs");
74
+ writeFileSync(join(tempDir, ".mcp.json"), "not json");
75
+
76
+ assert.throws(() => mergeClaudeMcpConfig("https://skillrepo.dev/api/mcp"), /invalid JSON/);
77
+ });
78
+ });