@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
|
@@ -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
|
+
}
|
package/cli/install-paths.js
CHANGED
|
@@ -13,6 +13,11 @@ export const LOCAL_MARKETPLACE_MANIFEST = join(
|
|
|
13
13
|
".claude-plugin",
|
|
14
14
|
"marketplace.json"
|
|
15
15
|
);
|
|
16
|
+
export const LOCAL_PLUGIN_MANIFEST = join(
|
|
17
|
+
PKG_ROOT,
|
|
18
|
+
".claude-plugin",
|
|
19
|
+
"plugin.json"
|
|
20
|
+
);
|
|
16
21
|
|
|
17
22
|
export function shouldUseOfflineInstall({ forceOnline }) {
|
|
18
23
|
return !forceOnline && existsSync(LOCAL_MARKETPLACE_MANIFEST);
|
|
@@ -30,8 +35,16 @@ export function getMarketplaceLabel(useOffline) {
|
|
|
30
35
|
|
|
31
36
|
export function readShippedVersion() {
|
|
32
37
|
try {
|
|
33
|
-
const
|
|
34
|
-
|
|
38
|
+
const plugin = JSON.parse(readFileSync(LOCAL_PLUGIN_MANIFEST, "utf-8"));
|
|
39
|
+
if (plugin?.version) return plugin.version;
|
|
40
|
+
} catch {
|
|
41
|
+
// Fall back to the marketplace manifest below. Online installs or old
|
|
42
|
+
// package layouts may not have a local plugin manifest available.
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const marketplace = JSON.parse(readFileSync(LOCAL_MARKETPLACE_MANIFEST, "utf-8"));
|
|
47
|
+
return marketplace?.metadata?.version || null;
|
|
35
48
|
} catch {
|
|
36
49
|
// marketplace not local (online install) or unreadable
|
|
37
50
|
return null;
|
|
@@ -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
|
+
}
|