@0dai-dev/cli 4.2.0 → 4.3.5

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 (52) hide show
  1. package/README.md +98 -10
  2. package/bin/0dai.js +298 -60
  3. package/lib/commands/audit.js +13 -0
  4. package/lib/commands/auth.js +344 -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 +39 -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 +504 -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 +104 -7
  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 +30 -1
  30. package/lib/commands/swarm.js +97 -4
  31. package/lib/commands/tui.js +81 -13
  32. package/lib/commands/upgrade.js +58 -0
  33. package/lib/commands/usage.js +87 -0
  34. package/lib/commands/vault.js +246 -0
  35. package/lib/onboarding.js +9 -3
  36. package/lib/shared.js +29 -14
  37. package/lib/utils/activation_telemetry.js +156 -0
  38. package/lib/utils/auth.js +1 -0
  39. package/lib/utils/canonical-counts.js +54 -0
  40. package/lib/utils/constants.js +7 -0
  41. package/lib/utils/diff-preview.js +192 -0
  42. package/lib/utils/identity.js +76 -18
  43. package/lib/utils/mcp-auth.js +607 -0
  44. package/lib/utils/plan.js +47 -2
  45. package/lib/utils/run_cost.js +91 -0
  46. package/lib/vault/cipher.js +125 -0
  47. package/lib/vault/identity.js +122 -0
  48. package/lib/vault/index.js +184 -0
  49. package/lib/vault/storage.js +84 -0
  50. package/lib/wizard.js +19 -12
  51. package/package.json +8 -4
  52. package/lib/tui/index.mjs +0 -34610
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * `0dai loop ack` — operator acknowledgement stub for the proactive loop
5
+ * (issue #2235).
6
+ *
7
+ * This is intentionally a sketch. v1 only writes one JSON line to
8
+ * `ai/meta/telemetry/operator-ack.jsonl`. There is no server round-trip,
9
+ * no signed token, no rate-limit. The dead man's switch (Python side,
10
+ * `scripts/dead_man_switch.py`) reads that file and pauses the loop if
11
+ * the most recent ack is older than 24h.
12
+ *
13
+ * TODO (v1.1): authenticate the ack against the 0dai control plane so an
14
+ * ack from a stolen developer machine cannot un-pause the loop.
15
+ * TODO (v1.1): print the most recent ack timestamp + the time until the
16
+ * dead-man would trip again, so the operator sees the runway.
17
+ * TODO (v1.x): expose `0dai loop status` and `0dai loop pause`.
18
+ */
19
+
20
+ const shared = require("../shared");
21
+ const { log, T, R, D, fs, path } = shared;
22
+
23
+ function nowIso() {
24
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
25
+ }
26
+
27
+ function detectOpId() {
28
+ // Best-effort identity. We do not exfiltrate this anywhere — it stays in a
29
+ // local jsonl. Order: $ODAI_OP_ID, $USER, $LOGNAME, "unknown".
30
+ return process.env.ODAI_OP_ID
31
+ || process.env.USER
32
+ || process.env.LOGNAME
33
+ || "unknown";
34
+ }
35
+
36
+ function ackPath(target) {
37
+ return path.join(target, "ai", "meta", "telemetry", "operator-ack.jsonl");
38
+ }
39
+
40
+ function cmdLoopAck(target, args) {
41
+ const noteIdx = args.indexOf("--note");
42
+ const note = noteIdx >= 0 && args[noteIdx + 1] ? args[noteIdx + 1] : "";
43
+
44
+ const file = ackPath(target);
45
+ try {
46
+ fs.mkdirSync(path.dirname(file), { recursive: true });
47
+ } catch (e) {
48
+ log(`error: cannot create telemetry dir: ${e.message}`);
49
+ process.exit(1);
50
+ }
51
+
52
+ const entry = { ts: nowIso(), op_id: detectOpId() };
53
+ if (note) entry.note = note.slice(0, 240);
54
+
55
+ try {
56
+ fs.appendFileSync(file, JSON.stringify(entry) + "\n");
57
+ } catch (e) {
58
+ log(`error: cannot write ack: ${e.message}`);
59
+ process.exit(1);
60
+ }
61
+
62
+ log(`${T}loop ack${R} recorded at ${entry.ts} (op_id=${entry.op_id})`);
63
+ console.log(` ${D}wrote ${file}${R}`);
64
+ console.log(` ${D}TODO v1.1: server-side ack + signed token${R}`);
65
+ }
66
+
67
+ function cmdLoopStatus(target) {
68
+ const file = ackPath(target);
69
+ if (!fs.existsSync(file)) {
70
+ log("no ack history yet — run `0dai loop ack` to start the dead-man clock");
71
+ return;
72
+ }
73
+ let lastTs = null;
74
+ try {
75
+ const lines = fs.readFileSync(file, "utf8").trim().split("\n").filter(Boolean);
76
+ for (const ln of lines) {
77
+ try {
78
+ const row = JSON.parse(ln);
79
+ if (row && row.ts) lastTs = row.ts;
80
+ } catch { /* ignore malformed */ }
81
+ }
82
+ } catch (e) {
83
+ log(`error: cannot read ack file: ${e.message}`);
84
+ process.exit(1);
85
+ }
86
+ if (!lastTs) {
87
+ log("ack file present but no parsable rows");
88
+ return;
89
+ }
90
+ const ageMs = Date.now() - new Date(lastTs).getTime();
91
+ const ageHours = (ageMs / 3_600_000).toFixed(1);
92
+ log(`last ack: ${lastTs} (${ageHours}h ago)`);
93
+ console.log(` ${D}dead-man trips at 24h since last ack${R}`);
94
+ }
95
+
96
+ function cmdLoop(target, sub, args) {
97
+ const subArgs = args.slice(2);
98
+ if (sub === "ack") return cmdLoopAck(target, subArgs);
99
+ if (sub === "status") return cmdLoopStatus(target);
100
+ console.log("Usage: 0dai loop [ack [--note '...'] | status]");
101
+ console.log("");
102
+ console.log(" ack Refresh dead-man-switch operator ack timestamp");
103
+ console.log(" status Show last ack and runway until dead-man trips");
104
+ console.log("");
105
+ console.log(` ${D}see ai/docs/proactive-loop-v1.md for the full design${R}`);
106
+ }
107
+
108
+ module.exports = { cmdLoop, cmdLoopAck, cmdLoopStatus };
@@ -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 };