@curdx/flow 2.0.8 → 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/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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Claude Code Discipline Layer — spec-driven workflow + goal-backward verification + Karpathy 4 principles enforced via gates. Stops Claude from faking \"done\" on non-trivial features.",
|
|
9
|
-
"version": "2.0.
|
|
9
|
+
"version": "2.0.9"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "curdx-flow",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.9",
|
|
4
4
|
"description": "Claude Code Discipline Layer — spec-driven workflow + goal-backward verification + Karpathy 4 principles enforced via gates. Stops Claude from faking \"done\" on non-trivial features.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "wdx",
|
package/bin/curdx-flow.js
CHANGED
|
@@ -23,113 +23,7 @@
|
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
24
|
import { realpathSync } from "node:fs";
|
|
25
25
|
|
|
26
|
-
import {
|
|
27
|
-
import { doctor } from "../cli/doctor.js";
|
|
28
|
-
import { upgrade } from "../cli/upgrade.js";
|
|
29
|
-
import { uninstall } from "../cli/uninstall.js";
|
|
30
|
-
import { VERSION, color } from "../cli/utils.js";
|
|
31
|
-
|
|
32
|
-
const args = process.argv.slice(2);
|
|
33
|
-
const cmd = args[0];
|
|
34
|
-
const rest = args.slice(1);
|
|
35
|
-
|
|
36
|
-
function printHelp() {
|
|
37
|
-
console.log(`
|
|
38
|
-
${color.bold("curdx-flow")} ${color.dim(`v${VERSION}`)}
|
|
39
|
-
CurdX-Flow installer & helper for Claude Code
|
|
40
|
-
|
|
41
|
-
${color.bold("USAGE")}
|
|
42
|
-
npx @curdx/flow <command> [options]
|
|
43
|
-
|
|
44
|
-
${color.bold("COMMANDS")}
|
|
45
|
-
${color.cyan("install")} Install curdx-flow plugin + optional recommended plugins
|
|
46
|
-
--all Install all recommended (skip prompt)
|
|
47
|
-
--no-deps Only install curdx-flow, skip recommendations
|
|
48
|
-
--online Fetch plugin from GitHub instead of using the
|
|
49
|
-
local npm package (slower; default is offline
|
|
50
|
-
when the plugin body is bundled)
|
|
51
|
-
|
|
52
|
-
${color.cyan("doctor")} Check health (claude CLI, plugin, MCPs, recommended)
|
|
53
|
-
|
|
54
|
-
${color.cyan("upgrade")} Update curdx-flow and recommended plugins to latest
|
|
55
|
-
|
|
56
|
-
${color.cyan("uninstall")} Remove curdx-flow plugin (and optionally recommended / artifacts)
|
|
57
|
-
-y, --yes Skip confirmation, keep recommended + .flow/
|
|
58
|
-
--keep-recommended Don't ask about pua/claude-mem/frontend-design
|
|
59
|
-
--purge Also remove ~/.local/bin/bun, ~/.local/bin/uv symlinks
|
|
60
|
-
|
|
61
|
-
${color.bold("OPTIONS")}
|
|
62
|
-
-v, --version Print version
|
|
63
|
-
-h, --help Show this CLI usage summary
|
|
64
|
-
|
|
65
|
-
${color.dim("For the full command / workflow reference (including all slash")}
|
|
66
|
-
${color.dim("commands like /curdx-flow:start, /curdx-flow:spec, …) run:")}
|
|
67
|
-
${color.cyan("/curdx-flow:help")} ${color.dim("(inside Claude Code)")}
|
|
68
|
-
|
|
69
|
-
${color.bold("EXAMPLES")}
|
|
70
|
-
${color.dim("# First-time install with recommended plugins")}
|
|
71
|
-
npx @curdx/flow install --all
|
|
72
|
-
|
|
73
|
-
${color.dim("# Check what's installed")}
|
|
74
|
-
npx @curdx/flow doctor
|
|
75
|
-
|
|
76
|
-
${color.dim("# Update everything")}
|
|
77
|
-
npx @curdx/flow upgrade
|
|
78
|
-
|
|
79
|
-
${color.bold("INITIALIZING A PROJECT")}
|
|
80
|
-
Once curdx-flow is installed, initialize your project inside Claude Code:
|
|
81
|
-
|
|
82
|
-
${color.cyan("claude")}
|
|
83
|
-
${color.cyan("/curdx-flow:init")}
|
|
84
|
-
${color.cyan("/curdx-flow:start my-feature \"<description>\"")}
|
|
85
|
-
|
|
86
|
-
${color.bold("LEARN MORE")}
|
|
87
|
-
https://github.com/curdx/curdx-flow
|
|
88
|
-
`);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async function main() {
|
|
92
|
-
// Version flags
|
|
93
|
-
if (cmd === "--version" || cmd === "-v") {
|
|
94
|
-
console.log(VERSION);
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Help flags (standard CLI convention: -h / --help / no args)
|
|
99
|
-
if (!cmd || cmd === "--help" || cmd === "-h") {
|
|
100
|
-
printHelp();
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Route
|
|
105
|
-
try {
|
|
106
|
-
switch (cmd) {
|
|
107
|
-
case "install":
|
|
108
|
-
await install(rest);
|
|
109
|
-
break;
|
|
110
|
-
case "doctor":
|
|
111
|
-
await doctor(rest);
|
|
112
|
-
break;
|
|
113
|
-
case "upgrade":
|
|
114
|
-
await upgrade(rest);
|
|
115
|
-
break;
|
|
116
|
-
case "uninstall":
|
|
117
|
-
case "remove":
|
|
118
|
-
await uninstall(rest);
|
|
119
|
-
break;
|
|
120
|
-
default:
|
|
121
|
-
console.error(color.red(`Unknown command: ${cmd}`));
|
|
122
|
-
console.error(`Run ${color.cyan("npx @curdx/flow --help")} for CLI usage.`);
|
|
123
|
-
process.exit(1);
|
|
124
|
-
}
|
|
125
|
-
} catch (err) {
|
|
126
|
-
console.error(color.red(`\n✗ ${err.message || err}`));
|
|
127
|
-
if (process.env.CURDX_DEBUG) {
|
|
128
|
-
console.error(err.stack || "");
|
|
129
|
-
}
|
|
130
|
-
process.exit(1);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
26
|
+
import { runCli } from "../cli/router.js";
|
|
133
27
|
|
|
134
28
|
// Only execute main() when invoked directly (`node bin/curdx-flow.js ...`
|
|
135
29
|
// or via the npm bin shim at node_modules/.bin/<name>). When the file is
|
|
@@ -156,5 +50,5 @@ function isInvokedDirectly() {
|
|
|
156
50
|
}
|
|
157
51
|
|
|
158
52
|
if (isInvokedDirectly()) {
|
|
159
|
-
|
|
53
|
+
runCli();
|
|
160
54
|
}
|
package/cli/help.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { VERSION, color } from "./utils.js";
|
|
2
|
+
|
|
3
|
+
export function printHelp() {
|
|
4
|
+
console.log(`
|
|
5
|
+
${color.bold("curdx-flow")} ${color.dim(`v${VERSION}`)}
|
|
6
|
+
CurdX-Flow installer & helper for Claude Code
|
|
7
|
+
|
|
8
|
+
${color.bold("USAGE")}
|
|
9
|
+
npx @curdx/flow <command> [options]
|
|
10
|
+
|
|
11
|
+
${color.bold("COMMANDS")}
|
|
12
|
+
${color.cyan("install")} Install curdx-flow plugin + optional recommended plugins
|
|
13
|
+
--all Install all recommended (skip prompt)
|
|
14
|
+
--no-deps Only install curdx-flow, skip recommendations
|
|
15
|
+
--online Fetch plugin from GitHub instead of using the
|
|
16
|
+
local npm package (slower; default is offline
|
|
17
|
+
when the plugin body is bundled)
|
|
18
|
+
|
|
19
|
+
${color.cyan("doctor")} Check health (claude CLI, plugin, MCPs, recommended)
|
|
20
|
+
|
|
21
|
+
${color.cyan("upgrade")} Update curdx-flow and recommended plugins to latest
|
|
22
|
+
|
|
23
|
+
${color.cyan("uninstall")} Remove curdx-flow plugin (and optionally recommended / artifacts)
|
|
24
|
+
-y, --yes Skip confirmation, keep recommended + .flow/
|
|
25
|
+
--keep-recommended Don't ask about pua/claude-mem/frontend-design
|
|
26
|
+
--purge Also remove ~/.local/bin/bun, ~/.local/bin/uv symlinks
|
|
27
|
+
|
|
28
|
+
${color.bold("OPTIONS")}
|
|
29
|
+
-v, --version Print version
|
|
30
|
+
-h, --help Show this CLI usage summary
|
|
31
|
+
|
|
32
|
+
${color.dim("For the full command / workflow reference (including all slash")}
|
|
33
|
+
${color.dim("commands like /curdx-flow:start, /curdx-flow:spec, …) run:")}
|
|
34
|
+
${color.cyan("/curdx-flow:help")} ${color.dim("(inside Claude Code)")}
|
|
35
|
+
|
|
36
|
+
${color.bold("EXAMPLES")}
|
|
37
|
+
${color.dim("# First-time install with recommended plugins")}
|
|
38
|
+
npx @curdx/flow install --all
|
|
39
|
+
|
|
40
|
+
${color.dim("# Check what's installed")}
|
|
41
|
+
npx @curdx/flow doctor
|
|
42
|
+
|
|
43
|
+
${color.dim("# Update everything")}
|
|
44
|
+
npx @curdx/flow upgrade
|
|
45
|
+
|
|
46
|
+
${color.bold("INITIALIZING A PROJECT")}
|
|
47
|
+
Once curdx-flow is installed, initialize your project inside Claude Code:
|
|
48
|
+
|
|
49
|
+
${color.cyan("claude")}
|
|
50
|
+
${color.cyan("/curdx-flow:init")}
|
|
51
|
+
${color.cyan("/curdx-flow:start my-feature \"<description>\"")}
|
|
52
|
+
|
|
53
|
+
${color.bold("LEARN MORE")}
|
|
54
|
+
https://github.com/curdx/curdx-flow
|
|
55
|
+
`);
|
|
56
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { has, runSync } from "./process.js";
|
|
6
|
+
|
|
7
|
+
const HOME = homedir();
|
|
8
|
+
|
|
9
|
+
export function claudeVersion() {
|
|
10
|
+
if (!has("claude")) return null;
|
|
11
|
+
const res = runSync("claude", ["--version"]);
|
|
12
|
+
if (res.code !== 0) return null;
|
|
13
|
+
const m = res.stdout.match(/(\d+\.\d+\.\d+)/);
|
|
14
|
+
return m ? m[1] : res.stdout.trim().split("\n")[0];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function listPlugins() {
|
|
18
|
+
const j = runSync("claude", ["plugin", "list", "--json"]);
|
|
19
|
+
if (j.code === 0 && j.stdout.trim().startsWith("[")) {
|
|
20
|
+
try {
|
|
21
|
+
const arr = JSON.parse(j.stdout);
|
|
22
|
+
return arr.map((p) => ({
|
|
23
|
+
id: String(p.id || ""),
|
|
24
|
+
name: String(p.id || "").split("@")[0],
|
|
25
|
+
marketplaceId: String(p.id || "").split("@")[1] || undefined,
|
|
26
|
+
version: p.version,
|
|
27
|
+
status: p.enabled === false ? "disabled" : "enabled",
|
|
28
|
+
scope: p.scope,
|
|
29
|
+
raw: JSON.stringify(p),
|
|
30
|
+
}));
|
|
31
|
+
} catch {
|
|
32
|
+
// JSON parse failed; fall through to legacy text parser.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const res = runSync("claude", ["plugin", "list"]);
|
|
37
|
+
if (res.code !== 0) return [];
|
|
38
|
+
const plugins = [];
|
|
39
|
+
const blocks = res.stdout.split(/\n\s*❯\s*/).slice(1);
|
|
40
|
+
for (const block of blocks) {
|
|
41
|
+
const lines = block.split("\n");
|
|
42
|
+
const id = lines[0].trim();
|
|
43
|
+
const name = id.split("@")[0];
|
|
44
|
+
const version = (block.match(/Version:\s*(\S+)/) || [])[1];
|
|
45
|
+
const status = block.includes("✔")
|
|
46
|
+
? "enabled"
|
|
47
|
+
: block.includes("✘")
|
|
48
|
+
? "failed"
|
|
49
|
+
: "unknown";
|
|
50
|
+
plugins.push({
|
|
51
|
+
id,
|
|
52
|
+
name,
|
|
53
|
+
marketplaceId: id.split("@")[1],
|
|
54
|
+
version,
|
|
55
|
+
status,
|
|
56
|
+
raw: block,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return plugins;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function listPluginMarketplaces() {
|
|
63
|
+
const j = runSync("claude", ["plugin", "marketplace", "list", "--json"]);
|
|
64
|
+
if (j.code === 0 && j.stdout.trim().startsWith("[")) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(j.stdout);
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function readUserMcpConfig() {
|
|
75
|
+
try {
|
|
76
|
+
const path = join(HOME, ".claude.json");
|
|
77
|
+
if (!existsSync(path)) return new Map();
|
|
78
|
+
const cfg = JSON.parse(readFileSync(path, "utf-8"));
|
|
79
|
+
const servers = cfg?.mcpServers || {};
|
|
80
|
+
return new Map(Object.entries(servers));
|
|
81
|
+
} catch {
|
|
82
|
+
return new Map();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function findDuplicateMcps(mcps, userConfig) {
|
|
87
|
+
const duplicates = [];
|
|
88
|
+
for (const m of mcps) {
|
|
89
|
+
if (m.plugin && userConfig.has(m.name)) {
|
|
90
|
+
duplicates.push({
|
|
91
|
+
name: m.name,
|
|
92
|
+
userConfig: userConfig.get(m.name),
|
|
93
|
+
pluginEntry: m,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return duplicates;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function listMcps() {
|
|
101
|
+
const res = runSync("claude", ["mcp", "list"]);
|
|
102
|
+
if (res.code !== 0) return [];
|
|
103
|
+
return parseMcpList(res.stdout);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function parseMcpList(output) {
|
|
107
|
+
const mcps = [];
|
|
108
|
+
for (const raw of output.split("\n")) {
|
|
109
|
+
const line = raw.trimEnd();
|
|
110
|
+
if (!line) continue;
|
|
111
|
+
if (line.startsWith("Checking") || line.startsWith("checking")) continue;
|
|
112
|
+
|
|
113
|
+
const statusSplit = line.lastIndexOf(" - ");
|
|
114
|
+
if (statusSplit === -1) continue;
|
|
115
|
+
const statusRaw = line.slice(statusSplit + 3).trim();
|
|
116
|
+
const beforeStatus = line.slice(0, statusSplit);
|
|
117
|
+
const nameSplit = beforeStatus.indexOf(": ");
|
|
118
|
+
if (nameSplit === -1) continue;
|
|
119
|
+
const fullName = beforeStatus.slice(0, nameSplit).trim();
|
|
120
|
+
const command = beforeStatus.slice(nameSplit + 2).trim();
|
|
121
|
+
|
|
122
|
+
let plugin = null;
|
|
123
|
+
let name = fullName;
|
|
124
|
+
if (fullName.startsWith("plugin:")) {
|
|
125
|
+
const parts = fullName.split(":");
|
|
126
|
+
if (parts.length >= 3) {
|
|
127
|
+
plugin = parts[1];
|
|
128
|
+
name = parts.slice(2).join(":");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const status = /Connected|✓/.test(statusRaw)
|
|
133
|
+
? "connected"
|
|
134
|
+
: /Failed|✗/.test(statusRaw)
|
|
135
|
+
? "failed"
|
|
136
|
+
: "unknown";
|
|
137
|
+
|
|
138
|
+
mcps.push({ name, plugin, fullName, status, command });
|
|
139
|
+
}
|
|
140
|
+
return mcps;
|
|
141
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = join(homedir(), ".claude");
|
|
6
|
+
const CONFIG_FILE = join(CONFIG_DIR, "curdx-flow-config.json");
|
|
7
|
+
|
|
8
|
+
export function readConfig() {
|
|
9
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
10
|
+
return {};
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
14
|
+
} catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function writeConfig(config) {
|
|
20
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
21
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
24
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const isTTY = process.stdout.isTTY && process.env.TERM !== "dumb";
|
|
2
|
+
const c = (code) => (s) => isTTY ? `\x1b[${code}m${s}\x1b[0m` : String(s);
|
|
3
|
+
|
|
4
|
+
export const color = {
|
|
5
|
+
red: c("31"),
|
|
6
|
+
green: c("32"),
|
|
7
|
+
yellow: c("33"),
|
|
8
|
+
blue: c("34"),
|
|
9
|
+
magenta: c("35"),
|
|
10
|
+
cyan: c("36"),
|
|
11
|
+
dim: c("2"),
|
|
12
|
+
bold: c("1"),
|
|
13
|
+
underline: c("4"),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const log = {
|
|
17
|
+
info: (msg) => console.log(`${color.cyan("ℹ")} ${msg}`),
|
|
18
|
+
ok: (msg) => console.log(`${color.green("✓")} ${msg}`),
|
|
19
|
+
warn: (msg) => console.log(`${color.yellow("⚠")} ${msg}`),
|
|
20
|
+
err: (msg) => console.error(`${color.red("✗")} ${msg}`),
|
|
21
|
+
step: (n, total, msg) =>
|
|
22
|
+
console.log(`${color.dim(`[${n}/${total}]`)} ${msg}`),
|
|
23
|
+
blank: () => console.log(""),
|
|
24
|
+
title: (msg) => console.log(`\n${color.bold(msg)}\n`),
|
|
25
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run a command, stream output live. Returns { code, stdout, stderr }.
|
|
5
|
+
*/
|
|
6
|
+
export function run(cmd, args = [], opts = {}) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const child = spawn(cmd, args, {
|
|
9
|
+
stdio: opts.silent ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
10
|
+
env: { ...process.env, ...opts.env },
|
|
11
|
+
cwd: opts.cwd || process.cwd(),
|
|
12
|
+
shell: false,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
let stdout = "";
|
|
16
|
+
let stderr = "";
|
|
17
|
+
if (opts.silent) {
|
|
18
|
+
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
19
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
23
|
+
child.on("error", (err) =>
|
|
24
|
+
resolve({ code: -1, stdout: "", stderr: err.message })
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sync run — for quick checks (e.g. "which claude").
|
|
31
|
+
*/
|
|
32
|
+
export function runSync(cmd, args = []) {
|
|
33
|
+
const res = spawnSync(cmd, args, { encoding: "utf-8", shell: false });
|
|
34
|
+
return {
|
|
35
|
+
code: res.status ?? -1,
|
|
36
|
+
stdout: res.stdout ?? "",
|
|
37
|
+
stderr: res.stderr ?? "",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function has(cmd) {
|
|
42
|
+
const res = runSync("which", [cmd]);
|
|
43
|
+
return res.code === 0 && res.stdout.trim().length > 0;
|
|
44
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
|
|
3
|
+
import { color } from "./logging.js";
|
|
4
|
+
|
|
5
|
+
let _clack = null;
|
|
6
|
+
|
|
7
|
+
async function getClack() {
|
|
8
|
+
if (!_clack) {
|
|
9
|
+
_clack = await import("@clack/prompts");
|
|
10
|
+
}
|
|
11
|
+
return _clack;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function handleCancel(value, message = "Operation cancelled") {
|
|
15
|
+
const clack = await getClack();
|
|
16
|
+
if (clack.isCancel(value)) {
|
|
17
|
+
clack.cancel(message);
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function select(options) {
|
|
24
|
+
const clack = await getClack();
|
|
25
|
+
const result = await clack.select({
|
|
26
|
+
message: options.message,
|
|
27
|
+
options: options.options,
|
|
28
|
+
initialValue: options.initialValue,
|
|
29
|
+
});
|
|
30
|
+
await handleCancel(result);
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function multiselectClack(options) {
|
|
35
|
+
const clack = await getClack();
|
|
36
|
+
const result = await clack.multiselect({
|
|
37
|
+
message: options.message,
|
|
38
|
+
options: options.options,
|
|
39
|
+
initialValues: options.initialValues || [],
|
|
40
|
+
required: options.required !== undefined ? options.required : false,
|
|
41
|
+
});
|
|
42
|
+
await handleCancel(result);
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function text(options) {
|
|
47
|
+
const clack = await getClack();
|
|
48
|
+
const result = await clack.text({
|
|
49
|
+
message: options.message,
|
|
50
|
+
placeholder: options.placeholder,
|
|
51
|
+
defaultValue: options.defaultValue,
|
|
52
|
+
validate: options.validate,
|
|
53
|
+
});
|
|
54
|
+
await handleCancel(result);
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function spinner() {
|
|
59
|
+
const clack = await getClack();
|
|
60
|
+
return clack.spinner();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function intro(message) {
|
|
64
|
+
const clack = await getClack();
|
|
65
|
+
clack.intro(message);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function outro(message) {
|
|
69
|
+
const clack = await getClack();
|
|
70
|
+
clack.outro(message);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function note(message, title) {
|
|
74
|
+
const clack = await getClack();
|
|
75
|
+
clack.note(message, title);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function confirm(message, defaultYes = true) {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const rl = createInterface({
|
|
81
|
+
input: process.stdin,
|
|
82
|
+
output: process.stdout,
|
|
83
|
+
});
|
|
84
|
+
const hint = defaultYes ? "[Y/n]" : "[y/N]";
|
|
85
|
+
rl.question(`${color.cyan("?")} ${message} ${color.dim(hint)} `, (ans) => {
|
|
86
|
+
rl.close();
|
|
87
|
+
const v = ans.trim().toLowerCase();
|
|
88
|
+
if (v === "") return resolve(defaultYes);
|
|
89
|
+
resolve(v === "y" || v === "yes");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function multiSelect(message, choices, defaults = null) {
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
const defaultSet = new Set(defaults ?? choices.map((_, i) => i));
|
|
97
|
+
console.log(`${color.cyan("?")} ${message}`);
|
|
98
|
+
choices.forEach((ch, i) => {
|
|
99
|
+
const checked = defaultSet.has(i)
|
|
100
|
+
? color.green("[x]")
|
|
101
|
+
: color.dim("[ ]");
|
|
102
|
+
console.log(
|
|
103
|
+
` ${checked} ${color.bold(String(i + 1))}. ${ch.label}${ch.hint ? color.dim(` — ${ch.hint}`) : ""}`
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
console.log(
|
|
107
|
+
color.dim(
|
|
108
|
+
" (comma-separated selection, e.g. 1,3 | a=all | n=none | Enter=default)"
|
|
109
|
+
)
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const rl = createInterface({
|
|
113
|
+
input: process.stdin,
|
|
114
|
+
output: process.stdout,
|
|
115
|
+
});
|
|
116
|
+
rl.question(` > `, (ans) => {
|
|
117
|
+
rl.close();
|
|
118
|
+
const v = ans.trim().toLowerCase();
|
|
119
|
+
let selected;
|
|
120
|
+
if (v === "") {
|
|
121
|
+
selected = [...defaultSet];
|
|
122
|
+
} else if (v === "a" || v === "all") {
|
|
123
|
+
selected = choices.map((_, i) => i);
|
|
124
|
+
} else if (v === "n" || v === "none") {
|
|
125
|
+
selected = [];
|
|
126
|
+
} else {
|
|
127
|
+
selected = v
|
|
128
|
+
.split(/[,\s]+/)
|
|
129
|
+
.map((x) => parseInt(x, 10) - 1)
|
|
130
|
+
.filter((i) => Number.isInteger(i) && i >= 0 && i < choices.length);
|
|
131
|
+
}
|
|
132
|
+
resolve(selected.map((i) => choices[i].value));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readlinkSync,
|
|
6
|
+
symlinkSync,
|
|
7
|
+
unlinkSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { has } from "./process.js";
|
|
13
|
+
|
|
14
|
+
const HOME = homedir();
|
|
15
|
+
|
|
16
|
+
const BUN_CANDIDATES = [
|
|
17
|
+
join(HOME, ".bun", "bin", "bun"),
|
|
18
|
+
"/opt/homebrew/bin/bun",
|
|
19
|
+
"/usr/local/bin/bun",
|
|
20
|
+
"/home/linuxbrew/.linuxbrew/bin/bun",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const UV_CANDIDATES = [
|
|
24
|
+
join(HOME, ".local", "bin", "uv"),
|
|
25
|
+
join(HOME, ".cargo", "bin", "uv"),
|
|
26
|
+
"/opt/homebrew/bin/uv",
|
|
27
|
+
"/usr/local/bin/uv",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const SYMLINK_TARGET_DIRS = [
|
|
31
|
+
join(HOME, ".local", "bin"),
|
|
32
|
+
join(HOME, ".npm-global", "bin"),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function findRuntime(candidates) {
|
|
36
|
+
for (const p of candidates) if (existsSync(p)) return p;
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findSymlinkDir() {
|
|
41
|
+
const pathDirs = (process.env.PATH || "").split(":").filter(Boolean);
|
|
42
|
+
for (const d of SYMLINK_TARGET_DIRS) {
|
|
43
|
+
if (pathDirs.includes(d)) {
|
|
44
|
+
try {
|
|
45
|
+
if (!existsSync(d)) mkdirSync(d, { recursive: true });
|
|
46
|
+
return d;
|
|
47
|
+
} catch {
|
|
48
|
+
// continue
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function ensureRuntimeInPath(cmd, candidates) {
|
|
56
|
+
if (has(cmd)) return { status: "ok" };
|
|
57
|
+
|
|
58
|
+
const realPath = findRuntime(candidates);
|
|
59
|
+
if (!realPath) return { status: "missing" };
|
|
60
|
+
|
|
61
|
+
const linkDir = findSymlinkDir();
|
|
62
|
+
if (!linkDir) return { status: "path-unwritable", path: realPath };
|
|
63
|
+
|
|
64
|
+
const linkPath = join(linkDir, cmd);
|
|
65
|
+
if (existsSync(linkPath)) {
|
|
66
|
+
try {
|
|
67
|
+
const stat = lstatSync(linkPath);
|
|
68
|
+
if (stat.isSymbolicLink() && readlinkSync(linkPath) === realPath) {
|
|
69
|
+
return { status: "ok", path: realPath, link: linkPath };
|
|
70
|
+
}
|
|
71
|
+
unlinkSync(linkPath);
|
|
72
|
+
} catch {
|
|
73
|
+
// ignore
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
symlinkSync(realPath, linkPath);
|
|
78
|
+
return { status: "linked", path: realPath, link: linkPath };
|
|
79
|
+
} catch {
|
|
80
|
+
return { status: "path-unwritable", path: realPath };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function ensureClaudeMemRuntimes() {
|
|
85
|
+
return {
|
|
86
|
+
bun: ensureRuntimeInPath("bun", BUN_CANDIDATES),
|
|
87
|
+
uv: ensureRuntimeInPath("uv", UV_CANDIDATES),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -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";
|