@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
@@ -5,9 +5,10 @@
5
5
  *
6
6
  * Subcommands (all read-only / inspection):
7
7
  * - list [--plan free|pro|team|enterprise] [--json]
8
- * Enumerate MCP tools exposed by scripts/mcp_server.py.
8
+ * Enumerate MCP tools exposed by the MCP tier manifest.
9
9
  * Resolves from ai/meta/manifest/mcp-tool-tiers.json when present,
10
- * falls back to scanning @mcp.tool decorators in scripts/mcp_server.py.
10
+ * falls back to package metadata, then scans @mcp.tool decorators in
11
+ * scripts/mcp_server.py for repo checkouts.
11
12
  * - catalog [--stack NAME] [--json]
12
13
  * Show the per-stack MCP server catalog (common + stack servers)
13
14
  * from templates/layer/ai/registry/mcp-catalog.json.
@@ -16,6 +17,8 @@
16
17
  * - call <tool> [--json '<args>'] [--json-out]
17
18
  * Invoke a single MCP tool synchronously via scripts/mcp_server.py
18
19
  * (no daemon spawn — direct in-process call inside the helper subprocess).
20
+ * This is a repo-checkout helper; npm installs use the OAuth HTTP /mcp
21
+ * entry for live MCP calls.
19
22
  *
20
23
  * This command never mutates project state. It is the documented
21
24
  * counterpart of the `mcp list` / `mcp call` / `mcp catalog` docs and the
@@ -31,9 +34,9 @@ function _printHelp() {
31
34
  console.log(" 0dai mcp list [--plan free|pro|team|enterprise] [--json]");
32
35
  console.log(" 0dai mcp catalog [--stack NAME] [--json]");
33
36
  console.log(" 0dai mcp doctor [--json]");
34
- console.log(" 0dai mcp call <tool> [--json '<json-args>'] [--json-out]");
37
+ console.log(" 0dai mcp call <tool> [--json '<json-args>'] [--json-out] (repo-checkout helper)");
35
38
  console.log("");
36
- console.log("Read-only inspection of the project's MCP server (scripts/mcp_server.py).");
39
+ console.log("Read-only inspection of MCP metadata; live npm MCP calls use the claude_ai_0dai HTTP /mcp entry.");
37
40
  console.log("");
38
41
  }
39
42
 
@@ -59,12 +62,20 @@ function _readJsonFile(filePath) {
59
62
  }
60
63
  }
61
64
 
62
- function _toolsFromTierManifest(target, plan) {
63
- const candidates = [
65
+ function _packageDataPath(...parts) {
66
+ return path.join(__dirname, "..", ...parts);
67
+ }
68
+
69
+ function _tierManifestCandidates(target) {
70
+ return [
64
71
  path.join(target, "ai", "meta", "manifest", "mcp-tool-tiers.json"),
65
72
  path.join(target, "ai", "manifest", "mcp-tool-tiers.json"),
73
+ _packageDataPath("ai", "meta", "manifest", "mcp-tool-tiers.json"),
66
74
  ];
67
- for (const candidate of candidates) {
75
+ }
76
+
77
+ function _toolsFromTierManifest(target, plan) {
78
+ for (const candidate of _tierManifestCandidates(target)) {
68
79
  if (!fs.existsSync(candidate)) continue;
69
80
  const data = _readJsonFile(candidate);
70
81
  if (!data || typeof data !== "object") continue;
@@ -125,6 +136,7 @@ function _resolveCatalogPath(target) {
125
136
  const candidates = [
126
137
  path.join(target, "ai", "registry", "mcp-catalog.json"),
127
138
  path.join(target, "templates", "layer", "ai", "registry", "mcp-catalog.json"),
139
+ _packageDataPath("ai", "registry", "mcp-catalog.json"),
128
140
  ];
129
141
  // Also look next to the npm package (when run from a sync'd repo elsewhere).
130
142
  const repoRoot = path.resolve(__dirname, "..", "..", "..", "..");
@@ -203,14 +215,12 @@ function _cmdCatalog(target, args) {
203
215
  console.log(`\n ${T}available stacks${R}: ${available.join(", ")}\n`);
204
216
  }
205
217
 
206
- // Flag MCP server entries left behind by a pre-4.3 `0dai sync` that no longer
207
- // connect. Two dead patterns: the deprecated `/sse` operator endpoint (HTTP 404,
208
- // 0dai-dev/docs#56) and the `transport: "sse"` form. `mergeMcpConfig` only manages
209
- // its own servers and never prunes unknown user entries -- even with --reset -- so
210
- // these persist after upgrade until removed by hand. The current generator connects
211
- // operator-MCP via the headerless "claude.ai 0dai" (OAuth /mcp) entry, so the stale
212
- // block is dead weight. Detection stays on the unambiguous /sse signal to avoid
213
- // false positives on a legitimately-wired bearer host-pool config (#57).
218
+ // Flag MCP server entries left behind by older `0dai sync` runs that no longer
219
+ // connect. `mergeMcpConfig` only manages its own servers and never prunes unknown
220
+ // user entries -- even with --reset -- so these persist after upgrade until
221
+ // removed by hand. The current generator connects operator-MCP via the headerless
222
+ // "claude_ai_0dai" OAuth /mcp entry; local stdio entries must point at an
223
+ // existing helper script inside the project or at an absolute helper path.
214
224
  function _detectStaleMcpServers(mcpServers) {
215
225
  const stale = [];
216
226
  for (const [name, cfg] of Object.entries(mcpServers || {})) {
@@ -224,10 +234,45 @@ function _detectStaleMcpServers(mcpServers) {
224
234
  return stale;
225
235
  }
226
236
 
237
+ function _mcpServerScriptArg(cfg) {
238
+ const args = Array.isArray(cfg && cfg.args) ? cfg.args : [];
239
+ for (const arg of args) {
240
+ const value = String(arg || "");
241
+ if (/(^|[/\\])mcp_server\.py$/.test(value)) return value;
242
+ }
243
+ const command = typeof (cfg && cfg.command) === "string" ? cfg.command : "";
244
+ return /(^|[/\\])mcp_server\.py$/.test(command) ? command : "";
245
+ }
246
+
247
+ function _resolveMcpConfigPath(rawPath, target) {
248
+ const value = String(rawPath || "").trim();
249
+ if (!value) return "";
250
+ return path.resolve(path.isAbsolute(value) ? value : path.join(target, value));
251
+ }
252
+
253
+ function _detectMissingMcpServerScripts(mcpServers, target) {
254
+ const stale = [];
255
+ for (const [name, cfg] of Object.entries(mcpServers || {})) {
256
+ if (!cfg || typeof cfg !== "object") continue;
257
+ const scriptArg = _mcpServerScriptArg(cfg);
258
+ if (!scriptArg) continue;
259
+ const resolved = _resolveMcpConfigPath(scriptArg, target);
260
+ if (!fs.existsSync(resolved)) {
261
+ stale.push({
262
+ name,
263
+ reason: `missing MCP server script referenced by config (${scriptArg})`,
264
+ path: resolved,
265
+ });
266
+ }
267
+ }
268
+ return stale;
269
+ }
270
+
227
271
  function _cmdDoctor(target, args) {
228
272
  const wantJson = args.includes("--json");
229
273
  const report = {
230
274
  target,
275
+ local_server_bundled: false,
231
276
  server_script: { path: "", ok: false },
232
277
  mcp_config: { path: "", ok: false, servers: [], stale: [] },
233
278
  tier_manifest: { path: "", ok: false, free_count: 0, pro_count: 0 },
@@ -236,6 +281,7 @@ function _cmdDoctor(target, args) {
236
281
 
237
282
  const script = findRepoScript(target, "mcp_server.py");
238
283
  if (script) {
284
+ report.local_server_bundled = true;
239
285
  report.server_script.path = script;
240
286
  report.server_script.ok = fs.existsSync(script);
241
287
  }
@@ -247,13 +293,15 @@ function _cmdDoctor(target, args) {
247
293
  if (data && data.mcpServers && typeof data.mcpServers === "object") {
248
294
  report.mcp_config.ok = true;
249
295
  report.mcp_config.servers = Object.keys(data.mcpServers);
250
- report.mcp_config.stale = _detectStaleMcpServers(data.mcpServers);
296
+ report.mcp_config.stale = [
297
+ ..._detectStaleMcpServers(data.mcpServers),
298
+ ..._detectMissingMcpServerScripts(data.mcpServers, target),
299
+ ];
251
300
  }
252
301
  }
253
302
 
254
303
  const tierCandidates = [
255
- path.join(target, "ai", "meta", "manifest", "mcp-tool-tiers.json"),
256
- path.join(target, "ai", "manifest", "mcp-tool-tiers.json"),
304
+ ..._tierManifestCandidates(target),
257
305
  ];
258
306
  for (const candidate of tierCandidates) {
259
307
  if (!fs.existsSync(candidate)) continue;
@@ -278,6 +326,18 @@ function _cmdDoctor(target, args) {
278
326
  }
279
327
  }
280
328
 
329
+ // The local stdio server script is a repo-checkout artifact that the
330
+ // published npm package intentionally does not bundle: npm users get live MCP
331
+ // calls over the OAuth "claude_ai_0dai" HTTP /mcp entry, not a local server.
332
+ // Read-only list/catalog metadata may be package-bundled, but only treat the
333
+ // server script absence as a failure when we ARE in a repo checkout.
334
+ const allOk = report.local_server_bundled
335
+ ? (report.server_script.ok && report.mcp_config.ok
336
+ && report.mcp_config.stale.length === 0
337
+ && (report.tier_manifest.ok || report.catalog.ok))
338
+ : report.mcp_config.stale.length === 0;
339
+ if (!allOk) process.exitCode = 1;
340
+
281
341
  if (wantJson) {
282
342
  console.log(JSON.stringify(report));
283
343
  return;
@@ -285,31 +345,47 @@ function _cmdDoctor(target, args) {
285
345
 
286
346
  const ok = (b) => (b ? G + "ok" + R : W + "missing" + R);
287
347
  console.log(`\n ${T}0dai mcp doctor${R} target=${target}\n`);
288
- console.log(` server script ${ok(report.server_script.ok)} ${D}${report.server_script.path || "(not found)"}${R}`);
348
+
349
+ // npm-installed package: the local stdio server is never shipped, so don't
350
+ // dead-end on a bare "missing" line. Explain WHY and point at the actual MCP
351
+ // path npm users have (the OAuth HTTP /mcp entry).
352
+ if (!report.local_server_bundled) {
353
+ console.log(` local stdio MCP server ${D}not bundled (npm install)${R}`);
354
+ console.log(` ${D}The published 0dai package does not ship the local stdio MCP server${R}`);
355
+ console.log(` ${D}(scripts/mcp_server.py). MCP tools are served over the OAuth${R}`);
356
+ console.log(` ${D}"claude_ai_0dai" HTTP /mcp entry instead.${R}`);
357
+ console.log(` ${D}Read-only list/catalog metadata is package-bundled when available.${R}`);
358
+ console.log(` ${G}→${R} run ${T}/mcp${R} in Claude Code and Authenticate the ${T}claude_ai_0dai${R} server`);
359
+ console.log(` ${G}→${R} if that entry is absent, run ${T}0dai sync --target ${target}${R} to write it`);
360
+ console.log("");
361
+ } else {
362
+ console.log(` server script ${ok(report.server_script.ok)} ${D}${report.server_script.path || "(not found)"}${R}`);
363
+ }
364
+
289
365
  console.log(` .mcp.json ${ok(report.mcp_config.ok)} ${D}${report.mcp_config.path || "(not found)"}${R}`);
290
366
  if (report.mcp_config.ok) {
291
367
  console.log(` servers: ${report.mcp_config.servers.join(", ")}`);
292
368
  }
293
369
  if (report.mcp_config.stale.length) {
294
- console.log(` ${W}stale${R} (remove from .mcp.json — superseded by the "claude.ai 0dai" OAuth /mcp entry):`);
370
+ console.log(` ${W}stale${R} (remove from .mcp.json — superseded by the "claude_ai_0dai" OAuth /mcp entry):`);
295
371
  for (const s of report.mcp_config.stale) {
296
- console.log(` ${W}!${R} ${s.name} ${D} ${s.reason}${R}`);
372
+ const detail = s.path ? `${s.reason}: ${s.path}` : s.reason;
373
+ console.log(` ${W}!${R} ${s.name} ${D}— ${detail}${R}`);
297
374
  }
298
375
  }
299
- console.log(` tier manifest ${ok(report.tier_manifest.ok)} ${D}${report.tier_manifest.path || "(not found)"}${R}`);
300
- if (report.tier_manifest.ok) {
301
- console.log(` free=${report.tier_manifest.free_count} pro=${report.tier_manifest.pro_count}`);
302
- }
303
- console.log(` catalog ${ok(report.catalog.ok)} ${D}${report.catalog.path || "(not found)"}${R}`);
304
- if (report.catalog.ok) {
305
- console.log(` stacks: ${report.catalog.stacks}`);
376
+
377
+ if (report.local_server_bundled) {
378
+ console.log(` tier manifest ${ok(report.tier_manifest.ok)} ${D}${report.tier_manifest.path || "(not found)"}${R}`);
379
+ if (report.tier_manifest.ok) {
380
+ console.log(` free=${report.tier_manifest.free_count} pro=${report.tier_manifest.pro_count}`);
381
+ }
382
+ console.log(` catalog ${ok(report.catalog.ok)} ${D}${report.catalog.path || "(not found)"}${R}`);
383
+ if (report.catalog.ok) {
384
+ console.log(` stacks: ${report.catalog.stacks}`);
385
+ }
306
386
  }
307
387
  console.log("");
308
388
 
309
- const allOk = report.server_script.ok && report.mcp_config.ok
310
- && report.mcp_config.stale.length === 0
311
- && (report.tier_manifest.ok || report.catalog.ok);
312
- if (!allOk) process.exitCode = 1;
313
389
  }
314
390
 
315
391
  function _cmdCall(target, toolName, args) {
@@ -323,7 +399,9 @@ function _cmdCall(target, toolName, args) {
323
399
 
324
400
  const script = findRepoScript(target, "mcp_server.py");
325
401
  if (!script) {
326
- log("scripts/mcp_server.py not found in this repo");
402
+ const message = "mcp call is a repo-checkout helper: scripts/mcp_server.py is not bundled in npm installs. Use the claude_ai_0dai OAuth HTTP /mcp entry for live MCP tool calls.";
403
+ if (wantJsonOut) console.log(JSON.stringify({ error: message, repo_checkout_required: true }));
404
+ else log(message);
327
405
  process.exitCode = 1;
328
406
  return;
329
407
  }
@@ -1,45 +1,114 @@
1
1
  "use strict";
2
2
  const shared = require("../shared");
3
- const { T, R, SUPPORTED_CLIS } = shared;
3
+ const { log, T, R, D, SUPPORTED_CLIS, spawnSync } = shared;
4
4
  const {
5
5
  probeInstalledCliNames,
6
6
  summarizeModelAvailability,
7
7
  formatAvailableFooter,
8
8
  } = require("../utils/model_ratings");
9
9
 
10
+ // Scores from benchmark_models.py (3-task: read/count/review) + field-tested ledger.
11
+ // Catalog refreshed 2026-04-19; prior benchmark baseline 2026-04-06.
12
+ // Since then: native claude `--model opus` alias routes to opus-4.7 1M-ctx (CC extended);
13
+ // gpt-5.5 promoted to primary in agent-matrix/model_registry;
14
+ // gpt-5.3-codex empirically verified — does heavy internal reasoning
15
+ // (66k tokens on trivial "hello" prompt), pick deliberately for architecture.
16
+ // Billing: native claude/codex/gemini = subscription (marginal $0/call but tokens tracked
17
+ // for opportunity cost per operator 2026-04-19). RuAPI / opencode-go = pay-per-use.
18
+ const MODELS = [
19
+ { name: "Claude Opus 4.7 (1M)", tier: "deep", score: 98, cli: "claude", flag: "--model opus", tested: true },
20
+ { name: "Claude Opus 4.7", tier: "deep", score: 97, cli: "opencode", flag: "-m RuAPI/claude-opus-4.7", tested: true },
21
+ { name: "GPT-5.5", tier: "deep", score: 96, cli: "codex", flag: "-m gpt-5.5", tested: true },
22
+ { name: "Claude Opus 4.6", tier: "deep", score: 95, cli: "claude", flag: "--model claude-opus-4-6" },
23
+ { name: "Opus 4.6 (RuAPI)", tier: "deep", score: 95, cli: "opencode", flag: "-m RuAPI/claude-opus-4.6", tested: true },
24
+ { name: "GPT-5.4-mini", tier: "fast", score: 93, cli: "codex", flag: "-m gpt-5.4-mini", tested: true },
25
+ { name: "MiniMax M2.7", tier: "balanced", score: 93, cli: "opencode", flag: "-m opencode-go/minimax-m2.7", tested: true },
26
+ { name: "Claude Sonnet 4.6", tier: "balanced", score: 90, cli: "claude", flag: "--model sonnet" },
27
+ { name: "Sonnet 4.6 (RuAPI)",tier: "balanced", score: 90, cli: "opencode", flag: "-m RuAPI/claude-sonnet-4.6", tested: true },
28
+ { name: "GPT-5.4", tier: "balanced", score: 90, cli: "codex", flag: "-m gpt-5.4", tested: true },
29
+ { name: "Gemini 3.1 Pro", tier: "deep", score: 89, cli: "opencode", flag: "-m RuAPI/gemini-3.1-pro-preview", tested: true },
30
+ { name: "Kimi K2.5", tier: "balanced", score: 88, cli: "opencode", flag: "-m opencode-go/kimi-k2.5", tested: true },
31
+ { name: "Qwen 3.6+ Free", tier: "free", score: 88, cli: "opencode", flag: "-m opencode/qwen3.6-plus-free", tested: true },
32
+ { name: "GPT-5.2", tier: "balanced", score: 87, cli: "codex", flag: "-m gpt-5.2", tested: true },
33
+ { name: "Gemini 3.1 Pro", tier: "balanced", score: 85, cli: "gemini", flag: "-m gemini-3.1-pro" },
34
+ { name: "GPT-5.3 Codex", tier: "deep", score: 83, cli: "codex", flag: "-m gpt-5.3-codex", tested: true },
35
+ { name: "GPT-5.3 Spark", tier: "fast", score: 82, cli: "codex", flag: "-m gpt-5.3-codex-spark" },
36
+ { name: "Claude Haiku 4.5", tier: "fast", score: 78, cli: "claude", flag: "--model haiku" },
37
+ { name: "Gemini 3 Flash", tier: "fast", score: 77, cli: "gemini", flag: "-m gemini-3-flash" },
38
+ { name: "Mimo v2 Pro", tier: "fast", score: 74, cli: "opencode", flag: "-m opencode-go/mimo-v2-pro", tested: true },
39
+ { name: "GPT-5.4 (opencode)",tier: "fast", score: 74, cli: "opencode", flag: "-m openai/gpt-5.4", tested: true },
40
+ { name: "MiniMax M2.5", tier: "slow", score: 57, cli: "opencode", flag: "-m opencode-go/minimax-m2.5", tested: true },
41
+ ];
42
+
43
+ function fallbackTierForTask(taskType) {
44
+ const normalized = String(taskType || "").toLowerCase();
45
+ if (["docs", "doc", "test", "tests", "lint"].includes(normalized)) return "fast";
46
+ if (["planning", "plan", "architecture", "design", "review"].includes(normalized)) return "deep";
47
+ return "balanced";
48
+ }
49
+
50
+ function localRecommendation(taskType, options = {}) {
51
+ const minQuality = Number(options.minQuality || 0);
52
+ const installedCliNames = options.installedCliNames || probeInstalledCliNames(SUPPORTED_CLIS);
53
+ const preferredTier = fallbackTierForTask(taskType);
54
+ const sorted = [...MODELS]
55
+ .filter((model) => !minQuality || model.score >= minQuality)
56
+ .sort((a, b) => b.score - a.score);
57
+ const available = sorted.filter((model) => installedCliNames.has(model.cli));
58
+ const pool = available.length ? available : sorted;
59
+ const tierPool = pool.filter((model) => model.tier === preferredTier);
60
+ const recommendation = tierPool[0] || pool[0] || sorted[0] || null;
61
+ if (!recommendation) return null;
62
+ return {
63
+ ...recommendation,
64
+ command: `${recommendation.cli} ${recommendation.flag}`.trim(),
65
+ fallback_reason: available.length ? "local_available_ratings" : "local_static_ratings",
66
+ preferred_tier: preferredTier,
67
+ };
68
+ }
69
+
70
+ function printLocalRecommendation(rec, reason) {
71
+ console.log(` ${T}${rec.name}${R} (${rec.tier}, score ${rec.score})`);
72
+ console.log(` command: ${rec.command}`);
73
+ console.log(` ${D}${reason}; fallback=${rec.fallback_reason}${R}`);
74
+ }
75
+
76
+ function firstOutputLine(result) {
77
+ if (!result) return "";
78
+ const text = `${result.stderr || ""}\n${result.stdout || ""}`.trim();
79
+ return text.split("\n").map((line) => line.trim()).find(Boolean) || "";
80
+ }
81
+
82
+ function emitLocalRecommendationFallback(taskType, goal, minQuality, asJson, reasonCode, message, routerError = "") {
83
+ const recommendation = localRecommendation(taskType, { minQuality });
84
+ if (asJson) {
85
+ console.log(JSON.stringify({
86
+ status: "fallback",
87
+ reason: reasonCode,
88
+ task: taskType,
89
+ goal,
90
+ ...(routerError ? { router_error: routerError } : {}),
91
+ recommendation,
92
+ }, null, 2));
93
+ } else {
94
+ log(message);
95
+ if (routerError) console.log(` ${D}router: ${routerError}${R}`);
96
+ if (recommendation) printLocalRecommendation(recommendation, message);
97
+ }
98
+ }
99
+
100
+ function runModelRouter(args) {
101
+ const result = spawnSync("python3", args, { encoding: "utf8" });
102
+ if (result.error) return { ok: false, result: { stderr: result.error.message, stdout: "" } };
103
+ if (typeof result.status === "number" && result.status === 0) {
104
+ if (result.stdout) process.stdout.write(result.stdout);
105
+ if (result.stderr) process.stderr.write(result.stderr);
106
+ return { ok: true, result };
107
+ }
108
+ return { ok: false, result };
109
+ }
110
+
10
111
  function cmdModels(filter) {
11
- // Scores from benchmark_models.py (3-task: read/count/review) + field-tested ledger.
12
- // Catalog refreshed 2026-04-19; prior benchmark baseline 2026-04-06.
13
- // Since then: native claude `--model opus` alias routes to opus-4.7 1M-ctx (CC extended);
14
- // gpt-5.5 promoted to primary in agent-matrix/model_registry;
15
- // gpt-5.3-codex empirically verified — does heavy internal reasoning
16
- // (66k tokens on trivial "hello" prompt), pick deliberately for architecture.
17
- // Billing: native claude/codex/gemini = subscription (marginal $0/call but tokens tracked
18
- // for opportunity cost per operator 2026-04-19). RuAPI / opencode-go = pay-per-use.
19
- const MODELS = [
20
- { name: "Claude Opus 4.7 (1M)", tier: "deep", score: 98, cli: "claude", flag: "--model opus", tested: true },
21
- { name: "Claude Opus 4.7", tier: "deep", score: 97, cli: "opencode", flag: "-m RuAPI/claude-opus-4.7", tested: true },
22
- { name: "GPT-5.5", tier: "deep", score: 96, cli: "codex", flag: "-m gpt-5.5", tested: true },
23
- { name: "Claude Opus 4.6", tier: "deep", score: 95, cli: "claude", flag: "--model claude-opus-4-6" },
24
- { name: "Opus 4.6 (RuAPI)", tier: "deep", score: 95, cli: "opencode", flag: "-m RuAPI/claude-opus-4.6", tested: true },
25
- { name: "GPT-5.4-mini", tier: "fast", score: 93, cli: "codex", flag: "-m gpt-5.4-mini", tested: true },
26
- { name: "MiniMax M2.7", tier: "balanced", score: 93, cli: "opencode", flag: "-m opencode-go/minimax-m2.7", tested: true },
27
- { name: "Claude Sonnet 4.6", tier: "balanced", score: 90, cli: "claude", flag: "--model sonnet" },
28
- { name: "Sonnet 4.6 (RuAPI)",tier: "balanced", score: 90, cli: "opencode", flag: "-m RuAPI/claude-sonnet-4.6", tested: true },
29
- { name: "GPT-5.4", tier: "balanced", score: 90, cli: "codex", flag: "-m gpt-5.4", tested: true },
30
- { name: "Gemini 3.1 Pro", tier: "deep", score: 89, cli: "opencode", flag: "-m RuAPI/gemini-3.1-pro-preview", tested: true },
31
- { name: "Kimi K2.5", tier: "balanced", score: 88, cli: "opencode", flag: "-m opencode-go/kimi-k2.5", tested: true },
32
- { name: "Qwen 3.6+ Free", tier: "free", score: 88, cli: "opencode", flag: "-m opencode/qwen3.6-plus-free", tested: true },
33
- { name: "GPT-5.2", tier: "balanced", score: 87, cli: "codex", flag: "-m gpt-5.2", tested: true },
34
- { name: "Gemini 3.1 Pro", tier: "balanced", score: 85, cli: "gemini", flag: "-m gemini-3.1-pro" },
35
- { name: "GPT-5.3 Codex", tier: "deep", score: 83, cli: "codex", flag: "-m gpt-5.3-codex", tested: true },
36
- { name: "GPT-5.3 Spark", tier: "fast", score: 82, cli: "codex", flag: "-m gpt-5.3-codex-spark" },
37
- { name: "Claude Haiku 4.5", tier: "fast", score: 78, cli: "claude", flag: "--model haiku" },
38
- { name: "Gemini 3 Flash", tier: "fast", score: 77, cli: "gemini", flag: "-m gemini-3-flash" },
39
- { name: "Mimo v2 Pro", tier: "fast", score: 74, cli: "opencode", flag: "-m opencode-go/mimo-v2-pro", tested: true },
40
- { name: "GPT-5.4 (opencode)",tier: "fast", score: 74, cli: "opencode", flag: "-m openai/gpt-5.4", tested: true },
41
- { name: "MiniMax M2.5", tier: "slow", score: 57, cli: "opencode", flag: "-m opencode-go/minimax-m2.5", tested: true },
42
- ];
43
112
 
44
113
  const installedCliNames = probeInstalledCliNames(SUPPORTED_CLIS);
45
114
  const availability = summarizeModelAvailability(MODELS, SUPPORTED_CLIS, installedCliNames);
@@ -75,7 +144,7 @@ function cmdModels(filter) {
75
144
 
76
145
  async function cmdModelsRecommend(target, args) {
77
146
  const shared = require("../shared");
78
- const { log, T, R, D, findRepoScript, spawnSync, requirePlan } = shared;
147
+ const { findRepoScript, requirePlan } = shared;
79
148
 
80
149
  const gate = requirePlan("pro", "Model Recommend", target);
81
150
  if (gate) { log(gate.error); log(gate.hint); return; }
@@ -92,8 +161,18 @@ async function cmdModelsRecommend(target, args) {
92
161
  return;
93
162
  }
94
163
 
95
- const recScript = findRepoScript(target, "model_router.py");
96
- if (!recScript) { log("model router unavailable"); return; }
164
+ const recScript = shared.resolvePythonScript(target, "model_router.py");
165
+ if (!recScript) {
166
+ emitLocalRecommendationFallback(
167
+ taskType,
168
+ goal,
169
+ minQuality,
170
+ asJson,
171
+ "model_router_unavailable",
172
+ "model router unavailable; using local ratings fallback",
173
+ );
174
+ return;
175
+ }
97
176
 
98
177
  // Ledger-recommend: pair per-repo outcome ledger (PR #509) with the static
99
178
  // experience-event ranking. Falls back to the legacy `recommend` subcommand
@@ -103,8 +182,8 @@ async function cmdModelsRecommend(target, args) {
103
182
  if (taskType) ledgerArgs.push("--task", taskType);
104
183
  if (asJson) ledgerArgs.push("--json");
105
184
 
106
- const ledgerResult = spawnSync("python3", ledgerArgs, { stdio: "inherit" });
107
- if (typeof ledgerResult.status === "number" && ledgerResult.status === 0) return;
185
+ const ledgerResult = runModelRouter(ledgerArgs);
186
+ if (ledgerResult.ok) return;
108
187
 
109
188
  const fwd = [recScript, "recommend", "--target", target];
110
189
  if (taskType) fwd.push("--task", taskType);
@@ -113,8 +192,26 @@ async function cmdModelsRecommend(target, args) {
113
192
  if (minQuality > 0) fwd.push("--min-quality", String(minQuality));
114
193
  if (asJson) fwd.push("--json");
115
194
 
116
- const result = spawnSync("python3", fwd, { stdio: "inherit" });
117
- if (typeof result.status === "number" && result.status !== 0) process.exit(result.status);
195
+ const result = runModelRouter(fwd);
196
+ if (result.ok) return;
197
+
198
+ const routerError = firstOutputLine(result.result) || firstOutputLine(ledgerResult.result);
199
+ emitLocalRecommendationFallback(
200
+ taskType,
201
+ goal,
202
+ minQuality,
203
+ asJson,
204
+ "model_router_failed",
205
+ "model router failed; using local ratings fallback",
206
+ routerError,
207
+ );
118
208
  }
119
209
 
120
- module.exports = { cmdModels, cmdModelsRecommend };
210
+ module.exports = {
211
+ cmdModels,
212
+ cmdModelsRecommend,
213
+ localRecommendation,
214
+ fallbackTierForTask,
215
+ MODELS,
216
+ emitLocalRecommendationFallback,
217
+ };
@@ -1,84 +1,55 @@
1
1
  "use strict";
2
2
 
3
3
  const shared = require("../shared");
4
- const { log, spawnSync, findRepoScript } = shared;
4
+ const { D, R, log, spawnSync, resolvePythonScript } = shared;
5
+
6
+ function hasTargetArg(args) {
7
+ return args.some((arg, index) => arg === "--target" || arg.startsWith("--target=") || args[index - 1] === "--target");
8
+ }
9
+
10
+ function providerProfileArgs(target, args) {
11
+ const subcommand = args[0] || "";
12
+ const forwarded = [...args];
13
+ if (target && !hasTargetArg(forwarded) && ["bind", "bindings", "invoke", "status"].includes(subcommand)) {
14
+ forwarded.push("--target", target);
15
+ }
16
+ return forwarded;
17
+ }
18
+
19
+ function providerRegistryArgs(target, args) {
20
+ const forwarded = [...args];
21
+ if (target && !hasTargetArg(forwarded)) {
22
+ forwarded.push("--target", target);
23
+ }
24
+ return forwarded;
25
+ }
5
26
 
6
27
  function cmdProvider(target, args) {
7
28
  // Check for per-repo provider commands first
8
29
  const subcommand = args[0] || "";
9
30
 
10
31
  if (subcommand === "list" || subcommand === "switch" || subcommand === "clear") {
11
- // New per-repo provider profile commands use provider_registry.py
12
- const script = findRepoScript(target, "provider_registry_cli.py");
32
+ // Per-repo provider profile commands: repo-first, bundled fallback.
33
+ const script = resolvePythonScript(target, "provider_registry_cli.py");
13
34
  if (!script) {
14
- // Fallback: inline Python call to provider_registry module
15
- const pyCode = `
16
- import sys
17
- import json
18
- import pathlib
19
- sys.path.insert(0, '${target}/scripts')
20
- import provider_registry as pr
21
-
22
- target = pathlib.Path('${target}')
23
- args = ${JSON.stringify(args)}
24
- subcmd = args[0] if args else 'list'
25
-
26
- try:
27
- if subcmd == 'list':
28
- providers = pr.list_providers_for_repo(target)
29
- print('Provider Status for', target.name)
30
- print('=' * 60)
31
- for p in providers:
32
- active = '→' if p['active'] else ' '
33
- source = f"[{p['source']}]" if p['source'] else ''
34
- creds = '✓' if p['has_credentials'] else '✗'
35
- print(f" {active} {p['name']:<12} {creds} creds {source:<10} {p['base_url']}")
36
- print()
37
- try:
38
- resolved = pr.resolve_for_repo(target)
39
- print(f"Active: {resolved['provider_name']} (source={resolved['source']})")
40
- except pr.ProviderRegistryError as e:
41
- print(f"No active provider: {e}")
42
- elif subcmd == 'switch':
43
- provider_name = ''
44
- for i, a in enumerate(args):
45
- if a == '--provider' and i + 1 < len(args):
46
- provider_name = args[i + 1]
47
- if not provider_name:
48
- print('Usage: 0dai provider switch --target . --provider NAME')
49
- sys.exit(1)
50
- result = pr.switch_provider(target, provider_name)
51
- print(f"Switched {target.name} to provider: {result['provider_name']}")
52
- print(f" base_url: {result['base_url']}")
53
- print(f" audit log: ~/.0dai/audit/provider-changes.jsonl")
54
- elif subcmd == 'clear':
55
- cleared = pr.clear_repo_profile(target)
56
- if cleared:
57
- print(f"Cleared per-repo profile for {target.name}")
58
- print(f"Will fall back to global default or env vars")
59
- else:
60
- print(f"No per-repo profile to clear for {target.name}")
61
- except pr.ProviderRegistryError as e:
62
- print(f"Error: {e}")
63
- sys.exit(1)
64
- `;
65
- const result = spawnSync("python3", ["-c", pyCode], { stdio: "inherit" });
66
- if (typeof result.status === "number") process.exit(result.status);
35
+ log("provider registry unavailable in this environment");
36
+ console.log(` ${D}Expected bundled lib/python/provider_registry_cli.py or scripts/provider_registry_cli.py in repo checkout${R}`);
67
37
  process.exit(1);
68
38
  }
69
- const forwarded = [script, ...args];
39
+ const forwarded = [script, ...providerRegistryArgs(target, args)];
70
40
  const result = spawnSync("python3", forwarded, { stdio: "inherit" });
71
41
  if (typeof result.status === "number") process.exit(result.status);
72
42
  process.exit(1);
73
43
  }
74
44
 
75
45
  // Legacy provider_profiles.py commands
76
- const script = findRepoScript(target, "provider_profiles.py");
46
+ const script = resolvePythonScript(target, "provider_profiles.py");
77
47
  if (!script) {
78
48
  log("provider profiles unavailable in this environment");
49
+ console.log(` ${D}Expected bundled lib/python/provider_profiles.py or scripts/provider_profiles.py in repo checkout${R}`);
79
50
  process.exit(1);
80
51
  }
81
- const forwarded = [script, ...args];
52
+ const forwarded = [script, ...providerProfileArgs(target, args)];
82
53
  const result = spawnSync("python3", forwarded, { stdio: "inherit" });
83
54
  if (typeof result.status === "number") process.exit(result.status);
84
55
  process.exit(1);
@@ -22,7 +22,7 @@ function _pad(s, n) { s = String(s); return s + " ".repeat(Math.max(0, n - s.len
22
22
  function _loadEnvelope(target, opts = {}) {
23
23
  const cachePath = path.join(target, "ai", "manifest", "agent-quotas.json");
24
24
  const force = !!opts.refresh;
25
- const script = findRepoScript(target, "agent_quotas.py");
25
+ const script = shared.resolvePythonScript(target, "agent_quotas.py");
26
26
  if (script) {
27
27
  const args = [script, "--target", target, "--json"];
28
28
  if (force) args.push("--refresh");
@@ -18,7 +18,7 @@ function _findArg(args, flag) {
18
18
  }
19
19
 
20
20
  function cmdReceipt(target, args) {
21
- const script = findRepoScript(target, "receipt_png.py");
21
+ const script = shared.resolvePythonScript(target, "receipt_png.py");
22
22
  if (!script) {
23
23
  log("receipt generator unavailable — missing scripts/receipt_png.py");
24
24
  console.log(` ${D}Run from a 0dai-initialized project root.${R}`);
@@ -9,12 +9,20 @@ const {
9
9
  } = require("../utils/run_cost");
10
10
  const { recordActivationFirstTask } = require("../utils/activation_telemetry");
11
11
 
12
+ function printRunHelp() {
13
+ console.log(`Usage: 0dai run <goal> [--dry-run] [--dry-cost] [--max-cost N] [--agent claude|codex|gemini] [--provider deepseek|gemini-direct|codex|claude-opus]`);
14
+ console.log(` Example: 0dai run "add dark mode to settings page"`);
15
+ console.log(` Example: 0dai run "fix login bug" --dry-cost`);
16
+ console.log(` Example: 0dai run "refactor auth" --max-cost 0.50`);
17
+ }
18
+
19
+ function isRunHelpRequest(goal, args = []) {
20
+ return goal === "--help" || goal === "-h" || args.includes("--help") || args.includes("-h");
21
+ }
22
+
12
23
  async function cmdRun(goal, target, args = []) {
13
- if (!goal) {
14
- console.log(`Usage: 0dai run <goal> [--dry-run] [--dry-cost] [--max-cost N] [--agent claude|codex|gemini] [--provider deepseek|gemini-direct|codex|claude-opus]`);
15
- console.log(` Example: 0dai run "add dark mode to settings page"`);
16
- console.log(` Example: 0dai run "fix login bug" --dry-cost`);
17
- console.log(` Example: 0dai run "refactor auth" --max-cost 0.50`);
24
+ if (!goal || isRunHelpRequest(goal, args)) {
25
+ printRunHelp();
18
26
  return;
19
27
  }
20
28
 
@@ -174,4 +182,4 @@ function providerAgent(provider) {
174
182
  return null;
175
183
  }
176
184
 
177
- module.exports = { cmdRun, printCostPreview };
185
+ module.exports = { cmdRun, printCostPreview, printRunHelp };