@0dai-dev/cli 4.3.6 → 4.3.7

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 (75) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +127 -30
  3. package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
  4. package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
  5. package/lib/ai/registry/mcp-catalog.json +98 -0
  6. package/lib/commands/auth.js +2 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/doctor.js +506 -12
  9. package/lib/commands/experience.js +40 -5
  10. package/lib/commands/feedback.js +157 -15
  11. package/lib/commands/gh.js +26 -0
  12. package/lib/commands/graph.js +9 -4
  13. package/lib/commands/heatmap.js +1 -1
  14. package/lib/commands/init.js +209 -27
  15. package/lib/commands/mcp.js +111 -33
  16. package/lib/commands/models.js +138 -41
  17. package/lib/commands/provider.js +30 -59
  18. package/lib/commands/quota.js +1 -1
  19. package/lib/commands/receipt.js +1 -1
  20. package/lib/commands/run.js +14 -6
  21. package/lib/commands/runner.js +31 -1
  22. package/lib/commands/status.js +38 -10
  23. package/lib/commands/swarm.js +130 -12
  24. package/lib/commands/update.js +184 -38
  25. package/lib/commands/usage.js +1 -1
  26. package/lib/commands/validate.js +32 -3
  27. package/lib/commands/vault.js +43 -8
  28. package/lib/python/__init__.py +0 -0
  29. package/lib/python/agent_quotas.py +525 -0
  30. package/lib/python/anomaly_alert.py +397 -0
  31. package/lib/python/anti_pattern_detector.py +799 -0
  32. package/lib/python/auth.py +443 -0
  33. package/lib/python/capi_profile_guard.py +477 -0
  34. package/lib/python/compliance_report.py +581 -0
  35. package/lib/python/drift_detector.py +388 -0
  36. package/lib/python/experience_pipeline.py +1130 -0
  37. package/lib/python/graph.py +19 -0
  38. package/lib/python/graph_core.py +293 -0
  39. package/lib/python/graph_io.py +179 -0
  40. package/lib/python/graph_legacy.py +2052 -0
  41. package/lib/python/graph_legacy_helpers.py +221 -0
  42. package/lib/python/graph_outcomes_core.py +85 -0
  43. package/lib/python/graph_queries.py +171 -0
  44. package/lib/python/graph_slice.py +198 -0
  45. package/lib/python/graph_slicer.py +576 -0
  46. package/lib/python/graph_slicer_cli.py +60 -0
  47. package/lib/python/graph_validation.py +64 -0
  48. package/lib/python/heatmap.py +934 -0
  49. package/lib/python/json_utils.py +193 -0
  50. package/lib/python/mcp_exposure_check.py +247 -0
  51. package/lib/python/model_router.py +1434 -0
  52. package/lib/python/project_manager.py +621 -0
  53. package/lib/python/provider_profiles.py +1618 -0
  54. package/lib/python/provider_registry.py +1211 -0
  55. package/lib/python/provider_registry_cli.py +125 -0
  56. package/lib/python/receipt_png.py +727 -0
  57. package/lib/python/structural_memory.py +325 -0
  58. package/lib/python/swarm_cost.py +177 -0
  59. package/lib/python/usage_ledger.py +569 -0
  60. package/lib/scripts/mcp_tier_config.py +240 -0
  61. package/lib/shared.js +95 -12
  62. package/lib/tui/index.mjs +35174 -0
  63. package/lib/utils/activation_telemetry.js +1 -4
  64. package/lib/utils/constants.js +7 -1
  65. package/lib/utils/identity.js +184 -0
  66. package/lib/utils/mcp-auth.js +81 -15
  67. package/lib/utils/plan.js +1 -1
  68. package/lib/vault/index.js +19 -3
  69. package/lib/vault/storage.js +21 -2
  70. package/lib/wizard.js +5 -2
  71. package/package.json +9 -3
  72. package/scripts/build-python-bundle.js +106 -0
  73. package/scripts/build-tui.js +14 -1
  74. package/scripts/harvest_experience.py +523 -0
  75. package/scripts/postinstall.js +15 -9
@@ -376,6 +376,31 @@ function runRouteDryRunController(target, configPath, forwarded) {
376
376
  }
377
377
  }
378
378
 
379
+ function runHostPoolPlanController(target, forwarded) {
380
+ const script = findRepoScript(target, "runner_host_pool_autosize.py");
381
+ if (!script) {
382
+ log("runner host-pool plan controller not found");
383
+ console.log(` ${D}expected: scripts/runner_host_pool_autosize.py${R}`);
384
+ process.exitCode = 1;
385
+ return;
386
+ }
387
+ const result = spawnSync("python3", [script, "host-pool-plan", ...forwarded], {
388
+ encoding: "utf8",
389
+ timeout: 30000,
390
+ maxBuffer: 8 * 1024 * 1024,
391
+ });
392
+ if (result.error) {
393
+ log(`runner host-pool plan controller failed: ${result.error.message}`);
394
+ process.exitCode = 1;
395
+ return;
396
+ }
397
+ if (result.stdout) process.stdout.write(result.stdout);
398
+ if (result.stderr) process.stderr.write(result.stderr);
399
+ if (typeof result.status === "number" && result.status !== 0) {
400
+ process.exitCode = result.status;
401
+ }
402
+ }
403
+
379
404
  function runLabelAuditController(target, configPath, forwarded) {
380
405
  const script = findRepoScript(target, "runner_label_audit.py");
381
406
  if (!script) {
@@ -468,6 +493,11 @@ function cmdRunner(target, sub, args) {
468
493
  return;
469
494
  }
470
495
 
496
+ if (command === "host-pool-plan" || command === "host-pool-autosize") {
497
+ runHostPoolPlanController(target, forwarded);
498
+ return;
499
+ }
500
+
471
501
  if (command === "label-audit") {
472
502
  runLabelAuditController(target, configPath, forwarded);
473
503
  return;
@@ -485,7 +515,7 @@ function cmdRunner(target, sub, args) {
485
515
  }
486
516
 
487
517
  if (command !== "status" && command !== "plan") {
488
- console.log("Usage: 0dai runner [status|plan|queue-status|label-audit|burst-plan|route-dry-run|burst-apply|burst-preflight|burst-reap|burst-reap-plan] [--json] [--offline]");
518
+ console.log("Usage: 0dai runner [status|plan|queue-status|label-audit|burst-plan|route-dry-run|host-pool-plan|burst-apply|burst-preflight|burst-reap|burst-reap-plan] [--json] [--offline]");
489
519
  process.exitCode = 1;
490
520
  return;
491
521
  }
@@ -9,6 +9,10 @@ const {
9
9
  getActivationMergedPrStats,
10
10
  printActivationStats,
11
11
  } = require("../utils/activation_telemetry");
12
+ const {
13
+ collectMcpExposurePayload,
14
+ formatMcpExposureLine,
15
+ } = require("./doctor");
12
16
 
13
17
  function countJson(dir) {
14
18
  try { return fs.readdirSync(dir).filter(f => f.endsWith(".json")).length; } catch { return 0; }
@@ -19,17 +23,32 @@ function loadJson(file) {
19
23
  }
20
24
 
21
25
  function loadProjectBinding(target) {
22
- return loadJson(path.join(target, ".0dai", "project-binding.json"));
26
+ return shared.loadProjectBinding(target);
23
27
  }
24
28
 
25
- function collectStatusPayload(target) {
29
+ function collectStatusPayload(target, options = {}) {
26
30
  const ai = path.join(target, "ai");
27
- const identity = shared.buildProjectIdentity(target, shared.collectMetadata(target));
31
+ const baseIdentity = shared.buildProjectIdentity(target, shared.collectMetadata(target));
28
32
  const binding = loadProjectBinding(target);
29
- const bindingStatus = (binding && binding.binding_status) || "local-only";
30
- const bindingReason = binding
31
- ? (binding.binding_status ? "" : "project-binding.json has no binding_status")
32
- : "no .0dai/project-binding.json";
33
+ const identity = shared.applyBoundProjectIdentity(target, baseIdentity, binding);
34
+ // A binding whose stored target no longer matches this path (the project was
35
+ // moved/copied) must not report "bound" the identity already falls back to
36
+ // the path-derived one, so the status must say "moved" and prompt a re-bind,
37
+ // not silently claim a stale project_id/name (#4363).
38
+ const bindingHasTarget =
39
+ !!binding &&
40
+ !!(binding.target || binding.current_target || (Array.isArray(binding.target_aliases) && binding.target_aliases.length));
41
+ const boundButMoved =
42
+ !!binding &&
43
+ binding.binding_status === "bound" &&
44
+ bindingHasTarget &&
45
+ !shared.bindingTargetMatches(target, binding);
46
+ const bindingStatus = boundButMoved ? "moved" : ((binding && binding.binding_status) || "local-only");
47
+ const bindingReason = boundButMoved
48
+ ? "binding target does not match this path — project moved or copied; re-bind to refresh identity"
49
+ : binding
50
+ ? (binding.binding_status ? "" : "project-binding.json has no binding_status")
51
+ : "no .0dai/project-binding.json";
33
52
  const bindingNextAction = bindingStatus === "bound" ? "" : "run 0dai project bind";
34
53
  let v = "?", stack = "?";
35
54
  try { v = fs.readFileSync(path.join(ai, "VERSION"), "utf8").trim(); } catch {}
@@ -64,7 +83,7 @@ function collectStatusPayload(target) {
64
83
  } catch {}
65
84
 
66
85
  try {
67
- const ds = findRepoScript(target, "drift_detector.py");
86
+ const ds = shared.resolvePythonScript(target, "drift_detector.py");
68
87
  if (ds) {
69
88
  const dr = spawnSync("python3", [ds, "report", "--target", target],
70
89
  { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000 });
@@ -93,7 +112,7 @@ function collectStatusPayload(target) {
93
112
  queued: q,
94
113
  active: a,
95
114
  done: d,
96
- quota_state: quota.plan === "free" ? "locked" : "available",
115
+ quota_state: Number(quota.daily_limit || 0) === 0 ? "locked" : "available",
97
116
  quota_plan: quota.plan,
98
117
  used_today: Number(quota.used_today || 0),
99
118
  daily_limit: Number(quota.daily_limit || 0),
@@ -120,6 +139,7 @@ function collectStatusPayload(target) {
120
139
  drift: {
121
140
  detected: driftDetected,
122
141
  },
142
+ mcp_exposure: collectMcpExposurePayload(target, options.mcpExposureOptions || {}),
123
143
  warnings: warningCount,
124
144
  activation_ttfv: getActivationDurationStats(target),
125
145
  activation_first_merged_pr: getActivationMergedPrStats(target),
@@ -127,7 +147,7 @@ function collectStatusPayload(target) {
127
147
  }
128
148
 
129
149
  function cmdStatus(target, options = {}) {
130
- const payload = collectStatusPayload(target);
150
+ const payload = collectStatusPayload(target, options);
131
151
  if (options.json) {
132
152
  console.log(JSON.stringify(payload, null, 2));
133
153
  return payload;
@@ -151,6 +171,14 @@ function cmdStatus(target, options = {}) {
151
171
  console.log(` session roaming: ${T}available (${payload.session.roaming_plan})${R}`);
152
172
  }
153
173
 
174
+ const mcpExposure = payload.mcp_exposure || {};
175
+ if (mcpExposure.status && mcpExposure.status !== "green") {
176
+ console.log(` mcp exposure: ${formatMcpExposureLine(mcpExposure)}`);
177
+ if (mcpExposure.next_step) console.log(` → ${mcpExposure.next_step}`);
178
+ } else if (mcpExposure.status === "green") {
179
+ console.log(` mcp exposure: ${formatMcpExposureLine(mcpExposure)}`);
180
+ }
181
+
154
182
  if (payload.session.name) {
155
183
  console.log(` session: ${payload.session.name} (agent: ${payload.session.agent || "?"})`);
156
184
  }
@@ -2,32 +2,121 @@
2
2
  const shared = require("../shared");
3
3
  const { log, T, R, D, fs, path, https, requirePlan, spawnSync, findRepoScript } = shared;
4
4
 
5
+ // The npm package ships the lightweight swarm client; the Python orchestration
6
+ // behind pick/run/quality/sessions runs from a 0dai repo checkout or dev install
7
+ // (it needs the full toolchain + heavier deps). Emit an honest, actionable line
8
+ // instead of dev-internal "probed: <path>" noise that npm users can't act on.
9
+ function logSwarmOrchestrationUnavailable(capability, envVar) {
10
+ log(`${capability} is not available in the npm package`);
11
+ console.log(` ${D}It runs from a 0dai repo checkout with the full Python toolchain.${R}`);
12
+ console.log(` ${D}You need: the repo checkout + python3 + an installed agent CLI (claude/codex/...) + the gh CLI.${R}`);
13
+ console.log(` ${D}Run 0dai doctor to see which prerequisites are missing.${R}`);
14
+ const power = envVar ? ` · power users: point ${envVar} at a local helper` : "";
15
+ console.log(` ${D}Docs: https://0dai.dev/docs${power}${R}`);
16
+ }
17
+
18
+ function printSwarmHelp() {
19
+ console.log("Usage: 0dai swarm [status|pick|run|budget|estimate|quality|sessions|swarm-run]");
20
+ console.log(" 0dai swarm add --task '...' [--for agent]");
21
+ console.log(" 0dai swarm delegate --task '...' [--to agent]");
22
+ console.log(" pick Pick one queued task for this agent: 0dai swarm pick --agent codex");
23
+ console.log(" run One-shot: 0dai swarm run \"<goal>\" creates a task and dispatches it (queue-drains if no goal)");
24
+ console.log(" sessions Show live session registry table (idle/busy/stale per session) [--json]");
25
+ console.log(" swarm-run Repo-checkout helper: add, dispatch, and wait for one swarm task as JSON");
26
+ }
27
+
28
+ function printSwarmRunHelp() {
29
+ console.log("Usage: 0dai swarm swarm-run --task '...' [--agent codex] [--files path ...] [--json]");
30
+ console.log("");
31
+ console.log("Adds, dispatches, and waits for one swarm task through scripts/swarm_run.py.");
32
+ console.log("Requires a full repo checkout helper or ODAI_SWARM_RUN_SCRIPT; otherwise use queue-only swarm commands.");
33
+ }
34
+
35
+ function swarmRunAvailability(target) {
36
+ const override = process.env.ODAI_SWARM_RUN_SCRIPT || "";
37
+ if (override) {
38
+ if (fs.existsSync(override)) return { available: true, source: "ODAI_SWARM_RUN_SCRIPT", path: override };
39
+ return {
40
+ available: false,
41
+ source: "ODAI_SWARM_RUN_SCRIPT",
42
+ path: override,
43
+ reason: "override path does not exist",
44
+ };
45
+ }
46
+ const script = findRepoScript(target, "swarm_run.py");
47
+ if (script) return { available: true, source: "auto", path: script };
48
+ return {
49
+ available: false,
50
+ source: "auto",
51
+ reason: "swarm_run.py not found",
52
+ probed: [
53
+ path.join(target, "scripts", "swarm_run.py"),
54
+ path.join(process.cwd(), "scripts", "swarm_run.py"),
55
+ ],
56
+ };
57
+ }
58
+
59
+ function printSwarmRunnerHint(target) {
60
+ const runner = swarmRunAvailability(target);
61
+ if (runner.available) {
62
+ console.log(` ${D}runner: available (${runner.path})${R}`);
63
+ return runner;
64
+ }
65
+ console.log(` ${D}runner: queue-only (${runner.reason}; set ODAI_SWARM_RUN_SCRIPT or sync scripts/swarm_run.py)${R}`);
66
+ return runner;
67
+ }
68
+
5
69
  function cmdSwarm(target, sub, args) {
6
70
  const swarmDir = path.join(target, "ai", "swarm");
7
71
  const queueDir = path.join(swarmDir, "queue");
8
72
 
73
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
74
+ printSwarmHelp();
75
+ return;
76
+ }
77
+
78
+ if (sub === "pick") {
79
+ cmdPythonSwarm(target, "pick", args.slice(2));
80
+ return;
81
+ }
9
82
  if (sub === "swarm-run") {
10
83
  cmdSwarmRun(target, args.slice(2));
11
84
  return;
12
85
  }
86
+ if (sub === "run") {
87
+ cmdRun(target, args.slice(2));
88
+ return;
89
+ }
13
90
  if (sub === "status") {
14
91
  const count = (d) => { try { return fs.readdirSync(d).filter(f => f.endsWith(".json")).length; } catch { return 0; } };
15
92
  const q = count(path.join(swarmDir, "queue"));
16
93
  const a = count(path.join(swarmDir, "active"));
17
94
  const d = count(path.join(swarmDir, "done"));
18
95
  log(`swarm: ${q} queued, ${a} active, ${d} done`);
96
+ printSwarmRunnerHint(target);
19
97
  return;
20
98
  }
21
99
  if (sub === "add" || sub === "delegate") {
100
+ if (args.includes("--help") || args.includes("-h")) {
101
+ printSwarmHelp();
102
+ return;
103
+ }
104
+ const taskIndex = args.indexOf("--task");
105
+ const task = taskIndex >= 0 ? args[taskIndex + 1] : "";
106
+ if (!task || task.startsWith("--")) {
107
+ log(`Usage: 0dai swarm ${sub} --task '...' [--for agent]`);
108
+ process.exitCode = 1;
109
+ return;
110
+ }
22
111
  const gate = requirePlan("pro", "Swarm", target);
23
112
  if (gate) { log(gate.error); log(gate.hint); return; }
24
113
  fs.mkdirSync(queueDir, { recursive: true });
25
- const task = args.find((_, i) => args[i - 1] === "--task") || "untitled";
26
114
  const forAgent = args.find((_, i) => ["--for", "--to"].includes(args[i - 1])) || "any";
27
115
  const id = `swarm-${Date.now()}`;
28
116
  const t = { id, title: task, assigned_to: forAgent, status: "pending", created_at: new Date().toISOString(), created_by: "cli" };
29
117
  fs.writeFileSync(path.join(queueDir, `${id}.json`), JSON.stringify(t, null, 2));
30
118
  log(`task created: ${id} → ${forAgent}`);
119
+ printSwarmRunnerHint(target);
31
120
  return;
32
121
  }
33
122
  if (sub === "webhook") {
@@ -185,7 +274,7 @@ function cmdSwarm(target, sub, args) {
185
274
  }
186
275
  if (sub === "quality") {
187
276
  const scorerScript = findRepoScript(target, "quality_scorer.py");
188
- if (!scorerScript) { log("quality scorer unavailable"); return; }
277
+ if (!scorerScript) { logSwarmOrchestrationUnavailable("swarm quality scoring"); return; }
189
278
  const fwd = [scorerScript, "--target", target];
190
279
  if (args.includes("--json")) fwd.push("--json");
191
280
  for (let i = 2; i < args.length; i++) {
@@ -198,7 +287,7 @@ function cmdSwarm(target, sub, args) {
198
287
  }
199
288
  if (sub === "sessions") {
200
289
  const script = findRepoScript(target, "swarm_session_registry.py");
201
- if (!script) { log("swarm session registry unavailable"); return; }
290
+ if (!script) { logSwarmOrchestrationUnavailable("the swarm session registry"); return; }
202
291
  const fwd = [script, "rebuild", "--stale-after", "600"];
203
292
  const asJson = args.includes("--json");
204
293
  if (!asJson) fwd.push("--write", path.join(swarmDir, "session_registry.json"));
@@ -250,12 +339,44 @@ function cmdSwarm(target, sub, args) {
250
339
  console.log(` ${G2}idle${reset}: ${idle} ${W2}busy${reset}: ${busy} ${M2}stale${reset}: ${stale} total: ${rows.length}\n`);
251
340
  return;
252
341
  }
253
- console.log("Usage: 0dai swarm [status|add|delegate|budget|estimate|quality|sessions|swarm-run] [--task '...'] [--to agent]");
254
- console.log(" sessions Show live session registry table (idle/busy/stale per session) [--json]");
255
- console.log(" swarm-run Add, dispatch, and wait for one swarm task as JSON");
342
+ printSwarmHelp();
343
+ }
344
+
345
+ function cmdPythonSwarm(target, subcommand, args) {
346
+ // Forward Python-backed swarm operations through scripts/swarm.py so quota,
347
+ // dispatch gates, pick semantics, and preflight messaging have one source of
348
+ // truth. `swarm-run` (hyphenated add+wait helper) stays on swarm_run.py.
349
+ let script = process.env.ODAI_SWARM_SCRIPT || "";
350
+ if (script && !fs.existsSync(script)) {
351
+ log(`swarm helper unavailable: ODAI_SWARM_SCRIPT=${script} does not exist`);
352
+ process.exit(1);
353
+ }
354
+ if (!script) script = findRepoScript(target, "swarm.py");
355
+ if (!script) {
356
+ logSwarmOrchestrationUnavailable("swarm orchestration", "ODAI_SWARM_SCRIPT");
357
+ process.exit(1);
358
+ }
359
+ const python = process.env.ODAI_SWARM_RUN_PYTHON || "python3";
360
+ const result = spawnSync(python, [script, "--target", target, subcommand, ...args], { stdio: "inherit" });
361
+ if (result.error) {
362
+ log(`swarm ${subcommand} failed: ${result.error.message}`);
363
+ process.exit(1);
364
+ }
365
+ if (typeof result.status === "number" && result.status !== 0) process.exit(result.status);
366
+ }
367
+
368
+ function cmdRun(target, args) {
369
+ // `0dai swarm run "<goal>"` — one-shot: forward to the python swarm.py `run`
370
+ // subcommand so the per-tier daily quota (check_swarm_quota / record_swarm_task)
371
+ // governs identically for the goal path and the queue-drain.
372
+ cmdPythonSwarm(target, "run", args);
256
373
  }
257
374
 
258
375
  function cmdSwarmRun(target, args) {
376
+ if (args.includes("--help") || args.includes("-h")) {
377
+ printSwarmRunHelp();
378
+ return;
379
+ }
259
380
  // #2143 hardening:
260
381
  // 1. `ODAI_SWARM_RUN_SCRIPT` env override lets tests inject a deterministic
261
382
  // script path, bypassing the `findRepoScript` heuristic that walks up
@@ -269,15 +390,12 @@ function cmdSwarmRun(target, args) {
269
390
  let script = process.env.ODAI_SWARM_RUN_SCRIPT || "";
270
391
  if (script && !fs.existsSync(script)) {
271
392
  log(`swarm-run helper unavailable: ODAI_SWARM_RUN_SCRIPT=${script} does not exist`);
393
+ log(` hint: set ODAI_SWARM_RUN_SCRIPT to a valid helper, unset it, or create the helper at that exact path`);
272
394
  process.exit(1);
273
395
  }
274
396
  if (!script) script = findRepoScript(target, "swarm_run.py");
275
397
  if (!script) {
276
- const cwd = process.cwd();
277
- log("swarm-run helper unavailable");
278
- log(` probed: ${path.join(target, "scripts", "swarm_run.py")}`);
279
- log(` probed: ${path.join(cwd, "scripts", "swarm_run.py")}`);
280
- log(` hint: set ODAI_SWARM_RUN_SCRIPT to override the lookup`);
398
+ logSwarmOrchestrationUnavailable("the swarm-run helper", "ODAI_SWARM_RUN_SCRIPT");
281
399
  process.exit(1);
282
400
  }
283
401
  const python = process.env.ODAI_SWARM_RUN_PYTHON || "python3";
@@ -289,4 +407,4 @@ function cmdSwarmRun(target, args) {
289
407
  if (typeof result.status === "number" && result.status !== 0) process.exit(result.status);
290
408
  }
291
409
 
292
- module.exports = { cmdSwarm, cmdSwarmRun };
410
+ module.exports = { cmdSwarm, cmdSwarmRun, cmdRun, cmdPythonSwarm, swarmRunAvailability };
@@ -1,10 +1,123 @@
1
1
  "use strict";
2
2
  const shared = require("../shared");
3
- const { log, SUPPORTED_CLIS } = shared;
3
+ const { log, SUPPORTED_CLIS, cliDisplayName } = shared;
4
+
5
+ function installCommand(cli) {
6
+ if (cli.pkgType === "npm" && cli.pkg) return `npm install -g ${cli.pkg}@latest`;
7
+ if (cli.pkgType === "pip" && cli.pkg) return `pip install --upgrade ${cli.pkg}`;
8
+ return cli.install || "";
9
+ }
10
+
11
+ function latestCommand(cli) {
12
+ if (cli.pkgType === "npm" && cli.pkg) return `npm view ${cli.pkg} version`;
13
+ if (cli.pkgType === "pip" && cli.pkg) return `pip index versions ${cli.pkg}`;
14
+ return "";
15
+ }
16
+
17
+ function parseVersion(out) {
18
+ const m = String(out || "").match(/(\d+\.\d+\.\d+)/);
19
+ return m ? m[1] : null;
20
+ }
21
+
22
+ function readInstalledVersion(cli, execFileSync) {
23
+ try {
24
+ const out = execFileSync(cli.bin, ["--version"], { timeout: 8000 }).toString().trim();
25
+ return { installed: true, version: parseVersion(out), raw: out };
26
+ } catch {
27
+ return { installed: false, version: null, raw: "" };
28
+ }
29
+ }
30
+
31
+ function readLatestVersion(cli, execFileSync) {
32
+ if (cli.pkgType === "npm" && cli.pkg) {
33
+ try {
34
+ const latest = execFileSync("npm", ["view", cli.pkg, "version"], { timeout: 5000 })
35
+ .toString()
36
+ .trim();
37
+ return { latest, error: null };
38
+ } catch (error) {
39
+ return { latest: null, error };
40
+ }
41
+ }
42
+ if (cli.pkgType === "pip" && cli.pkg) {
43
+ try {
44
+ const out = execFileSync("pip", ["index", "versions", cli.pkg], { timeout: 8000, encoding: "utf8" });
45
+ const m = String(out).match(/LATEST:\s*(\d+\.\d+\.\d+)/i) || String(out).match(/(\d+\.\d+\.\d+)/);
46
+ return { latest: m ? m[1] : null, error: null };
47
+ } catch (error) {
48
+ return { latest: null, error };
49
+ }
50
+ }
51
+ return { latest: null, error: null };
52
+ }
53
+
54
+ function verifyUpdatedVersion(cli, expected, execFileSync) {
55
+ const after = readInstalledVersion(cli, execFileSync);
56
+ if (!after.installed) {
57
+ return {
58
+ ok: false,
59
+ version: null,
60
+ error: `post-update verification failed: ${cli.bin} is not available after install`,
61
+ };
62
+ }
63
+ if (after.version === expected) {
64
+ return { ok: true, version: after.version, error: null };
65
+ }
66
+ const actual = after.version
67
+ ? after.version
68
+ : (after.raw ? `unparseable output ${JSON.stringify(after.raw)}` : "unknown");
69
+ return {
70
+ ok: false,
71
+ version: after.version,
72
+ error: `post-update verification failed: ${cli.name} is still ${actual} (expected ${expected})`,
73
+ };
74
+ }
75
+
76
+ function updateEntry(cli, current, latest, status, extra = {}) {
77
+ return {
78
+ name: cli.name,
79
+ bin: cli.bin,
80
+ current: current || "",
81
+ latest: latest || "",
82
+ status,
83
+ ...(extra.error ? { error: extra.error } : {}),
84
+ ...(extra.next ? { next: extra.next } : {}),
85
+ };
86
+ }
87
+
88
+ function formatEntry(entry) {
89
+ const current = entry.current ? String(entry.current) : "";
90
+ const latest = entry.latest ? String(entry.latest) : "";
91
+ const name = cliDisplayName(entry);
92
+ const version = latest && current && latest !== current
93
+ ? `${current} -> ${latest}`
94
+ : (current || latest || "unknown");
95
+ return `${name} ${version}`;
96
+ }
97
+
98
+ function printSummary(summary) {
99
+ log("update summary");
100
+ for (const key of ["updated", "would_update", "failed", "unchanged"]) {
101
+ const entries = summary[key] || [];
102
+ if (!entries.length) continue;
103
+ console.log(` ${key.replace("_", " ")}: ${entries.map(formatEntry).join(", ")}`);
104
+ for (const entry of entries) {
105
+ const name = cliDisplayName(entry);
106
+ if (entry.next) console.log(` next ${name}: ${entry.next}`);
107
+ if (entry.error) console.log(` error ${name}: ${entry.error}`);
108
+ }
109
+ }
110
+ const nextEntries = ["would_update", "failed"]
111
+ .flatMap((key) => summary[key] || []);
112
+ if (nextEntries.some((entry) => entry.next && entry.next.startsWith("npm install "))) {
113
+ console.log(" note: global npm may need sudo/admin");
114
+ }
115
+ }
4
116
 
5
117
  function cmdUpdate(args) {
6
118
  const { execFileSync: _ef3, execSync } = require("child_process");
7
119
  const dryRun = args.includes("--dry-run");
120
+ const wantJson = args.includes("--json");
8
121
 
9
122
  // 0dai self-update entry, then all supported agent CLIs.
10
123
  const CLIS = [
@@ -12,58 +125,91 @@ function cmdUpdate(args) {
12
125
  ...SUPPORTED_CLIS,
13
126
  ];
14
127
 
15
- let updated = 0;
128
+ const summary = {
129
+ dry_run: dryRun,
130
+ updated: [],
131
+ would_update: [],
132
+ failed: [],
133
+ unchanged: [],
134
+ skipped: [],
135
+ };
16
136
  for (const cli of CLIS) {
17
- let installed = false, ver = null;
18
- try {
19
- const out = _ef3(cli.bin, ["--version"], { timeout: 8000 }).toString().trim();
20
- installed = true;
21
- const m = out.match(/(\d+\.\d+\.\d+)/);
22
- if (m) ver = m[1];
23
- } catch {}
24
-
25
- if (!installed) continue;
26
-
27
- let latest = null;
28
- if (cli.pkgType === "npm" && cli.pkg) {
29
- try { latest = _ef3("npm", ["view", cli.pkg, "version"], { timeout: 5000 }).toString().trim(); } catch {}
30
- } else if (cli.pkgType === "pip" && cli.pkg) {
31
- try {
32
- const out = _ef3("pip", ["index", "versions", cli.pkg], { timeout: 8000, encoding: "utf8" });
33
- const m = out.match(/LATEST:\s*(\d+\.\d+\.\d+)/i) || out.match(/(\d+\.\d+\.\d+)/);
34
- if (m) latest = m[1];
35
- } catch {}
137
+ const label = cliDisplayName(cli);
138
+ const installedInfo = readInstalledVersion(cli, _ef3);
139
+ const ver = installedInfo.version;
140
+
141
+ if (!installedInfo.installed) {
142
+ summary.skipped.push(updateEntry(cli, "", "", "not_installed"));
143
+ continue;
144
+ }
145
+
146
+ const latestInfo = readLatestVersion(cli, _ef3);
147
+ const latest = latestInfo.latest;
148
+ const latestError = latestInfo.error;
149
+
150
+ if (latestError) {
151
+ const error = `latest check failed: ${String(latestError && latestError.message ? latestError.message : latestError).split("\n")[0]}`;
152
+ summary.failed.push(updateEntry(cli, ver, latest, "latest_unknown", { error, next: latestCommand(cli) }));
153
+ if (!wantJson) log(`failed to check ${label}: ${error}`);
154
+ continue;
36
155
  }
37
156
 
38
157
  if (!latest || latest === ver) {
39
- console.log(` ${cli.name} ${ver || ""} — up to date`);
158
+ summary.unchanged.push(updateEntry(cli, ver, latest, "unchanged"));
159
+ if (!wantJson) console.log(` ${label} ${ver || ""} — up to date`);
40
160
  continue;
41
161
  }
42
162
 
43
- console.log(` ${cli.name} ${ver} → ${latest}`);
44
- if (dryRun) { updated++; continue; }
163
+ if (!wantJson) console.log(` ${label} ${ver} → ${latest}`);
164
+ if (dryRun) {
165
+ summary.would_update.push(updateEntry(cli, ver, latest, "would_update", { next: installCommand(cli) }));
166
+ continue;
167
+ }
45
168
 
46
169
  try {
47
- if (cli.pkgType === "npm") {
48
- log(`updating ${cli.name}...`);
49
- execSync(`npm install -g ${cli.pkg}@latest`, { timeout: 60000, stdio: "pipe" });
50
- log(`${cli.name} updated to ${latest}`);
51
- updated++;
52
- } else if (cli.pkgType === "pip") {
53
- log(`updating ${cli.name}...`);
54
- execSync(`pip install --upgrade ${cli.pkg}`, { timeout: 60000, stdio: "pipe" });
55
- log(`${cli.name} updated to ${latest}`);
56
- updated++;
170
+ if (cli.pkgType === "npm" || cli.pkgType === "pip") {
171
+ const next = installCommand(cli);
172
+ if (!wantJson) log(`updating ${label}...`);
173
+ execSync(next, { timeout: 60000, stdio: "pipe" });
174
+ const verified = verifyUpdatedVersion(cli, latest, _ef3);
175
+ if (!verified.ok) {
176
+ summary.failed.push(updateEntry(cli, ver, latest, "verify_failed", { error: verified.error, next }));
177
+ if (!wantJson) log(`failed to verify ${label}: ${verified.error}`);
178
+ continue;
179
+ }
180
+ if (!wantJson) log(`${label} updated to ${latest}`);
181
+ summary.updated.push(updateEntry(cli, ver, latest, "updated"));
182
+ } else {
183
+ summary.skipped.push(updateEntry(cli, ver, latest, "unsupported"));
57
184
  }
58
185
  } catch (e) {
59
- log(`failed to update ${cli.name}: ${e.message.split("\n")[0]}`);
186
+ const error = String(e && e.message ? e.message : e).split("\n")[0];
187
+ summary.failed.push(updateEntry(cli, ver, latest, "failed", { error, next: installCommand(cli) }));
188
+ if (!wantJson) log(`failed to update ${label}: ${error}`);
60
189
  }
61
190
  }
62
- if (updated) {
63
- log(`${dryRun ? "would update" : "updated"} ${updated} CLI(s)`);
191
+
192
+ summary.counts = {
193
+ updated: summary.updated.length,
194
+ would_update: summary.would_update.length,
195
+ failed: summary.failed.length,
196
+ unchanged: summary.unchanged.length,
197
+ skipped: summary.skipped.length,
198
+ };
199
+
200
+ if (wantJson) {
201
+ console.log(JSON.stringify(summary, null, 2));
202
+ } else if (summary.updated.length || summary.would_update.length || summary.failed.length) {
203
+ printSummary(summary);
64
204
  } else {
65
205
  log("all CLIs are up to date");
66
206
  }
207
+ return summary;
67
208
  }
68
209
 
69
- module.exports = { cmdUpdate };
210
+ module.exports = {
211
+ cmdUpdate,
212
+ installCommand,
213
+ latestCommand,
214
+ formatEntry,
215
+ };
@@ -67,7 +67,7 @@ function cmdUsage(target, rawArgs = []) {
67
67
  process.exit(1);
68
68
  }
69
69
 
70
- const script = findRepoScript(target, "usage_ledger.py");
70
+ const script = shared.resolvePythonScript(target, "usage_ledger.py");
71
71
  if (!script) {
72
72
  log("usage ledger unavailable");
73
73
  console.log(` ${D}Expected scripts/usage_ledger.py in this project${R}`);