mcp-castor 2026.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +487 -0
- package/bin/castor.js +706 -0
- package/index.js +206 -0
- package/package.json +97 -0
- package/skills/canary-test-staging/SKILL.md +24 -0
- package/skills/evo-mutation-rollback/SKILL.md +29 -0
- package/skills/hypothesis-generation/SKILL.md +26 -0
- package/skills/traceback-condensing/SKILL.md +26 -0
- package/src/castor_runner.js +469 -0
- package/src/config.js +1204 -0
- package/src/env.js +10 -0
- package/src/evo_engine.js +214 -0
- package/src/harness/core/events.js +75 -0
- package/src/harness/core/kernel.js +209 -0
- package/src/harness/evo/evaluator.js +156 -0
- package/src/harness/evo/evo_operator.js +550 -0
- package/src/harness/evo/lineage_dag.js +383 -0
- package/src/harness/evo/trace_repair.js +173 -0
- package/src/harness/evo/watchdog.js +72 -0
- package/src/harness/loop_detector.js +135 -0
- package/src/harness/runner.js +1216 -0
- package/src/harness/services/ast_service.js +1813 -0
- package/src/harness/services/event_logger.js +275 -0
- package/src/harness/services/mcp_bridge.js +408 -0
- package/src/harness/services/provider_vllm.js +728 -0
- package/src/harness/services/sandbox_fs.js +1238 -0
- package/src/harness/services/searxng_lifecycle.js +254 -0
- package/src/harness/services/shell_executor.js +264 -0
- package/src/harness/services/shell_validator.js +506 -0
- package/src/harness/services/web_service.js +828 -0
- package/src/platform.js +344 -0
- package/src/repetition_detector.js +139 -0
- package/src/semaphore.js +373 -0
- package/src/server_lifecycle.js +781 -0
- package/src/skills.js +400 -0
- package/src/state_pruner.js +392 -0
- package/src/task_registry.js +1357 -0
- package/src/telemetry.js +638 -0
- package/src/tools.js +997 -0
- package/src/wsl_bridge.js +629 -0
- package/src/wsl_env.js +171 -0
- package/stream_proxy.js +453 -0
package/bin/castor.js
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Castor CLI entrypoint (packaged as the `castor` bin).
|
|
4
|
+
*
|
|
5
|
+
* Subcommands:
|
|
6
|
+
* castor [mcp] Start the MCP server on stdio (default).
|
|
7
|
+
* castor init Initialize ~/.castor config (prompts, or --yes for defaults).
|
|
8
|
+
* castor install Register the Castor MCP server with Antigravity / Claude Code.
|
|
9
|
+
* castor config Show or edit configuration.
|
|
10
|
+
* castor status Show engine / task status.
|
|
11
|
+
* castor clean Prune old sessions / orphan temp files from the state dir.
|
|
12
|
+
*
|
|
13
|
+
* Stdio purity: in MCP mode NOTHING is written to stdout except JSON-RPC
|
|
14
|
+
* frames emitted by the MCP transport. All diagnostics go to stderr.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
import readline from "node:readline";
|
|
23
|
+
import { QWEN_STATE_DIR, getEngineConfig } from "../src/config.js";
|
|
24
|
+
|
|
25
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const REPO_ROOT = path.resolve(__dirname, "..");
|
|
27
|
+
// Direct script path used by `install --dev` (the MCP server entrypoint).
|
|
28
|
+
const INDEX_JS = path.join(REPO_ROOT, "index.js");
|
|
29
|
+
|
|
30
|
+
const USAGE = `Usage: castor [command]
|
|
31
|
+
|
|
32
|
+
Commands:
|
|
33
|
+
mcp Start the MCP server on stdio (default)
|
|
34
|
+
init Initialize the ~/.castor config (prompts, or --yes for defaults)
|
|
35
|
+
install Register the Castor MCP server with a host
|
|
36
|
+
install [--antigravity] [--claude] [--all] [--dev]
|
|
37
|
+
--antigravity Write tool schemas + merge mcp_config.json (Antigravity IDE)
|
|
38
|
+
--claude Register with Claude Code (claude mcp add)
|
|
39
|
+
--all Both (default when no host flag is given)
|
|
40
|
+
--dev Use the local script path instead of the published package
|
|
41
|
+
config Show or edit configuration
|
|
42
|
+
config path Print path to config.json
|
|
43
|
+
config get [key] Print a config value (or full JSON if no key)
|
|
44
|
+
config set <k> <v> Set a config key (model, baseURL, max_context,
|
|
45
|
+
launch_command, tool_prefix, api_key, engine_type,
|
|
46
|
+
stop_command, engine_log_path, use_stream_proxy,
|
|
47
|
+
stream_proxy_port, status_port, vllm_port, search)
|
|
48
|
+
config edit Open config in $EDITOR (or notepad/vi)
|
|
49
|
+
status Show engine and task status
|
|
50
|
+
clean Prune old sessions and orphan .tmp_* files from the state dir
|
|
51
|
+
clean [--dry-run] [--max-age-days N] [--max-sessions N] [--max-size-mb N]
|
|
52
|
+
--dry-run Report what would be reclaimed without deleting
|
|
53
|
+
--max-age-days N Evict sessions older than N days (default: 14)
|
|
54
|
+
--max-sessions N Keep at most N sessions (default: 200)
|
|
55
|
+
--max-size-mb N Keep total session size under N MB (default: 50)
|
|
56
|
+
|
|
57
|
+
Run 'castor mcp' (or just 'castor') to start the MCP server.
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// config subcommand
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const CONFIG_FILE = path.join(QWEN_STATE_DIR, "config.json");
|
|
65
|
+
const VALID_KEYS = new Set([
|
|
66
|
+
"model",
|
|
67
|
+
"baseURL",
|
|
68
|
+
"max_context",
|
|
69
|
+
"launch_command",
|
|
70
|
+
"tool_prefix",
|
|
71
|
+
"api_key",
|
|
72
|
+
"engine_type",
|
|
73
|
+
"stop_command",
|
|
74
|
+
"engine_log_path",
|
|
75
|
+
"use_stream_proxy",
|
|
76
|
+
"stream_proxy_port",
|
|
77
|
+
"status_port",
|
|
78
|
+
"vllm_port",
|
|
79
|
+
"search",
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
function readConfig() {
|
|
83
|
+
try {
|
|
84
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
85
|
+
const raw = fs.readFileSync(CONFIG_FILE, "utf8").replace(/^\uFEFF/, "");
|
|
86
|
+
const parsed = JSON.parse(raw);
|
|
87
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
process.stderr.write(`[config] Corrupt ${CONFIG_FILE}: ${err.message}\n`);
|
|
91
|
+
}
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function writeConfig(cfg) {
|
|
96
|
+
fs.mkdirSync(QWEN_STATE_DIR, { recursive: true });
|
|
97
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function configPath() {
|
|
101
|
+
process.stdout.write(CONFIG_FILE + "\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function configGet(key) {
|
|
105
|
+
const cfg = readConfig();
|
|
106
|
+
if (key === undefined) {
|
|
107
|
+
process.stdout.write(JSON.stringify(cfg, null, 2) + "\n");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (!VALID_KEYS.has(key)) {
|
|
111
|
+
process.stderr.write(`Unknown config key: ${key}\n`);
|
|
112
|
+
process.stderr.write(`Valid keys: ${[...VALID_KEYS].join(", ")}\n`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
const val = cfg[key];
|
|
116
|
+
if (val === undefined) {
|
|
117
|
+
process.stdout.write("(not set)\n");
|
|
118
|
+
} else {
|
|
119
|
+
process.stdout.write(String(val) + "\n");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function configSet(key, val) {
|
|
124
|
+
if (!VALID_KEYS.has(key)) {
|
|
125
|
+
process.stderr.write(`Unknown config key: ${key}\n`);
|
|
126
|
+
process.stderr.write(`Valid keys: ${[...VALID_KEYS].join(", ")}\n`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
const cfg = readConfig();
|
|
130
|
+
if (key === "max_context" || key === "stream_proxy_port" || key === "status_port" || key === "vllm_port") {
|
|
131
|
+
const num = Number(val);
|
|
132
|
+
if (!Number.isFinite(num) || num <= 0) {
|
|
133
|
+
process.stderr.write(
|
|
134
|
+
`Invalid value for ${key}: ${val} (must be a positive number)\n`
|
|
135
|
+
);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
cfg[key] = num;
|
|
139
|
+
} else if (key === "use_stream_proxy") {
|
|
140
|
+
cfg[key] = val === "true" || val === "1";
|
|
141
|
+
} else {
|
|
142
|
+
cfg[key] = val;
|
|
143
|
+
}
|
|
144
|
+
writeConfig(cfg);
|
|
145
|
+
const isNumeric = key === "max_context" || key === "stream_proxy_port" || key === "status_port" || key === "vllm_port";
|
|
146
|
+
const isBoolean = key === "use_stream_proxy";
|
|
147
|
+
const display = isNumeric || isBoolean ? String(cfg[key]) : `"${cfg[key]}"`;
|
|
148
|
+
process.stdout.write(`Set ${key} = ${display}\n`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function configEdit() {
|
|
152
|
+
const isWindows = process.platform === "win32";
|
|
153
|
+
const editor = process.env.EDITOR || (isWindows ? "notepad" : "vi");
|
|
154
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
155
|
+
writeConfig({});
|
|
156
|
+
}
|
|
157
|
+
const result = spawnSync(editor, [CONFIG_FILE], { stdio: "inherit" });
|
|
158
|
+
if (result.error) {
|
|
159
|
+
process.stderr.write(`Failed to open editor: ${result.error.message}\n`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function handleConfig(args) {
|
|
165
|
+
const sub = args[0];
|
|
166
|
+
if (sub === "path") {
|
|
167
|
+
configPath();
|
|
168
|
+
} else if (sub === "get") {
|
|
169
|
+
configGet(args[1]);
|
|
170
|
+
} else if (sub === "set") {
|
|
171
|
+
if (args.length < 3) {
|
|
172
|
+
process.stderr.write("Usage: castor config set <key> <value>\n");
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
configSet(args[1], args[2]);
|
|
176
|
+
} else if (sub === "edit") {
|
|
177
|
+
configEdit();
|
|
178
|
+
} else {
|
|
179
|
+
process.stderr.write(
|
|
180
|
+
"Usage: castor config [path|get [key]|set <key> <val>|edit]\n"
|
|
181
|
+
);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// install subcommand
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Base home directory for the .gemini config tree. Overridable via CASTOR_HOME
|
|
192
|
+
* for test isolation (defaults to the OS user home).
|
|
193
|
+
*/
|
|
194
|
+
function castorHome() {
|
|
195
|
+
return process.env.CASTOR_HOME || os.homedir();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Path to the Antigravity/Gemini mcp_config.json (overridable for tests). */
|
|
199
|
+
function mcpConfigPath() {
|
|
200
|
+
return (
|
|
201
|
+
process.env.CASTOR_MCP_CONFIG ||
|
|
202
|
+
path.join(castorHome(), ".gemini", "config", "mcp_config.json")
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Directory where Antigravity auto-discovers the Castor MCP tool schemas
|
|
208
|
+
* (overridable for tests). Defaults to ~/.gemini/antigravity-ide/mcp/castor/.
|
|
209
|
+
*/
|
|
210
|
+
function antigravityMcpDir() {
|
|
211
|
+
return (
|
|
212
|
+
process.env.CASTOR_ANTIGRAVITY_MCP_DIR ||
|
|
213
|
+
path.join(castorHome(), ".gemini", "antigravity-ide", "mcp", "castor")
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Build the mcp_config.json server spec for the Castor MCP server.
|
|
219
|
+
*
|
|
220
|
+
* On Windows, `npx` must be invoked through `cmd.exe /c`; on POSIX, `npx` is
|
|
221
|
+
* invoked directly. With `--dev`, the local index.js script path is used
|
|
222
|
+
* instead of the published `mcp-castor` package.
|
|
223
|
+
*
|
|
224
|
+
* @param {boolean} dev
|
|
225
|
+
* @returns {{ command: string, args: string[] }}
|
|
226
|
+
*/
|
|
227
|
+
function buildMcpServerSpec(dev) {
|
|
228
|
+
const isWin = process.platform === "win32";
|
|
229
|
+
if (dev) {
|
|
230
|
+
return isWin
|
|
231
|
+
? { command: "cmd.exe", args: ["/c", "node", INDEX_JS] }
|
|
232
|
+
: { command: "node", args: [INDEX_JS] };
|
|
233
|
+
}
|
|
234
|
+
return isWin
|
|
235
|
+
? { command: "cmd.exe", args: ["/c", "npx", "-y", "mcp-castor"] }
|
|
236
|
+
: { command: "npx", args: ["-y", "mcp-castor"] };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Build the server spec for `claude mcp add`. Claude Code spawns the command
|
|
241
|
+
* directly (no `cmd.exe` wrapper needed), so this is simpler than the
|
|
242
|
+
* mcp_config.json spec.
|
|
243
|
+
*
|
|
244
|
+
* @param {boolean} dev
|
|
245
|
+
* @returns {{ command: string, args: string[] }}
|
|
246
|
+
*/
|
|
247
|
+
function buildClaudeServerSpec(dev) {
|
|
248
|
+
if (dev) return { command: "node", args: [INDEX_JS] };
|
|
249
|
+
return { command: "npx", args: ["-y", "mcp-castor"] };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Read the existing mcp_config.json (if present), merge the Castor server spec
|
|
254
|
+
* into `mcpServers.castor` (preserving ALL sibling servers such as `treemap`),
|
|
255
|
+
* and write it back. Returns the merged config object.
|
|
256
|
+
*
|
|
257
|
+
* @param {{ command: string, args: string[] }} serverSpec
|
|
258
|
+
* @returns {object}
|
|
259
|
+
*/
|
|
260
|
+
function mergeMcpConfig(serverSpec) {
|
|
261
|
+
const cfgPath = mcpConfigPath();
|
|
262
|
+
let cfg = {};
|
|
263
|
+
if (fs.existsSync(cfgPath)) {
|
|
264
|
+
try {
|
|
265
|
+
const raw = fs.readFileSync(cfgPath, "utf8").replace(/^\uFEFF/, "");
|
|
266
|
+
const parsed = JSON.parse(raw);
|
|
267
|
+
if (parsed && typeof parsed === "object") cfg = parsed;
|
|
268
|
+
} catch (err) {
|
|
269
|
+
process.stderr.write(
|
|
270
|
+
`[install] Corrupt ${cfgPath}: ${err.message}; starting fresh.\n`
|
|
271
|
+
);
|
|
272
|
+
cfg = {};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (!cfg.mcpServers || typeof cfg.mcpServers !== "object") {
|
|
276
|
+
cfg.mcpServers = {};
|
|
277
|
+
}
|
|
278
|
+
cfg.mcpServers.castor = serverSpec;
|
|
279
|
+
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
280
|
+
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
281
|
+
return cfg;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Write the tool JSON schemas (from getToolManifest()) into the Antigravity
|
|
286
|
+
* MCP schema directory. Each tool becomes `<name>.json` with
|
|
287
|
+
* `{ name, description, parameters }` (the same shape update_schemas.py
|
|
288
|
+
* emits, so the schema_parity test can consume it). Stale `.json` files in
|
|
289
|
+
* the directory are removed first so a renamed/removed tool does not linger.
|
|
290
|
+
*
|
|
291
|
+
* @returns {Promise<Array<{name: string, description: string, inputSchema: object}>>}
|
|
292
|
+
*/
|
|
293
|
+
async function writeAntigravitySchemas() {
|
|
294
|
+
const { getToolManifest } = await import("../src/tools.js");
|
|
295
|
+
const manifest = getToolManifest();
|
|
296
|
+
const dir = antigravityMcpDir();
|
|
297
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
298
|
+
for (const f of fs.readdirSync(dir)) {
|
|
299
|
+
if (f.endsWith(".json")) {
|
|
300
|
+
try {
|
|
301
|
+
fs.rmSync(path.join(dir, f));
|
|
302
|
+
} catch {}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
for (const tool of manifest) {
|
|
306
|
+
const payload = {
|
|
307
|
+
name: tool.name,
|
|
308
|
+
description: tool.description,
|
|
309
|
+
parameters: tool.inputSchema,
|
|
310
|
+
};
|
|
311
|
+
const p = path.join(dir, `${tool.name}.json`);
|
|
312
|
+
fs.writeFileSync(p, JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
313
|
+
process.stdout.write(`[install] Wrote ${p}\n`);
|
|
314
|
+
}
|
|
315
|
+
return manifest;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Antigravity install: write the tool schemas, then merge the Castor server
|
|
320
|
+
* spec into mcp_config.json (preserving sibling servers).
|
|
321
|
+
*
|
|
322
|
+
* @param {boolean} dev
|
|
323
|
+
*/
|
|
324
|
+
async function installAntigravity(dev) {
|
|
325
|
+
process.stdout.write("[install] Antigravity IDE...\n");
|
|
326
|
+
await writeAntigravitySchemas();
|
|
327
|
+
const spec = buildMcpServerSpec(dev);
|
|
328
|
+
const cfg = mergeMcpConfig(spec);
|
|
329
|
+
const names = Object.keys(cfg.mcpServers);
|
|
330
|
+
process.stdout.write(
|
|
331
|
+
`[install] Merged mcpServers.castor into ${mcpConfigPath()} (servers: ${names.join(", ")})\n`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Claude Code install: run `claude mcp add castor -- <command> <args...>`.
|
|
337
|
+
* The `claude` binary is overridable via CASTOR_CLAUDE_CMD for tests. If the
|
|
338
|
+
* CLI is unavailable or the command fails, a manual command is printed and
|
|
339
|
+
* false is returned (non-fatal).
|
|
340
|
+
*
|
|
341
|
+
* @param {boolean} dev
|
|
342
|
+
* @returns {boolean}
|
|
343
|
+
*/
|
|
344
|
+
function installClaude(dev) {
|
|
345
|
+
process.stdout.write("[install] Claude Code...\n");
|
|
346
|
+
const spec = buildClaudeServerSpec(dev);
|
|
347
|
+
const claudeCmd = process.env.CASTOR_CLAUDE_CMD || "claude";
|
|
348
|
+
const args = ["mcp", "add", "castor", "--", spec.command, ...spec.args];
|
|
349
|
+
// On Windows the `claude` CLI is a .cmd shim that must be resolved through
|
|
350
|
+
// the shell; on POSIX it is a regular executable.
|
|
351
|
+
const res = spawnSync(claudeCmd, args, {
|
|
352
|
+
encoding: "utf8",
|
|
353
|
+
timeout: 15000,
|
|
354
|
+
shell: process.platform === "win32",
|
|
355
|
+
});
|
|
356
|
+
if (res.error) {
|
|
357
|
+
process.stderr.write(
|
|
358
|
+
`[install] claude CLI unavailable (${res.error.message}); run manually: ${claudeCmd} mcp add castor -- ${spec.command} ${spec.args.join(" ")}\n`
|
|
359
|
+
);
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
if (res.status !== 0) {
|
|
363
|
+
process.stderr.write(
|
|
364
|
+
`[install] claude mcp add failed (exit ${res.status}): ${(res.stderr || "").trim()}\n`
|
|
365
|
+
);
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
process.stdout.write("[install] Registered castor with Claude Code.\n");
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Parse `install` flags. Unknown flags are a hard error (exit 1).
|
|
374
|
+
*
|
|
375
|
+
* @param {string[]} args
|
|
376
|
+
* @returns {{ antigravity: boolean, claude: boolean, all: boolean, dev: boolean }}
|
|
377
|
+
*/
|
|
378
|
+
function parseInstallFlags(args) {
|
|
379
|
+
const flags = { antigravity: false, claude: false, all: false, dev: false };
|
|
380
|
+
for (const a of args) {
|
|
381
|
+
if (a === "--antigravity") flags.antigravity = true;
|
|
382
|
+
else if (a === "--claude") flags.claude = true;
|
|
383
|
+
else if (a === "--all") flags.all = true;
|
|
384
|
+
else if (a === "--dev") flags.dev = true;
|
|
385
|
+
else {
|
|
386
|
+
process.stderr.write(`[install] Unknown flag: ${a}\n`);
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return flags;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Orchestrate the install across the requested hosts. With no host flag,
|
|
395
|
+
* defaults to both (equivalent to --all).
|
|
396
|
+
*
|
|
397
|
+
* @param {{ antigravity: boolean, claude: boolean, all: boolean, dev: boolean }} flags
|
|
398
|
+
* @returns {Promise<boolean>}
|
|
399
|
+
*/
|
|
400
|
+
async function runInstall(flags) {
|
|
401
|
+
const targets = [];
|
|
402
|
+
if (flags.all) {
|
|
403
|
+
targets.push("antigravity", "claude");
|
|
404
|
+
} else {
|
|
405
|
+
if (flags.antigravity) targets.push("antigravity");
|
|
406
|
+
if (flags.claude) targets.push("claude");
|
|
407
|
+
}
|
|
408
|
+
if (targets.length === 0) {
|
|
409
|
+
// No host flag: default to both.
|
|
410
|
+
targets.push("antigravity", "claude");
|
|
411
|
+
}
|
|
412
|
+
let ok = true;
|
|
413
|
+
for (const t of targets) {
|
|
414
|
+
if (t === "antigravity") {
|
|
415
|
+
try {
|
|
416
|
+
await installAntigravity(flags.dev);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
process.stderr.write(`[install] Antigravity failed: ${err.message}\n`);
|
|
419
|
+
ok = false;
|
|
420
|
+
}
|
|
421
|
+
} else if (t === "claude") {
|
|
422
|
+
if (!installClaude(flags.dev)) ok = false;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
process.stdout.write(ok ? "[install] Done.\n" : "[install] Completed with warnings.\n");
|
|
426
|
+
return ok;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function handleInstall(args) {
|
|
430
|
+
const flags = parseInstallFlags(args);
|
|
431
|
+
await runInstall(flags);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// init subcommand
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
// Piped-stdin line reader. When stdin is a pipe (not a TTY) — e.g. in tests or
|
|
439
|
+
// when the user pipes answers — readline.question() is unreliable (it can
|
|
440
|
+
// close after the first line). Instead we buffer all of stdin once and hand
|
|
441
|
+
// out lines in order. For a real TTY we fall back to readline.question().
|
|
442
|
+
let _pipedLines = null;
|
|
443
|
+
let _pipedDone = false;
|
|
444
|
+
function _ensurePipedLines() {
|
|
445
|
+
if (_pipedLines !== null) return;
|
|
446
|
+
_pipedLines = [];
|
|
447
|
+
if (process.stdin.isTTY) {
|
|
448
|
+
_pipedDone = true;
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
let buf = "";
|
|
452
|
+
process.stdin.setEncoding("utf8");
|
|
453
|
+
process.stdin.on("data", (chunk) => {
|
|
454
|
+
buf += chunk;
|
|
455
|
+
let idx;
|
|
456
|
+
while ((idx = buf.indexOf("\n")) !== -1) {
|
|
457
|
+
_pipedLines.push(buf.slice(0, idx));
|
|
458
|
+
buf = buf.slice(idx + 1);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
process.stdin.on("end", () => {
|
|
462
|
+
if (buf.length > 0) _pipedLines.push(buf);
|
|
463
|
+
_pipedDone = true;
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Read a single line from stdin (interactive prompt).
|
|
469
|
+
*
|
|
470
|
+
* @param {string} question
|
|
471
|
+
* @returns {Promise<string>}
|
|
472
|
+
*/
|
|
473
|
+
function promptLine(question) {
|
|
474
|
+
process.stdout.write(question);
|
|
475
|
+
if (process.stdin.isTTY) {
|
|
476
|
+
return new Promise((resolve) => {
|
|
477
|
+
const rl = readline.createInterface({
|
|
478
|
+
input: process.stdin,
|
|
479
|
+
output: process.stdout,
|
|
480
|
+
});
|
|
481
|
+
rl.question("", (answer) => {
|
|
482
|
+
rl.close();
|
|
483
|
+
resolve(answer);
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
// Piped: wait until at least one line is available (or stdin ends).
|
|
488
|
+
return new Promise((resolve) => {
|
|
489
|
+
_ensurePipedLines();
|
|
490
|
+
const poll = () => {
|
|
491
|
+
if (_pipedLines.length > 0) {
|
|
492
|
+
resolve(_pipedLines.shift());
|
|
493
|
+
} else if (_pipedDone) {
|
|
494
|
+
resolve("");
|
|
495
|
+
} else {
|
|
496
|
+
setTimeout(poll, 5);
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
poll();
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Initialize the ~/.castor config. Prompts for baseURL, model, max_context,
|
|
505
|
+
* and tool_prefix (showing the current value as the default), saves them to
|
|
506
|
+
* config.json, then offers to run `castor install`. With `--yes`, writes the
|
|
507
|
+
* defaults non-interactively and auto-runs the install.
|
|
508
|
+
*
|
|
509
|
+
* @param {string[]} args
|
|
510
|
+
*/
|
|
511
|
+
async function handleInit(args) {
|
|
512
|
+
const yes = args.includes("--yes") || args.includes("-y");
|
|
513
|
+
const cfg = readConfig();
|
|
514
|
+
const engine = getEngineConfig();
|
|
515
|
+
const defaults = {
|
|
516
|
+
baseURL: engine.baseURL,
|
|
517
|
+
model: engine.model,
|
|
518
|
+
max_context: engine.max_context,
|
|
519
|
+
tool_prefix: engine.tool_prefix,
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
let values;
|
|
523
|
+
if (yes) {
|
|
524
|
+
values = { ...defaults };
|
|
525
|
+
process.stdout.write("[init] Writing defaults (non-interactive):\n");
|
|
526
|
+
for (const [k, v] of Object.entries(values)) {
|
|
527
|
+
process.stdout.write(` ${k} = ${JSON.stringify(v)}\n`);
|
|
528
|
+
}
|
|
529
|
+
} else {
|
|
530
|
+
values = {};
|
|
531
|
+
for (const [k, def] of Object.entries(defaults)) {
|
|
532
|
+
const answer = await promptLine(`${k} [${def}]: `);
|
|
533
|
+
values[k] = answer.trim() === "" ? def : answer.trim();
|
|
534
|
+
}
|
|
535
|
+
const num = Number(values.max_context);
|
|
536
|
+
if (!Number.isFinite(num) || num <= 0) {
|
|
537
|
+
process.stderr.write(`[init] Invalid max_context: ${values.max_context}\n`);
|
|
538
|
+
process.exit(1);
|
|
539
|
+
}
|
|
540
|
+
values.max_context = num;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Merge over any existing config (preserving other keys like search /
|
|
544
|
+
// launch_command).
|
|
545
|
+
const merged = { ...cfg, ...values };
|
|
546
|
+
writeConfig(merged);
|
|
547
|
+
process.stdout.write(`[init] Wrote ${CONFIG_FILE}\n`);
|
|
548
|
+
|
|
549
|
+
if (yes) {
|
|
550
|
+
process.stdout.write("[init] Running castor install --all...\n");
|
|
551
|
+
await runInstall({ all: true });
|
|
552
|
+
} else {
|
|
553
|
+
const answer = await promptLine("Run 'castor install' now? [y/N]: ");
|
|
554
|
+
if (answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes") {
|
|
555
|
+
await runInstall({ all: true });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// status subcommand
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
async function handleStatus() {
|
|
565
|
+
const cfg = getEngineConfig();
|
|
566
|
+
const stateDir = QWEN_STATE_DIR;
|
|
567
|
+
|
|
568
|
+
// Same offline discipline as tests/helpers/engine_probe.js: never probe
|
|
569
|
+
// the engine unless ALLOW_ENGINE_INTERRUPT=1 (avoids interrupting live
|
|
570
|
+
// workloads and keeps `castor status` deterministic in CI).
|
|
571
|
+
const probeDisabled =
|
|
572
|
+
process.env.TEST_OFFLINE === "1" ||
|
|
573
|
+
process.env.ALLOW_ENGINE_INTERRUPT !== "1";
|
|
574
|
+
let engineReachable = null; // null = probe skipped (unknown)
|
|
575
|
+
let probeDetail = "skipped (ALLOW_ENGINE_INTERRUPT=0)";
|
|
576
|
+
if (!probeDisabled) {
|
|
577
|
+
try {
|
|
578
|
+
const res = await fetch(cfg.baseURL, {
|
|
579
|
+
signal: AbortSignal.timeout(2000),
|
|
580
|
+
});
|
|
581
|
+
engineReachable = true;
|
|
582
|
+
probeDetail = `HTTP ${res.status}`;
|
|
583
|
+
} catch (err) {
|
|
584
|
+
engineReachable = false;
|
|
585
|
+
probeDetail =
|
|
586
|
+
err.name === "TimeoutError" || err.name === "AbortError"
|
|
587
|
+
? "timeout (2s)"
|
|
588
|
+
: err.message;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const lines = [
|
|
593
|
+
"Castor Status",
|
|
594
|
+
"=============",
|
|
595
|
+
`Engine: ${cfg.model}`,
|
|
596
|
+
`Base URL: ${cfg.baseURL}`,
|
|
597
|
+
`Max Context: ${cfg.max_context} tokens`,
|
|
598
|
+
`Tool Prefix: ${cfg.tool_prefix}`,
|
|
599
|
+
`State Dir: ${stateDir}`,
|
|
600
|
+
`Engine Up: ${
|
|
601
|
+
engineReachable === null ? "UNKNOWN" : engineReachable ? "YES" : "NO"
|
|
602
|
+
} (${probeDetail})`,
|
|
603
|
+
];
|
|
604
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ---------------------------------------------------------------------------
|
|
608
|
+
// clean subcommand
|
|
609
|
+
// ---------------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
async function handleClean(argv) {
|
|
612
|
+
const dryRun = argv.includes("--dry-run");
|
|
613
|
+
const flagVal = (name) => {
|
|
614
|
+
const i = argv.indexOf(name);
|
|
615
|
+
return i === -1 ? undefined : argv[i + 1];
|
|
616
|
+
};
|
|
617
|
+
const maxAgeDays = flagVal("--max-age-days");
|
|
618
|
+
const maxSessions = flagVal("--max-sessions");
|
|
619
|
+
const maxSizeMb = flagVal("--max-size-mb");
|
|
620
|
+
|
|
621
|
+
const opts = { dryRun };
|
|
622
|
+
if (maxAgeDays !== undefined) {
|
|
623
|
+
const n = Number(maxAgeDays);
|
|
624
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
625
|
+
process.stderr.write(`Invalid --max-age-days: ${maxAgeDays}\n`);
|
|
626
|
+
process.exit(1);
|
|
627
|
+
}
|
|
628
|
+
opts.maxAgeMs = n * 86_400_000;
|
|
629
|
+
}
|
|
630
|
+
if (maxSessions !== undefined) {
|
|
631
|
+
const n = Number(maxSessions);
|
|
632
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
633
|
+
process.stderr.write(`Invalid --max-sessions: ${maxSessions}\n`);
|
|
634
|
+
process.exit(1);
|
|
635
|
+
}
|
|
636
|
+
opts.maxCount = n;
|
|
637
|
+
}
|
|
638
|
+
if (maxSizeMb !== undefined) {
|
|
639
|
+
const n = Number(maxSizeMb);
|
|
640
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
641
|
+
process.stderr.write(`Invalid --max-size-mb: ${maxSizeMb}\n`);
|
|
642
|
+
process.exit(1);
|
|
643
|
+
}
|
|
644
|
+
opts.maxSizeMb = n;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const { cleanStateDir } = await import("../src/state_pruner.js");
|
|
648
|
+
const result = cleanStateDir(opts);
|
|
649
|
+
|
|
650
|
+
const mb = (b) => (b / (1024 * 1024)).toFixed(2);
|
|
651
|
+
const prefix = dryRun ? "[dry-run] would" : "";
|
|
652
|
+
process.stdout.write(
|
|
653
|
+
`${prefix} prune ${result.sessionsPruned} session(s) ` +
|
|
654
|
+
`(${mb(result.bytesReclaimed)} MB), ` +
|
|
655
|
+
`${prefix} clean ${result.tmpFilesCleaned} orphan .tmp_* file(s) ` +
|
|
656
|
+
`(${mb(result.tmpBytesReclaimed)} MB); ` +
|
|
657
|
+
`${result.protected} session(s) protected (active/incomplete)\n`
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ---------------------------------------------------------------------------
|
|
662
|
+
// main dispatch
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
|
|
665
|
+
const subcommand = process.argv[2];
|
|
666
|
+
|
|
667
|
+
if (subcommand === undefined || subcommand === "mcp") {
|
|
668
|
+
// MCP mode: stdio purity — no stdout logging, ever.
|
|
669
|
+
// Lazy import so `config`/`status`/`install`/`init` never load the MCP SDK.
|
|
670
|
+
const { startMcpServer } = await import("../index.js");
|
|
671
|
+
startMcpServer().catch((err) => {
|
|
672
|
+
process.stderr.write(
|
|
673
|
+
`MCP Server Fatal Error: ${err && err.stack ? err.stack : err}\n`
|
|
674
|
+
);
|
|
675
|
+
process.exit(1);
|
|
676
|
+
});
|
|
677
|
+
} else if (subcommand === "-h" || subcommand === "--help" || subcommand === "help") {
|
|
678
|
+
process.stderr.write(USAGE);
|
|
679
|
+
process.exit(0);
|
|
680
|
+
} else if (subcommand === "config") {
|
|
681
|
+
handleConfig(process.argv.slice(3));
|
|
682
|
+
} else if (subcommand === "status") {
|
|
683
|
+
handleStatus().catch((err) => {
|
|
684
|
+
process.stderr.write(`Status error: ${err.message}\n`);
|
|
685
|
+
process.exit(1);
|
|
686
|
+
});
|
|
687
|
+
} else if (subcommand === "init") {
|
|
688
|
+
handleInit(process.argv.slice(3)).catch((err) => {
|
|
689
|
+
process.stderr.write(`Init error: ${err.message}\n`);
|
|
690
|
+
process.exit(1);
|
|
691
|
+
});
|
|
692
|
+
} else if (subcommand === "install") {
|
|
693
|
+
handleInstall(process.argv.slice(3)).catch((err) => {
|
|
694
|
+
process.stderr.write(`Install error: ${err.message}\n`);
|
|
695
|
+
process.exit(1);
|
|
696
|
+
});
|
|
697
|
+
} else if (subcommand === "clean") {
|
|
698
|
+
handleClean(process.argv.slice(3)).catch((err) => {
|
|
699
|
+
process.stderr.write(`Clean error: ${err.message}\n`);
|
|
700
|
+
process.exit(1);
|
|
701
|
+
});
|
|
702
|
+
} else {
|
|
703
|
+
process.stderr.write(`Unknown command: ${subcommand}\n\n`);
|
|
704
|
+
process.stderr.write(USAGE);
|
|
705
|
+
process.exit(1);
|
|
706
|
+
}
|