@claudexor/cli 1.0.1

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.
Files changed (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -0
  3. package/dist/args.d.ts +12 -0
  4. package/dist/args.d.ts.map +1 -0
  5. package/dist/args.js +88 -0
  6. package/dist/args.js.map +1 -0
  7. package/dist/attachment-resolver.d.ts +11 -0
  8. package/dist/attachment-resolver.d.ts.map +1 -0
  9. package/dist/attachment-resolver.js +61 -0
  10. package/dist/attachment-resolver.js.map +1 -0
  11. package/dist/capabilities.d.ts +13 -0
  12. package/dist/capabilities.d.ts.map +1 -0
  13. package/dist/capabilities.js +90 -0
  14. package/dist/capabilities.js.map +1 -0
  15. package/dist/claudexord.d.ts +3 -0
  16. package/dist/claudexord.d.ts.map +1 -0
  17. package/dist/claudexord.js +564 -0
  18. package/dist/claudexord.js.map +1 -0
  19. package/dist/cli-io.d.ts +28 -0
  20. package/dist/cli-io.d.ts.map +1 -0
  21. package/dist/cli-io.js +37 -0
  22. package/dist/cli-io.js.map +1 -0
  23. package/dist/cli.d.ts +3 -0
  24. package/dist/cli.d.ts.map +1 -0
  25. package/dist/cli.js +1459 -0
  26. package/dist/cli.js.map +1 -0
  27. package/dist/command-registry.d.ts +105 -0
  28. package/dist/command-registry.d.ts.map +1 -0
  29. package/dist/command-registry.js +516 -0
  30. package/dist/command-registry.js.map +1 -0
  31. package/dist/daemon-lifecycle.d.ts +18 -0
  32. package/dist/daemon-lifecycle.d.ts.map +1 -0
  33. package/dist/daemon-lifecycle.js +62 -0
  34. package/dist/daemon-lifecycle.js.map +1 -0
  35. package/dist/daemon-run.d.ts +86 -0
  36. package/dist/daemon-run.d.ts.map +1 -0
  37. package/dist/daemon-run.js +269 -0
  38. package/dist/daemon-run.js.map +1 -0
  39. package/dist/decision.d.ts +18 -0
  40. package/dist/decision.d.ts.map +1 -0
  41. package/dist/decision.js +46 -0
  42. package/dist/decision.js.map +1 -0
  43. package/dist/live.d.ts +26 -0
  44. package/dist/live.d.ts.map +1 -0
  45. package/dist/live.js +311 -0
  46. package/dist/live.js.map +1 -0
  47. package/dist/mcp-runner.d.ts +41 -0
  48. package/dist/mcp-runner.d.ts.map +1 -0
  49. package/dist/mcp-runner.js +306 -0
  50. package/dist/mcp-runner.js.map +1 -0
  51. package/dist/ops-commands.d.ts +11 -0
  52. package/dist/ops-commands.d.ts.map +1 -0
  53. package/dist/ops-commands.js +358 -0
  54. package/dist/ops-commands.js.map +1 -0
  55. package/dist/orchestrate-options.d.ts +9 -0
  56. package/dist/orchestrate-options.d.ts.map +1 -0
  57. package/dist/orchestrate-options.js +16 -0
  58. package/dist/orchestrate-options.js.map +1 -0
  59. package/dist/orphan-reaper.d.ts +9 -0
  60. package/dist/orphan-reaper.d.ts.map +1 -0
  61. package/dist/orphan-reaper.js +97 -0
  62. package/dist/orphan-reaper.js.map +1 -0
  63. package/dist/orphan-sweeper.d.ts +7 -0
  64. package/dist/orphan-sweeper.d.ts.map +1 -0
  65. package/dist/orphan-sweeper.js +289 -0
  66. package/dist/orphan-sweeper.js.map +1 -0
  67. package/dist/plugins.d.ts +44 -0
  68. package/dist/plugins.d.ts.map +1 -0
  69. package/dist/plugins.js +1383 -0
  70. package/dist/plugins.js.map +1 -0
  71. package/dist/primary-output.d.ts +14 -0
  72. package/dist/primary-output.d.ts.map +1 -0
  73. package/dist/primary-output.js +34 -0
  74. package/dist/primary-output.js.map +1 -0
  75. package/dist/registry.d.ts +26 -0
  76. package/dist/registry.d.ts.map +1 -0
  77. package/dist/registry.js +71 -0
  78. package/dist/registry.js.map +1 -0
  79. package/dist/release.d.ts +14 -0
  80. package/dist/release.d.ts.map +1 -0
  81. package/dist/release.js +49 -0
  82. package/dist/release.js.map +1 -0
  83. package/dist/repl.d.ts +26 -0
  84. package/dist/repl.d.ts.map +1 -0
  85. package/dist/repl.js +277 -0
  86. package/dist/repl.js.map +1 -0
  87. package/dist/review-command.d.ts +3 -0
  88. package/dist/review-command.d.ts.map +1 -0
  89. package/dist/review-command.js +83 -0
  90. package/dist/review-command.js.map +1 -0
  91. package/dist/reviewer-options.d.ts +18 -0
  92. package/dist/reviewer-options.d.ts.map +1 -0
  93. package/dist/reviewer-options.js +110 -0
  94. package/dist/reviewer-options.js.map +1 -0
  95. package/dist/run-options.d.ts +8 -0
  96. package/dist/run-options.d.ts.map +1 -0
  97. package/dist/run-options.js +50 -0
  98. package/dist/run-options.js.map +1 -0
  99. package/dist/run-secret-scan.d.ts +2 -0
  100. package/dist/run-secret-scan.d.ts.map +1 -0
  101. package/dist/run-secret-scan.js +5 -0
  102. package/dist/run-secret-scan.js.map +1 -0
  103. package/dist/settings-command.d.ts +3 -0
  104. package/dist/settings-command.d.ts.map +1 -0
  105. package/dist/settings-command.js +202 -0
  106. package/dist/settings-command.js.map +1 -0
  107. package/dist/settings-display.d.ts +3 -0
  108. package/dist/settings-display.d.ts.map +1 -0
  109. package/dist/settings-display.js +33 -0
  110. package/dist/settings-display.js.map +1 -0
  111. package/dist/settings-service.d.ts +15 -0
  112. package/dist/settings-service.d.ts.map +1 -0
  113. package/dist/settings-service.js +91 -0
  114. package/dist/settings-service.js.map +1 -0
  115. package/dist/setup-jobs.d.ts +28 -0
  116. package/dist/setup-jobs.d.ts.map +1 -0
  117. package/dist/setup-jobs.js +477 -0
  118. package/dist/setup-jobs.js.map +1 -0
  119. package/dist/spec.d.ts +84 -0
  120. package/dist/spec.d.ts.map +1 -0
  121. package/dist/spec.js +438 -0
  122. package/dist/spec.js.map +1 -0
  123. package/dist/trust-command.d.ts +3 -0
  124. package/dist/trust-command.d.ts.map +1 -0
  125. package/dist/trust-command.js +80 -0
  126. package/dist/trust-command.js.map +1 -0
  127. package/dist/trust-services.d.ts +7 -0
  128. package/dist/trust-services.d.ts.map +1 -0
  129. package/dist/trust-services.js +35 -0
  130. package/dist/trust-services.js.map +1 -0
  131. package/package.json +59 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1459 @@
1
+ #!/usr/bin/env node
2
+ import process from "node:process";
3
+ import { existsSync, lstatSync, readdirSync } from "node:fs";
4
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
5
+ import { Orchestrator } from "@claudexor/orchestrator";
6
+ import { ArtifactStore } from "@claudexor/artifact-store";
7
+ import { DELIVER_MODES, checkPatch, deliver, validateApplyGate, } from "@claudexor/delivery";
8
+ import { CLAUDEXOR_VERSION, containsSecretLikeToken, ensureDir, newId, noProjectRepoRoot, readTextSafe, userConfigDir, writeJson, } from "@claudexor/util";
9
+ import { checkName } from "./release.js";
10
+ import { defaultClaudexorTools, serveClaudexorMcp } from "@claudexor/mcp-server";
11
+ import { AcpServer } from "@claudexor/acp-server";
12
+ import { initProjectConfig, loadConfig } from "@claudexor/config";
13
+ import { atRiskNodeAdvisory, validateModel } from "@claudexor/core";
14
+ import { DecisionRecord, EffortHint, ExternalContextPolicy, ModeKind as ModeKindSchema, Portfolio, RunTelemetry, TaskContract, WorkProduct, } from "@claudexor/schema";
15
+ import { flagBool, flagStr, flagStringList, flagValues, parseArgs, requiredStringFlagError, } from "./args.js";
16
+ import { authSourceAvailability, checksSummary, print, printJson, printUsageError, statusGlyph } from "./cli-io.js";
17
+ import { KNOWN_FLAGS, VALUE_FLAGS, commandFlagScopeError, helpJson, renderHelp } from "./command-registry.js";
18
+ import { buildAgentCapabilityCatalog } from "./capabilities.js";
19
+ import { authCommand, daemonCommand, modelsCommand, secretsCommand } from "./ops-commands.js";
20
+ import { reviewCommand } from "./review-command.js";
21
+ import { followRun, formatRunEventLine, promptQuestionsOnTty } from "./live.js";
22
+ import { assertCliRunParamsHaveNoInlineSecrets } from "./run-secret-scan.js";
23
+ import { connectDaemonIfRunning, daemonOutcomeSummary, ensureDaemon, enqueueAndAwait, exitCodeForState, fetchApplyEligibility, } from "./daemon-run.js";
24
+ import { resolveDecisionBody } from "./decision.js";
25
+ import { primaryOutputForCli } from "./primary-output.js";
26
+ import { PLUGIN_TARGETS, PLUGIN_VERBS, formatPluginResult, pluginCommandErrorResult, runPluginCommand, } from "./plugins.js";
27
+ import { buildGateway, buildRegistry, harnessModels } from "./registry.js";
28
+ import { mcpSurfaceRunner, orchestratorRunner } from "./mcp-runner.js";
29
+ import { settingsCommand } from "./settings-command.js";
30
+ import { trustCommand } from "./trust-command.js";
31
+ import { extractQuestionsFromPlan, freezeSpecFromGrounding, loadFrozenSpec, loadPreviousSpec, persistSpec, readAnswers, resolveRunTestCommands, } from "./spec.js";
32
+ import { parseAutonomy } from "./orchestrate-options.js";
33
+ import { runRepl } from "./repl.js";
34
+ import { parseProtectedPathApprovalFlags, parseTestCommandFlags, parseReviewerEffortFlags, parseReviewerModelFlags, parseReviewerPanelFlags, } from "./run-options.js";
35
+ // Single version SSOT: the generated CLAUDEXOR_VERSION constant (from the root
36
+ // package.json) so the banner / --version can never ship stale or drift.
37
+ const CLI_VERSION = CLAUDEXOR_VERSION;
38
+ // The help text is a rendered view of the command registry (the ONE owner of
39
+ // the CLI surface — command-registry.ts). Never hand-edit a help literal.
40
+ const HELP = renderHelp(CLI_VERSION);
41
+ const MODES = new Set(["ask", "plan", "audit", "agent", "orchestrate"]);
42
+ /** Canonical mode ids are single words; trim and validate against the schema. */
43
+ function normalizeMode(s) {
44
+ const trimmed = s.trim();
45
+ const parsed = ModeKindSchema.safeParse(trimmed);
46
+ if (!parsed.success)
47
+ return trimmed;
48
+ return parsed.data;
49
+ }
50
+ function harnessList(args) {
51
+ const values = flagStringList(args, "harness");
52
+ return values.length > 0 ? values : undefined;
53
+ }
54
+ /** Invalid numeric flag values FAIL LOUDLY: `--n abc` must never silently run with the default. */
55
+ function intFlag(args, key) {
56
+ const v = flagStr(args, key);
57
+ if (v === undefined)
58
+ return undefined;
59
+ const n = Number.parseInt(v, 10);
60
+ if (!Number.isFinite(n) || String(n) !== v.trim())
61
+ throw new Error(`invalid --${key} '${v}' (expected an integer)`);
62
+ return n;
63
+ }
64
+ function floatFlag(args, key) {
65
+ const v = flagStr(args, key);
66
+ if (v === undefined)
67
+ return undefined;
68
+ // Number() parses the WHOLE string ('1abc' -> NaN), unlike parseFloat.
69
+ const n = Number(v.trim());
70
+ if (!Number.isFinite(n) || n < 0 || v.trim() === "")
71
+ throw new Error(`invalid --${key} '${v}' (expected a non-negative number)`);
72
+ return n;
73
+ }
74
+ /** Deterministic gate commands from `--test "<cmd>"`; repeat flag or separate with `;;`. */
75
+ function testCommands(args) {
76
+ return parseTestCommandFlags(flagValues(args, "test"));
77
+ }
78
+ /** Typed approval for protected gate/test path changes; never inferred from prompt text. */
79
+ function protectedPathApprovals(args) {
80
+ return parseProtectedPathApprovalFlags(flagValues(args, "allow-protected-path"));
81
+ }
82
+ const ACCESS_PROFILES = new Set([
83
+ "readonly",
84
+ "workspace_write",
85
+ "full",
86
+ "external_sandbox_full",
87
+ "inherit_native",
88
+ ]);
89
+ /** Access profile from `--access`. Invalid profiles FAIL LOUDLY (a typo must never silently run with the default write profile). */
90
+ function accessProfile(args) {
91
+ const v = flagStr(args, "access");
92
+ if (v === undefined)
93
+ return undefined;
94
+ if (!ACCESS_PROFILES.has(v)) {
95
+ throw new Error(`invalid --access '${v}' (expected readonly|workspace_write|full|external_sandbox_full|inherit_native)`);
96
+ }
97
+ return v;
98
+ }
99
+ function effortHint(args) {
100
+ const v = flagStr(args, "effort");
101
+ if (v === undefined)
102
+ return undefined;
103
+ const parsed = EffortHint.safeParse(v);
104
+ if (!parsed.success)
105
+ throw new Error(`invalid --effort '${v}' (expected low|medium|high|xhigh|max)`);
106
+ return parsed.data;
107
+ }
108
+ function synthesisMode(args) {
109
+ const v = flagStr(args, "synthesis");
110
+ if (v === undefined)
111
+ return undefined;
112
+ if (v !== "auto" && v !== "always" && v !== "never") {
113
+ throw new Error(`invalid --synthesis '${v}' (expected auto|always|never)`);
114
+ }
115
+ return v;
116
+ }
117
+ function webPolicy(args) {
118
+ const v = flagStr(args, "web");
119
+ if (v === undefined)
120
+ return undefined;
121
+ const parsed = ExternalContextPolicy.safeParse(v);
122
+ if (!parsed.success)
123
+ throw new Error(`invalid --web '${v}' (expected off|auto|cached|live)`);
124
+ return parsed.data;
125
+ }
126
+ function attachmentPaths(args) {
127
+ const values = [];
128
+ for (const [key, forceImage] of [
129
+ ["attach", false],
130
+ ["image", true],
131
+ ]) {
132
+ for (const path of flagStringList(args, key))
133
+ values.push({ path, forceImage });
134
+ }
135
+ return values;
136
+ }
137
+ function imageMimeFor(path) {
138
+ switch (extname(path).toLowerCase()) {
139
+ case ".png":
140
+ return "image/png";
141
+ case ".jpg":
142
+ case ".jpeg":
143
+ return "image/jpeg";
144
+ case ".gif":
145
+ return "image/gif";
146
+ case ".webp":
147
+ return "image/webp";
148
+ default:
149
+ return null;
150
+ }
151
+ }
152
+ function attachmentInputs(args) {
153
+ const out = attachmentPaths(args).map(({ path, forceImage }) => {
154
+ const resolved = resolve(path);
155
+ if (!existsSync(resolved) || !lstatSync(resolved).isFile())
156
+ throw new Error(`attachment must be an existing file: ${path}`);
157
+ const imageMime = imageMimeFor(resolved);
158
+ const kind = forceImage || imageMime ? "image" : "file";
159
+ return {
160
+ kind,
161
+ mime: imageMime ?? "application/octet-stream",
162
+ name: basename(resolved),
163
+ path: resolved,
164
+ };
165
+ });
166
+ return out.length > 0 ? out : undefined;
167
+ }
168
+ function attachmentsFromInputs(inputs) {
169
+ return inputs?.map((a) => ({
170
+ id: newId("att"),
171
+ kind: a.kind,
172
+ mime: a.mime,
173
+ name: a.name,
174
+ path: a.path,
175
+ }));
176
+ }
177
+ /** Per-family reviewer model map from `--reviewer-model "openai=gpt-4o-mini,anthropic=claude-haiku"`. Fails loudly on malformed input. */
178
+ function reviewerModels(args) {
179
+ return parseReviewerModelFlags(flagValues(args, "reviewer-model"));
180
+ }
181
+ /** Per-family reviewer effort map from `--reviewer-effort "openai=xhigh,anthropic=high"`. */
182
+ function reviewerEfforts(args) {
183
+ return parseReviewerEffortFlags(flagValues(args, "reviewer-effort"));
184
+ }
185
+ /** Ordered explicit reviewer panel from `--reviewer-panel "claude=claude-opus-4-8:max,cursor=gpt-5.5-extra-high"`. */
186
+ function reviewerPanel(args) {
187
+ return parseReviewerPanelFlags(flagValues(args, "reviewer-panel"));
188
+ }
189
+ async function orchestrate(args, mode, json, forced = {}) {
190
+ const rawPrompt = args._.slice(1).join(" ").trim();
191
+ const specPath = flagStr(args, "spec");
192
+ let loadedSpec = null;
193
+ try {
194
+ loadedSpec = specPath ? loadFrozenSpec(specPath) : null;
195
+ }
196
+ catch (err) {
197
+ return printUsageError(json, `claudexor: ${err instanceof Error ? err.message : String(err)}`);
198
+ }
199
+ const prompt = loadedSpec
200
+ ? [
201
+ rawPrompt || loadedSpec.spec.intent.raw,
202
+ "",
203
+ "Use this frozen Claudexor SpecPack as the contract. Do not re-litigate settled choices; implement against the acceptance criteria and tests.",
204
+ "",
205
+ `Spec id: ${loadedSpec.spec.id} v${loadedSpec.spec.version}`,
206
+ `Spec hash: ${loadedSpec.specHash}`,
207
+ "",
208
+ "## Summary",
209
+ loadedSpec.spec.summary || "(none)",
210
+ "",
211
+ "## Acceptance Criteria",
212
+ ...(loadedSpec.spec.success_criteria.length
213
+ ? loadedSpec.spec.success_criteria.map((c) => `- [${c.id}] ${c.behavior}`)
214
+ : ["- (none)"]),
215
+ "",
216
+ "## Non-goals",
217
+ ...(loadedSpec.spec.non_goals.length
218
+ ? loadedSpec.spec.non_goals.map((x) => `- ${x}`)
219
+ : ["- (none)"]),
220
+ "",
221
+ "## Forbidden approaches",
222
+ ...(loadedSpec.spec.forbidden_approaches.length
223
+ ? loadedSpec.spec.forbidden_approaches.map((x) => `- ${x}`)
224
+ : ["- (none)"]),
225
+ ].join("\n")
226
+ : rawPrompt;
227
+ const spec = loadedSpec?.spec ?? null;
228
+ if (!prompt && mode !== "audit") {
229
+ return printUsageError(json, "claudexor: missing prompt");
230
+ }
231
+ const portfolioRaw = flagStr(args, "portfolio");
232
+ const portfolio = portfolioRaw !== undefined ? Portfolio.safeParse(portfolioRaw) : null;
233
+ if (portfolioRaw !== undefined && !portfolio?.success) {
234
+ return printUsageError(json, `claudexor: unknown --portfolio '${portfolioRaw}'`);
235
+ }
236
+ let reviewerEffortOverrides;
237
+ let resolvedReviewerModels;
238
+ let resolvedReviewerPanel;
239
+ let resolvedWebPolicy = undefined;
240
+ let resolvedAccess = undefined;
241
+ let resolvedEffort;
242
+ let maxUsd;
243
+ let maxToolCalls;
244
+ let nFlag;
245
+ let attemptsFlag;
246
+ let autonomy;
247
+ let resolvedSynthesis = undefined;
248
+ let resolvedHarnesses;
249
+ let resolvedPrimaryHarness;
250
+ let resolvedModel;
251
+ let attachments;
252
+ let attachmentRequest;
253
+ let resolvedProtectedPathApprovals;
254
+ try {
255
+ reviewerEffortOverrides = reviewerEfforts(args);
256
+ resolvedReviewerModels = reviewerModels(args);
257
+ resolvedReviewerPanel = reviewerPanel(args);
258
+ resolvedWebPolicy = webPolicy(args);
259
+ resolvedAccess = accessProfile(args);
260
+ resolvedEffort = effortHint(args);
261
+ resolvedHarnesses = harnessList(args);
262
+ resolvedPrimaryHarness = flagStr(args, "primary-harness");
263
+ resolvedModel = flagStr(args, "model");
264
+ maxUsd = floatFlag(args, "max-usd");
265
+ maxToolCalls = intFlag(args, "max-tool-calls");
266
+ nFlag = intFlag(args, "n");
267
+ attemptsFlag = intFlag(args, "attempts");
268
+ autonomy = parseAutonomy(flagStr(args, "autonomy"));
269
+ resolvedSynthesis = synthesisMode(args);
270
+ attachmentRequest = attachmentInputs(args);
271
+ attachments = attachmentsFromInputs(attachmentRequest);
272
+ resolvedProtectedPathApprovals = protectedPathApprovals(args);
273
+ }
274
+ catch (err) {
275
+ return printUsageError(json, `claudexor: ${err instanceof Error ? err.message : String(err)}`);
276
+ }
277
+ let tests;
278
+ try {
279
+ const cliTests = testCommands(args) ?? [];
280
+ tests = resolveRunTestCommands(cliTests, spec);
281
+ assertCliRunParamsHaveNoInlineSecrets({
282
+ prompt,
283
+ attachments: attachmentRequest,
284
+ mode,
285
+ harnesses: resolvedHarnesses,
286
+ primaryHarness: resolvedPrimaryHarness,
287
+ model: resolvedModel,
288
+ effort: resolvedEffort,
289
+ reviewerPanel: resolvedReviewerPanel,
290
+ reviewerModels: resolvedReviewerModels,
291
+ reviewerEfforts: reviewerEffortOverrides,
292
+ tests,
293
+ protectedPathApprovals: resolvedProtectedPathApprovals,
294
+ maxUsd,
295
+ access: resolvedAccess,
296
+ web: resolvedWebPolicy,
297
+ externalContextPolicy: resolvedWebPolicy,
298
+ synthesis: resolvedSynthesis,
299
+ autonomy,
300
+ specId: spec?.id,
301
+ specHash: loadedSpec?.specHash,
302
+ specPath: loadedSpec?.specPath,
303
+ });
304
+ }
305
+ catch (err) {
306
+ return printUsageError(json, `claudexor: ${err instanceof Error ? err.message : String(err)}`);
307
+ }
308
+ // `--autonomy` only governs the orchestrate executor; on any other
309
+ // mode it would silently do nothing, so reject it loudly (a misplaced flag is
310
+ // an error, not a no-op).
311
+ if (autonomy !== undefined && mode !== "orchestrate") {
312
+ return printUsageError(json, `claudexor: --autonomy only applies to 'orchestrate' (got mode '${mode}')`);
313
+ }
314
+ // The mutating run paths are DAEMON-TRACKED: they enqueue via the daemon
315
+ // (auto-started if needed) so the control-api can see/unblock them and a
316
+ // blocked run is unblockable through `claudexor decision`. This covers `agent`
317
+ // and an `orchestrate` run that actually ACTS (auto_safe/auto_full execute the
318
+ // plan against the tree). Read-only routes (ask/plan/audit and orchestrate in
319
+ // the default `suggest` autonomy, where the plan IS the work product) have
320
+ // nothing to apply or unblock, so they stay in-process.
321
+ const orchestrateExecutes = mode === "orchestrate" && autonomy !== undefined && autonomy !== "suggest";
322
+ // --max-tool-calls caps the orchestrate EXECUTOR's plan steps; on any other
323
+ // mode it would be a silent no-op knob (INV-023) — refuse loudly.
324
+ if (maxToolCalls !== undefined && mode !== "orchestrate") {
325
+ return printUsageError(json, "claudexor: --max-tool-calls only applies to orchestrate runs");
326
+ }
327
+ if (mode === "agent" || orchestrateExecutes) {
328
+ return daemonAgentRun(args, json, {
329
+ mode,
330
+ autonomy,
331
+ prompt,
332
+ tests,
333
+ portfolio: portfolio?.success ? portfolio.data : undefined,
334
+ maxUsd,
335
+ maxToolCalls,
336
+ reviewerPanel: resolvedReviewerPanel,
337
+ reviewerModels: resolvedReviewerModels,
338
+ reviewerEfforts: reviewerEffortOverrides,
339
+ protectedPathApprovals: resolvedProtectedPathApprovals,
340
+ resolvedWebPolicy,
341
+ resolvedAccess,
342
+ resolvedEffort,
343
+ resolvedSynthesis,
344
+ nFlag,
345
+ attemptsFlag,
346
+ specId: spec?.id,
347
+ specHash: loadedSpec?.specHash,
348
+ specPath: loadedSpec?.specPath,
349
+ attachmentRequest,
350
+ forced,
351
+ });
352
+ }
353
+ const orch = new Orchestrator({
354
+ registry: buildRegistry(),
355
+ portfolio: portfolio?.success ? portfolio.data : undefined,
356
+ maxUsd: maxUsd ?? null,
357
+ reviewerPanel: resolvedReviewerPanel,
358
+ reviewerModels: resolvedReviewerModels,
359
+ reviewerEfforts: reviewerEffortOverrides,
360
+ });
361
+ // Ctrl-C on a direct (in-process) run cancels GRACEFULLY: abort the run
362
+ // signal so harness children die via the process-group kill plumbing, gates
363
+ // are skipped, and the run ends with a typed cancelled terminal — instead
364
+ // of the node process dying and orphaning children + a terminal-less
365
+ // events.jsonl. A second Ctrl-C force-exits.
366
+ const cancelController = new AbortController();
367
+ let sigints = 0;
368
+ const onSigint = () => {
369
+ sigints += 1;
370
+ if (sigints >= 2)
371
+ process.exit(130);
372
+ process.stderr.write("\ncancelling run (Ctrl-C again to force-quit)...\n");
373
+ cancelController.abort();
374
+ };
375
+ process.on("SIGINT", onSigint);
376
+ process.on("SIGTERM", onSigint);
377
+ try {
378
+ const res = await orch.run({
379
+ repoRoot: process.cwd(),
380
+ prompt: prompt || "audit this repository",
381
+ attachments,
382
+ mode,
383
+ signal: cancelController.signal,
384
+ // Only `suggest` (or unset) reaches the in-process path; auto_safe/auto_full
385
+ // are routed to the daemon above. Forward it so an explicit --autonomy
386
+ // suggest is honoured rather than silently dropped.
387
+ ...(autonomy ? { autonomy } : {}),
388
+ harnesses: resolvedHarnesses,
389
+ primaryHarness: resolvedPrimaryHarness,
390
+ portfolio: portfolio?.success ? portfolio.data : undefined,
391
+ n: forced.race === true ? (nFlag ?? 2) : nFlag,
392
+ attempts: attemptsFlag ?? null,
393
+ untilClean: flagBool(args, "until-clean"),
394
+ swarm: forced.swarm === true || flagBool(args, "swarm"),
395
+ create: forced.create === true || flagBool(args, "create"),
396
+ tests,
397
+ protectedPathApprovals: resolvedProtectedPathApprovals,
398
+ maxUsd: maxUsd ?? null,
399
+ maxToolCalls: maxToolCalls ?? null,
400
+ access: resolvedAccess,
401
+ web: resolvedWebPolicy,
402
+ externalContextPolicy: resolvedWebPolicy,
403
+ model: resolvedModel,
404
+ effort: resolvedEffort,
405
+ synthesis: resolvedSynthesis,
406
+ specId: spec?.id,
407
+ specHash: loadedSpec?.specHash,
408
+ specPath: loadedSpec?.specPath,
409
+ inPlace: flagBool(args, "in-place"),
410
+ // Live progress + interactive answers on a TTY; --json stays a pure
411
+ // machine surface (no printer, no prompts — questions decline benignly).
412
+ ...(json
413
+ ? {}
414
+ : {
415
+ onEvent: (ev) => {
416
+ const line = formatRunEventLine(ev);
417
+ if (line)
418
+ print(line);
419
+ },
420
+ onInteraction: (ctx) => promptQuestionsOnTty(ctx.request.interaction_id, ctx.request.questions, ctx.timeoutAt),
421
+ }),
422
+ });
423
+ if (json) {
424
+ // One machine surface: on a non-success terminal, ADD a top-level `error`
425
+ // (mirroring the daemon path's `error`) so a JSON consumer reads the reason
426
+ // the same way regardless of which run path produced it. Add-only: the full
427
+ // result (runId/runDir/status/mode/winner/summary/candidates) is preserved.
428
+ printJson(res.status === "success" ? res : { ...res, error: res.summary });
429
+ }
430
+ else {
431
+ print(`run ${res.runId} [${res.status}] mode=${res.mode} winner=${res.winner ?? "none"}`);
432
+ print(` artifacts: ${res.runDir}`);
433
+ for (const c of res.candidates)
434
+ print(` - ${c.attemptId} ${c.harnessId} [${c.status}]`);
435
+ print("");
436
+ print(res.summary);
437
+ }
438
+ return res.status === "success" ? 0 : 1;
439
+ }
440
+ catch (err) {
441
+ if (json)
442
+ printJson({
443
+ ok: false,
444
+ exitCode: 1,
445
+ error: `claudexor: ${err instanceof Error ? err.message : String(err)}`,
446
+ });
447
+ else
448
+ process.stderr.write(`claudexor: ${err instanceof Error ? err.message : String(err)}\n`);
449
+ return 1;
450
+ }
451
+ finally {
452
+ process.removeListener("SIGINT", onSigint);
453
+ process.removeListener("SIGTERM", onSigint);
454
+ }
455
+ }
456
+ /**
457
+ * DAEMON-TRACKED mutating run (the unified `agent`/`best-of`/`create` path, and an
458
+ * `orchestrate` run with auto_safe/auto_full autonomy). Auto-starts
459
+ * the daemon if needed, enqueues via the control API (so the run is registered
460
+ * and the control-api can see/unblock it), streams its events to the TTY (text
461
+ * mode) and resolves the runDir from the daemon (the run lives under the daemon
462
+ * dir, not project-local) so apply/decision/inspect can find it. `--json` prints
463
+ * exactly one object `{ runId, runDir, status }` — the SWE/TB benchmark runner
464
+ * parses this to read <runDir>/final/patch.diff.
465
+ */
466
+ async function daemonAgentRun(args, json, p) {
467
+ // The run is always project-scoped (the cwd is its repo); execution is
468
+ // live in-place only under the explicit --in-place convergence path.
469
+ const inPlace = flagBool(args, "in-place");
470
+ const body = {
471
+ prompt: p.prompt,
472
+ ...(p.attachmentRequest ? { attachments: p.attachmentRequest } : {}),
473
+ mode: p.mode,
474
+ // Autonomy only governs the orchestrate executor; send it only for that mode.
475
+ ...(p.mode === "orchestrate" && p.autonomy ? { autonomy: p.autonomy } : {}),
476
+ scope: { kind: "project", root: process.cwd() },
477
+ execution: { isolation: inPlace ? "live" : "envelope" },
478
+ ...(harnessList(args) ? { harnesses: harnessList(args) } : {}),
479
+ ...(flagStr(args, "primary-harness")
480
+ ? { primaryHarness: flagStr(args, "primary-harness") }
481
+ : {}),
482
+ ...(p.portfolio ? { portfolio: p.portfolio } : {}),
483
+ ...(p.forced.race === true ? { n: p.nFlag ?? 2 } : p.nFlag !== undefined ? { n: p.nFlag } : {}),
484
+ ...(p.attemptsFlag !== undefined ? { attempts: p.attemptsFlag } : {}),
485
+ ...(flagBool(args, "until-clean") ? { untilClean: true } : {}),
486
+ ...(p.forced.swarm === true || flagBool(args, "swarm") ? { swarm: true } : {}),
487
+ ...(p.forced.create === true || flagBool(args, "create") ? { create: true } : {}),
488
+ ...(p.resolvedSynthesis ? { synthesis: p.resolvedSynthesis } : {}),
489
+ ...(p.tests ? { tests: p.tests } : {}),
490
+ ...(p.protectedPathApprovals ? { protectedPathApprovals: p.protectedPathApprovals } : {}),
491
+ ...(p.maxUsd !== undefined ? { maxUsd: p.maxUsd } : {}),
492
+ ...(p.mode === "orchestrate" && p.maxToolCalls !== undefined ? { maxToolCalls: p.maxToolCalls } : {}),
493
+ ...(p.resolvedAccess ? { access: p.resolvedAccess } : {}),
494
+ ...(p.resolvedWebPolicy ? { web: p.resolvedWebPolicy } : {}),
495
+ ...(flagStr(args, "model") ? { model: flagStr(args, "model") } : {}),
496
+ ...(p.resolvedEffort ? { effort: p.resolvedEffort } : {}),
497
+ ...(p.reviewerPanel ? { reviewerPanel: p.reviewerPanel } : {}),
498
+ ...(p.reviewerModels ? { reviewerModels: p.reviewerModels } : {}),
499
+ ...(p.reviewerEfforts ? { reviewerEfforts: p.reviewerEfforts } : {}),
500
+ ...(p.specId ? { specId: p.specId } : {}),
501
+ ...(p.specHash ? { specHash: p.specHash } : {}),
502
+ ...(p.specPath ? { specPath: p.specPath } : {}),
503
+ };
504
+ try {
505
+ const { client, addr } = await ensureDaemon();
506
+ if (json) {
507
+ // Pure machine surface: enqueue, wait for the terminal outcome, print
508
+ // exactly one JSON object. No event printer, no TTY prompts.
509
+ const out = await enqueueAndAwait(client, addr, body, { waitForTerminal: true });
510
+ // Keep the documented bench-parser keys {runId,runDir,status}; ADD `mode`,
511
+ // `error` (only for real errors), and `summary` (a machine-readable reason
512
+ // for EVERY non-success terminal incl. `blocked`, which carries no error)
513
+ // so this path matches the in-process one (P2: one machine surface).
514
+ const reason = daemonOutcomeSummary(out);
515
+ // ADD-ONLY key (bench contract keeps {runId,runDir,status}): the derived
516
+ // apply-gate verdict, so machine callers act on truth instead of
517
+ // re-implying eligibility from status.
518
+ const applyEligibility = await fetchApplyEligibility(addr, out.runId);
519
+ printJson({
520
+ runId: out.runId,
521
+ runDir: out.runDir,
522
+ status: out.status,
523
+ jobId: out.jobId,
524
+ mode: p.mode,
525
+ ...(out.error ? { error: out.error } : {}),
526
+ ...(reason ? { summary: reason } : {}),
527
+ ...(applyEligibility ? { applyEligibility } : {}),
528
+ });
529
+ return exitCodeForState(out.status);
530
+ }
531
+ // Text mode: enqueue, then live-stream the run through the shared follow
532
+ // pipeline (replay + push + interactive TTY question answering), then print
533
+ // the honest terminal line + artifacts dir resolved from the daemon.
534
+ const started = await enqueueAndAwait(client, addr, body, { waitForTerminal: false });
535
+ if (!started.runId) {
536
+ print(`run did not start: ${started.status}${started.error ? ` — ${started.error}` : ""}`);
537
+ return exitCodeForState(started.status);
538
+ }
539
+ await followRun(started.runId, false);
540
+ const final = started.jobId ? await client.status(started.jobId) : null;
541
+ const status = final?.state ?? started.status;
542
+ print("");
543
+ print(`run ${started.runId} [${status}]`);
544
+ print(` artifacts: ${final?.runDir ?? started.runDir}`);
545
+ if (status === "blocked") {
546
+ print(` blocked (needs human): unblock with \`claudexor decision ${started.runId} --accept-risk\` or rerun with \`claudexor decision ${started.runId} --rerun --feedback "..."\``);
547
+ }
548
+ else if (exitCodeForState(status) === 0) {
549
+ // Honest post-run hint: "apply with" is printed ONLY when the apply
550
+ // gate verifiably accepts the patch. A null verdict means no patch
551
+ // artifact (answer/no-op runs) or an unreachable detail endpoint —
552
+ // either way, promising `claudexor apply` would be a lie, so print the
553
+ // inspect handle instead. Ineligible runs get the honest unblock hint
554
+ // (ungated/review_not_run used to get a doomed apply command here).
555
+ const eligibility = await fetchApplyEligibility(addr, started.runId);
556
+ if (eligibility?.eligible) {
557
+ print(` apply with: claudexor apply ${started.runId}`);
558
+ }
559
+ else if (eligibility?.requiredAction) {
560
+ print(` not applyable yet: ${eligibility.requiredAction}`);
561
+ }
562
+ else {
563
+ print(` inspect with: claudexor inspect ${started.runId}`);
564
+ }
565
+ }
566
+ return exitCodeForState(status);
567
+ }
568
+ catch (err) {
569
+ if (json)
570
+ printJson({
571
+ ok: false,
572
+ exitCode: 1,
573
+ error: `claudexor: ${err instanceof Error ? err.message : String(err)}`,
574
+ });
575
+ else
576
+ process.stderr.write(`claudexor: ${err instanceof Error ? err.message : String(err)}\n`);
577
+ return 1;
578
+ }
579
+ }
580
+ /**
581
+ * `claudexor decision <runId> ...` — the CLI safety net that unblocks a
582
+ * daemon-tracked blocked run (the surface that closes the un-unblockable gap).
583
+ * Maps the flag to a typed RunDecisionAction and POSTs to /runs/:id/decision via
584
+ * the daemon control API, printing the response honestly.
585
+ */
586
+ async function decisionCommand(args, json) {
587
+ const runId = args._[1];
588
+ if (!runId) {
589
+ return printUsageError(json, 'usage: claudexor decision <run_id> --accept-risk | --override | --revert | --accept-clean-patch [--apply-mode <m>] | --rerun --feedback "<text>"');
590
+ }
591
+ const resolved = resolveDecisionBody(args);
592
+ if (!resolved.ok) {
593
+ return printUsageError(json, `claudexor decision: ${resolved.message}`);
594
+ }
595
+ const { action, body } = resolved;
596
+ try {
597
+ const { addr } = await ensureDaemon();
598
+ const res = await fetch(`${addr.baseUrl}/runs/${encodeURIComponent(runId)}/decision`, {
599
+ method: "POST",
600
+ headers: { Authorization: `Bearer ${addr.token}`, "content-type": "application/json" },
601
+ body: JSON.stringify(body),
602
+ });
603
+ const text = await res.text();
604
+ const data = text ? JSON.parse(text) : {};
605
+ if (!res.ok) {
606
+ // A typed decision rejection (e.g. revert refused: tree diverged) carries
607
+ // its reason in `message`; transport/gate failures use `error`. Surface
608
+ // whichever is present so the concrete reason is never lost behind "HTTP 409".
609
+ const msg = typeof data["error"] === "string"
610
+ ? data["error"]
611
+ : typeof data["message"] === "string"
612
+ ? data["message"]
613
+ : `decision failed (HTTP ${res.status})`;
614
+ if (json)
615
+ printJson({ accepted: false, status: "rejected", message: msg });
616
+ else
617
+ process.stderr.write(`claudexor decision: ${msg}\n`);
618
+ return 1;
619
+ }
620
+ if (json) {
621
+ printJson(data);
622
+ }
623
+ else {
624
+ const accepted = data["accepted"] === true;
625
+ print(`decision ${action} on ${runId}: ${accepted ? "accepted" : "rejected"} [${String(data["status"] ?? "?")}]`);
626
+ if (typeof data["newRunId"] === "string")
627
+ print(` new run: ${data["newRunId"]}`);
628
+ if (typeof data["message"] === "string")
629
+ print(` ${data["message"]}`);
630
+ }
631
+ return data["accepted"] === true ? 0 : 1;
632
+ }
633
+ catch (err) {
634
+ process.stderr.write(`claudexor decision: ${err instanceof Error ? err.message : String(err)}\n`);
635
+ return 1;
636
+ }
637
+ }
638
+ /**
639
+ * Resolve the ArtifactStore that owns a given run, regardless of the cwd the
640
+ * CLI is invoked from. Order:
641
+ * 1. the project store rooted at the current cwd (the common case);
642
+ * 2. the user-level store (~/.claudexor/runs) used by no-project Ask runs;
643
+ * 3. a daemon-tracked run that started in ANOTHER project — agent/race/create
644
+ * runs live under `<thatProjectRoot>/.claudexor/runs/<runId>`, so we ask
645
+ * the daemon for the run's absolute runDir (GET /runs/:id ->
646
+ * summary.runDir) and rebuild a store whose runPaths(runId).root matches.
647
+ * Returns null when no store can be located (the run does not exist anywhere
648
+ * reachable). Never throws on daemon unavailability — it falls through.
649
+ */
650
+ async function resolveRunStore(runId) {
651
+ // An id that fails the store's shape fence (separators, `..`) can never
652
+ // name a run: report it as "no such run" through the normal typed path —
653
+ // the fence must not turn a typo'd id into a raw crash that breaks --json
654
+ // purity on inspect/apply/follow.
655
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(runId))
656
+ return null;
657
+ // 1. project cwd store
658
+ const cwdStore = new ArtifactStore(process.cwd());
659
+ if (existsSync(cwdStore.runPaths(runId).root))
660
+ return { store: cwdStore, root: cwdStore.runPaths(runId).root };
661
+ // 2. user-level (no-project Ask) store
662
+ const userStore = new ArtifactStore(noProjectRepoRoot(), { claudexorDir: userConfigDir() });
663
+ if (existsSync(userStore.runPaths(runId).root))
664
+ return { store: userStore, root: userStore.runPaths(runId).root };
665
+ // 3. daemon-tracked run in another project: ask the daemon for its runDir.
666
+ // Connect ONLY to an already-running daemon — never auto-spawn one for a
667
+ // read-only lookup (a typo'd id must report "no such run", not silently
668
+ // launch a background daemon). Acting paths (decision/enqueue) still use
669
+ // ensureDaemon().
670
+ try {
671
+ const conn = await connectDaemonIfRunning();
672
+ if (!conn)
673
+ return null;
674
+ const { addr } = conn;
675
+ const resp = await fetch(`${addr.baseUrl}/runs/${encodeURIComponent(runId)}`, {
676
+ headers: { Authorization: `Bearer ${addr.token}` },
677
+ });
678
+ if (resp.ok) {
679
+ const detail = (await resp.json());
680
+ const runDir = detail.summary?.runDir;
681
+ if (runDir && existsSync(runDir)) {
682
+ // runDir = <repoRoot>/.claudexor/runs/<runId>; reconstruct a store whose
683
+ // runPaths(runId).root === runDir: runId -> runs -> .claudexor.
684
+ const claudexorDir = resolve(runDir, "..", "..");
685
+ const ds = new ArtifactStore(dirname(claudexorDir), { claudexorDir });
686
+ if (existsSync(ds.runPaths(runId).root))
687
+ return { store: ds, root: ds.runPaths(runId).root };
688
+ }
689
+ }
690
+ }
691
+ catch {
692
+ /* daemon unavailable: fall through */
693
+ }
694
+ return null;
695
+ }
696
+ async function specCommand(args, json) {
697
+ const prompt = args._.slice(1).join(" ").trim();
698
+ if (!prompt) {
699
+ return printUsageError(json, "claudexor: missing spec prompt");
700
+ }
701
+ if (containsSecretLikeToken(prompt)) {
702
+ return printUsageError(json, "claudexor spec: prompt contains a secret-like token; specs are durable artifacts, so store secrets by ref and retry with a sanitized prompt");
703
+ }
704
+ const answersPath = flagStr(args, "answers");
705
+ // The grounding step is a real (multi-)harness plan run, so the same
706
+ // routing/cost/review controls as the plan verb apply — but ONLY that step
707
+ // spawns a run. Parse them up-front so malformed values fail loudly on
708
+ // every path, and refuse ALL grounding-only flags on the --answers path,
709
+ // where no grounding run exists for them to control.
710
+ let groundingEffort;
711
+ let groundingMaxUsd;
712
+ let groundingHarnesses;
713
+ let groundingN;
714
+ let groundingWeb;
715
+ let groundingReviewerPanel;
716
+ let groundingReviewerModels;
717
+ let groundingReviewerEfforts;
718
+ try {
719
+ groundingEffort = effortHint(args);
720
+ groundingMaxUsd = floatFlag(args, "max-usd");
721
+ groundingHarnesses = harnessList(args);
722
+ groundingN = intFlag(args, "n");
723
+ groundingWeb = webPolicy(args);
724
+ groundingReviewerPanel = reviewerPanel(args);
725
+ groundingReviewerModels = reviewerModels(args);
726
+ groundingReviewerEfforts = reviewerEfforts(args);
727
+ }
728
+ catch (err) {
729
+ return printUsageError(json, `claudexor: ${err instanceof Error ? err.message : String(err)}`);
730
+ }
731
+ if (answersPath &&
732
+ (groundingEffort !== undefined ||
733
+ groundingMaxUsd !== undefined ||
734
+ groundingHarnesses !== undefined ||
735
+ groundingN !== undefined ||
736
+ groundingWeb !== undefined ||
737
+ groundingReviewerPanel !== undefined ||
738
+ groundingReviewerModels !== undefined ||
739
+ groundingReviewerEfforts !== undefined)) {
740
+ return printUsageError(json, "claudexor spec: --harness/--n/--web/--effort/--max-usd/--reviewer-panel/--reviewer-model/--reviewer-effort control the grounding plan run and only apply when generating questions; drop them when re-running with --answers");
741
+ }
742
+ try {
743
+ const answers = answersPath ? readAnswers(answersPath) : null;
744
+ let planRunId = answers?.planRunId ?? "";
745
+ let planDir = answers?.planDir ?? "";
746
+ let planText = planDir ? (readTextSafe(join(planDir, "final", "plan.md")) ?? "") : "";
747
+ if (!planText) {
748
+ if (answersPath) {
749
+ throw new Error("answers file does not contain a usable planDir/final/plan.md; re-run without --answers to generate a fresh questions file");
750
+ }
751
+ const orch = new Orchestrator({
752
+ registry: buildRegistry(),
753
+ reviewerPanel: groundingReviewerPanel,
754
+ reviewerModels: groundingReviewerModels,
755
+ reviewerEfforts: groundingReviewerEfforts,
756
+ });
757
+ const plan = await orch.run({
758
+ repoRoot: process.cwd(),
759
+ prompt,
760
+ mode: "plan",
761
+ harnesses: groundingHarnesses,
762
+ n: groundingN,
763
+ access: "readonly",
764
+ web: groundingWeb,
765
+ effort: groundingEffort,
766
+ maxUsd: groundingMaxUsd ?? null,
767
+ });
768
+ planRunId = plan.runId;
769
+ planDir = plan.runDir;
770
+ planText = readTextSafe(join(plan.runDir, "final", "plan.md")) ?? plan.summary;
771
+ }
772
+ const questions = extractQuestionsFromPlan(planText);
773
+ if (!answersPath) {
774
+ const draftDir = join(process.cwd(), ".claudexor", "specs", "drafts", planRunId);
775
+ ensureDir(draftDir);
776
+ const questionsPath = join(draftDir, "questions.json");
777
+ writeJson(questionsPath, { prompt, planRunId, planDir, questions, answers: [] });
778
+ const result = {
779
+ status: "questions",
780
+ planRunId,
781
+ planDir,
782
+ questionsPath,
783
+ questions,
784
+ };
785
+ if (json)
786
+ printJson(result);
787
+ else {
788
+ print(`plan grounding run: ${planRunId}`);
789
+ print(`questions: ${questionsPath}`);
790
+ print(`answer with: claudexor spec ${JSON.stringify(prompt)} --answers ${questionsPath}`);
791
+ }
792
+ return 0;
793
+ }
794
+ const spec = await freezeSpecFromGrounding(prompt, planText, answers ?? readAnswers(answersPath));
795
+ const persisted = persistSpec(process.cwd(), spec, planText, loadPreviousSpec(flagStr(args, "previous")));
796
+ const specJsonPath = join(persisted.specDir, "spec.json");
797
+ const runHint = `claudexor best-of --spec ${JSON.stringify(specJsonPath)}`;
798
+ const result = {
799
+ status: "frozen",
800
+ planRunId,
801
+ planDir,
802
+ specId: spec.id,
803
+ specDir: persisted.specDir,
804
+ specHash: persisted.specHash,
805
+ runHint,
806
+ questions,
807
+ changes: persisted.changes,
808
+ };
809
+ if (json)
810
+ printJson(result);
811
+ else {
812
+ print(`frozen SpecPack: ${spec.id} v${spec.version}`);
813
+ print(` dir: ${persisted.specDir}`);
814
+ print(` hash: ${persisted.specHash}`);
815
+ print(` native projection: ${join(persisted.specDir, "PLANS.md")}`);
816
+ print(`run: ${runHint}`);
817
+ }
818
+ return 0;
819
+ }
820
+ catch (err) {
821
+ // Same runtime-error envelope contract as the run/race commands: --json
822
+ // callers get a machine-readable {ok:false} on stdout, never bare stderr.
823
+ if (json)
824
+ printJson({
825
+ ok: false,
826
+ exitCode: 1,
827
+ error: `claudexor spec: ${err instanceof Error ? err.message : String(err)}`,
828
+ });
829
+ else
830
+ process.stderr.write(`claudexor spec: ${err instanceof Error ? err.message : String(err)}\n`);
831
+ return 1;
832
+ }
833
+ }
834
+ function printPreflightError(args, json, error) {
835
+ if (json && (args._[0] ?? "help") === "plugin") {
836
+ printJson(pluginCommandErrorResult(args._[1], args._[2], flagBool(args, "dry-run"), 2, error));
837
+ return 2;
838
+ }
839
+ return printUsageError(json, error);
840
+ }
841
+ function listCliArtifacts(root) {
842
+ if (!existsSync(root))
843
+ return [];
844
+ const out = [];
845
+ const walk = (dir) => {
846
+ for (const name of readdirSync(dir)) {
847
+ const abs = join(dir, name);
848
+ const rel = relative(root, abs).split(sep).join("/");
849
+ const st = lstatSync(abs);
850
+ out.push(st.isDirectory() ? `${rel}/` : rel);
851
+ if (st.isDirectory())
852
+ walk(abs);
853
+ }
854
+ };
855
+ walk(root);
856
+ return out.sort();
857
+ }
858
+ // KNOWN_FLAGS / VALUE_FLAGS (imported above) and the per-command scope check
859
+ // are projections of the command registry. Unknown flags FAIL LOUDLY: `--harnes
860
+ // codex` must never silently run all harnesses.
861
+ async function main() {
862
+ const args = parseArgs(process.argv.slice(2));
863
+ // --version / --help are standard CLI affordances, not unknown flags.
864
+ if (flagBool(args, "version")) {
865
+ process.stdout.write(`${CLI_VERSION}\n`);
866
+ return 0;
867
+ }
868
+ if (flagBool(args, "help")) {
869
+ process.stdout.write(HELP);
870
+ return 0;
871
+ }
872
+ const json = flagBool(args, "json");
873
+ const cmd = args._[0] ?? "help";
874
+ const unknownFlags = Object.keys(args.flags).filter((f) => !KNOWN_FLAGS.has(f));
875
+ if (unknownFlags.length > 0) {
876
+ const error = `claudexor: unknown flag(s): ${unknownFlags.map((f) => `--${f}`).join(", ")} (see \`claudexor help\`)`;
877
+ return printPreflightError(args, json, error);
878
+ }
879
+ const valueFlagError = requiredStringFlagError(args, VALUE_FLAGS);
880
+ if (valueFlagError)
881
+ return printPreflightError(args, json, valueFlagError);
882
+ // Registry-enforced per-command flag scope: a KNOWN flag outside the
883
+ // command's declared set (e.g. `spec --model`, `ask --force`) fails loudly
884
+ // instead of being silently ignored. Data-driven from CLI_COMMANDS for
885
+ // every verb (this replaced the old hand-listed plugin/--force special cases).
886
+ const scopeError = commandFlagScopeError(cmd, Object.keys(args.flags));
887
+ if (scopeError)
888
+ return printPreflightError(args, json, scopeError);
889
+ // No arguments at all = the interactive REPL: a thread of turns over the
890
+ // current project with native session continuity (chat is the normal loop).
891
+ if (args._.length === 0 && process.stdin.isTTY) {
892
+ return runRepl(process.cwd());
893
+ }
894
+ const cwd = process.cwd();
895
+ switch (cmd) {
896
+ case "init": {
897
+ const res = initProjectConfig(cwd);
898
+ if (json)
899
+ printJson(res);
900
+ else
901
+ print(res.created ? `Created ${res.configPath}` : `Config already exists: ${res.configPath}`);
902
+ return 0;
903
+ }
904
+ case "doctor": {
905
+ const cfg = loadConfig(cwd);
906
+ const only = flagStr(args, "harness");
907
+ const onlyList = only
908
+ ? only
909
+ .split(",")
910
+ .map((s) => s.trim())
911
+ .filter(Boolean)
912
+ : undefined;
913
+ const gateway = buildGateway({ includeFakes: flagBool(args, "all") });
914
+ // Scope discovery to the requested harness(es) (P14): a single-harness
915
+ // query no longer probes every adapter (incl. paid smokes) just to filter.
916
+ const statuses = await gateway.statusAll({ cwd }, onlyList);
917
+ // An explicit --harness typo must FAIL LOUDLY, not silently succeed over an
918
+ // empty list (the scoped probe returns nothing for an unknown id).
919
+ if (onlyList) {
920
+ const got = new Set(statuses.map((s) => s.id));
921
+ const unknown = onlyList.filter((id) => !got.has(id));
922
+ if (unknown.length)
923
+ return printUsageError(json, `claudexor: unknown harness(es): ${unknown.join(", ")} (run \`claudexor harness list --all\`)`);
924
+ }
925
+ const filtered = statuses;
926
+ // a configured default model that the harness does not recognize must
927
+ // not be silently masked by a smoke that ran a DIFFERENT model. Validate
928
+ // each harness's configured default against its model truth source (live
929
+ // inventory or manifest hints via the shared harnessModels SSOT) and
930
+ // surface a violation honestly as INVALID (strict model truth — no "unverified").
931
+ const configuredChecks = new Map();
932
+ await Promise.all(filtered.map(async (s) => {
933
+ const configured = cfg.global.harnesses[s.id]?.default_model ?? null;
934
+ if (!configured)
935
+ return;
936
+ const truth = await harnessModels(s.id, process.cwd(), true);
937
+ const check = validateModel(configured, truth.models.map((m) => m.id), truth.source === "api" ? "api" : "manifest");
938
+ configuredChecks.set(s.id, { configured, check });
939
+ }));
940
+ const modelNote = (s) => {
941
+ const entry = configuredChecks.get(s.id);
942
+ if (!entry || entry.check.status === "ok")
943
+ return null;
944
+ return ` model: INVALID — ${entry.check.message}`;
945
+ };
946
+ if (json) {
947
+ printJson({
948
+ harnesses: filtered.map((s) => ({
949
+ ...s,
950
+ configured_model: configuredChecks.get(s.id)?.configured ?? null,
951
+ configured_model_check: configuredChecks.get(s.id)?.check ?? null,
952
+ })),
953
+ node_advisory: atRiskNodeAdvisory(),
954
+ });
955
+ return 0;
956
+ }
957
+ for (const s of filtered) {
958
+ const ver = s.manifest?.version ? ` ${s.manifest.version}` : "";
959
+ print(`${statusGlyph(s.status)} ${s.id}${ver}`);
960
+ if (s.enabledIntents.length)
961
+ print(` intents: ${s.enabledIntents.join(", ")}`);
962
+ print(` auth sources: ${authSourceAvailability(s)}`);
963
+ print(` checks: ${checksSummary(s)}`);
964
+ if (s.reasons.length)
965
+ print(` reasons: ${s.reasons.join(", ")}`);
966
+ const mn = modelNote(s);
967
+ if (mn)
968
+ print(mn);
969
+ }
970
+ const advisory = atRiskNodeAdvisory();
971
+ if (advisory)
972
+ print(`advisory: ${advisory}`);
973
+ return 0;
974
+ }
975
+ case "agent": {
976
+ const specStrategyError = "claudexor: --spec requires a gated strategy; use 'claudexor best-of --spec <file>' or 'claudexor agent --attempts N --spec <file>'";
977
+ // ONE gate for both spellings: `agent --spec` and `agent --mode agent --spec`
978
+ // must enforce the same gated-strategy requirement (a flag spelling must
979
+ // never bypass a policy the bare verb enforces).
980
+ const agentSpecGateError = () => {
981
+ if (!flagStr(args, "spec") || flagBool(args, "until-clean"))
982
+ return null;
983
+ try {
984
+ const hasGatedStrategy = intFlag(args, "attempts") !== undefined || intFlag(args, "n") !== undefined;
985
+ return hasGatedStrategy ? null : specStrategyError;
986
+ }
987
+ catch (err) {
988
+ return `claudexor: ${err instanceof Error ? err.message : String(err)}`;
989
+ }
990
+ };
991
+ const modeStr = flagStr(args, "mode");
992
+ if (modeStr !== undefined) {
993
+ const mode = normalizeMode(modeStr);
994
+ if (!MODES.has(mode)) {
995
+ return printUsageError(json, `claudexor: unknown --mode '${modeStr}'. valid: ${[...MODES].join(", ")}`);
996
+ }
997
+ if ((mode === "ask" || mode === "audit") && flagStr(args, "spec")) {
998
+ return printUsageError(json, specStrategyError);
999
+ }
1000
+ if (mode === "agent") {
1001
+ const gateError = agentSpecGateError();
1002
+ if (gateError)
1003
+ return printUsageError(json, gateError);
1004
+ }
1005
+ return orchestrate(args, mode, json);
1006
+ }
1007
+ const gateError = agentSpecGateError();
1008
+ if (gateError)
1009
+ return printUsageError(json, gateError);
1010
+ return orchestrate(args, "agent", json);
1011
+ }
1012
+ case "ask":
1013
+ return orchestrate(args, "ask", json);
1014
+ case "explore":
1015
+ return orchestrate(args, "audit", json, { swarm: true });
1016
+ case "best-of":
1017
+ return orchestrate(args, "agent", json, { race: true });
1018
+ // RENAMED verbs hard-error with the new name — no silent aliases (the
1019
+ // same doctrine as retired mode ids: a stale script must fail loudly).
1020
+ case "run":
1021
+ return printPreflightError(args, json, "claudexor: the 'run' verb was renamed; use 'claudexor agent' (same flags)");
1022
+ case "race":
1023
+ return printPreflightError(args, json, "claudexor: the 'race' verb was renamed; use 'claudexor best-of' (same flags)");
1024
+ case "orchestrate":
1025
+ return orchestrate(args, "orchestrate", json);
1026
+ case "plan":
1027
+ return orchestrate(args, "plan", json);
1028
+ case "spec":
1029
+ return specCommand(args, json);
1030
+ case "create":
1031
+ return orchestrate(args, "agent", json, { create: true });
1032
+ case "audit":
1033
+ case "map":
1034
+ return orchestrate(args, "audit", json);
1035
+ case "daemon":
1036
+ return daemonCommand(args, json);
1037
+ case "settings":
1038
+ return settingsCommand(args, json);
1039
+ case "trust":
1040
+ return trustCommand(args, json);
1041
+ case "auth":
1042
+ return authCommand(args, json);
1043
+ case "models":
1044
+ return modelsCommand(args, json);
1045
+ case "secrets":
1046
+ return secretsCommand(args, json);
1047
+ case "mcp": {
1048
+ if (args._[1] === "serve") {
1049
+ // SDK-owned protocol core; mutating verbs are daemon-tracked, so a
1050
+ // run started from an MCP host is visible/unblockable like a CLI run.
1051
+ serveClaudexorMcp({
1052
+ version: CLAUDEXOR_VERSION,
1053
+ tools: defaultClaudexorTools(mcpSurfaceRunner()),
1054
+ transport: { read: process.stdin, write: process.stdout },
1055
+ });
1056
+ // Serve until stdin closes (the SDK handle owns the transport).
1057
+ await new Promise((resolve) => process.stdin.once("close", resolve));
1058
+ return 0;
1059
+ }
1060
+ return printUsageError(json, "usage: claudexor mcp serve");
1061
+ }
1062
+ case "acp": {
1063
+ if (args._[1] === "serve") {
1064
+ await new AcpServer({
1065
+ version: CLAUDEXOR_VERSION,
1066
+ runner: orchestratorRunner(),
1067
+ transport: { read: process.stdin, write: process.stdout },
1068
+ }).serve();
1069
+ return 0;
1070
+ }
1071
+ return printUsageError(json, "usage: claudexor acp serve");
1072
+ }
1073
+ case "follow": {
1074
+ const runId = args._[1];
1075
+ if (!runId) {
1076
+ return printUsageError(json, "usage: claudexor follow <run_id>");
1077
+ }
1078
+ return followRun(runId, json);
1079
+ }
1080
+ case "review":
1081
+ return reviewCommand(args, json);
1082
+ case "inspect": {
1083
+ const runId = args._[1];
1084
+ if (!runId) {
1085
+ return printUsageError(json, "usage: claudexor inspect <run_id>");
1086
+ }
1087
+ // Resolve the owning store from any cwd: project store, user-level Ask
1088
+ // store, or a daemon-tracked run that started in another project.
1089
+ const resolved = await resolveRunStore(runId);
1090
+ if (!resolved) {
1091
+ if (json)
1092
+ printJson({ runId, error: `no such run ${runId}` });
1093
+ else
1094
+ print(`no such run ${runId}`);
1095
+ return 1;
1096
+ }
1097
+ const store = resolved.store;
1098
+ const paths = store.runPaths(runId);
1099
+ const decision = store.readYaml(join(paths.arbitrationDir, "decision.yaml"));
1100
+ const workProduct = store.readYaml(join(paths.finalDir, "work_product.yaml"));
1101
+ const contract = TaskContract.safeParse(store.readYaml(join(paths.contextDir, "task.yaml")));
1102
+ const primary = primaryOutputForCli(paths.root, contract.success ? contract.data.mode.kind : undefined);
1103
+ // The CLI projects the orchestrator-owned telemetry artifact and NEVER
1104
+ // recomputes evidence from raw events (single-owner rule); a missing
1105
+ // artifact (legacy run) renders "telemetry unavailable".
1106
+ const parsedTelemetry = RunTelemetry.safeParse(store.readYaml(join(paths.finalDir, "telemetry.yaml")));
1107
+ const telemetry = parsedTelemetry.success ? parsedTelemetry.data : null;
1108
+ const toolErrors = telemetry
1109
+ ? telemetry.attempts.flatMap((a) => a.tool_errors
1110
+ .filter((e) => !e.recovered && e.kind === "web")
1111
+ .map((e) => ({
1112
+ attemptId: a.attempt_id,
1113
+ tool: e.tool,
1114
+ target: e.target ?? undefined,
1115
+ summary: e.summary,
1116
+ })))
1117
+ : [];
1118
+ const toolWarnings = telemetry
1119
+ ? telemetry.attempts.flatMap((a) => a.tool_errors
1120
+ .filter((e) => !e.recovered && e.kind !== "web")
1121
+ .map((e) => ({
1122
+ attemptId: a.attempt_id,
1123
+ tool: e.tool,
1124
+ target: e.target ?? undefined,
1125
+ summary: e.summary,
1126
+ })))
1127
+ : [];
1128
+ const artifacts = listCliArtifacts(paths.root).filter((p) => !p.endsWith("/"));
1129
+ const outputReadyState = primary?.kind === "diagnostic"
1130
+ ? "diagnostic"
1131
+ : primary?.text.trim()
1132
+ ? "ready"
1133
+ : readTextSafe(join(paths.finalDir, "failure.yaml"))
1134
+ ? "diagnostic"
1135
+ : "finalizing";
1136
+ const parsedDecision = DecisionRecord.safeParse(decision);
1137
+ const summary = readTextSafe(join(paths.finalDir, "summary.md"));
1138
+ if (json) {
1139
+ printJson({
1140
+ runId,
1141
+ runDir: paths.root,
1142
+ outputReadyState,
1143
+ contract: contract.success ? contract.data : null,
1144
+ telemetry,
1145
+ toolErrors,
1146
+ toolWarnings,
1147
+ primaryOutput: primary,
1148
+ decision,
1149
+ work_product: workProduct,
1150
+ artifacts,
1151
+ });
1152
+ // exit-code parity with the text mode: read-only runs have no decision record
1153
+ return summary || primary ? 0 : 1;
1154
+ }
1155
+ print(`run ${runId} @ ${paths.root}`);
1156
+ if (contract.success) {
1157
+ print(`mode: ${contract.data.mode.kind}`);
1158
+ print(`access: requested=${contract.data.access.requested_profile} effective=${contract.data.access.effective_profile}`);
1159
+ }
1160
+ if (telemetry) {
1161
+ print(`web: policy=${telemetry.external_context_policy} effective=${telemetry.effective_web_mode} required=${telemetry.web_required} evidence=${telemetry.web.status}`);
1162
+ }
1163
+ else if (contract.success) {
1164
+ print(`web: policy=${contract.data.external_context.policy} required=${contract.data.external_context.web_required} evidence=unavailable (no telemetry.yaml)`);
1165
+ }
1166
+ print(`output: ${outputReadyState}${primary ? ` ${primary.path}` : ""}`);
1167
+ if (parsedDecision.success) {
1168
+ const vb = parsedDecision.data.verification_basis;
1169
+ print(`decision: ${parsedDecision.data.status} outcome=${parsedDecision.data.outcome} apply=${parsedDecision.data.apply_recommendation}${vb !== "none" ? ` verified_by=${vb}` : ""}`);
1170
+ const budget = parsedDecision.data.budget_summary;
1171
+ print(`budget: spend=${budget.spend_usd ?? "unknown"}${budget.estimated ? " estimated" : ""}`);
1172
+ }
1173
+ if (telemetry && (telemetry.web.attempted || telemetry.web.required)) {
1174
+ print(`web evidence: status=${telemetry.web.status} tool=${telemetry.web.tool ?? "none"} target=${telemetry.web.target ?? "none"}${telemetry.web.error_summary ? ` error=${telemetry.web.error_summary}` : ""}`);
1175
+ }
1176
+ if (toolErrors.length) {
1177
+ print("tool errors (unrecovered):");
1178
+ for (const err of toolErrors.slice(-8))
1179
+ print(` - ${err.attemptId} ${err.tool}: ${err.summary}${err.target ? ` (${err.target})` : ""}`);
1180
+ }
1181
+ if (toolWarnings.length) {
1182
+ print("tool warnings (non-blocking):");
1183
+ for (const err of toolWarnings.slice(-8))
1184
+ print(` - ${err.attemptId} ${err.tool}: ${err.summary}${err.target ? ` (${err.target})` : ""}`);
1185
+ }
1186
+ if (primary?.text.trim()) {
1187
+ print("");
1188
+ print(primary.text.trim());
1189
+ }
1190
+ else {
1191
+ print(summary ?? "(no summary — run may not exist)");
1192
+ }
1193
+ if (artifacts.length) {
1194
+ print("");
1195
+ print("artifacts:");
1196
+ for (const a of artifacts.slice(0, 40))
1197
+ print(` - ${a}`);
1198
+ }
1199
+ return summary || primary ? 0 : 1;
1200
+ }
1201
+ case "apply": {
1202
+ const runId = args._[1];
1203
+ if (!runId) {
1204
+ return printUsageError(json, "usage: claudexor apply <run_id> [--mode apply|commit|branch|pr] [--dry-run]");
1205
+ }
1206
+ // Resolve the owning store from any cwd (project / user Ask / daemon-tracked
1207
+ // run in another project) before reading the patch artifact.
1208
+ const resolved = await resolveRunStore(runId);
1209
+ if (!resolved) {
1210
+ if (json)
1211
+ printJson({ runId, error: `no such run ${runId}` });
1212
+ else
1213
+ print(`no such run ${runId}`);
1214
+ return 1;
1215
+ }
1216
+ const store = resolved.store;
1217
+ const paths = store.runPaths(runId);
1218
+ const patch = readTextSafe(join(paths.finalDir, "patch.diff"));
1219
+ if (!patch || patch.trim().length === 0) {
1220
+ if (json)
1221
+ printJson({ runId, error: `no patch found for run ${runId}` });
1222
+ else
1223
+ print(`no patch found for run ${runId}`);
1224
+ return 1;
1225
+ }
1226
+ if (containsSecretLikeToken(patch)) {
1227
+ if (json)
1228
+ printJson({ runId, error: "patch contains secret-like token; refusing apply" });
1229
+ else
1230
+ print("patch contains secret-like token; refusing apply");
1231
+ return 1;
1232
+ }
1233
+ // Apply policy has ONE owner (delivery.validateApplyGate) shared with the
1234
+ // Control API; the CLI only adapts artifact reads into it. No duplicated
1235
+ // pre-checks here: a NEEDS_HUMAN run unblocked through the typed decision
1236
+ // endpoint (arbitration/operator_decision.yaml, hash-bound) must apply
1237
+ // identically from BOTH surfaces.
1238
+ const applyDecision = DecisionRecord.safeParse(store.readYaml(join(paths.arbitrationDir, "decision.yaml")));
1239
+ const workProduct = WorkProduct.safeParse(store.readYaml(join(paths.finalDir, "work_product.yaml")));
1240
+ const contract = TaskContract.safeParse(store.readYaml(join(paths.contextDir, "task.yaml")));
1241
+ const operatorDecisionRaw = store.readYaml(join(paths.arbitrationDir, "operator_decision.yaml"));
1242
+ const operatorDecision = operatorDecisionRaw && typeof operatorDecisionRaw["action"] === "string"
1243
+ ? {
1244
+ action: operatorDecisionRaw["action"],
1245
+ patch_sha256: typeof operatorDecisionRaw["patch_sha256"] === "string"
1246
+ ? operatorDecisionRaw["patch_sha256"]
1247
+ : undefined,
1248
+ }
1249
+ : null;
1250
+ // The default apply target is the run's ORIGINAL project (from its contract),
1251
+ // not the current working directory — so a daemon-tracked run resolved via
1252
+ // the registry applies correctly from any cwd (namespace unification).
1253
+ // Fall back to cwd only for a legacy run with no readable contract.
1254
+ const applyRoot = contract.success ? contract.data.repo.root : process.cwd();
1255
+ // Artifact-only path: the live daemon job state is unavailable, but the
1256
+ // orchestrator records the terminal run status in work_product.meta.status.
1257
+ // Feed it (mapped to daemon-state vocab) into the shared gate so the CLI
1258
+ // enforces the SAME terminal-state bar the Control API does — e.g. a
1259
+ // convergence run that persists decision.status=success but terminal
1260
+ // not_converged (stale diff after a required review) is refused identically.
1261
+ const recordedStatus = workProduct.success
1262
+ ? workProduct.data.meta?.["status"]
1263
+ : undefined;
1264
+ const recordedState = recordedStatus
1265
+ ? recordedStatus === "success"
1266
+ ? "succeeded"
1267
+ : recordedStatus
1268
+ : null;
1269
+ const gateError = validateApplyGate({
1270
+ state: recordedState,
1271
+ decision: applyDecision.success ? applyDecision.data : null,
1272
+ workProduct: workProduct.success ? workProduct.data : null,
1273
+ patch,
1274
+ originalRepoRoot: contract.success ? contract.data.repo.root : null,
1275
+ targetRepoRoot: applyRoot,
1276
+ operatorDecision,
1277
+ });
1278
+ if (gateError) {
1279
+ if (json)
1280
+ printJson({ runId, error: gateError });
1281
+ else
1282
+ print(gateError);
1283
+ return 1;
1284
+ }
1285
+ if (flagBool(args, "dry-run")) {
1286
+ const r = await checkPatch(applyRoot, patch);
1287
+ if (json)
1288
+ printJson({
1289
+ runId,
1290
+ dryRun: true,
1291
+ applies: r.ok,
1292
+ ...(r.ok ? {} : { error: r.stderr.trim() }),
1293
+ });
1294
+ else
1295
+ print(r.ok ? "patch applies cleanly" : `patch does not apply: ${r.stderr.trim()}`);
1296
+ return r.ok ? 0 : 1;
1297
+ }
1298
+ const rawMode = flagStr(args, "mode") ?? "apply";
1299
+ if (!DELIVER_MODES.has(rawMode)) {
1300
+ if (json)
1301
+ printJson({ runId, error: `unsupported apply mode: ${rawMode}` });
1302
+ else
1303
+ print(`unsupported apply mode: ${rawMode}`);
1304
+ return 2;
1305
+ }
1306
+ // `apply` mutates the tree; `artifact_only` produces no mutation and the
1307
+ // patch artifact was already emitted by the run — reject it here (it stays
1308
+ // valid on the control-api for clients that want a dry materialization).
1309
+ if (rawMode === "artifact_only") {
1310
+ const msg = "apply --mode artifact_only is a no-op (the patch artifact already exists at <runDir>/final/patch.diff); use apply|branch|commit|pr to mutate, or read the artifact directly";
1311
+ if (json)
1312
+ printJson({ runId, error: msg });
1313
+ else
1314
+ print(msg);
1315
+ return 2;
1316
+ }
1317
+ const mode = rawMode;
1318
+ const res = await deliver(applyRoot, patch, { mode, message: `claudexor: apply ${runId}` });
1319
+ if (json)
1320
+ printJson(res);
1321
+ else
1322
+ print(`${res.mode}: applied=${res.applied}` +
1323
+ (res.commit ? ` commit=${res.commit.slice(0, 8)}` : "") +
1324
+ (res.branch ? ` branch=${res.branch}` : "") +
1325
+ (res.detail ? ` (${res.detail})` : ""));
1326
+ return res.applied ? 0 : 1;
1327
+ }
1328
+ case "decision":
1329
+ return decisionCommand(args, json);
1330
+ case "release": {
1331
+ if (args._[1] === "check-name") {
1332
+ const name = args._[2] ?? "claudexor";
1333
+ const checks = await checkName(name);
1334
+ if (json)
1335
+ printJson({ name, checks });
1336
+ else {
1337
+ print(`naming gate for "${name}":`);
1338
+ for (const c of checks) {
1339
+ const tag = c.availability === "free"
1340
+ ? "[free] "
1341
+ : c.availability === "taken"
1342
+ ? "[taken] "
1343
+ : "[unknown]";
1344
+ print(` ${tag} ${c.registry}: ${c.detail}`);
1345
+ }
1346
+ }
1347
+ return 0;
1348
+ }
1349
+ return printUsageError(json, "usage: claudexor release check-name <name>");
1350
+ }
1351
+ case "plugin": {
1352
+ const sub = args._[1];
1353
+ const target = args._[2];
1354
+ const dryRun = flagBool(args, "dry-run");
1355
+ if (!sub || !PLUGIN_VERBS.includes(sub)) {
1356
+ const error = "usage: claudexor plugin <install|status|doctor|repair|uninstall> <cursor|claude|codex|opencode|all> [--dry-run] [--force] [--json]";
1357
+ if (json)
1358
+ printJson(pluginCommandErrorResult(sub, target, dryRun, 2, error));
1359
+ else
1360
+ print(error);
1361
+ return 2;
1362
+ }
1363
+ if (!target || !PLUGIN_TARGETS.includes(target)) {
1364
+ const error = `claudexor: unknown plugin target '${target ?? ""}' (expected ${PLUGIN_TARGETS.join("|")})`;
1365
+ if (json)
1366
+ printJson(pluginCommandErrorResult(sub, target, dryRun, 2, error));
1367
+ else
1368
+ process.stderr.write(`${error}\n`);
1369
+ return 2;
1370
+ }
1371
+ if (args._.length > 3) {
1372
+ const error = `claudexor: unexpected plugin argument(s): ${args._.slice(3).join(" ")}`;
1373
+ if (json)
1374
+ printJson(pluginCommandErrorResult(sub, target, dryRun, 2, error));
1375
+ else
1376
+ process.stderr.write(`${error}\n`);
1377
+ return 2;
1378
+ }
1379
+ try {
1380
+ const r = await runPluginCommand(sub, target, {
1381
+ dryRun,
1382
+ force: flagBool(args, "force"),
1383
+ json,
1384
+ });
1385
+ if (json)
1386
+ printJson(r);
1387
+ else
1388
+ print(formatPluginResult(r));
1389
+ return r.exitCode;
1390
+ }
1391
+ catch (err) {
1392
+ if (json) {
1393
+ printJson(pluginCommandErrorResult(sub, target, dryRun, 1, err instanceof Error ? err.message : String(err)));
1394
+ return 1;
1395
+ }
1396
+ throw err;
1397
+ }
1398
+ }
1399
+ case "harness": {
1400
+ const sub = args._[1];
1401
+ if (sub === "list") {
1402
+ // Fakes are test fixtures, not real harnesses; `--all` reveals them.
1403
+ const includeFakes = flagBool(args, "all");
1404
+ const ids = [...buildRegistry({ includeFakes }).keys()];
1405
+ if (json)
1406
+ printJson({ harnesses: ids });
1407
+ else
1408
+ ids.forEach((id) => print(id));
1409
+ return 0;
1410
+ }
1411
+ return printUsageError(json, "usage: claudexor harness list [--all]");
1412
+ }
1413
+ case "capabilities": {
1414
+ // The derived AgentCapabilityCatalog — same composer as the daemon's
1415
+ // GET /agent-capabilities and the MCP claudexor_capabilities tool.
1416
+ const catalog = await buildAgentCapabilityCatalog();
1417
+ if (json)
1418
+ printJson(catalog);
1419
+ else {
1420
+ print(`claudexor ${catalog.version} — capability catalog`);
1421
+ print(`modes: ${catalog.modes.join(", ")}`);
1422
+ print(`available harnesses: ${catalog.availableHarnesses.join(", ") || "(none)"}`);
1423
+ for (const h of catalog.harnesses) {
1424
+ const model = h.configuredModel ? ` model=${h.configuredModel}${h.configuredModelValid === false ? " (REJECTED)" : ""}` : "";
1425
+ print(` ${statusGlyph(h.status)} ${h.id}: ${h.status}; intents=${h.enabledIntents.join(",") || "-"}; models=${h.models.count} (${h.models.source})${model}`);
1426
+ }
1427
+ print(`mcp tools: ${catalog.mcpTools.join(", ")}`);
1428
+ print(`run-control keys: ${catalog.runControlKeys.join(", ")}`);
1429
+ print(`full JSON: claudexor capabilities --json (or GET /agent-capabilities on the daemon)`);
1430
+ }
1431
+ return 0;
1432
+ }
1433
+ case "help":
1434
+ // `help --json` is the machine-readable command catalog (agents parse
1435
+ // it instead of scraping the text help).
1436
+ if (json)
1437
+ printJson(helpJson(CLI_VERSION));
1438
+ else
1439
+ print(HELP);
1440
+ return 0;
1441
+ default:
1442
+ // Unknown command is an ERROR (exit 2), not a silent help print with
1443
+ // exit 0 — scripts must not mistake a typo'd verb for success. --json
1444
+ // callers get the machine envelope on stdout (stdout purity contract).
1445
+ if (json) {
1446
+ printJson({ ok: false, exitCode: 2, error: `claudexor: unknown command '${cmd}' (see \`claudexor help --json\`)` });
1447
+ return 2;
1448
+ }
1449
+ process.stderr.write(`claudexor: unknown command '${cmd}'\n\n${HELP}\n`);
1450
+ return 2;
1451
+ }
1452
+ }
1453
+ main()
1454
+ .then((code) => process.exit(code))
1455
+ .catch((err) => {
1456
+ process.stderr.write(`claudexor: ${err instanceof Error ? err.message : String(err)}\n`);
1457
+ process.exit(1);
1458
+ });
1459
+ //# sourceMappingURL=cli.js.map