@0dai-dev/cli 4.2.0 → 4.3.4

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 (48) hide show
  1. package/README.md +30 -5
  2. package/bin/0dai.js +289 -60
  3. package/lib/commands/audit.js +13 -0
  4. package/lib/commands/auth.js +341 -98
  5. package/lib/commands/boneyard.js +44 -0
  6. package/lib/commands/ci.js +329 -0
  7. package/lib/commands/compliance.js +20 -0
  8. package/lib/commands/doctor.js +20 -1
  9. package/lib/commands/experience.js +5 -1
  10. package/lib/commands/feedback.js +92 -5
  11. package/lib/commands/gh.js +506 -0
  12. package/lib/commands/graph.js +78 -10
  13. package/lib/commands/heatmap.js +17 -0
  14. package/lib/commands/import_claude_code_agents.js +367 -0
  15. package/lib/commands/init.js +440 -28
  16. package/lib/commands/loop.js +108 -0
  17. package/lib/commands/mcp.js +410 -0
  18. package/lib/commands/models.js +27 -3
  19. package/lib/commands/paste.js +114 -0
  20. package/lib/commands/play.js +173 -0
  21. package/lib/commands/provider.js +69 -0
  22. package/lib/commands/quota.js +76 -0
  23. package/lib/commands/receipt.js +53 -0
  24. package/lib/commands/report.js +29 -2
  25. package/lib/commands/run.js +44 -4
  26. package/lib/commands/runner.js +527 -0
  27. package/lib/commands/session.js +1 -7
  28. package/lib/commands/standup.js +40 -0
  29. package/lib/commands/status.js +26 -1
  30. package/lib/commands/swarm.js +97 -4
  31. package/lib/commands/tui.js +81 -13
  32. package/lib/commands/usage.js +87 -0
  33. package/lib/commands/vault.js +246 -0
  34. package/lib/onboarding.js +9 -3
  35. package/lib/shared.js +29 -14
  36. package/lib/tui/index.mjs +571 -187
  37. package/lib/utils/auth.js +1 -0
  38. package/lib/utils/canonical-counts.js +54 -0
  39. package/lib/utils/diff-preview.js +192 -0
  40. package/lib/utils/identity.js +76 -18
  41. package/lib/utils/mcp-auth.js +607 -0
  42. package/lib/utils/plan.js +37 -2
  43. package/lib/vault/cipher.js +125 -0
  44. package/lib/vault/identity.js +122 -0
  45. package/lib/vault/index.js +184 -0
  46. package/lib/vault/storage.js +84 -0
  47. package/lib/wizard.js +19 -12
  48. package/package.json +2 -2
@@ -0,0 +1,410 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * `0dai mcp` — inspect and test the project's MCP server surface.
5
+ *
6
+ * Subcommands (all read-only / inspection):
7
+ * - list [--plan free|pro|team|enterprise] [--json]
8
+ * Enumerate MCP tools exposed by scripts/mcp_server.py.
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.
11
+ * - catalog [--stack NAME] [--json]
12
+ * Show the per-stack MCP server catalog (common + stack servers)
13
+ * from templates/layer/ai/registry/mcp-catalog.json.
14
+ * - doctor [--json]
15
+ * Verify .mcp.json + server script reachability + plan visibility.
16
+ * - call <tool> [--json '<args>'] [--json-out]
17
+ * Invoke a single MCP tool synchronously via scripts/mcp_server.py
18
+ * (no daemon spawn — direct in-process call inside the helper subprocess).
19
+ *
20
+ * This command never mutates project state. It is the documented
21
+ * counterpart of the `mcp list` / `mcp call` / `mcp catalog` docs and the
22
+ * missing dispatch surface flagged by issue #2414.
23
+ */
24
+
25
+ const shared = require("../shared");
26
+ const { T, R, D, G, W, log, fs, path, spawnSync, findRepoScript } = shared;
27
+
28
+ function _printHelp() {
29
+ console.log(`\n ${T}0dai mcp${R} — inspect MCP tool surface\n`);
30
+ console.log("Usage:");
31
+ console.log(" 0dai mcp list [--plan free|pro|team|enterprise] [--json]");
32
+ console.log(" 0dai mcp catalog [--stack NAME] [--json]");
33
+ console.log(" 0dai mcp doctor [--json]");
34
+ console.log(" 0dai mcp call <tool> [--json '<json-args>'] [--json-out]");
35
+ console.log("");
36
+ console.log("Read-only inspection of the project's MCP server (scripts/mcp_server.py).");
37
+ console.log("");
38
+ }
39
+
40
+ function _argAfter(args, name) {
41
+ for (let i = 0; i < args.length; i++) {
42
+ if (args[i] === name && args[i + 1]) return args[i + 1];
43
+ }
44
+ return "";
45
+ }
46
+
47
+ function _normalizePlan(raw) {
48
+ const p = String(raw || "").toLowerCase().trim();
49
+ if (!p) return "free";
50
+ if (["free", "pro", "team", "enterprise"].includes(p)) return p;
51
+ return "free";
52
+ }
53
+
54
+ function _readJsonFile(filePath) {
55
+ try {
56
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
57
+ } catch (e) {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ function _toolsFromTierManifest(target, plan) {
63
+ const candidates = [
64
+ path.join(target, "ai", "meta", "manifest", "mcp-tool-tiers.json"),
65
+ path.join(target, "ai", "manifest", "mcp-tool-tiers.json"),
66
+ ];
67
+ for (const candidate of candidates) {
68
+ if (!fs.existsSync(candidate)) continue;
69
+ const data = _readJsonFile(candidate);
70
+ if (!data || typeof data !== "object") continue;
71
+ const exposure = data.exposure || {};
72
+ const section = exposure[plan] || exposure.free;
73
+ if (!section) continue;
74
+ const tools = Array.isArray(section.tools) ? section.tools.slice() : [];
75
+ return { source: candidate, plan, tools, count: tools.length };
76
+ }
77
+ return null;
78
+ }
79
+
80
+ function _toolsByScanning(target) {
81
+ const script = findRepoScript(target, "mcp_server.py");
82
+ if (!script) return null;
83
+ let text;
84
+ try { text = fs.readFileSync(script, "utf8"); } catch { return null; }
85
+ const tools = [];
86
+ const lines = text.split("\n");
87
+ for (let i = 0; i < lines.length; i++) {
88
+ if (lines[i].trim() !== "@mcp.tool") continue;
89
+ // find next `def NAME(...)`
90
+ for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
91
+ const m = lines[j].match(/^def\s+([A-Za-z_][A-Za-z0-9_]*)/);
92
+ if (m) { tools.push(m[1]); break; }
93
+ }
94
+ }
95
+ return { source: script, plan: "scan", tools, count: tools.length };
96
+ }
97
+
98
+ function _cmdList(target, args) {
99
+ const wantJson = args.includes("--json");
100
+ const plan = _normalizePlan(_argAfter(args, "--plan"));
101
+
102
+ let result = _toolsFromTierManifest(target, plan);
103
+ if (!result) result = _toolsByScanning(target);
104
+ if (!result) {
105
+ if (wantJson) {
106
+ console.log(JSON.stringify({ error: "no MCP tool source resolved", tools: [], count: 0 }));
107
+ } else {
108
+ log("no MCP tool source resolved (expected ai/meta/manifest/mcp-tool-tiers.json or scripts/mcp_server.py)");
109
+ }
110
+ process.exitCode = 1;
111
+ return;
112
+ }
113
+
114
+ if (wantJson) {
115
+ console.log(JSON.stringify(result));
116
+ return;
117
+ }
118
+
119
+ console.log(`\n ${T}0dai mcp list${R} source=${result.source} plan=${result.plan} count=${result.count}\n`);
120
+ for (const tool of result.tools) console.log(` ${G}•${R} ${tool}`);
121
+ console.log("");
122
+ }
123
+
124
+ function _resolveCatalogPath(target) {
125
+ const candidates = [
126
+ path.join(target, "ai", "registry", "mcp-catalog.json"),
127
+ path.join(target, "templates", "layer", "ai", "registry", "mcp-catalog.json"),
128
+ ];
129
+ // Also look next to the npm package (when run from a sync'd repo elsewhere).
130
+ const repoRoot = path.resolve(__dirname, "..", "..", "..", "..");
131
+ candidates.push(path.join(repoRoot, "templates", "layer", "ai", "registry", "mcp-catalog.json"));
132
+ candidates.push(path.join(repoRoot, "ai", "registry", "mcp-catalog.json"));
133
+ for (const candidate of candidates) {
134
+ if (fs.existsSync(candidate)) return candidate;
135
+ }
136
+ return "";
137
+ }
138
+
139
+ function _detectStack(target) {
140
+ const candidates = [
141
+ path.join(target, "ai", "manifest", "discovery.json"),
142
+ ];
143
+ for (const candidate of candidates) {
144
+ if (!fs.existsSync(candidate)) continue;
145
+ const data = _readJsonFile(candidate);
146
+ if (data && typeof data.stack === "string" && data.stack) return data.stack;
147
+ }
148
+ return "generic";
149
+ }
150
+
151
+ function _cmdCatalog(target, args) {
152
+ const wantJson = args.includes("--json");
153
+ const stackArg = _argAfter(args, "--stack");
154
+ const catalogPath = _resolveCatalogPath(target);
155
+ if (!catalogPath) {
156
+ if (wantJson) console.log(JSON.stringify({ error: "MCP catalog not found" }));
157
+ else log("MCP catalog not found (expected ai/registry/mcp-catalog.json or templates/layer/ai/registry/mcp-catalog.json)");
158
+ process.exitCode = 1;
159
+ return;
160
+ }
161
+ const catalog = _readJsonFile(catalogPath);
162
+ if (!catalog) {
163
+ if (wantJson) console.log(JSON.stringify({ error: "MCP catalog not parseable", path: catalogPath }));
164
+ else log(`MCP catalog not parseable: ${catalogPath}`);
165
+ process.exitCode = 1;
166
+ return;
167
+ }
168
+ const stack = stackArg || _detectStack(target);
169
+ const common = catalog.common || {};
170
+ const stacks = catalog.stacks || {};
171
+ const stackEntry = stacks[stack] || {};
172
+ const stackServers = stackEntry.servers || {};
173
+ const available = Object.keys(stacks).sort();
174
+
175
+ if (wantJson) {
176
+ const commonOut = {};
177
+ for (const [k, v] of Object.entries(common)) commonOut[k] = (v && v.description) || "";
178
+ const stackOut = {};
179
+ for (const [k, v] of Object.entries(stackServers)) stackOut[k] = (v && v.description) || "";
180
+ console.log(JSON.stringify({
181
+ stack,
182
+ source: catalogPath,
183
+ common_servers: commonOut,
184
+ stack_servers: stackOut,
185
+ available_stacks: available,
186
+ }));
187
+ return;
188
+ }
189
+
190
+ console.log(`\n ${T}0dai mcp catalog${R} source=${catalogPath} stack=${stack}\n`);
191
+ console.log(` ${T}common servers${R}`);
192
+ for (const [name, entry] of Object.entries(common)) {
193
+ console.log(` ${G}•${R} ${name} ${D}— ${(entry && entry.description) || ""}${R}`);
194
+ }
195
+ console.log(`\n ${T}stack servers${R} (${stack})`);
196
+ if (!Object.keys(stackServers).length) {
197
+ console.log(` ${D}(none)${R}`);
198
+ } else {
199
+ for (const [name, entry] of Object.entries(stackServers)) {
200
+ console.log(` ${G}•${R} ${name} ${D}— ${(entry && entry.description) || ""}${R}`);
201
+ }
202
+ }
203
+ console.log(`\n ${T}available stacks${R}: ${available.join(", ")}\n`);
204
+ }
205
+
206
+ function _cmdDoctor(target, args) {
207
+ const wantJson = args.includes("--json");
208
+ const report = {
209
+ target,
210
+ server_script: { path: "", ok: false },
211
+ mcp_config: { path: "", ok: false, servers: [] },
212
+ tier_manifest: { path: "", ok: false, free_count: 0, pro_count: 0 },
213
+ catalog: { path: "", ok: false, stacks: 0 },
214
+ };
215
+
216
+ const script = findRepoScript(target, "mcp_server.py");
217
+ if (script) {
218
+ report.server_script.path = script;
219
+ report.server_script.ok = fs.existsSync(script);
220
+ }
221
+
222
+ const mcpJson = path.join(target, ".mcp.json");
223
+ if (fs.existsSync(mcpJson)) {
224
+ report.mcp_config.path = mcpJson;
225
+ const data = _readJsonFile(mcpJson);
226
+ if (data && data.mcpServers && typeof data.mcpServers === "object") {
227
+ report.mcp_config.ok = true;
228
+ report.mcp_config.servers = Object.keys(data.mcpServers);
229
+ }
230
+ }
231
+
232
+ const tierCandidates = [
233
+ path.join(target, "ai", "meta", "manifest", "mcp-tool-tiers.json"),
234
+ path.join(target, "ai", "manifest", "mcp-tool-tiers.json"),
235
+ ];
236
+ for (const candidate of tierCandidates) {
237
+ if (!fs.existsSync(candidate)) continue;
238
+ const data = _readJsonFile(candidate);
239
+ if (!data || !data.exposure) continue;
240
+ report.tier_manifest.path = candidate;
241
+ report.tier_manifest.ok = true;
242
+ const free = data.exposure.free;
243
+ const pro = data.exposure.pro;
244
+ if (free && Array.isArray(free.tools)) report.tier_manifest.free_count = free.tools.length;
245
+ if (pro && Array.isArray(pro.tools)) report.tier_manifest.pro_count = pro.tools.length;
246
+ break;
247
+ }
248
+
249
+ const catalogPath = _resolveCatalogPath(target);
250
+ if (catalogPath) {
251
+ const data = _readJsonFile(catalogPath);
252
+ if (data) {
253
+ report.catalog.path = catalogPath;
254
+ report.catalog.ok = true;
255
+ report.catalog.stacks = Object.keys(data.stacks || {}).length;
256
+ }
257
+ }
258
+
259
+ if (wantJson) {
260
+ console.log(JSON.stringify(report));
261
+ return;
262
+ }
263
+
264
+ const ok = (b) => (b ? G + "ok" + R : W + "missing" + R);
265
+ console.log(`\n ${T}0dai mcp doctor${R} target=${target}\n`);
266
+ console.log(` server script ${ok(report.server_script.ok)} ${D}${report.server_script.path || "(not found)"}${R}`);
267
+ console.log(` .mcp.json ${ok(report.mcp_config.ok)} ${D}${report.mcp_config.path || "(not found)"}${R}`);
268
+ if (report.mcp_config.ok) {
269
+ console.log(` servers: ${report.mcp_config.servers.join(", ")}`);
270
+ }
271
+ console.log(` tier manifest ${ok(report.tier_manifest.ok)} ${D}${report.tier_manifest.path || "(not found)"}${R}`);
272
+ if (report.tier_manifest.ok) {
273
+ console.log(` free=${report.tier_manifest.free_count} pro=${report.tier_manifest.pro_count}`);
274
+ }
275
+ console.log(` catalog ${ok(report.catalog.ok)} ${D}${report.catalog.path || "(not found)"}${R}`);
276
+ if (report.catalog.ok) {
277
+ console.log(` stacks: ${report.catalog.stacks}`);
278
+ }
279
+ console.log("");
280
+
281
+ const allOk = report.server_script.ok && report.mcp_config.ok && (report.tier_manifest.ok || report.catalog.ok);
282
+ if (!allOk) process.exitCode = 1;
283
+ }
284
+
285
+ function _cmdCall(target, toolName, args) {
286
+ const wantJsonOut = args.includes("--json-out");
287
+ const jsonArgs = _argAfter(args, "--json");
288
+ if (!toolName) {
289
+ log("usage: 0dai mcp call <tool> [--json '<json-args>'] [--json-out]");
290
+ process.exitCode = 1;
291
+ return;
292
+ }
293
+
294
+ const script = findRepoScript(target, "mcp_server.py");
295
+ if (!script) {
296
+ log("scripts/mcp_server.py not found in this repo");
297
+ process.exitCode = 1;
298
+ return;
299
+ }
300
+
301
+ let parsedArgs = {};
302
+ if (jsonArgs) {
303
+ try { parsedArgs = JSON.parse(jsonArgs); }
304
+ catch (e) { log(`--json is not valid JSON: ${e.message}`); process.exitCode = 1; return; }
305
+ if (!parsedArgs || typeof parsedArgs !== "object" || Array.isArray(parsedArgs)) {
306
+ log("--json must be a JSON object");
307
+ process.exitCode = 1;
308
+ return;
309
+ }
310
+ }
311
+
312
+ // Run a tiny inline helper that imports the server module and invokes the
313
+ // named @mcp.tool function directly. We deliberately don't spin up the
314
+ // FastMCP stdio loop — this is debugging convenience, not the runtime path.
315
+ const helper = [
316
+ "import importlib.util, json, os, pathlib, sys",
317
+ "spec = importlib.util.spec_from_file_location('odai_mcp_server', sys.argv[1])",
318
+ "mod = importlib.util.module_from_spec(spec)",
319
+ "spec.loader.exec_module(mod)",
320
+ "target = pathlib.Path(sys.argv[2]).resolve()",
321
+ "if hasattr(mod, 'TARGET_DIR'): mod.TARGET_DIR = target",
322
+ "name = sys.argv[3]",
323
+ "kwargs = json.loads(sys.argv[4] or '{}')",
324
+ "fn = getattr(mod, name, None)",
325
+ "if fn is None:",
326
+ " print(json.dumps({'error': f'unknown tool: {name}'}))",
327
+ " sys.exit(2)",
328
+ "# FastMCP wraps the function but the underlying callable is still",
329
+ "# accessible. Try direct call first, then .fn / .func attrs.",
330
+ "underlying = fn",
331
+ "for attr in ('fn', 'func', '_func'):",
332
+ " if hasattr(underlying, attr) and callable(getattr(underlying, attr)):",
333
+ " underlying = getattr(underlying, attr)",
334
+ " break",
335
+ "try:",
336
+ " result = underlying(**kwargs) if callable(underlying) else None",
337
+ "except TypeError as e:",
338
+ " print(json.dumps({'error': f'bad args for {name}: {e}'}))",
339
+ " sys.exit(3)",
340
+ "except Exception as e:",
341
+ " print(json.dumps({'error': str(e), 'tool': name}))",
342
+ " sys.exit(4)",
343
+ "print(json.dumps(result, default=str))",
344
+ ].join("\n");
345
+
346
+ const r = spawnSync("python3", ["-c", helper, script, target, toolName, JSON.stringify(parsedArgs)], {
347
+ encoding: "utf8",
348
+ timeout: 30000,
349
+ stdio: ["ignore", "pipe", "pipe"],
350
+ });
351
+
352
+ if (r.error) { log(`call failed: ${r.error.message}`); process.exitCode = 1; return; }
353
+ const stdout = (r.stdout || "").trim();
354
+ const stderr = (r.stderr || "").trim();
355
+
356
+ if (wantJsonOut) {
357
+ if (stdout) console.log(stdout);
358
+ else if (stderr) console.log(JSON.stringify({ error: stderr }));
359
+ if (typeof r.status === "number" && r.status !== 0) process.exitCode = r.status;
360
+ return;
361
+ }
362
+
363
+ if (stderr) console.log(`${D}${stderr}${R}`);
364
+ if (stdout) {
365
+ try {
366
+ const parsed = JSON.parse(stdout);
367
+ console.log(JSON.stringify(parsed, null, 2));
368
+ } catch {
369
+ console.log(stdout);
370
+ }
371
+ }
372
+ if (typeof r.status === "number" && r.status !== 0) process.exitCode = r.status;
373
+ }
374
+
375
+ function cmdMcp(target, sub, args) {
376
+ const restAll = Array.isArray(args) ? args : [];
377
+ // args here is the full argv from bin/0dai.js (already target-stripped),
378
+ // so restAll[0] === "mcp", restAll[1] === sub. Forwarded options are restAll.slice(2).
379
+ const rest = restAll.slice(2);
380
+ const command = (sub || "").toLowerCase();
381
+
382
+ if (!command || command === "help" || command === "--help" || command === "-h") {
383
+ _printHelp();
384
+ return;
385
+ }
386
+
387
+ switch (command) {
388
+ case "list":
389
+ _cmdList(target, rest);
390
+ return;
391
+ case "catalog":
392
+ _cmdCatalog(target, rest);
393
+ return;
394
+ case "doctor":
395
+ _cmdDoctor(target, rest);
396
+ return;
397
+ case "call": {
398
+ const toolName = rest[0] && !rest[0].startsWith("-") ? rest[0] : "";
399
+ const callArgs = toolName ? rest.slice(1) : rest;
400
+ _cmdCall(target, toolName, callArgs);
401
+ return;
402
+ }
403
+ default:
404
+ log(`unknown mcp subcommand: ${command}`);
405
+ _printHelp();
406
+ process.exitCode = 1;
407
+ }
408
+ }
409
+
410
+ module.exports = { cmdMcp };
@@ -8,15 +8,29 @@ const {
8
8
  } = require("../utils/model_ratings");
9
9
 
10
10
  function cmdModels(filter) {
11
- // Scores from benchmark_models.py (3-task: read/count/review, 2026-04-06)
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.
12
19
  const MODELS = [
13
- { name: "Claude Opus 4.6", tier: "deep", score: 95, cli: "claude", flag: "--model opus" },
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 },
14
25
  { name: "GPT-5.4-mini", tier: "fast", score: 93, cli: "codex", flag: "-m gpt-5.4-mini", tested: true },
15
26
  { name: "MiniMax M2.7", tier: "balanced", score: 93, cli: "opencode", flag: "-m opencode-go/minimax-m2.7", tested: true },
16
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 },
17
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 },
18
31
  { name: "Kimi K2.5", tier: "balanced", score: 88, cli: "opencode", flag: "-m opencode-go/kimi-k2.5", tested: true },
19
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 },
20
34
  { name: "Gemini 3.1 Pro", tier: "balanced", score: 85, cli: "gemini", flag: "-m gemini-3.1-pro" },
21
35
  { name: "GPT-5.3 Codex", tier: "deep", score: 83, cli: "codex", flag: "-m gpt-5.3-codex", tested: true },
22
36
  { name: "GPT-5.3 Spark", tier: "fast", score: 82, cli: "codex", flag: "-m gpt-5.3-codex-spark" },
@@ -24,7 +38,6 @@ function cmdModels(filter) {
24
38
  { name: "Gemini 3 Flash", tier: "fast", score: 77, cli: "gemini", flag: "-m gemini-3-flash" },
25
39
  { name: "Mimo v2 Pro", tier: "fast", score: 74, cli: "opencode", flag: "-m opencode-go/mimo-v2-pro", tested: true },
26
40
  { name: "GPT-5.4 (opencode)",tier: "fast", score: 74, cli: "opencode", flag: "-m openai/gpt-5.4", tested: true },
27
- { name: "GPT-5.2", tier: "balanced", score: 87, cli: "codex", flag: "-m gpt-5.2", tested: true },
28
41
  { name: "MiniMax M2.5", tier: "slow", score: 57, cli: "opencode", flag: "-m opencode-go/minimax-m2.5", tested: true },
29
42
  ];
30
43
 
@@ -82,6 +95,17 @@ async function cmdModelsRecommend(target, args) {
82
95
  const recScript = findRepoScript(target, "model_router.py");
83
96
  if (!recScript) { log("model router unavailable"); return; }
84
97
 
98
+ // Ledger-recommend: pair per-repo outcome ledger (PR #509) with the static
99
+ // experience-event ranking. Falls back to the legacy `recommend` subcommand
100
+ // if the installed copy of model_router.py predates ledger-recommend —
101
+ // happens if an operator upgrades the CLI before the ai/ layer catches up.
102
+ const ledgerArgs = [recScript, "ledger-recommend", "--target", target];
103
+ if (taskType) ledgerArgs.push("--task", taskType);
104
+ if (asJson) ledgerArgs.push("--json");
105
+
106
+ const ledgerResult = spawnSync("python3", ledgerArgs, { stdio: "inherit" });
107
+ if (typeof ledgerResult.status === "number" && ledgerResult.status === 0) return;
108
+
85
109
  const fwd = [recScript, "recommend", "--target", target];
86
110
  if (taskType) fwd.push("--task", taskType);
87
111
  if (goal) fwd.push("--goal", goal);
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ const shared = require("../shared");
3
+ const { log, T, R, D, apiCall } = shared;
4
+
5
+ const MAX_PASTE_BYTES = 1024 * 1024;
6
+
7
+ function formatBytes(bytes) {
8
+ if (bytes >= 1024 * 1024) {
9
+ const mib = bytes / (1024 * 1024);
10
+ return `${Number.isInteger(mib) ? mib : mib.toFixed(1)} MiB`;
11
+ }
12
+ if (bytes >= 1024) {
13
+ const kib = bytes / 1024;
14
+ return `${Number.isInteger(kib) ? kib : kib.toFixed(1)} KiB`;
15
+ }
16
+ return `${bytes} B`;
17
+ }
18
+
19
+ function readStdin({ maxBytes = MAX_PASTE_BYTES } = {}) {
20
+ return new Promise((resolve, reject) => {
21
+ if (process.stdin.isTTY) {
22
+ reject(new Error("no stdin — pipe text in, e.g. `cat prompt.md | 0dai paste`"));
23
+ return;
24
+ }
25
+ const chunks = [];
26
+ let bytes = 0;
27
+ let settled = false;
28
+
29
+ const cleanup = () => {
30
+ process.stdin.off("data", onData);
31
+ process.stdin.off("end", onEnd);
32
+ process.stdin.off("error", onError);
33
+ };
34
+ const fail = (error) => {
35
+ if (settled) return;
36
+ settled = true;
37
+ cleanup();
38
+ try { process.stdin.pause(); } catch {}
39
+ reject(error);
40
+ };
41
+ const onData = (chunk) => {
42
+ const text = String(chunk);
43
+ bytes += Buffer.byteLength(text, "utf8");
44
+ if (bytes > maxBytes) {
45
+ fail(new Error(`paste body exceeds ${formatBytes(maxBytes)} limit (received at least ${formatBytes(bytes)})`));
46
+ return;
47
+ }
48
+ chunks.push(text);
49
+ };
50
+ const onEnd = () => {
51
+ if (settled) return;
52
+ settled = true;
53
+ cleanup();
54
+ resolve(chunks.join(""));
55
+ };
56
+ const onError = (error) => fail(error);
57
+
58
+ process.stdin.setEncoding("utf8");
59
+ process.stdin.on("data", onData);
60
+ process.stdin.on("end", onEnd);
61
+ process.stdin.on("error", onError);
62
+ process.stdin.resume();
63
+ });
64
+ }
65
+
66
+ function parseArgs(args) {
67
+ const out = { title: "", author: "", tags: [], quiet: false };
68
+ for (let i = 0; i < args.length; i++) {
69
+ const a = args[i];
70
+ if (a === "--title") out.title = args[++i] || "";
71
+ else if (a === "--author") out.author = args[++i] || "";
72
+ else if (a === "--tag") out.tags.push(args[++i] || "");
73
+ else if (a === "-q" || a === "--quiet") out.quiet = true;
74
+ }
75
+ return out;
76
+ }
77
+
78
+ async function cmdPaste(args) {
79
+ const opts = parseArgs(args);
80
+ let body;
81
+ try {
82
+ body = await readStdin();
83
+ } catch (e) {
84
+ console.error(`error: ${e.message}`);
85
+ process.exit(1);
86
+ }
87
+ if (!body || !body.trim()) {
88
+ console.error("error: paste body is empty");
89
+ process.exit(1);
90
+ }
91
+
92
+ const result = await apiCall("/v1/paste", {
93
+ body,
94
+ title: opts.title,
95
+ author: opts.author,
96
+ tags: opts.tags,
97
+ });
98
+
99
+ if (result.error) {
100
+ console.error(`error: ${result.error}`);
101
+ process.exit(1);
102
+ }
103
+
104
+ if (opts.quiet) {
105
+ console.log(result.url || result.slug || "");
106
+ return;
107
+ }
108
+ console.log(`${T}slug:${R} ${result.slug}`);
109
+ console.log(`${T}url: ${R} ${result.url}`);
110
+ console.log(`${T}raw: ${R} ${result.raw_url}`);
111
+ console.log(`${D}size: ${result.size_bytes} bytes${R}`);
112
+ }
113
+
114
+ module.exports = { cmdPaste, readStdin, parseArgs, MAX_PASTE_BYTES, formatBytes };