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.
- package/bin/skillrepo.mjs +46 -0
- package/package.json +39 -0
- package/src/commands/init.mjs +184 -0
- package/src/lib/detect-ides.mjs +63 -0
- package/src/lib/fs-utils.mjs +43 -0
- package/src/lib/http.mjs +66 -0
- package/src/lib/mergers/claude-mcp.mjs +47 -0
- package/src/lib/mergers/cursor-mcp.mjs +46 -0
- package/src/lib/mergers/env-local.mjs +48 -0
- package/src/lib/mergers/hooks-json.mjs +69 -0
- package/src/lib/mergers/vscode-mcp.mjs +69 -0
- package/src/lib/mergers/windsurf-mcp.mjs +47 -0
- package/src/lib/paths.mjs +35 -0
- package/src/lib/prompt.mjs +139 -0
- package/src/lib/write-configs.mjs +68 -0
- package/src/test/detect-ides.test.mjs +70 -0
- package/src/test/env-local.test.mjs +71 -0
- package/src/test/mergers/claude-mcp.test.mjs +78 -0
|
@@ -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
|
+
}
|
package/src/lib/http.mjs
ADDED
|
@@ -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
|
+
});
|