@curdx/flow 2.0.7 → 2.0.9
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/bin/curdx-flow.js +2 -108
- package/cli/help.js +56 -0
- package/cli/install-paths.js +15 -2
- package/cli/lib/claude.js +141 -0
- package/cli/lib/config.js +24 -0
- package/cli/lib/logging.js +25 -0
- package/cli/lib/process.js +44 -0
- package/cli/lib/prompts.js +135 -0
- package/cli/lib/runtime.js +89 -0
- package/cli/lib/version.js +12 -0
- package/cli/router.js +49 -0
- package/cli/utils.js +29 -603
- package/package.json +1 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
// Read version dynamically from package.json so `curdx-flow --version` always
|
|
6
|
+
// reflects the installed package version.
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const pkgJson = JSON.parse(
|
|
9
|
+
readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8")
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
export const VERSION = pkgJson.version;
|
package/cli/router.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { doctor } from "./doctor.js";
|
|
2
|
+
import { install } from "./install.js";
|
|
3
|
+
import { uninstall } from "./uninstall.js";
|
|
4
|
+
import { upgrade } from "./upgrade.js";
|
|
5
|
+
import { printHelp } from "./help.js";
|
|
6
|
+
import { VERSION, color } from "./utils.js";
|
|
7
|
+
|
|
8
|
+
export async function runCli(args = process.argv.slice(2)) {
|
|
9
|
+
const cmd = args[0];
|
|
10
|
+
const rest = args.slice(1);
|
|
11
|
+
|
|
12
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
13
|
+
console.log(VERSION);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!cmd || cmd === "--help" || cmd === "-h") {
|
|
18
|
+
printHelp();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
switch (cmd) {
|
|
24
|
+
case "install":
|
|
25
|
+
await install(rest);
|
|
26
|
+
break;
|
|
27
|
+
case "doctor":
|
|
28
|
+
await doctor(rest);
|
|
29
|
+
break;
|
|
30
|
+
case "upgrade":
|
|
31
|
+
await upgrade(rest);
|
|
32
|
+
break;
|
|
33
|
+
case "uninstall":
|
|
34
|
+
case "remove":
|
|
35
|
+
await uninstall(rest);
|
|
36
|
+
break;
|
|
37
|
+
default:
|
|
38
|
+
console.error(color.red(`Unknown command: ${cmd}`));
|
|
39
|
+
console.error(`Run ${color.cyan("npx @curdx/flow --help")} for CLI usage.`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.error(color.red(`\n✗ ${err.message || err}`));
|
|
44
|
+
if (process.env.CURDX_DEBUG) {
|
|
45
|
+
console.error(err.stack || "");
|
|
46
|
+
}
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
package/cli/utils.js
CHANGED
|
@@ -1,606 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared utilities for curdx-flow CLI.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
-
import { createInterface } from "node:readline";
|
|
7
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, symlinkSync, lstatSync, unlinkSync, readlinkSync } from "node:fs";
|
|
8
|
-
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { dirname, join } from "node:path";
|
|
10
|
-
import { homedir } from "node:os";
|
|
11
|
-
|
|
12
|
-
// Read version dynamically from package.json so `curdx-flow --version` always
|
|
13
|
-
// reflects the installed package version (avoids drift after npm version bumps).
|
|
14
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
-
const pkgJson = JSON.parse(
|
|
16
|
-
readFileSync(join(__dirname, "..", "package.json"), "utf-8")
|
|
17
|
-
);
|
|
18
|
-
export const VERSION = pkgJson.version;
|
|
19
|
-
|
|
20
|
-
// ---------- Color helpers (no chalk dep) ----------
|
|
21
|
-
const isTTY = process.stdout.isTTY && process.env.TERM !== "dumb";
|
|
22
|
-
const c = (code) => (s) => isTTY ? `\x1b[${code}m${s}\x1b[0m` : String(s);
|
|
23
|
-
|
|
24
|
-
export const color = {
|
|
25
|
-
red: c("31"),
|
|
26
|
-
green: c("32"),
|
|
27
|
-
yellow: c("33"),
|
|
28
|
-
blue: c("34"),
|
|
29
|
-
magenta: c("35"),
|
|
30
|
-
cyan: c("36"),
|
|
31
|
-
dim: c("2"),
|
|
32
|
-
bold: c("1"),
|
|
33
|
-
underline: c("4"),
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
// ---------- Logging helpers ----------
|
|
37
|
-
export const log = {
|
|
38
|
-
info: (msg) => console.log(`${color.cyan("ℹ")} ${msg}`),
|
|
39
|
-
ok: (msg) => console.log(`${color.green("✓")} ${msg}`),
|
|
40
|
-
warn: (msg) => console.log(`${color.yellow("⚠")} ${msg}`),
|
|
41
|
-
err: (msg) => console.error(`${color.red("✗")} ${msg}`),
|
|
42
|
-
step: (n, total, msg) =>
|
|
43
|
-
console.log(`${color.dim(`[${n}/${total}]`)} ${msg}`),
|
|
44
|
-
blank: () => console.log(""),
|
|
45
|
-
title: (msg) => console.log(`\n${color.bold(msg)}\n`),
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
// ---------- Run shell command ----------
|
|
49
|
-
/**
|
|
50
|
-
* Run a command, stream output live. Returns { code, stdout, stderr }.
|
|
51
|
-
*/
|
|
52
|
-
export function run(cmd, args = [], opts = {}) {
|
|
53
|
-
return new Promise((resolve) => {
|
|
54
|
-
const child = spawn(cmd, args, {
|
|
55
|
-
stdio: opts.silent ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
56
|
-
env: { ...process.env, ...opts.env },
|
|
57
|
-
cwd: opts.cwd || process.cwd(),
|
|
58
|
-
shell: false,
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
let stdout = "";
|
|
62
|
-
let stderr = "";
|
|
63
|
-
if (opts.silent) {
|
|
64
|
-
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
65
|
-
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
69
|
-
child.on("error", (err) => resolve({ code: -1, stdout: "", stderr: err.message }));
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Sync run — for quick checks (e.g. "which claude").
|
|
75
|
-
*/
|
|
76
|
-
export function runSync(cmd, args = []) {
|
|
77
|
-
const res = spawnSync(cmd, args, { encoding: "utf-8", shell: false });
|
|
78
|
-
return {
|
|
79
|
-
code: res.status ?? -1,
|
|
80
|
-
stdout: res.stdout ?? "",
|
|
81
|
-
stderr: res.stderr ?? "",
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// ---------- Check if a command exists ----------
|
|
86
|
-
export function has(cmd) {
|
|
87
|
-
const res = runSync("which", [cmd]);
|
|
88
|
-
return res.code === 0 && res.stdout.trim().length > 0;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ---------- @clack/prompts wrappers ----------
|
|
92
|
-
let _clack = null;
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Lazy-load @clack/prompts (ESM module)
|
|
96
|
-
*/
|
|
97
|
-
async function getClack() {
|
|
98
|
-
if (!_clack) {
|
|
99
|
-
_clack = await import("@clack/prompts");
|
|
100
|
-
}
|
|
101
|
-
return _clack;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Handle user cancellation gracefully
|
|
106
|
-
*/
|
|
107
|
-
async function handleCancel(value, message = "Operation cancelled") {
|
|
108
|
-
const clack = await getClack();
|
|
109
|
-
if (clack.isCancel(value)) {
|
|
110
|
-
clack.cancel(message);
|
|
111
|
-
process.exit(0);
|
|
112
|
-
}
|
|
113
|
-
return false;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Single-select prompt with arrow key navigation
|
|
118
|
-
* @param {Object} options
|
|
119
|
-
* @param {string} options.message - Question to ask
|
|
120
|
-
* @param {Array} options.options - Array of {value, label, hint?}
|
|
121
|
-
* @param {any} [options.initialValue] - Default selected value
|
|
122
|
-
* @returns {Promise<any>} Selected value
|
|
123
|
-
*/
|
|
124
|
-
export async function select(options) {
|
|
125
|
-
const clack = await getClack();
|
|
126
|
-
const result = await clack.select({
|
|
127
|
-
message: options.message,
|
|
128
|
-
options: options.options,
|
|
129
|
-
initialValue: options.initialValue,
|
|
130
|
-
});
|
|
131
|
-
await handleCancel(result);
|
|
132
|
-
return result;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Multi-select prompt with checkboxes (arrow keys + space to toggle)
|
|
137
|
-
* @param {Object} options
|
|
138
|
-
* @param {string} options.message - Question to ask
|
|
139
|
-
* @param {Array} options.options - Array of {value, label, hint?}
|
|
140
|
-
* @param {Array} [options.initialValues] - Default selected values
|
|
141
|
-
* @param {boolean} [options.required] - Whether at least one must be selected
|
|
142
|
-
* @returns {Promise<Array>} Array of selected values
|
|
143
|
-
*/
|
|
144
|
-
export async function multiselectClack(options) {
|
|
145
|
-
const clack = await getClack();
|
|
146
|
-
const result = await clack.multiselect({
|
|
147
|
-
message: options.message,
|
|
148
|
-
options: options.options,
|
|
149
|
-
initialValues: options.initialValues || [],
|
|
150
|
-
required: options.required !== undefined ? options.required : false,
|
|
151
|
-
});
|
|
152
|
-
await handleCancel(result);
|
|
153
|
-
return result;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Text input prompt with validation
|
|
158
|
-
* @param {Object} options
|
|
159
|
-
* @param {string} options.message - Question to ask
|
|
160
|
-
* @param {string} [options.placeholder] - Placeholder text
|
|
161
|
-
* @param {string} [options.defaultValue] - Default value
|
|
162
|
-
* @param {Function} [options.validate] - Validation function (return string for error, undefined for success)
|
|
163
|
-
* @returns {Promise<string>} User input
|
|
164
|
-
*/
|
|
165
|
-
export async function text(options) {
|
|
166
|
-
const clack = await getClack();
|
|
167
|
-
const result = await clack.text({
|
|
168
|
-
message: options.message,
|
|
169
|
-
placeholder: options.placeholder,
|
|
170
|
-
defaultValue: options.defaultValue,
|
|
171
|
-
validate: options.validate,
|
|
172
|
-
});
|
|
173
|
-
await handleCancel(result);
|
|
174
|
-
return result;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Spinner for async operations
|
|
179
|
-
* @returns {Promise<Object>} Spinner controller
|
|
180
|
-
*/
|
|
181
|
-
export async function spinner() {
|
|
182
|
-
const clack = await getClack();
|
|
183
|
-
return clack.spinner();
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Display intro message
|
|
188
|
-
*/
|
|
189
|
-
export async function intro(message) {
|
|
190
|
-
const clack = await getClack();
|
|
191
|
-
clack.intro(message);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Display outro message
|
|
196
|
-
*/
|
|
197
|
-
export async function outro(message) {
|
|
198
|
-
const clack = await getClack();
|
|
199
|
-
clack.outro(message);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Display a note/info box
|
|
204
|
-
*/
|
|
205
|
-
export async function note(message, title) {
|
|
206
|
-
const clack = await getClack();
|
|
207
|
-
clack.note(message, title);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// ---------- Config file helpers ----------
|
|
211
|
-
const CONFIG_DIR = join(homedir(), ".claude");
|
|
212
|
-
const CONFIG_FILE = join(CONFIG_DIR, "curdx-flow-config.json");
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Read curdx-flow config from ~/.claude/curdx-flow-config.json
|
|
216
|
-
*/
|
|
217
|
-
export function readConfig() {
|
|
218
|
-
if (!existsSync(CONFIG_FILE)) {
|
|
219
|
-
return {};
|
|
220
|
-
}
|
|
221
|
-
try {
|
|
222
|
-
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
223
|
-
} catch {
|
|
224
|
-
return {};
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Write curdx-flow config to ~/.claude/curdx-flow-config.json
|
|
230
|
-
*/
|
|
231
|
-
export function writeConfig(config) {
|
|
232
|
-
if (!existsSync(CONFIG_DIR)) {
|
|
233
|
-
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
234
|
-
}
|
|
235
|
-
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// ---------- Interactive prompts (readline, legacy) ----------
|
|
239
|
-
/**
|
|
240
|
-
* Ask user a yes/no question. Default applies on empty input.
|
|
241
|
-
*/
|
|
242
|
-
export function confirm(message, defaultYes = true) {
|
|
243
|
-
return new Promise((resolve) => {
|
|
244
|
-
const rl = createInterface({
|
|
245
|
-
input: process.stdin,
|
|
246
|
-
output: process.stdout,
|
|
247
|
-
});
|
|
248
|
-
const hint = defaultYes ? "[Y/n]" : "[y/N]";
|
|
249
|
-
rl.question(`${color.cyan("?")} ${message} ${color.dim(hint)} `, (ans) => {
|
|
250
|
-
rl.close();
|
|
251
|
-
const v = ans.trim().toLowerCase();
|
|
252
|
-
if (v === "") return resolve(defaultYes);
|
|
253
|
-
resolve(v === "y" || v === "yes");
|
|
254
|
-
});
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Multi-select (checkbox-style via comma-separated input).
|
|
260
|
-
* Returns array of selected values.
|
|
261
|
-
*/
|
|
262
|
-
export function multiSelect(message, choices, defaults = null) {
|
|
263
|
-
return new Promise((resolve) => {
|
|
264
|
-
const defaultSet = new Set(
|
|
265
|
-
defaults ?? choices.map((_, i) => i)
|
|
266
|
-
);
|
|
267
|
-
console.log(`${color.cyan("?")} ${message}`);
|
|
268
|
-
choices.forEach((ch, i) => {
|
|
269
|
-
const checked = defaultSet.has(i)
|
|
270
|
-
? color.green("[x]")
|
|
271
|
-
: color.dim("[ ]");
|
|
272
|
-
console.log(` ${checked} ${color.bold(String(i + 1))}. ${ch.label}${ch.hint ? color.dim(` — ${ch.hint}`) : ""}`);
|
|
273
|
-
});
|
|
274
|
-
console.log(
|
|
275
|
-
color.dim(
|
|
276
|
-
" (comma-separated selection, e.g. 1,3 | a=all | n=none | Enter=default)"
|
|
277
|
-
)
|
|
278
|
-
);
|
|
279
|
-
|
|
280
|
-
const rl = createInterface({
|
|
281
|
-
input: process.stdin,
|
|
282
|
-
output: process.stdout,
|
|
283
|
-
});
|
|
284
|
-
rl.question(` > `, (ans) => {
|
|
285
|
-
rl.close();
|
|
286
|
-
const v = ans.trim().toLowerCase();
|
|
287
|
-
let selected;
|
|
288
|
-
if (v === "") {
|
|
289
|
-
selected = [...defaultSet];
|
|
290
|
-
} else if (v === "a" || v === "all") {
|
|
291
|
-
selected = choices.map((_, i) => i);
|
|
292
|
-
} else if (v === "n" || v === "none") {
|
|
293
|
-
selected = [];
|
|
294
|
-
} else {
|
|
295
|
-
selected = v
|
|
296
|
-
.split(/[,\s]+/)
|
|
297
|
-
.map((x) => parseInt(x, 10) - 1)
|
|
298
|
-
.filter((i) => Number.isInteger(i) && i >= 0 && i < choices.length);
|
|
299
|
-
}
|
|
300
|
-
resolve(selected.map((i) => choices[i].value));
|
|
301
|
-
});
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// ---------- Claude CLI helpers ----------
|
|
306
|
-
/** Get claude CLI version, or null if not installed. */
|
|
307
|
-
export function claudeVersion() {
|
|
308
|
-
if (!has("claude")) return null;
|
|
309
|
-
const res = runSync("claude", ["--version"]);
|
|
310
|
-
if (res.code !== 0) return null;
|
|
311
|
-
// Output like "2.1.114 (Claude Code)"
|
|
312
|
-
const m = res.stdout.match(/(\d+\.\d+\.\d+)/);
|
|
313
|
-
return m ? m[1] : res.stdout.trim().split("\n")[0];
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* List installed plugins. Prefers the structured `claude plugin list --json`
|
|
318
|
-
* output (stable machine-readable format; confirmed present in claude
|
|
319
|
-
* 2.1.117+). Falls back to parsing the human-readable stream-text output
|
|
320
|
-
* for older CLI versions, but warns that parser is brittle.
|
|
321
3
|
*
|
|
322
|
-
*
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
if (res.code !== 0) return [];
|
|
352
|
-
const plugins = [];
|
|
353
|
-
const blocks = res.stdout.split(/\n\s*❯\s*/).slice(1);
|
|
354
|
-
for (const block of blocks) {
|
|
355
|
-
const lines = block.split("\n");
|
|
356
|
-
const id = lines[0].trim();
|
|
357
|
-
const name = id.split("@")[0];
|
|
358
|
-
const version = (block.match(/Version:\s*(\S+)/) || [])[1];
|
|
359
|
-
const status = block.includes("✔")
|
|
360
|
-
? "enabled"
|
|
361
|
-
: block.includes("✘")
|
|
362
|
-
? "failed"
|
|
363
|
-
: "unknown";
|
|
364
|
-
plugins.push({ id, name, marketplaceId: id.split("@")[1], version, status, raw: block });
|
|
365
|
-
}
|
|
366
|
-
return plugins;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* List configured Claude Code plugin marketplaces.
|
|
371
|
-
* Returns array of { name, source, repo, path } when `--json` is supported.
|
|
372
|
-
*/
|
|
373
|
-
export function listPluginMarketplaces() {
|
|
374
|
-
const j = runSync("claude", ["plugin", "marketplace", "list", "--json"]);
|
|
375
|
-
if (j.code === 0 && j.stdout.trim().startsWith("[")) {
|
|
376
|
-
try {
|
|
377
|
-
return JSON.parse(j.stdout);
|
|
378
|
-
} catch {
|
|
379
|
-
return [];
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
return [];
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* Read the user-level MCP registrations from ~/.claude.json. These are the
|
|
387
|
-
* MCPs the user added manually via `claude mcp add …` — distinct from
|
|
388
|
-
* plugin-bundled MCPs (which live in plugin.json).
|
|
389
|
-
*
|
|
390
|
-
* Returns a Map keyed by server name with the raw config object. Returns
|
|
391
|
-
* an empty Map if the file is missing / unreadable / has no mcpServers
|
|
392
|
-
* section — all of which are normal states and not errors.
|
|
393
|
-
*/
|
|
394
|
-
export function readUserMcpConfig() {
|
|
395
|
-
try {
|
|
396
|
-
const path = join(HOME, ".claude.json");
|
|
397
|
-
if (!existsSync(path)) return new Map();
|
|
398
|
-
const cfg = JSON.parse(readFileSync(path, "utf-8"));
|
|
399
|
-
const servers = cfg?.mcpServers || {};
|
|
400
|
-
return new Map(Object.entries(servers));
|
|
401
|
-
} catch {
|
|
402
|
-
return new Map();
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/**
|
|
407
|
-
* Given the output of listMcps() and a user-level MCP config map, find
|
|
408
|
-
* MCPs that are registered BOTH as user-level AND as plugin-bundled.
|
|
409
|
-
* The plugin-bundled form shows up as `plugin:<plugin>:<name>` in
|
|
410
|
-
* listMcps output, so a user-level "context7" and a plugin-level
|
|
411
|
-
* "plugin:curdx-flow:context7" are a duplicate pair.
|
|
412
|
-
*
|
|
413
|
-
* Returns array of { name, userConfig, pluginEntry }.
|
|
414
|
-
*/
|
|
415
|
-
export function findDuplicateMcps(mcps, userConfig) {
|
|
416
|
-
const duplicates = [];
|
|
417
|
-
for (const m of mcps) {
|
|
418
|
-
// Only look at plugin-prefixed entries — they're the reference for
|
|
419
|
-
// what's bundled. Check if user has their own non-prefixed version.
|
|
420
|
-
if (m.plugin && userConfig.has(m.name)) {
|
|
421
|
-
duplicates.push({
|
|
422
|
-
name: m.name,
|
|
423
|
-
userConfig: userConfig.get(m.name),
|
|
424
|
-
pluginEntry: m,
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
return duplicates;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/**
|
|
432
|
-
* List MCP servers registered with the `claude` CLI. Returns array of
|
|
433
|
-
* { name, plugin, fullName, status, command }
|
|
434
|
-
* where `plugin` is set when the MCP came from a plugin (real name is
|
|
435
|
-
* `plugin:<plugin>:<mcp>`), `name` is the trailing segment, and `fullName`
|
|
436
|
-
* is the original as reported by claude.
|
|
437
|
-
*
|
|
438
|
-
* Fixture captured from `claude mcp list` (2.1.117):
|
|
439
|
-
* Checking MCP server health…
|
|
440
|
-
*
|
|
441
|
-
* plugin:curdx-flow:context7: npx -y @upstash/context7-mcp@latest - ✓ Connected
|
|
442
|
-
* context7: npx -y @upstash/context7-mcp --api-key ... - ✓ Connected
|
|
443
|
-
* claude.ai Gmail: https://gmailmcp... - ✓ Connected
|
|
444
|
-
*
|
|
445
|
-
* `claude mcp list --json` does not exist on 2.1.117 (verified), so this
|
|
446
|
-
* parser is the primary path. It is fixture-tested in test/utils.test.js
|
|
447
|
-
* so format regressions get caught in CI.
|
|
448
|
-
*/
|
|
449
|
-
export function listMcps() {
|
|
450
|
-
const res = runSync("claude", ["mcp", "list"]);
|
|
451
|
-
if (res.code !== 0) return [];
|
|
452
|
-
return parseMcpList(res.stdout);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/** Exported for testing against a fixed input. */
|
|
456
|
-
export function parseMcpList(output) {
|
|
457
|
-
const mcps = [];
|
|
458
|
-
for (const raw of output.split("\n")) {
|
|
459
|
-
const line = raw.trimEnd();
|
|
460
|
-
if (!line) continue;
|
|
461
|
-
// skip the health-check header line
|
|
462
|
-
if (line.startsWith("Checking") || line.startsWith("checking")) continue;
|
|
463
|
-
// Expected format: "<fullName>: <command-or-url> - <status>"
|
|
464
|
-
// fullName may itself contain colons when prefixed with "plugin:<p>:<m>".
|
|
465
|
-
// Match from the end to find the status sentinel " - ", then split off
|
|
466
|
-
// the name at the first ": " after the identifier prefix.
|
|
467
|
-
const statusSplit = line.lastIndexOf(" - ");
|
|
468
|
-
if (statusSplit === -1) continue;
|
|
469
|
-
const statusRaw = line.slice(statusSplit + 3).trim();
|
|
470
|
-
const beforeStatus = line.slice(0, statusSplit);
|
|
471
|
-
// Find the first ": " that separates name from command. Note the space
|
|
472
|
-
// after the colon — this disambiguates from the colons inside
|
|
473
|
-
// "plugin:foo:bar".
|
|
474
|
-
const nameSplit = beforeStatus.indexOf(": ");
|
|
475
|
-
if (nameSplit === -1) continue;
|
|
476
|
-
const fullName = beforeStatus.slice(0, nameSplit).trim();
|
|
477
|
-
const command = beforeStatus.slice(nameSplit + 2).trim();
|
|
478
|
-
|
|
479
|
-
let plugin = null;
|
|
480
|
-
let name = fullName;
|
|
481
|
-
if (fullName.startsWith("plugin:")) {
|
|
482
|
-
const parts = fullName.split(":");
|
|
483
|
-
if (parts.length >= 3) {
|
|
484
|
-
plugin = parts[1];
|
|
485
|
-
name = parts.slice(2).join(":");
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
const status = /Connected|✓/.test(statusRaw)
|
|
490
|
-
? "connected"
|
|
491
|
-
: /Failed|✗/.test(statusRaw)
|
|
492
|
-
? "failed"
|
|
493
|
-
: "unknown";
|
|
494
|
-
|
|
495
|
-
mcps.push({ name, plugin, fullName, status, command });
|
|
496
|
-
}
|
|
497
|
-
return mcps;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// ---------- Runtime PATH guards (bun / uv) ----------
|
|
501
|
-
// claude-mem hard-codes `command: "bun"` in its .mcp.json, but bun installs to
|
|
502
|
-
// ~/.bun/bin which is not on PATH when Claude Code spawns MCP servers
|
|
503
|
-
// (macOS non-interactive shells do not source .zshrc). This module provides
|
|
504
|
-
// detection + self-healing: create a symlink to the user-level bun install
|
|
505
|
-
// in a PATH-visible directory.
|
|
506
|
-
|
|
507
|
-
// Note: existsSync, mkdirSync, symlinkSync, lstatSync, unlinkSync, readlinkSync, homedir, join
|
|
508
|
-
// are already imported at the top of this file.
|
|
509
|
-
|
|
510
|
-
// os.homedir() is sourced from the OS-level user record and works even
|
|
511
|
-
// when $HOME is empty (non-login shells, some CI containers). See the
|
|
512
|
-
// same rationale in cli/protocols.js.
|
|
513
|
-
const HOME = homedir();
|
|
514
|
-
|
|
515
|
-
/** Candidate bun install locations (priority order) */
|
|
516
|
-
const BUN_CANDIDATES = [
|
|
517
|
-
join(HOME, ".bun", "bin", "bun"),
|
|
518
|
-
"/opt/homebrew/bin/bun",
|
|
519
|
-
"/usr/local/bin/bun",
|
|
520
|
-
"/home/linuxbrew/.linuxbrew/bin/bun",
|
|
521
|
-
];
|
|
522
|
-
|
|
523
|
-
/** Candidate uv install locations */
|
|
524
|
-
const UV_CANDIDATES = [
|
|
525
|
-
join(HOME, ".local", "bin", "uv"),
|
|
526
|
-
join(HOME, ".cargo", "bin", "uv"),
|
|
527
|
-
"/opt/homebrew/bin/uv",
|
|
528
|
-
"/usr/local/bin/uv",
|
|
529
|
-
];
|
|
530
|
-
|
|
531
|
-
/** PATH-visible directories where symlinks can be created (priority order; use if exists, else try to create) */
|
|
532
|
-
const SYMLINK_TARGET_DIRS = [
|
|
533
|
-
join(HOME, ".local", "bin"),
|
|
534
|
-
join(HOME, ".npm-global", "bin"),
|
|
535
|
-
];
|
|
536
|
-
|
|
537
|
-
/** Find the absolute path of a runtime that actually exists */
|
|
538
|
-
function findRuntime(candidates) {
|
|
539
|
-
for (const p of candidates) if (existsSync(p)) return p;
|
|
540
|
-
return null;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/** Whether the current PATH can resolve this command */
|
|
544
|
-
function inPath(cmd) {
|
|
545
|
-
return has(cmd);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/** Find a writable PATH-visible directory for symlink creation */
|
|
549
|
-
function findSymlinkDir() {
|
|
550
|
-
const pathDirs = (process.env.PATH || "").split(":").filter(Boolean);
|
|
551
|
-
for (const d of SYMLINK_TARGET_DIRS) {
|
|
552
|
-
if (pathDirs.includes(d)) {
|
|
553
|
-
try {
|
|
554
|
-
if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
|
555
|
-
return d;
|
|
556
|
-
} catch {
|
|
557
|
-
// continue
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
return null;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
/**
|
|
565
|
-
* Ensure cmd is resolvable on PATH. If it is installed but not visible
|
|
566
|
-
* on PATH, create a symlink automatically.
|
|
567
|
-
* @returns {{status:"ok"|"linked"|"missing"|"path-unwritable", path?:string, link?:string}}
|
|
568
|
-
*/
|
|
569
|
-
export function ensureRuntimeInPath(cmd, candidates) {
|
|
570
|
-
if (inPath(cmd)) return { status: "ok" };
|
|
571
|
-
|
|
572
|
-
const realPath = findRuntime(candidates);
|
|
573
|
-
if (!realPath) return { status: "missing" };
|
|
574
|
-
|
|
575
|
-
const linkDir = findSymlinkDir();
|
|
576
|
-
if (!linkDir) return { status: "path-unwritable", path: realPath };
|
|
577
|
-
|
|
578
|
-
const linkPath = join(linkDir, cmd);
|
|
579
|
-
// If it already exists and points to the same target, return idempotently
|
|
580
|
-
if (existsSync(linkPath)) {
|
|
581
|
-
try {
|
|
582
|
-
const stat = lstatSync(linkPath);
|
|
583
|
-
if (stat.isSymbolicLink() && readlinkSync(linkPath) === realPath) {
|
|
584
|
-
return { status: "ok", path: realPath, link: linkPath };
|
|
585
|
-
}
|
|
586
|
-
// Old symlink/file points elsewhere — overwrite
|
|
587
|
-
unlinkSync(linkPath);
|
|
588
|
-
} catch {
|
|
589
|
-
// ignore
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
try {
|
|
593
|
-
symlinkSync(realPath, linkPath);
|
|
594
|
-
return { status: "linked", path: realPath, link: linkPath };
|
|
595
|
-
} catch (err) {
|
|
596
|
-
return { status: "path-unwritable", path: realPath };
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/** One-shot: ensure both bun and uv (claude-mem's runtimes) are resolvable on PATH */
|
|
601
|
-
export function ensureClaudeMemRuntimes() {
|
|
602
|
-
return {
|
|
603
|
-
bun: ensureRuntimeInPath("bun", BUN_CANDIDATES),
|
|
604
|
-
uv: ensureRuntimeInPath("uv", UV_CANDIDATES),
|
|
605
|
-
};
|
|
606
|
-
}
|
|
4
|
+
* This module is kept as the stable public import surface for existing CLI
|
|
5
|
+
* commands. Implementations live under cli/lib/ so each concern stays small.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { VERSION } from "./lib/version.js";
|
|
9
|
+
export { color, log } from "./lib/logging.js";
|
|
10
|
+
export { has, run, runSync } from "./lib/process.js";
|
|
11
|
+
export {
|
|
12
|
+
confirm,
|
|
13
|
+
intro,
|
|
14
|
+
multiSelect,
|
|
15
|
+
multiselectClack,
|
|
16
|
+
note,
|
|
17
|
+
outro,
|
|
18
|
+
select,
|
|
19
|
+
spinner,
|
|
20
|
+
text,
|
|
21
|
+
} from "./lib/prompts.js";
|
|
22
|
+
export { readConfig, writeConfig } from "./lib/config.js";
|
|
23
|
+
export {
|
|
24
|
+
claudeVersion,
|
|
25
|
+
findDuplicateMcps,
|
|
26
|
+
listMcps,
|
|
27
|
+
listPluginMarketplaces,
|
|
28
|
+
listPlugins,
|
|
29
|
+
parseMcpList,
|
|
30
|
+
readUserMcpConfig,
|
|
31
|
+
} from "./lib/claude.js";
|
|
32
|
+
export { ensureClaudeMemRuntimes, ensureRuntimeInPath } from "./lib/runtime.js";
|