@0dai-dev/cli 3.10.0 → 4.0.0

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.
package/bin/0dai.js CHANGED
@@ -1,2153 +1,37 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
3
 
4
- const https = require("https");
5
- const http = require("http");
6
- const fs = require("fs");
7
- const path = require("path");
8
- const os = require("os");
9
-
10
- const VERSION = require("../package.json").version;
11
-
12
- // CRITICAL: validate API_URL to prevent credential exfiltration to attacker hosts
13
- function _validateApiUrl(url) {
14
- const DEFAULT = "https://api.0dai.dev";
15
- if (!url) return DEFAULT;
16
- try {
17
- const u = new URL(url);
18
- // Only allow https (or http for localhost during dev)
19
- if (u.protocol === "https:") return url;
20
- if (u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "::1")) {
21
- return url;
22
- }
23
- console.error(`[0dai] warning: ODAI_API_URL must use https (or http://localhost). Got: ${u.protocol}//${u.hostname}. Ignoring.`);
24
- return DEFAULT;
25
- } catch {
26
- console.error(`[0dai] warning: ODAI_API_URL is not a valid URL, ignoring`);
27
- return DEFAULT;
28
- }
29
- }
30
- const API_URL = _validateApiUrl(process.env.ODAI_API_URL);
31
- const T = process.stdout.isTTY ? "\x1b[38;2;45;212;168m" : ""; // teal
32
- const R = process.stdout.isTTY ? "\x1b[0m" : ""; // reset
33
- const D = process.stdout.isTTY ? "\x1b[2m" : ""; // dim
34
- const log = (msg) => console.log(`${T}[0dai]${R} ${msg}`);
35
- const CONFIG_DIR = path.join(os.homedir(), ".0dai");
36
- const AUTH_FILE = path.join(CONFIG_DIR, "auth.json");
37
- const VERSION_CHECK_FILE = path.join(CONFIG_DIR, ".version_check");
38
- const PROJECTS_FILE = path.join(CONFIG_DIR, "projects.json");
39
-
40
- const MANIFEST_FILES = [
41
- // Node ecosystem
42
- "package.json", "tsconfig.json",
43
- "next.config.js", "next.config.mjs", "next.config.ts",
44
- "vite.config.js", "vite.config.ts", "vite.config.mjs",
45
- "vue.config.js", "nuxt.config.js", "nuxt.config.ts",
46
- "svelte.config.js", "astro.config.mjs", "astro.config.ts",
47
- "remix.config.js", "angular.json",
48
- // Other languages
49
- "go.mod", "pyproject.toml", "requirements.txt", "setup.py",
50
- "pubspec.yaml", "Cargo.toml", "pom.xml", "build.gradle",
51
- "Gemfile", "composer.json",
52
- // Build/deploy
53
- "Makefile", "docker-compose.yml", "Dockerfile",
54
- "pnpm-workspace.yaml", "lerna.json", "turbo.json", "nx.json",
55
- ];
56
-
57
- const PROBE_DIRS = [
58
- "src", "lib", "app", "apps", "packages", "services",
59
- "cmd", "internal", "web", "frontend", "backend",
60
- "tests", "test", "spec", "__tests__",
61
- "infra", "deploy", "docker", ".github",
62
- "android", "ios", "linux", "macos", "windows",
63
- ];
64
-
65
- // Single source of truth for all supported agent CLIs.
66
- // Adding a new CLI here automatically wires detection, doctor, validate,
67
- // and update — no other changes needed in this file. Keep in sync with
68
- // bootstrap/common.sh's detect_available_clis() and scripts/swarm.py's
69
- // AGENT_STRENGTHS dict (those are the bash + python equivalents).
70
- //
71
- // Fields:
72
- // name logical name used in discovery.json + swarm
73
- // bin actual binary name in $PATH (only differs from name for qoder)
74
- // pkg package name for install/update (null = no auto-update)
75
- // pkgType "npm" | "pip" | "go" — drives update install command
76
- // install human-readable install hint shown in doctor output
77
- // altAuth optional alternative auth hint (subscription, etc.)
78
- // agentFiles files that must exist when this CLI is selected (cmdValidate)
79
- const SUPPORTED_CLIS = [
80
- {
81
- name: "claude", bin: "claude",
82
- pkg: "@anthropic-ai/claude-code", pkgType: "npm",
83
- install: "npm i -g @anthropic-ai/claude-code",
84
- altAuth: "Pro/Team subscription",
85
- agentFiles: [".claude/settings.json", ".claude/CLAUDE.md", ".mcp.json"],
86
- },
87
- {
88
- name: "codex", bin: "codex",
89
- pkg: "@openai/codex", pkgType: "npm",
90
- install: "npm i -g @openai/codex",
91
- altAuth: "ChatGPT Pro subscription",
92
- agentFiles: ["AGENTS.md", ".codex/config.toml"],
93
- },
94
- {
95
- name: "opencode", bin: "opencode",
96
- pkg: null, pkgType: "go",
97
- install: "go install github.com/nichochar/opencode@latest",
98
- altAuth: null,
99
- agentFiles: ["opencode.json"],
100
- },
101
- {
102
- name: "gemini", bin: "gemini",
103
- pkg: "@google/gemini-cli", pkgType: "npm",
104
- install: "npm i -g @google/gemini-cli",
105
- altAuth: null,
106
- agentFiles: null,
107
- },
108
- {
109
- name: "aider", bin: "aider",
110
- pkg: "aider-chat", pkgType: "pip",
111
- install: "pip install aider-chat",
112
- altAuth: null,
113
- agentFiles: null,
114
- },
115
- {
116
- name: "qoder", bin: "qodercli",
117
- pkg: "@qoder-ai/qodercli", pkgType: "npm",
118
- install: "npm i -g @qoder-ai/qodercli",
119
- altAuth: null,
120
- agentFiles: [".qoder/settings.json"],
121
- },
122
- ];
123
-
124
- function deviceFingerprint() {
125
- const crypto = require("crypto");
126
- const parts = [
127
- os.hostname(),
128
- os.userInfo().username,
129
- os.platform(),
130
- os.arch(),
131
- os.cpus().length.toString(),
132
- os.totalmem().toString(),
133
- ];
134
- // Try machine-id
135
- try {
136
- if (os.platform() === "linux") parts.push(fs.readFileSync("/etc/machine-id", "utf8").trim());
137
- else if (os.platform() === "darwin") {
138
- const { execSync } = require("child_process");
139
- parts.push(execSync("ioreg -rd1 -c IOPlatformExpertDevice | awk '/IOPlatformUUID/'", { encoding: "utf8" }).trim());
140
- }
141
- } catch {}
142
- return crypto.createHash("sha256").update(parts.join(":")).digest("hex").slice(0, 32);
143
- }
144
-
145
- function registerProject(projectPath, name, stack) {
146
- try {
147
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
148
- let projects = [];
149
- try { projects = JSON.parse(fs.readFileSync(PROJECTS_FILE, "utf8")).projects || []; } catch {}
150
- const abs = path.resolve(projectPath);
151
- const idx = projects.findIndex(p => p.path === abs);
152
- const entry = { path: abs, name: name || path.basename(abs), stack: stack || "?", last_seen: new Date().toISOString() };
153
- if (idx >= 0) projects[idx] = entry;
154
- else projects.unshift(entry);
155
- fs.writeFileSync(PROJECTS_FILE, JSON.stringify({ projects: projects.slice(0, 50) }, null, 2));
156
- } catch {}
157
- }
158
-
159
- function cmdPortfolio() {
160
- let projects = [];
161
- try { projects = JSON.parse(fs.readFileSync(PROJECTS_FILE, "utf8")).projects || []; } catch {}
162
-
163
- if (!projects.length) {
164
- log(`no projects registered yet`);
165
- console.log(` Run ${D}0dai init${R} in a project to start tracking it.`);
166
- return;
167
- }
168
-
169
- const rows = [];
170
- let totalSessions = 0, totalScore = 0, scored = 0;
171
-
172
- for (const p of projects) {
173
- if (!fs.existsSync(p.path)) continue;
174
-
175
- let sessions = 0, lastSession = null, agentMap = {};
176
- try {
177
- const stats = JSON.parse(fs.readFileSync(path.join(p.path, "ai", "feedback", ".usage_stats.json"), "utf8"));
178
- sessions = stats.total_sessions || 0;
179
- lastSession = stats.last_session || null;
180
- agentMap = stats.agents || {};
181
- } catch {}
182
-
183
- let name = p.name, stack = p.stack;
184
- try {
185
- const disc = JSON.parse(fs.readFileSync(path.join(p.path, "ai", "manifest", "discovery.json"), "utf8"));
186
- name = disc.project_name || name;
187
- stack = disc.stack || stack;
188
- } catch {}
189
-
190
- // Effectiveness score (mirrors metrics command)
191
- let score = 0;
192
- if (sessions > 0) {
193
- score += Math.min(sessions * 5, 35);
194
- let done = 0;
195
- try { done = fs.readdirSync(path.join(p.path, "ai", "swarm", "done")).filter(f => f.endsWith(".json")).length; } catch {}
196
- if (done > 0) score += Math.min(done * 6, 30);
197
- try {
198
- const hasFb = fs.readdirSync(path.join(p.path, "ai", "feedback")).some(f => f.endsWith("-report.json"));
199
- if (hasFb) score += 20;
200
- } catch {}
201
- const layerFiles = ["ai/manifest/discovery.json", "ai/manifest/commands.yaml", "ai/playbooks/quick-start.md"];
202
- score += Math.round(layerFiles.filter(f => fs.existsSync(path.join(p.path, f))).length / layerFiles.length * 15);
203
- }
204
-
205
- totalSessions += sessions;
206
- if (sessions > 0) { totalScore += score; scored++; }
207
-
208
- const agentList = Object.keys(agentMap).join("·") || "—";
209
-
210
- let ago = "never";
211
- if (lastSession) {
212
- const h = Math.floor((Date.now() - new Date(lastSession).getTime()) / 3600000);
213
- const d = Math.floor(h / 24);
214
- if (h < 1) ago = "<1h ago";
215
- else if (h < 24) ago = `${h}h ago`;
216
- else if (d < 7) ago = `${d}d ago`;
217
- else ago = `${Math.floor(d / 7)}w ago`;
218
- }
219
-
220
- rows.push({ name, stack, score: sessions > 0 ? score : null, sessions, agents: agentList, ago });
221
- }
222
-
223
- if (!rows.length) {
224
- log("no projects found (paths may have moved)");
225
- return;
226
- }
227
-
228
- const nameW = Math.min(Math.max(...rows.map(r => r.name.length), 4), 28);
229
- const stackW = Math.min(Math.max(...rows.map(r => r.stack.length), 5), 16);
230
-
231
- console.log(`\n ${T}Portfolio${R} — ${rows.length} project${rows.length === 1 ? "" : "s"}\n`);
232
- for (const r of rows) {
233
- const nm = r.name.slice(0, nameW).padEnd(nameW);
234
- const st = r.stack.slice(0, stackW).padEnd(stackW);
235
- const sc = r.score !== null ? `score ${String(r.score).padStart(3)}` : " ";
236
- const se = `${String(r.sessions).padStart(2)} session${r.sessions === 1 ? " " : "s"}`;
237
- console.log(` ${T}${nm}${R} ${D}${st}${R} ${sc} ${se} ${r.agents.padEnd(14)} ${D}${r.ago}${R}`);
238
- }
239
-
240
- if (totalSessions > 0) {
241
- const avg = scored > 0 ? Math.round(totalScore / scored) : 0;
242
- const stacks = [...new Set(rows.map(r => r.stack))].length;
243
- console.log(`\n ${D}${"─".repeat(70)}`);
244
- console.log(` Total: ${totalSessions} sessions ${stacks} stack${stacks === 1 ? "" : "s"} avg effectiveness: ${avg}${R}\n`);
245
- } else {
246
- console.log(`\n ${D}Tip: run '0dai init' in your projects to start tracking sessions.${R}\n`);
247
- }
248
- }
249
-
250
- async function cmdRun(goal, target, args = []) {
251
- if (!goal) {
252
- console.log(`Usage: 0dai run <goal> [--dry-run] [--agent claude|codex|gemini]`);
253
- console.log(` Example: 0dai run "add dark mode to settings page"`);
254
- return;
255
- }
256
-
257
- const dryRun = args.includes("--dry-run");
258
- const agentIdx = args.indexOf("--agent");
259
- const agentOverride = agentIdx >= 0 ? args[agentIdx + 1] : null;
260
-
261
- // Read project context
262
- let stack = "generic", agents = ["claude"], commands = {};
263
- try {
264
- const disc = JSON.parse(fs.readFileSync(path.join(target, "ai", "manifest", "discovery.json"), "utf8"));
265
- stack = disc.stack || "generic";
266
- agents = disc.selected_agents || ["claude"];
267
- } catch {}
268
-
269
- if (!dryRun) process.stdout.write(`${T}[0dai]${R} decomposing goal...`);
270
- const result = await apiCall("/v1/run", { goal, context: { stack, agents, commands } });
271
- if (!dryRun) process.stdout.write("\r" + " ".repeat(40) + "\r");
272
-
273
- if (result.error) { log(`error: ${result.error}`); return; }
274
-
275
- const tasks = result.tasks || [];
276
- if (!tasks.length) { log("no tasks returned"); return; }
277
-
278
- console.log(`\n ${T}Goal:${R} ${goal}`);
279
- console.log(` Decomposed into ${tasks.length} task${tasks.length === 1 ? "" : "s"}:\n`);
280
-
281
- for (let i = 0; i < tasks.length; i++) {
282
- const t = tasks[i];
283
- const agent = agentOverride || t.assigned_to;
284
- console.log(` ${T}${i + 1}.${R} ${t.title}`);
285
- console.log(` ${D}→ ${agent} [${t.model_tier}]${t.description ? " " + t.description : ""}${R}`);
286
- }
287
-
288
- if (dryRun) {
289
- console.log(`\n ${D}[dry-run] would create ${tasks.length} task(s) in swarm queue${R}\n`);
290
- return;
291
- }
292
-
293
- // Create queue entries
294
- const queueDir = path.join(target, "ai", "swarm", "queue");
295
- try { fs.mkdirSync(queueDir, { recursive: true }); } catch {}
296
-
297
- const created = [];
298
- for (const t of tasks) {
299
- const ts = Date.now();
300
- const rand = Math.random().toString(36).slice(2, 6);
301
- const id = `run-${ts}-${rand}`;
302
- const entry = {
303
- id,
304
- title: t.title,
305
- description: t.description || "",
306
- assigned_to: agentOverride || t.assigned_to,
307
- model_tier: t.model_tier,
308
- created_by: "run",
309
- created_at: new Date().toISOString(),
310
- context: { goal },
311
- };
312
- try {
313
- fs.writeFileSync(path.join(queueDir, `${id}.json`), JSON.stringify(entry, null, 2));
314
- created.push(id);
315
- } catch (e) { log(`warn: could not write task ${id}: ${e.message}`); }
316
- }
317
-
318
- console.log(`\n ${T}✓${R} ${created.length} task${created.length === 1 ? "" : "s"} added to swarm queue`);
319
- console.log(` ${D}Monitor: 0dai watch | Queue: 0dai swarm status${R}\n`);
320
- }
321
-
322
- function apiCall(endpoint, data) {
323
- return new Promise((resolve, reject) => {
324
- const url = new URL(endpoint, API_URL);
325
- const mod = url.protocol === "https:" ? https : http;
326
- const body = data ? JSON.stringify(data) : null;
327
- const headers = { "Content-Type": "application/json", "X-Device-ID": deviceFingerprint(), "X-CLI-Version": VERSION };
328
-
329
- try {
330
- const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
331
- const token = auth.api_key || auth.access_token || auth.token;
332
- if (token) headers["Authorization"] = `Bearer ${token}`;
333
- } catch {}
334
-
335
- const opts = {
336
- hostname: url.hostname,
337
- port: url.port || (url.protocol === "https:" ? 443 : 80),
338
- path: url.pathname,
339
- method: body ? "POST" : "GET",
340
- headers,
341
- timeout: 60000,
342
- };
343
- if (body) opts.headers["Content-Length"] = Buffer.byteLength(body);
344
-
345
- const req = mod.request(opts, (res) => {
346
- let chunks = [];
347
- res.on("data", (c) => chunks.push(c));
348
- res.on("end", () => {
349
- try {
350
- resolve(JSON.parse(Buffer.concat(chunks).toString()));
351
- } catch {
352
- resolve({ error: `HTTP ${res.statusCode}` });
353
- }
354
- });
355
- });
356
- req.on("error", (e) => resolve({ error: `${e.message}. Is ${API_URL} reachable?` }));
357
- req.on("timeout", () => { req.destroy(); resolve({ error: "timeout" }); });
358
- if (body) req.write(body);
359
- req.end();
360
- });
361
- }
362
-
363
- function collectMetadata(target) {
364
- const projectFiles = [];
365
- const fileContents = {};
366
-
367
- for (const d of PROBE_DIRS) {
368
- try { if (fs.statSync(path.join(target, d)).isDirectory()) projectFiles.push(d + "/"); } catch {}
369
- }
370
-
371
- for (const f of MANIFEST_FILES) {
372
- const p = path.join(target, f);
373
- try {
374
- const stat = fs.statSync(p);
375
- if (stat.isFile()) {
376
- projectFiles.push(f);
377
- const content = fs.readFileSync(p, "utf8");
378
- if (content.length < 10000) fileContents[f] = content;
379
- }
380
- } catch {}
381
- }
382
-
383
- const clis = [];
384
- const { execSync } = require("child_process");
385
- for (const cli of SUPPORTED_CLIS) {
386
- try {
387
- execSync(`command -v ${cli.bin}`, { stdio: "ignore", shell: "/bin/sh", env: process.env });
388
- clis.push(cli.name);
389
- } catch {}
390
- }
391
-
392
- return { projectFiles, fileContents, clis };
393
- }
394
-
395
- // Fields in settings.json that belong to the user, not to 0dai
396
- const SETTINGS_PRESERVE_FIELDS = ["model", "permissionMode", "effortLevel"];
397
-
398
- function mergeSettingsJson(existing, incoming) {
399
- try {
400
- const base = JSON.parse(incoming);
401
- const user = JSON.parse(existing);
402
- // Preserve user-owned fields
403
- for (const field of SETTINGS_PRESERVE_FIELDS) {
404
- if (field in user && user[field] !== base[field]) {
405
- base[field] = user[field];
406
- }
407
- }
408
- return JSON.stringify(base, null, 2) + "\n";
409
- } catch { return incoming; }
410
- }
411
-
412
- function writeFiles(target, files) {
413
- let created = 0, updated = 0, unchanged = 0, merged = 0, skipped = 0;
414
- const targetResolved = path.resolve(target);
415
- for (const [rel, content] of Object.entries(files)) {
416
- // HIGH: path traversal protection — reject absolute paths and `..` escapes
417
- if (typeof rel !== "string" || !rel || path.isAbsolute(rel) || rel.split(/[/\\]/).includes("..")) {
418
- skipped++;
419
- continue;
420
- }
421
- const p = path.resolve(targetResolved, rel);
422
- if (!p.startsWith(targetResolved + path.sep) && p !== targetResolved) {
423
- skipped++;
424
- continue;
425
- }
426
- fs.mkdirSync(path.dirname(p), { recursive: true });
427
-
428
- let finalContent = content;
429
-
430
- // Smart merge for specific files
431
- if (fs.existsSync(p)) {
432
- const existing = fs.readFileSync(p, "utf8");
433
- if (existing === content) { unchanged++; continue; }
434
-
435
- if (rel.endsWith("settings.json")) {
436
- finalContent = mergeSettingsJson(existing, content);
437
- merged++;
438
- } else if (rel === "AGENTS.md") {
439
- if (existing.includes("managed: false")) {
440
- unchanged++; // User owns this file, skip
441
- continue;
442
- }
443
- // Backup existing AGENTS.md before overwrite
444
- const backupDir = path.join(target, "ai", ".backups");
445
- fs.mkdirSync(backupDir, { recursive: true });
446
- fs.writeFileSync(path.join(backupDir, "AGENTS.md.bak"), existing, "utf8");
447
- updated++;
448
- } else {
449
- updated++;
450
- }
451
- } else {
452
- created++;
453
- }
454
-
455
- fs.writeFileSync(p, finalContent, "utf8");
456
- }
457
- const parts = [`${created} created`, `${updated} updated`, `${unchanged} unchanged`];
458
- if (merged) parts.push(`${merged} merged`);
459
- if (skipped) parts.push(`${skipped} skipped (unsafe path)`);
460
- log(parts.join(", "));
461
- return created + updated;
462
- }
463
-
464
- async function cmdInit(target, args = []) {
465
- const dryRun = args.includes("--dry-run");
466
- const minimal = args.includes("--minimal");
467
-
468
- if (fs.existsSync(path.join(target, "ai", "VERSION"))) {
469
- const v = fs.readFileSync(path.join(target, "ai", "VERSION"), "utf8").trim();
470
- log(`ai/ layer already exists (v${v}). Run '0dai sync' to update.`);
471
- return;
472
- }
473
-
474
- const isTTY = process.stdout.isTTY;
475
- let spinner = null;
476
- if (isTTY) {
477
- try { spinner = require("@clack/prompts").spinner(); } catch {}
478
- }
479
-
480
- const { projectFiles, fileContents, clis } = collectMetadata(target);
481
- if (dryRun) log(`${D}dry-run: would generate ai/ layer (${projectFiles.length} files, ${clis.length} CLIs)${R}`);
482
- if (spinner) spinner.start(`${dryRun ? "[dry-run] " : ""}Generating ai/ layer (${projectFiles.length} files, ${clis.length} CLIs)...`);
483
- else if (!dryRun) log(`sending to API (${projectFiles.length} files, ${clis.length} CLIs)...`);
484
- const result = await apiCall("/v1/init", {
485
- project_files: projectFiles,
486
- file_contents: fileContents,
487
- available_clis: clis,
488
- dry_run: dryRun,
489
- minimal: minimal,
490
- });
491
-
492
- if (result.error) {
493
- if (result.error.includes("authentication")) {
494
- log(`authentication required`);
495
- console.log(` 0dai auth is separate from your agent CLIs (Claude Code, Codex).`);
496
- console.log(` It tracks projects, usage limits, and team features.\n`);
497
- console.log(` ${D}Run: 0dai auth login${R}`);
498
- } else if (result.hint) {
499
- log(`${result.message || result.error}`);
500
- console.log(` ${result.hint}\n`);
501
- } else {
502
- log(`error: ${result.error}`);
503
- }
504
- process.exit(1);
505
- }
506
-
507
- if (spinner) spinner.stop(`${dryRun ? "[dry-run] " : ""}Detected: ${result.stack || "?"}`);
508
- else log(`detected: ${result.stack || "?"}`);
509
- if (dryRun) {
510
- const files = Object.keys(result.files || {});
511
- log(`${D}dry-run: would write ${files.length} files:${R}`);
512
- for (const f of files.slice(0, 20)) console.log(` ${D}+ ${f}${R}`);
513
- if (files.length > 20) console.log(` ${D}… and ${files.length - 20} more${R}`);
514
- return;
515
- }
516
- writeFiles(target, result.files || {});
517
-
518
- // Ensure ai/VERSION matches CLI version
519
- const versionFile = path.join(target, "ai", "VERSION");
520
- fs.mkdirSync(path.dirname(versionFile), { recursive: true });
521
- fs.writeFileSync(versionFile, VERSION + "\n", "utf8");
522
-
523
- // Add to .gitignore
524
- const gi = path.join(target, ".gitignore");
525
- try {
526
- const text = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : "";
527
- if (!text.includes(".0dai")) fs.appendFileSync(gi, "\n.0dai/\n");
528
- } catch {}
529
-
530
- // Register in global portfolio
531
- registerProject(target, path.basename(target), result.stack);
532
-
533
- log(`initialized (${result.file_count || "?"} files)`);
534
- console.log(" skills: /build /review /status /feedback /bugfix /delegate");
535
-
536
- // Detect agent auth status for smart onboarding hints
537
- const { execFileSync: _ef } = require("child_process");
538
- const agents = [];
539
- try { _ef("claude", ["--version"], { timeout: 8000 }); agents.push("claude"); } catch {}
540
- try { _ef("codex", ["--version"], { timeout: 8000 }); agents.push("codex"); } catch {}
541
- try { _ef("gemini", ["--version"], { timeout: 8000 }); agents.push("gemini"); } catch {}
542
-
543
- // Next steps — guide user to first value
544
- console.log(`\n ${T}Next steps:${R}`);
545
- console.log(` ${D}1.${R} Check health: ${D}0dai doctor${R}`);
546
- if (agents.length > 0) {
547
- const a = agents[0];
548
- console.log(` ${D}2.${R} Try delegation: ${D}0dai run "write tests for auth"${R}`);
549
- console.log(` ${D}(${agents.join(", ")} detected — delegation will use ${a} by default)${R}`);
550
- } else {
551
- console.log(` ${D}2.${R} Install an agent CLI to enable delegation:`);
552
- console.log(` ${D}claude:${R} npm i -g @anthropic-ai/claude-code ${D}(or Pro subscription)${R}`);
553
- console.log(` ${D}codex:${R} npm i -g @openai/codex ${D}(or ChatGPT Pro)${R}`);
554
- }
555
- console.log(` ${D}3.${R} Open dashboard: ${D}https://0dai.dev/dashboard${R}`);
556
-
557
- // Send anonymous usage ping
558
- apiCall("/v1/feedback", { report: {
559
- stack_detected: result.stack || "?", _auto: true, _plan: result.plan || "trial",
560
- _cli_version: VERSION, _files_generated: result.file_count || 0,
561
- }}).catch(() => {});
562
- }
563
-
564
- async function cmdSync(target, args = []) {
565
- const dryRun = args.includes("--dry-run");
566
- const quiet = args.includes("--quiet") || args.includes("-q");
567
-
568
- // Quick local check: skip API if already at current version (unless dry-run)
569
- let version = "unknown";
570
- try { version = fs.readFileSync(path.join(target, "ai", "VERSION"), "utf8").trim(); } catch {}
571
- if (!dryRun && version === VERSION) {
572
- log("already up to date (v" + version + ")");
573
- return;
574
- }
575
-
576
- const { projectFiles, fileContents, clis } = collectMetadata(target);
577
- let stack = "generic", agents = [];
578
- try {
579
- const d = JSON.parse(fs.readFileSync(path.join(target, "ai", "manifest", "discovery.json"), "utf8"));
580
- stack = d.stack || "generic";
581
- agents = d.selected_agents || [];
582
- } catch {}
583
-
584
- // Collect current ai/ files
585
- const currentFiles = {};
586
- const aiDir = path.join(target, "ai");
587
- if (fs.existsSync(aiDir)) {
588
- const walk = (dir) => {
589
- for (const f of fs.readdirSync(dir, { withFileTypes: true })) {
590
- const p = path.join(dir, f.name);
591
- if (f.isDirectory()) walk(p);
592
- else {
593
- try {
594
- const stat = fs.statSync(p);
595
- if (stat.size < 10000) currentFiles[path.relative(target, p)] = fs.readFileSync(p, "utf8");
596
- } catch {}
597
- }
598
- }
599
- };
600
- walk(aiDir);
601
- }
602
-
603
- if (dryRun) log(`${D}dry-run: checking what sync would change...${R}`);
604
-
605
- const result = await apiCall("/v1/sync", {
606
- ai_version: version, stack, agents: agents.length ? agents : clis,
607
- current_files: currentFiles, file_contents: fileContents,
608
- dry_run: dryRun, quiet,
609
- });
610
-
611
- if (result.error) {
612
- if (result.error.includes("authentication")) {
613
- log(`authentication required`);
614
- console.log(` 0dai auth is separate from your agent CLIs (Claude Code, Codex).`);
615
- console.log(` It tracks projects, usage limits, and team features.\n`);
616
- console.log(` ${D}Run: 0dai auth login${R}`);
617
- } else {
618
- log(`error: ${result.error}`);
619
- }
620
- process.exit(1);
621
- }
622
-
623
- const updated = result.files_updated || {};
624
- if (dryRun) {
625
- const files = Object.keys(updated);
626
- if (files.length) {
627
- log(`${D}dry-run: would update ${files.length} file(s):${R}`);
628
- for (const f of files) console.log(` ${D}~ ${f}${R}`);
629
- } else {
630
- log(`${D}dry-run: nothing to update${R}`);
631
- }
632
- return;
633
- }
634
- const changedCount = Object.keys(updated).length;
635
- if (changedCount) {
636
- writeFiles(target, updated);
637
- if (!quiet) {
638
- for (const f of Object.keys(updated)) console.log(` ~ ${f}`);
639
- }
640
- log(`sync: ${changedCount} file(s) updated`);
641
- console.log(` ${D}Run: 0dai doctor to verify project health${R}`);
642
- } else {
643
- log("already up to date");
644
- }
645
-
646
- // Ensure ai/VERSION matches CLI version after successful sync
647
- const versionFile = path.join(target, "ai", "VERSION");
648
- try {
649
- const current = fs.existsSync(versionFile) ? fs.readFileSync(versionFile, "utf8").trim() : "";
650
- if (current !== VERSION) {
651
- fs.writeFileSync(versionFile, VERSION + "\n", "utf8");
652
- }
653
- } catch {}
654
-
655
- // Update portfolio registry
656
- registerProject(target, path.basename(target), stack);
657
- }
658
-
659
- async function cmdDetect(target) {
660
- const OPTIONAL_CLIS = ["gemini", "aider", "opencode"];
661
- const { projectFiles, fileContents, clis: localClis } = collectMetadata(target);
662
- // Send file contents AND local CLI inventory so server can do content-based detection
663
- const result = await apiCall("/v1/detect", {
664
- project_files: projectFiles,
665
- file_contents: fileContents,
666
- available_clis: localClis,
667
- });
668
- if (result.error) { log(`error: ${result.error}`); return; }
669
- console.log(`stack: ${result.stack || "?"}`);
670
- // Use local CLIs if server didn't return any (server can't detect locally installed binaries)
671
- const clis = (result.available_clis && result.available_clis.length && result.available_clis[0]) ? result.available_clis : localClis;
672
- if (clis.length) {
673
- console.log(`clis: ${clis.join(", ")}`);
674
- } else {
675
- console.log(`clis: none detected`);
676
- console.log(` ${D}install claude, codex, or opencode to use 0dai${R}`);
677
- }
678
- // Explain optional CLIs so missing doesn't alarm users
679
- const missing = OPTIONAL_CLIS.filter(c => !clis.includes(c));
680
- if (missing.length && clis.length) {
681
- console.log(` ${D}optional (not installed): ${missing.join(", ")}${R}`);
682
- }
683
- }
684
-
685
- function cmdAudit(target) {
686
- const W = process.stdout.isTTY ? "\x1b[33m" : ""; // yellow
687
- const RE = process.stdout.isTTY ? "\x1b[31m" : ""; // red
688
- const G = process.stdout.isTTY ? "\x1b[32m" : ""; // green
689
-
690
- // Secret patterns: [label, regex, severity]
691
- const PATTERNS = [
692
- ["Anthropic API key", /sk-ant-api[0-9A-Za-z_-]{20,}/g, "critical"],
693
- ["OpenAI API key", /sk-[A-Za-z0-9]{20,}/g, "critical"],
694
- ["GitHub PAT (ghp)", /ghp_[A-Za-z0-9]{36}/g, "critical"],
695
- ["GitHub PAT (gho)", /gho_[A-Za-z0-9]{36}/g, "critical"],
696
- ["GitHub fine-grained",/github_pat_[A-Za-z0-9_]{59}/g, "critical"],
697
- ["AWS access key", /AKIA[0-9A-Z]{16}/g, "critical"],
698
- ["AWS secret key", /aws_secret_access_key\s*[=:]\s*\S{20,}/gi,"critical"],
699
- ["Google API key", /AIza[0-9A-Za-z_-]{35}/g, "high"],
700
- ["Bearer token", /Bearer\s+[A-Za-z0-9_-]{32,}/g, "high"],
701
- ["Private key block", /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY/g, "critical"],
702
- ["Generic secret var", /(?:secret|password|passwd|pwd)\s*[=:]\s*["']?[A-Za-z0-9+/=_-]{12,}["']?/gi, "medium"],
703
- ];
704
-
705
- // Files to scan
706
- const SCAN_FILES = [
707
- "CLAUDE.md", "AGENTS.md", "GEMINI.md", ".cursorrules",
708
- ".codex/config.md", ".codex/instructions.md",
709
- "opencode.json", ".mcp.json",
710
- ];
711
-
712
- // Walk a directory recursively, return all file paths
713
- function walk(dir, maxDepth = 6, _depth = 0) {
714
- if (_depth > maxDepth) return [];
715
- let results = [];
716
- try {
717
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
718
- if (entry.name.startsWith(".git") || entry.name === "node_modules") continue;
719
- const full = path.join(dir, entry.name);
720
- if (entry.isDirectory()) results = results.concat(walk(full, maxDepth, _depth + 1));
721
- else results.push(full);
722
- }
723
- } catch { /* skip unreadable */ }
724
- return results;
725
- }
726
-
727
- const findings = []; // {file, line, label, severity, excerpt}
728
-
729
- function scanContent(filePath, content) {
730
- const lines = content.split("\n");
731
- for (const [label, regex, severity] of PATTERNS) {
732
- regex.lastIndex = 0;
733
- for (let i = 0; i < lines.length; i++) {
734
- let m;
735
- regex.lastIndex = 0;
736
- while ((m = regex.exec(lines[i])) !== null) {
737
- const val = m[0];
738
- // Redact: show first 6 + ... + last 4 chars
739
- const excerpt = val.length > 14 ? val.slice(0, 6) + "..." + val.slice(-4) : val.slice(0, 4) + "...";
740
- findings.push({ file: filePath, line: i + 1, label, severity, excerpt });
741
- }
742
- }
743
- }
744
- }
745
-
746
- // Collect files to scan
747
- const toScan = new Set();
748
-
749
- for (const rel of SCAN_FILES) {
750
- const p = path.join(target, rel);
751
- if (fs.existsSync(p)) toScan.add(p);
752
- }
753
-
754
- const aiDir = path.join(target, "ai");
755
- if (fs.existsSync(aiDir)) {
756
- for (const f of walk(aiDir)) {
757
- if (/\.(md|json|yaml|yml|txt|toml)$/.test(f)) toScan.add(f);
758
- }
759
- }
760
-
761
- // Warn about .env files (don't scan content, just flag existence)
762
- const envFiles = [".env", ".env.local", ".env.production", ".env.development"];
763
- const foundEnv = envFiles.filter(e => fs.existsSync(path.join(target, e)));
764
-
765
- console.log(`\n ${T}0dai audit${R} — scanning for leaked secrets\n`);
766
- console.log(` ${D}target: ${target}${R}`);
767
- console.log(` ${D}files: ${toScan.size} scanned${R}\n`);
768
-
769
- for (const filePath of toScan) {
770
- try {
771
- const content = fs.readFileSync(filePath, "utf8");
772
- scanContent(filePath, content);
773
- } catch { /* skip */ }
774
- }
775
-
776
- if (foundEnv.length > 0) {
777
- console.log(` ${W}WARN${R} .env files detected — ensure they are in .gitignore`);
778
- for (const e of foundEnv) console.log(` ${D}${e}${R}`);
779
- console.log();
780
- }
781
-
782
- if (findings.length === 0) {
783
- console.log(` ${G}✓ No secrets found${R} in scanned files\n`);
784
- return;
785
- }
786
-
787
- const critical = findings.filter(f => f.severity === "critical");
788
- const high = findings.filter(f => f.severity === "high");
789
- const medium = findings.filter(f => f.severity === "medium");
790
-
791
- const colorFor = (s) => s === "critical" ? RE : s === "high" ? W : D;
792
-
793
- for (const f of findings) {
794
- const c = colorFor(f.severity);
795
- const rel = path.relative(target, f.file);
796
- console.log(` ${c}${f.severity.toUpperCase().padEnd(8)}${R} ${rel}:${f.line}`);
797
- console.log(` ${D}${f.label}: ${f.excerpt}${R}`);
798
- }
799
-
800
- console.log();
801
- if (critical.length > 0) console.log(` ${RE}${critical.length} critical${R} · ${W}${high.length} high${R} · ${D}${medium.length} medium${R}\n`);
802
- else console.log(` ${W}${high.length} high${R} · ${D}${medium.length} medium${R}\n`);
803
-
804
- console.log(` ${D}Tip: add secrets to .gitignore or use env vars, not plaintext files${R}\n`);
805
-
806
- if (critical.length > 0) process.exit(1);
807
- }
808
-
809
- function cmdDoctor(target) {
810
- const ai = path.join(target, "ai");
811
- if (!fs.existsSync(ai)) { log("no ai/ layer. Run '0dai init' first."); return; }
812
- let v = "?", stack = "generic";
813
- try { v = fs.readFileSync(path.join(ai, "VERSION"), "utf8").trim(); } catch {}
814
- try { stack = JSON.parse(fs.readFileSync(path.join(ai, "manifest", "discovery.json"), "utf8")).stack || "generic"; } catch {}
815
-
816
- const W = process.stdout.isTTY ? "\x1b[33m" : ""; // yellow
817
- const E = process.stdout.isTTY ? "\x1b[31m" : ""; // red
818
- const G = process.stdout.isTTY ? "\x1b[32m" : ""; // green
819
- const R2 = process.stdout.isTTY ? "\x1b[0m" : "";
820
-
821
- // --- ai/ layer checks ---
822
- const layerChecks = {
823
- "ai/VERSION": { path: path.join(ai, "VERSION"), sev: "error" },
824
- "ai/manifest/project.yaml": { path: path.join(ai, "manifest", "project.yaml"), sev: "error" },
825
- "ai/manifest/commands.yaml": { path: path.join(ai, "manifest", "commands.yaml"), sev: "warn" },
826
- "ai/manifest/discovery.json": { path: path.join(ai, "manifest", "discovery.json"),sev: "warn" },
827
- ".claude/settings.json": { path: path.join(target, ".claude", "settings.json"), sev: "warn" },
828
- "AGENTS.md": { path: path.join(target, "AGENTS.md"), sev: "warn" },
829
- };
830
-
831
- // --- credentials checklist ---
832
- // Detect subscription-based auth (not just env API keys)
833
- const { execFileSync: _execFile } = require("child_process");
834
- function cliAuthed(cli) {
835
- try {
836
- if (cli === "claude") {
837
- const out = _execFile("claude", ["auth", "status"], { timeout: 5000 }).toString();
838
- try { return JSON.parse(out).loggedIn === true; } catch {}
839
- return out.includes("loggedIn");
840
- }
841
- _execFile("which", [cli], { timeout: 2000 });
842
- return true;
843
- } catch { return false; }
844
- }
845
-
846
- const claudeAuth = cliAuthed("claude");
847
- const codexAuth = cliAuthed("codex");
848
-
849
- const credChecks = [
850
- {
851
- name: "Claude Code",
852
- present: claudeAuth || !!process.env.ANTHROPIC_API_KEY,
853
- sev: (claudeAuth || process.env.ANTHROPIC_API_KEY) ? "ok" : "warn",
854
- hint: claudeAuth ? "authenticated via subscription" : "run: claude auth login (or set ANTHROPIC_API_KEY)",
855
- },
856
- {
857
- name: "Codex CLI",
858
- present: codexAuth || !!process.env.OPENAI_API_KEY,
859
- sev: (codexAuth || process.env.OPENAI_API_KEY) ? "ok" : "warn",
860
- hint: codexAuth ? "installed (uses ChatGPT subscription)" : "run: npm i -g @openai/codex (or set OPENAI_API_KEY)",
861
- },
862
- {
863
- name: "GITHUB_TOKEN",
864
- present: !!process.env.GITHUB_TOKEN,
865
- sev: process.env.GITHUB_TOKEN ? "ok" : "info",
866
- hint: "Optional — for gh CLI, PR creation",
867
- },
868
- ];
869
-
870
- // Stack-specific creds
871
- if (stack.includes("vercel") || stack.includes("next")) {
872
- credChecks.push({ name: "VERCEL_TOKEN", present: !!process.env.VERCEL_TOKEN, sev: process.env.VERCEL_TOKEN ? "ok" : "info", hint: "Optional — for Vercel deployments" });
873
- }
874
- if (stack.includes("aws") || stack.includes("lambda") || stack.includes("cdk")) {
875
- credChecks.push({ name: "AWS_ACCESS_KEY_ID", present: !!process.env.AWS_ACCESS_KEY_ID, sev: process.env.AWS_ACCESS_KEY_ID ? "ok" : "info", hint: "Optional — for AWS deployments" });
876
- }
877
- if (stack.includes("gcp") || stack.includes("firebase") || stack.includes("flutter")) {
878
- credChecks.push({ name: "GCP_CREDENTIALS", present: !!process.env.GOOGLE_APPLICATION_CREDENTIALS, sev: process.env.GOOGLE_APPLICATION_CREDENTIALS ? "ok" : "info", hint: "Optional — for GCP/Firebase" });
879
- }
880
-
881
- // --- run checks ---
882
- let errors = 0, warnings = 0;
883
- log(`v${v} | stack: ${stack}\n`);
884
-
885
- const missingConfigs = [];
886
- console.log(" ai/ layer:");
887
- for (const [name, { path: p, sev }] of Object.entries(layerChecks)) {
888
- const exists = fs.existsSync(p);
889
- if (!exists) {
890
- sev === "error" ? errors++ : warnings++;
891
- if (sev === "warn") missingConfigs.push(name);
892
- }
893
- const mark = exists ? `${G}ok${R2}` : sev === "error" ? `${E}MISSING${R2}` : `${W}missing${R2}`;
894
- console.log(` ${mark.padEnd(22)} ${name}`);
895
- }
896
- // Explain WHY native configs are missing and what to do
897
- if (missingConfigs.length > 0) {
898
- const hasDiscovery = fs.existsSync(path.join(ai, "manifest", "discovery.json"));
899
- if (hasDiscovery) {
900
- console.log(`\n ${W}→ Native configs not generated yet.${R2}`);
901
- console.log(` ${D}Run: 0dai sync --target .${R2}`);
902
- } else {
903
- console.log(`\n ${W}→ ai/ layer incomplete — run '0dai init' first.${R2}`);
904
- }
905
- }
906
-
907
- console.log("\n credentials:");
908
- for (const c of credChecks) {
909
- if (!c.present && c.sev === "warn") warnings++;
910
- const mark = c.present ? `${G}ok${R2}` : c.sev === "warn" ? `${W}not set${R2}` : `${D}not set${R2}`;
911
- const hint = c.present && c.hint.includes("subscription") ? ` ${D}(${c.hint})${R2}` : (!c.present ? `\n ${D}→ ${c.hint}${R2}` : "");
912
- console.log(` ${mark.padEnd(22)} ${c.name}${hint}`);
913
- }
914
-
915
- // --- agent CLIs check ---
916
- const { execFileSync: _ef2 } = require("child_process");
917
- let updatesAvailable = 0;
918
- console.log("\n agent CLIs:");
919
- for (const cli of SUPPORTED_CLIS) {
920
- let installed = false, ver = null;
921
- try {
922
- const out = _ef2(cli.bin, ["--version"], { timeout: 8000 }).toString().trim();
923
- installed = true;
924
- const m = out.match(/(\d+\.\d+\.\d+)/);
925
- if (m) ver = m[1];
926
- } catch {}
927
-
928
- if (installed) {
929
- let latest = null;
930
- if (cli.pkg) {
931
- try {
932
- const npmOut = _ef2("npm", ["view", cli.pkg, "version"], { timeout: 5000 }).toString().trim();
933
- if (npmOut.match(/^\d+\.\d+\.\d+$/)) latest = npmOut;
934
- } catch {}
935
- }
936
- if (latest && ver && latest !== ver) {
937
- updatesAvailable++;
938
- console.log(` ${W}update${R2} ${cli.name} ${D}${ver} → ${latest}${R2}`);
939
- console.log(` ${D}→ ${cli.install}${R2}`);
940
- } else {
941
- console.log(` ${G}ok${R2} ${cli.name}${ver ? ` ${D}v${ver}${R2}` : ""}`);
942
- }
943
- } else {
944
- console.log(` ${D}—${R2} ${cli.name} ${D}not installed${R2}`);
945
- console.log(` ${D}→ ${cli.install}${cli.altAuth ? ` (or ${cli.altAuth})` : ""}${R2}`);
946
- }
947
- }
948
- if (updatesAvailable) {
949
- console.log(`\n ${D}Run: 0dai update${R2} to update all`);
950
- }
951
-
952
- // --- swarm check ---
953
- const swarmDir = path.join(ai, "swarm");
954
- const countDir = (d) => { try { return fs.readdirSync(d).filter(f => f.endsWith(".json")).length; } catch { return 0; } };
955
- const qCount = countDir(path.join(swarmDir, "queue"));
956
- const dCount = countDir(path.join(swarmDir, "done"));
957
- if (qCount || dCount) {
958
- console.log(`\n swarm: ${qCount} queued, ${dCount} done`);
959
- if (qCount) console.log(` ${W}→ run '0dai reflect' to review pending tasks${R2}`);
960
- }
961
-
962
- const summary = errors ? `${E}${errors} error(s)${R2}` : warnings ? `${W}${warnings} warning(s)${R2}` : `${G}healthy${R2}`;
963
- console.log(`\n status: ${summary}`);
964
- if (errors) process.exitCode = 1;
965
- }
966
-
967
- function cmdValidate(target) {
968
- const ai = path.join(target, "ai");
969
- if (!fs.existsSync(ai)) {
970
- log("no ai/ layer. Run '0dai init' first.");
971
- process.exitCode = 1;
972
- return;
973
- }
974
- const E = process.stdout.isTTY ? "\x1b[31m" : "";
975
- const G = process.stdout.isTTY ? "\x1b[32m" : "";
976
- const D2 = process.stdout.isTTY ? "\x1b[2m" : "";
977
- const R2 = process.stdout.isTTY ? "\x1b[0m" : "";
978
-
979
- const required = [
980
- "ai/VERSION", "ai/VERSION_SCHEMA",
981
- "ai/manifest/project.yaml", "ai/manifest/discovery.json",
982
- "ai/manifest/applied-lock.json", "ai/manifest/environment.yaml",
983
- "ai/manifest/commands.yaml",
984
- ];
985
-
986
- let agents = [];
987
- try {
988
- agents = JSON.parse(fs.readFileSync(path.join(ai, "manifest", "discovery.json"), "utf8")).selected_agents || [];
989
- } catch {}
990
-
991
- const agentFiles = Object.fromEntries(
992
- SUPPORTED_CLIS
993
- .filter((c) => c.agentFiles && c.agentFiles.length > 0)
994
- .map((c) => [c.name, c.agentFiles])
995
- );
996
- for (const agent of agents) {
997
- for (const f of agentFiles[agent] || []) required.push(f);
998
- }
999
-
1000
- const FIX_HINTS = {
1001
- "ai/VERSION": "run: 0dai init",
1002
- "ai/VERSION_SCHEMA": "run: 0dai sync",
1003
- "ai/manifest/project.yaml": "run: 0dai init",
1004
- "ai/manifest/discovery.json": "run: 0dai init",
1005
- "ai/manifest/applied-lock.json": "run: 0dai sync",
1006
- "ai/manifest/environment.yaml": "run: 0dai sync",
1007
- "ai/manifest/commands.yaml": "run: 0dai sync",
1008
- "AGENTS.md": "run: 0dai sync",
1009
- ".claude/settings.json": "run: 0dai sync",
1010
- ".claude/CLAUDE.md": "run: 0dai sync",
1011
- ".mcp.json": "run: 0dai sync",
1012
- ".codex/config.toml": "install codex, then: 0dai sync",
1013
- "opencode.json": "install opencode, then: 0dai sync",
1014
- };
1015
-
1016
- const present = required.filter(f => fs.existsSync(path.join(target, f)));
1017
- const missing = required.filter(f => !fs.existsSync(path.join(target, f)));
1018
-
1019
- for (const f of present) console.log(` ${G}✓${R2} ${f}`);
1020
- for (const f of missing) {
1021
- const hint = FIX_HINTS[f] || "run: 0dai sync";
1022
- console.log(` ${E}✗${R2} ${f} ${D2}— ${hint}${R2}`);
1023
- }
1024
-
1025
- if (missing.length) {
1026
- console.log(`\n${E}${missing.length} missing${R2} / ${present.length + missing.length} total`);
1027
- process.exitCode = 1;
1028
- } else {
1029
- log(`${G}validate ok${R2} — all ${present.length} required files present`);
1030
- }
1031
- }
1032
-
1033
- // --- Update agent CLIs ---
1034
- function cmdUpdate(args) {
1035
- const { execFileSync: _ef3, execSync } = require("child_process");
1036
- const dryRun = args.includes("--dry-run");
1037
-
1038
- // 0dai self-update entry, then all supported agent CLIs.
1039
- const CLIS = [
1040
- { name: "0dai", pkg: "@0dai-dev/cli", bin: "0dai", pkgType: "npm" },
1041
- ...SUPPORTED_CLIS,
1042
- ];
1043
-
1044
- let updated = 0;
1045
- for (const cli of CLIS) {
1046
- let installed = false, ver = null;
1047
- try {
1048
- const out = _ef3(cli.bin, ["--version"], { timeout: 8000 }).toString().trim();
1049
- installed = true;
1050
- const m = out.match(/(\d+\.\d+\.\d+)/);
1051
- if (m) ver = m[1];
1052
- } catch {}
1053
-
1054
- if (!installed) continue;
1055
-
1056
- let latest = null;
1057
- if (cli.pkgType === "npm" && cli.pkg) {
1058
- try { latest = _ef3("npm", ["view", cli.pkg, "version"], { timeout: 5000 }).toString().trim(); } catch {}
1059
- } else if (cli.pkgType === "pip" && cli.pkg) {
1060
- try {
1061
- const out = _ef3("pip", ["index", "versions", cli.pkg], { timeout: 8000, encoding: "utf8" });
1062
- const m = out.match(/LATEST:\s*(\d+\.\d+\.\d+)/i) || out.match(/(\d+\.\d+\.\d+)/);
1063
- if (m) latest = m[1];
1064
- } catch {}
1065
- }
1066
-
1067
- if (!latest || latest === ver) {
1068
- console.log(` ${cli.name} ${ver || ""} — up to date`);
1069
- continue;
1070
- }
1071
-
1072
- console.log(` ${cli.name} ${ver} → ${latest}`);
1073
- if (dryRun) { updated++; continue; }
1074
-
1075
- try {
1076
- if (cli.pkgType === "npm") {
1077
- log(`updating ${cli.name}...`);
1078
- execSync(`npm install -g ${cli.pkg}@latest`, { timeout: 60000, stdio: "pipe" });
1079
- log(`${cli.name} updated to ${latest}`);
1080
- updated++;
1081
- } else if (cli.pkgType === "pip") {
1082
- log(`updating ${cli.name}...`);
1083
- execSync(`pip install --upgrade ${cli.pkg}`, { timeout: 60000, stdio: "pipe" });
1084
- log(`${cli.name} updated to ${latest}`);
1085
- updated++;
1086
- }
1087
- } catch (e) {
1088
- log(`failed to update ${cli.name}: ${e.message.split("\n")[0]}`);
1089
- }
1090
- }
1091
- if (updated) {
1092
- log(`${dryRun ? "would update" : "updated"} ${updated} CLI(s)`);
1093
- } else {
1094
- log("all CLIs are up to date");
1095
- }
1096
- }
1097
-
1098
- // --- Session reflection --- (dogfood feedback #36)
1099
- function cmdReflect(target, args) {
1100
- const ai = path.join(target, "ai");
1101
- const W = process.stdout.isTTY ? "\x1b[33m" : "";
1102
- const G = process.stdout.isTTY ? "\x1b[32m" : "";
1103
- const R2 = process.stdout.isTTY ? "\x1b[0m" : "";
1104
- const B = process.stdout.isTTY ? "\x1b[1m" : "";
1105
-
1106
- // Collect swarm done tasks
1107
- const doneDir = path.join(ai, "swarm", "done");
1108
- const queueDir = path.join(ai, "swarm", "queue");
1109
- const activeDir = path.join(ai, "swarm", "active");
1110
- const doneTasks = [], queueTasks = [];
1111
-
1112
- // -- how many days to look back (default 7)
1113
- const daysArg = args.find((_, i) => args[i - 1] === "--days");
1114
- const days = parseInt(daysArg || "7");
1115
- const since = Date.now() - days * 24 * 60 * 60 * 1000;
1116
-
1117
- const readJsonDir = (dir) => {
1118
- const out = [];
1119
- try {
1120
- for (const f of fs.readdirSync(dir).filter(f => f.endsWith(".json"))) {
1121
- try { out.push(JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"))); } catch {}
1122
- }
1123
- } catch {}
1124
- return out;
1125
- };
1126
-
1127
- const allDone = readJsonDir(doneDir).filter(t => {
1128
- const ts = t.completed_at || t.created_at;
1129
- return !ts || new Date(ts).getTime() >= since;
1130
- });
1131
- const allQueue = readJsonDir(queueDir);
1132
- const allActive = readJsonDir(activeDir);
1133
-
1134
- // Aggregate by agent
1135
- const byAgent = {};
1136
- for (const t of allDone) {
1137
- const a = t.assigned_to || t.agent || "unknown";
1138
- if (!byAgent[a]) byAgent[a] = { done: 0, tasks: [] };
1139
- byAgent[a].done++;
1140
- byAgent[a].tasks.push(t.title || t.id || "?");
1141
- }
1142
-
1143
- // Session data
1144
- let sessionGoal = "?";
1145
- try {
1146
- const active = JSON.parse(fs.readFileSync(path.join(ai, "sessions", "active.json"), "utf8"));
1147
- sessionGoal = (active.task || {}).goal || "?";
1148
- } catch {}
1149
-
1150
- console.log(`\n ${B}${T}0dai reflect${R2}${R} — last ${days} days\n`);
1151
-
1152
- // Goal
1153
- if (sessionGoal !== "?") console.log(` ${B}Goal${R2} ${sessionGoal}`);
1154
-
1155
- // Delegation stats
1156
- const totalDone = allDone.length;
1157
- const totalPending = allQueue.length + allActive.length;
1158
- const successRate = totalDone + totalPending > 0
1159
- ? Math.round((totalDone / (totalDone + totalPending)) * 100)
1160
- : null;
1161
-
1162
- console.log(` ${B}Delivered${R2} ${G}${totalDone}${R2} tasks completed`);
1163
- if (totalPending) console.log(` ${B}Remaining${R2} ${W}${totalPending}${R2} tasks still pending`);
1164
- if (successRate !== null) console.log(` ${B}Rate${R2} ${successRate >= 80 ? G : W}${successRate}%${R2} delegation success rate`);
1165
-
1166
- // By agent breakdown with per-agent completion rate
1167
- const allPendingByAgent = {};
1168
- for (const t of [...allQueue, ...allActive]) {
1169
- const a = t.assigned_to || "unknown";
1170
- allPendingByAgent[a] = (allPendingByAgent[a] || 0) + 1;
1171
- }
1172
-
1173
- if (Object.keys(byAgent).length) {
1174
- console.log(`\n ${B}By agent:${R2}`);
1175
- for (const [agent, data] of Object.entries(byAgent).sort((a, b) => b[1].done - a[1].done)) {
1176
- const pending = allPendingByAgent[agent] || 0;
1177
- const total = data.done + pending;
1178
- const rate = total > 0 ? Math.round((data.done / total) * 100) : 100;
1179
- const bar = "█".repeat(Math.min(data.done, 20));
1180
- const rateStr = total > 1 ? ` (${rate >= 80 ? G : W}${rate}%${R2})` : "";
1181
- console.log(` ${(agent + " ").padEnd(14)} ${G}${bar}${R2} ${data.done}/${total}${rateStr}`);
1182
- }
1183
- // Agents with only pending tasks (never completed anything in window)
1184
- for (const [agent, count] of Object.entries(allPendingByAgent)) {
1185
- if (!byAgent[agent]) {
1186
- console.log(` ${(agent + " ").padEnd(14)} ${W}${"░".repeat(Math.min(count, 20))}${R2} 0/${count} ${W}(pending)${R2}`);
1187
- }
1188
- }
1189
- }
1190
-
1191
- // Budget summary from budget.json
1192
- const budgetFile = path.join(ai, "swarm", "budget.json");
1193
- try {
1194
- const budget = JSON.parse(fs.readFileSync(budgetFile, "utf8"));
1195
- const today = new Date().toISOString().slice(0, 10);
1196
- const sessionKey = process.env.ODAI_SESSION_ID ||
1197
- new Date().toISOString().slice(0, 13).replace("T", "-"); // YYYY-MM-DD-HH
1198
- const dailySpent = budget.daily?.[today] || 0;
1199
- const totalSpent = budget.total_spent || 0;
1200
- const sess = budget.sessions?.[sessionKey];
1201
-
1202
- // Tier distribution across all tasks
1203
- const tierCount = { fast: 0, balanced: 0, deep: 0 };
1204
- for (const t of Object.values(budget.tasks || {})) {
1205
- if (t.tier && tierCount[t.tier] !== undefined) tierCount[t.tier]++;
1206
- }
1207
- const tieredTotal = tierCount.fast + tierCount.balanced + tierCount.deep;
1208
-
1209
- if (dailySpent > 0 || totalSpent > 0 || sess) {
1210
- console.log(`\n ${B}Budget:${R2}`);
1211
- if (sess && sess.total_cost > 0) {
1212
- const taskCount = (sess.tasks || []).length;
1213
- const avgCost = taskCount > 0 ? (sess.total_cost / taskCount).toFixed(4) : "0";
1214
- console.log(` ${B}This session${R2} $${sess.total_cost.toFixed(4)} · ${taskCount} tasks · avg $${avgCost}/task`);
1215
- if (sess.tiers) {
1216
- const tiers = Object.entries(sess.tiers).filter(([, n]) => n > 0).map(([t, n]) => `${n}×${t}`).join(" ");
1217
- if (tiers) console.log(` ${D}Tiers ${tiers}${R2}`);
1218
- }
1219
- }
1220
- if (dailySpent > 0) {
1221
- const dailyLimit = parseFloat(process.env.ODAI_DAILY_BUDGET || "5");
1222
- const pct = Math.round((dailySpent / dailyLimit) * 100);
1223
- const bar = "█".repeat(Math.round(pct / 5)).padEnd(20, "░");
1224
- const col = pct < 50 ? G : pct < 80 ? W : "\x1b[31m";
1225
- console.log(` ${B}Today${R2} ${col}$${dailySpent.toFixed(4)}${R2} / $${dailyLimit.toFixed(2)} ${D}${bar} ${pct}%${R2}`);
1226
- }
1227
- if (totalSpent > 0) {
1228
- console.log(` ${B}All time${R2} ${D}$${totalSpent.toFixed(4)}${R2}`);
1229
- }
1230
- if (tieredTotal > 0) {
1231
- const fastPct = Math.round((tierCount.fast / tieredTotal) * 100);
1232
- console.log(` ${D}Model routing ${tierCount.fast}×fast ${tierCount.balanced}×balanced ${tierCount.deep}×deep (${fastPct}% cheap)${R2}`);
1233
- }
1234
- }
1235
- } catch {}
1236
-
1237
- // Insights — learn from patterns
1238
- if (totalDone >= 3) {
1239
- const insights = [];
1240
- // Best agent by completion rate
1241
- const agentEntries = Object.entries(byAgent).map(([a, d]) => {
1242
- const pending = allPendingByAgent[a] || 0;
1243
- const total = d.done + pending;
1244
- return { agent: a, done: d.done, total, rate: total > 0 ? d.done / total : 1 };
1245
- });
1246
- const best = agentEntries.sort((a, b) => b.rate - a.rate)[0];
1247
- if (best && agentEntries.length > 1) {
1248
- insights.push(`${best.agent} has the highest success rate (${Math.round(best.rate * 100)}%)`);
1249
- }
1250
- // Fastest agent by avg elapsed
1251
- try {
1252
- const budget = JSON.parse(fs.readFileSync(path.join(ai, "swarm", "budget.json"), "utf8"));
1253
- const agentElapsed = {};
1254
- const agentCounts = {};
1255
- for (const t of Object.values(budget.tasks || {})) {
1256
- if (t.elapsed > 0) {
1257
- agentElapsed[t.agent] = (agentElapsed[t.agent] || 0) + t.elapsed;
1258
- agentCounts[t.agent] = (agentCounts[t.agent] || 0) + 1;
1259
- }
1260
- }
1261
- let fastest = null, fastestAvg = Infinity;
1262
- for (const [a, total] of Object.entries(agentElapsed)) {
1263
- const avg = total / agentCounts[a];
1264
- if (avg < fastestAvg) { fastest = a; fastestAvg = avg; }
1265
- }
1266
- if (fastest && Object.keys(agentElapsed).length > 1) {
1267
- insights.push(`${fastest} is fastest (avg ${Math.round(fastestAvg)}s per task)`);
1268
- }
1269
- } catch {}
1270
- // Cost efficiency
1271
- if (allDone.length > 0) {
1272
- try {
1273
- const budget = JSON.parse(fs.readFileSync(path.join(ai, "swarm", "budget.json"), "utf8"));
1274
- if (budget.total_spent > 0) {
1275
- const costPerTask = budget.total_spent / allDone.length;
1276
- insights.push(`avg cost: $${costPerTask.toFixed(4)}/task`);
1277
- }
1278
- } catch {}
1279
- }
1280
- if (insights.length) {
1281
- console.log(`\n ${B}Insights:${R2}`);
1282
- for (const i of insights) console.log(` ${G}→${R2} ${i}`);
1283
- }
1284
- }
1285
-
1286
- // Remaining blockers
1287
- if (allQueue.length) {
1288
- console.log(`\n ${B}Blockers / queue:${R2}`);
1289
- for (const t of allQueue.slice(0, 8)) {
1290
- const agent = t.assigned_to ? ` → ${t.assigned_to}` : "";
1291
- console.log(` ${W}•${R2} ${(t.title || t.id || "?").slice(0, 60)}${agent}`);
1292
- }
1293
- if (allQueue.length > 8) console.log(` ${D}… and ${allQueue.length - 8} more${R2}`);
1294
- }
1295
-
1296
- // No data
1297
- if (!totalDone && !totalPending) {
1298
- console.log(` ${D}No swarm tasks found in the last ${days} days.${R2}`);
1299
- console.log(` ${D}Use '0dai swarm add --task "..." --to codex' to delegate tasks.${R2}`);
1300
- }
1301
-
1302
- console.log();
1303
- }
1304
-
1305
- function cmdMetrics(target) {
1306
- const ai = path.join(target, "ai");
1307
- const G = "\x1b[32m", W = "\x1b[33m", R2 = "\x1b[0m", D = "\x1b[2m",
1308
- B = "\x1b[34m", T = "\x1b[36m", M = "\x1b[35m";
1309
-
1310
- // --- Data sources ---
1311
- let stats = {}, budget = {}, discovery = {};
1312
- try { stats = JSON.parse(fs.readFileSync(path.join(ai, "feedback", ".usage_stats.json"), "utf8")); } catch {}
1313
- try { budget = JSON.parse(fs.readFileSync(path.join(ai, "swarm", "budget.json"), "utf8")); } catch {}
1314
- try { discovery = JSON.parse(fs.readFileSync(path.join(ai, "manifest", "discovery.json"), "utf8")); } catch {}
1315
-
1316
- const projectName = discovery.project_name || path.basename(target);
1317
- const stack = discovery.stack || "?";
1318
- const totalSessions = stats.total_sessions || 0;
1319
- const agentBreakdown = stats.agents || {};
1320
- const layerInfo = stats.layer || {};
1321
- const lastSession = stats.last_session ? new Date(stats.last_session) : null;
1322
- const layerVersion = stats.version || "?";
1323
-
1324
- // Swarm tasks: count done files
1325
- let tasksDone = 0, tasksQueue = 0;
1326
- const doneDir = path.join(ai, "swarm", "done");
1327
- const queueDir = path.join(ai, "swarm", "queue");
1328
- try { tasksDone = fs.readdirSync(doneDir).filter(f => f.endsWith(".json")).length; } catch {}
1329
- try { tasksQueue = fs.readdirSync(queueDir).filter(f => f.endsWith(".json")).length; } catch {}
1330
-
1331
- // Activity: count events from activity.jsonl
1332
- let activityEvents = 0;
1333
- try {
1334
- const lines = fs.readFileSync(path.join(ai, "swarm", "activity.jsonl"), "utf8").trim().split("\n").filter(Boolean);
1335
- activityEvents = lines.length;
1336
- } catch {}
1337
-
1338
- // Feedback submissions
1339
- let feedbackCount = layerInfo.feedback_reports || 0;
1340
-
1341
- // Budget totals
1342
- const totalSpent = budget.total_spent || 0;
1343
- const sessionsWithBudget = Object.keys(budget.sessions || {}).length;
1344
-
1345
- // --- Effectiveness score (0-100) ---
1346
- let score = 0, scoreNotes = [];
1347
-
1348
- // Sessions depth: 1 = tried, 3 = habit forming, 7 = regular use
1349
- const sessionScore = Math.min(Math.floor((totalSessions / 7) * 35), 35);
1350
- score += sessionScore;
1351
- if (totalSessions === 0) scoreNotes.push("not started");
1352
- else if (totalSessions === 1) scoreNotes.push("first session");
1353
- else if (totalSessions < 3) scoreNotes.push("early");
1354
- else if (totalSessions < 7) scoreNotes.push("habit forming");
1355
- else scoreNotes.push("regular use");
1356
-
1357
- // Delegation: did they delegate to swarm?
1358
- const delegationScore = tasksDone > 0 ? Math.min(Math.floor((tasksDone / 5) * 30), 30) : 0;
1359
- score += delegationScore;
1360
- if (tasksDone > 0) scoreNotes.push(`${tasksDone} tasks delegated`);
1361
-
1362
- // Feedback: submitted = trust signal
1363
- const feedbackScore = feedbackCount > 0 ? 20 : 0;
1364
- score += feedbackScore;
1365
- if (feedbackCount > 0) scoreNotes.push("feedback submitted");
1366
-
1367
- // Layer completeness: has playbooks and commands?
1368
- const layerScore = (layerInfo.playbooks && layerInfo.commands) ? 15 : (layerInfo.commands ? 8 : 0);
1369
- score += layerScore;
1370
-
1371
- const scoreColor = score >= 70 ? G : score >= 40 ? W : "\x1b[31m";
1372
- const bar = "█".repeat(Math.round(score / 5)).padEnd(20, "░");
1373
-
1374
- // --- Output ---
1375
- console.log(`\n ${T}Metrics${R2} ${D}${projectName} · ${stack} · ai v${layerVersion}${R2}\n`);
1376
-
1377
- // Effectiveness score
1378
- console.log(` ${B}Effectiveness${R2}`);
1379
- console.log(` ${scoreColor}${score}/100${R2} ${D}${bar}${R2}`);
1380
- if (scoreNotes.length) console.log(` ${D}${scoreNotes.join(" · ")}${R2}`);
1381
-
1382
- // Adoption funnel
1383
- console.log(`\n ${B}Adoption funnel${R2}`);
1384
- const funnelStep = (label, value, done, hint) => {
1385
- const icon = done ? `${G}✓${R2}` : `${D}○${R2}`;
1386
- const val = value !== null ? ` ${D}${value}${R2}` : "";
1387
- const h = !done && hint ? ` ${D}← ${hint}${R2}` : "";
1388
- console.log(` ${icon} ${label}${val}${h}`);
1389
- };
1390
- funnelStep("Initialized", `ai/ v${layerVersion}`, true);
1391
- funnelStep("Returned (>1 session)", `${totalSessions} total`, totalSessions > 1, "run 0dai reflect after each session");
1392
- funnelStep("Used swarm delegation", tasksDone > 0 ? `${tasksDone} tasks done` : null, tasksDone > 0, "try: 0dai swarm add --task '...' --to codex");
1393
- funnelStep("Submitted feedback", feedbackCount > 0 ? `${feedbackCount} reports` : null, feedbackCount > 0, "0dai feedback log + push");
1394
-
1395
- // Session stats
1396
- if (totalSessions > 0) {
1397
- console.log(`\n ${B}Sessions${R2}`);
1398
- console.log(` Total ${totalSessions}`);
1399
- if (lastSession) {
1400
- const daysAgo = Math.floor((Date.now() - lastSession.getTime()) / 86400000);
1401
- const when = daysAgo === 0 ? "today" : daysAgo === 1 ? "yesterday" : `${daysAgo}d ago`;
1402
- console.log(` Last ${when}`);
1403
- }
1404
- const agentEntries = Object.entries(agentBreakdown).sort((a, b) => b[1] - a[1]);
1405
- if (agentEntries.length) {
1406
- console.log(` Agents ${agentEntries.map(([a, n]) => `${a}: ${n}`).join(" ")}`);
1407
- }
1408
- if (sessionsWithBudget > 0 && totalSpent > 0) {
1409
- console.log(` Cost $${totalSpent.toFixed(4)} total ${D}(${sessionsWithBudget} sessions tracked)${R2}`);
1410
- }
1411
- }
1412
-
1413
- // Delegation stats
1414
- if (tasksDone > 0 || tasksQueue > 0) {
1415
- console.log(`\n ${B}Delegation${R2}`);
1416
- if (tasksDone > 0) console.log(` Done ${G}${tasksDone}${R2}`);
1417
- if (tasksQueue > 0) console.log(` Queue ${W}${tasksQueue}${R2}`);
1418
- if (activityEvents > 0) console.log(` Events ${activityEvents}`);
1419
- }
1420
-
1421
- // Layer health
1422
- console.log(`\n ${B}ai/ layer${R2}`);
1423
- const checks = [
1424
- ["commands.yaml", layerInfo.commands],
1425
- ["playbooks", layerInfo.playbooks],
1426
- ["personas", layerInfo.personas],
1427
- ["session roaming", layerInfo.session_active],
1428
- ["swarm queue", (layerInfo.swarm_queue || 0) > 0],
1429
- ];
1430
- for (const [label, ok] of checks) {
1431
- const icon = ok ? `${G}✓${R2}` : `${D}—${R2}`;
1432
- console.log(` ${icon} ${label}`);
1433
- }
1434
-
1435
- // Next suggested action
1436
- console.log(`\n ${B}Next${R2}`);
1437
- if (totalSessions === 0) console.log(` ${D}Start a Claude Code session — session_start hook will print project context${R2}`);
1438
- else if (tasksDone === 0) console.log(` ${D}Try delegating a task: 0dai swarm add --task "write tests for auth module" --to codex${R2}`);
1439
- else if (feedbackCount === 0) console.log(` ${D}Submit feedback: 0dai feedback log --type positive --detail "what worked"${R2}`);
1440
- else console.log(` ${D}Score ${score}/100 — keep delegating and submitting feedback${R2}`);
1441
-
1442
- console.log();
1443
- }
1444
-
1445
- function cmdStatus(target) {
1446
- const ai = path.join(target, "ai");
1447
- let v = "?", stack = "?";
1448
- try { v = fs.readFileSync(path.join(ai, "VERSION"), "utf8").trim(); } catch {}
1449
- try { stack = JSON.parse(fs.readFileSync(path.join(ai, "manifest", "discovery.json"), "utf8")).stack || "?"; } catch {}
1450
- log(`v${v} | stack: ${stack}`);
1451
-
1452
- const count = (dir) => { try { return fs.readdirSync(dir).filter(f => f.endsWith(".json")).length; } catch { return 0; } };
1453
- const q = count(path.join(ai, "swarm", "queue"));
1454
- const a = count(path.join(ai, "swarm", "active"));
1455
- const d = count(path.join(ai, "swarm", "done"));
1456
- if (q || a || d) console.log(` swarm: ${q} queued, ${a} active, ${d} done`);
1457
-
1458
- try {
1459
- const s = JSON.parse(fs.readFileSync(path.join(ai, "sessions", "active.json"), "utf8"));
1460
- console.log(` session: ${(s.task || {}).goal || "?"} (agent: ${s.current_agent || "?"})`);
1461
- } catch {}
1462
- }
1463
-
1464
- async function checkVersion() {
1465
- try {
1466
- // Check interval: 1 hour during debug, configurable via env
1467
- const intervalSec = parseInt(process.env.ODAI_UPDATE_CHECK_INTERVAL || "3600");
1468
- let lastCheck = 0;
1469
- try { lastCheck = parseFloat(fs.readFileSync(VERSION_CHECK_FILE, "utf8")); } catch {}
1470
- if (Date.now() / 1000 - lastCheck < intervalSec) return;
1471
-
1472
- fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
1473
- fs.writeFileSync(VERSION_CHECK_FILE, String(Date.now() / 1000));
1474
-
1475
- const result = await apiCall("/v1/version");
1476
- if (result.version && result.version !== VERSION) {
1477
- // Only suggest update if remote version is actually newer
1478
- const cmp = (a, b) => { const [a1,a2,a3] = a.split(".").map(Number); const [b1,b2,b3] = b.split(".").map(Number); return a1 - b1 || a2 - b2 || a3 - b3; };
1479
- if (cmp(result.version, VERSION) > 0) {
1480
- log(`Update available: ${VERSION} → ${result.version}`);
1481
- console.log(` Run: npm update -g @0dai-dev/cli\n`);
1482
- }
1483
- }
1484
- } catch {}
1485
- }
1486
-
1487
- async function cmdAuthLogin() {
1488
- const isTTY = process.stdout.isTTY && process.stdin.isTTY;
1489
-
1490
- // Check if already authenticated
1491
- try {
1492
- const existing = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
1493
- if (existing.access_token || existing.email) {
1494
- if (isTTY) {
1495
- const p = require("@clack/prompts");
1496
- p.intro(`${T}0dai${R} authentication`);
1497
- p.log.success(`Already logged in as ${T}${existing.email || "unknown"}${R} (${existing.plan || "free"} plan)`);
1498
- const reauth = await p.confirm({ message: "Sign in with a different account?" });
1499
- if (p.isCancel(reauth) || !reauth) {
1500
- p.outro("Current session kept");
1501
- return;
1502
- }
1503
- } else {
1504
- log(`Already logged in as ${existing.email || "unknown"} (${existing.plan || "free"} plan)`);
1505
- log("To switch accounts, delete ~/.0dai/auth.json and run again");
1506
- return;
1507
- }
1508
- }
1509
- } catch {}
1510
-
1511
- if (isTTY) {
1512
- // Interactive TUI flow
1513
- const p = require("@clack/prompts");
1514
- if (!p._intro_shown) p.intro(`${T}0dai${R} authentication`);
1515
-
1516
- p.note(
1517
- "0dai auth is separate from agent CLIs (Claude Code, Codex).\n" +
1518
- "It tracks your projects, usage limits, and team features.\n" +
1519
- "Your agent CLIs keep their own auth (subscription/API key).",
1520
- "Why sign in?"
1521
- );
1522
-
1523
- const method = await p.select({
1524
- message: "How would you like to sign in?",
1525
- options: [
1526
- { value: "github", label: "GitHub", hint: "recommended" },
1527
- { value: "google", label: "Google" },
1528
- { value: "device", label: "Device code", hint: "no browser needed" },
1529
- ],
1530
- });
1531
- if (p.isCancel(method)) { p.cancel("Cancelled"); process.exit(0); }
1532
-
1533
- if (method === "github" || method === "google") {
1534
- const url = `${API_URL}/v1/auth/${method}?cli=true`;
1535
- p.log.info(`Opening browser: ${url}`);
1536
- try {
1537
- const { execFileSync } = require("child_process");
1538
- const cmd = os.platform() === "darwin" ? "open" : os.platform() === "win32" ? "start" : "xdg-open";
1539
- // MED: use execFileSync to avoid shell injection via URL metacharacters
1540
- execFileSync(cmd, [url], { stdio: "ignore" });
1541
- } catch {
1542
- p.log.warn(`Could not open browser. Visit manually:\n ${url}`);
1543
- }
1544
-
1545
- const s = p.spinner();
1546
- s.start("Waiting for browser confirmation...");
1547
-
1548
- // Poll auth/status until we get a new token (check every 3s, 5min timeout)
1549
- // For now, ask user to paste token from success page
1550
- s.stop("Browser opened");
1551
- const token = await p.text({
1552
- message: "Paste your token from the success page (or press Enter to skip):",
1553
- placeholder: "0dai_at_...",
1554
- });
1555
- if (token && !p.isCancel(token) && token.startsWith("0dai_at_")) {
1556
- fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
1557
- fs.writeFileSync(AUTH_FILE, JSON.stringify({
1558
- access_token: token,
1559
- authenticated_at: new Date().toISOString(),
1560
- }, null, 2) + "\n", { mode: 0o600 });
1561
- // Fetch profile
1562
- const status = await apiCall("/v1/auth/status");
1563
- if (status.email) {
1564
- const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
1565
- auth.email = status.email;
1566
- auth.plan = status.plan;
1567
- auth.name = status.name;
1568
- fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2) + "\n", { mode: 0o600 });
1569
- p.outro(`${T}Logged in${R} as ${status.email} (${status.plan} plan)`);
1570
- } else {
1571
- p.outro(`${T}Token saved${R}`);
1572
- }
1573
- return;
1574
- }
1575
- p.log.info("Skipped. You can also use device code flow:");
1576
- }
1577
-
1578
- // Device code fallback
1579
- const result = await apiCall("/v1/auth/device", { client_id: "cli" });
1580
- if (result.error) { p.log.error(result.error); process.exit(1); }
1581
-
1582
- p.log.step(`Open: ${result.verification_uri}`);
1583
- p.log.step(`Code: ${T}${result.user_code}${R}`);
1584
-
1585
- const s = p.spinner();
1586
- s.start("Waiting for confirmation...");
1587
-
1588
- const interval = (result.interval || 5) * 1000;
1589
- const deadline = Date.now() + (result.expires_in || 600) * 1000;
1590
- while (Date.now() < deadline) {
1591
- await new Promise(r => setTimeout(r, interval));
1592
- const poll = await apiCall("/v1/auth/token", { device_code: result.device_code });
1593
- if (poll.access_token) {
1594
- s.stop("Authorized!");
1595
- fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
1596
- fs.writeFileSync(AUTH_FILE, JSON.stringify({
1597
- access_token: poll.access_token, email: poll.email,
1598
- plan: poll.plan || "free", authenticated_at: new Date().toISOString(),
1599
- expires_at: poll.expires_at,
1600
- }, null, 2) + "\n", { mode: 0o600 });
1601
- p.outro(`${T}Logged in${R} as ${poll.email} (${poll.plan} plan)`);
1602
- return;
1603
- }
1604
- if (poll.error && poll.error !== "authorization_pending") {
1605
- s.stop("Failed");
1606
- p.log.error(poll.error);
1607
- process.exit(1);
1608
- }
1609
- }
1610
- s.stop("Timed out");
1611
- p.log.error("Try again.");
1612
- process.exit(1);
1613
-
1614
- } else {
1615
- // Non-interactive: device code only
1616
- log("0dai auth is separate from agent CLIs. It tracks projects, limits, and team features.");
1617
- const result = await apiCall("/v1/auth/device", { client_id: "cli" });
1618
- if (result.error) { log(`error: ${result.error}`); process.exit(1); }
1619
- log(`Open: ${result.verification_uri}`);
1620
- log(`Code: ${result.user_code}`);
1621
- log("Waiting...");
1622
- const interval = (result.interval || 5) * 1000;
1623
- const deadline = Date.now() + (result.expires_in || 600) * 1000;
1624
- while (Date.now() < deadline) {
1625
- await new Promise(r => setTimeout(r, interval));
1626
- const poll = await apiCall("/v1/auth/token", { device_code: result.device_code });
1627
- if (poll.access_token) {
1628
- fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
1629
- fs.writeFileSync(AUTH_FILE, JSON.stringify({
1630
- access_token: poll.access_token, email: poll.email,
1631
- plan: poll.plan || "free", authenticated_at: new Date().toISOString(),
1632
- }, null, 2) + "\n", { mode: 0o600 });
1633
- log(`Logged in as ${poll.email}`);
1634
- return;
1635
- }
1636
- }
1637
- log("Timed out");
1638
- process.exit(1);
1639
- }
1640
- }
1641
-
1642
- function cmdAuthLogout() {
1643
- try { fs.unlinkSync(AUTH_FILE); } catch {}
1644
- log("Logged out");
1645
- }
1646
-
1647
- async function cmdRedeem(code) {
1648
- if (!code) {
1649
- console.log("Usage: 0dai redeem <CODE>");
1650
- console.log("Example: 0dai redeem ESSE-ABCD-1234");
1651
- process.exit(1);
1652
- }
1653
- try {
1654
- JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
1655
- } catch {
1656
- log("Not logged in. Run: 0dai auth login");
1657
- process.exit(1);
1658
- }
1659
- log(`Redeeming code ${T}${code.toUpperCase()}${R}...`);
1660
- const result = await apiCall("/v1/redeem", { code: code.toUpperCase().trim() });
1661
- if (result.ok) {
1662
- log(`${T}✓${R} ${result.message}`);
1663
- if (result.duration_days) {
1664
- log(` Plan active for ${result.duration_days} days`);
1665
- }
1666
- log(` Run ${D}0dai auth status${R} to see updated limits`);
1667
- } else {
1668
- log(`error: ${result.error || "unknown"}`);
1669
- if (result.hint) log(`hint: ${result.hint}`);
1670
- process.exit(1);
1671
- }
1672
- }
1673
-
1674
- async function cmdAuthStatus() {
1675
- try {
1676
- const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
1677
- // Backwards compat: old auth.json used `user`, new uses `email`
1678
- const email = auth.email || auth.user || "unknown";
1679
- log(`${email} (${auth.plan || "free"} plan)`);
1680
- // Get usage from API
1681
- const status = await apiCall("/v1/auth/status");
1682
- if (status.usage_today) {
1683
- console.log(" Usage today:");
1684
- for (const [k, v] of Object.entries(status.usage_today))
1685
- console.log(` ${k}: ${v} / ${status.limits[k]}`);
1686
- }
1687
- } catch {
1688
- log("Not logged in. Run: 0dai auth login");
1689
- }
1690
- }
1691
-
1692
- async function cmdFeedbackPush(target) {
1693
- const fbDir = path.join(target, "ai", "feedback");
1694
- const items = [];
1695
-
1696
- // Collect from report JSON files
1697
- try {
1698
- for (const f of fs.readdirSync(fbDir)) {
1699
- if (f.endsWith("-report.json") || (f.endsWith(".json") && f.match(/^\d{8}/))) {
1700
- try {
1701
- const d = JSON.parse(fs.readFileSync(path.join(fbDir, f), "utf8"));
1702
- if (d.project || d.verdict) items.push({ type: "report", data: d, file: f });
1703
- } catch {}
1704
- }
1705
- }
1706
- } catch {}
1707
-
1708
- // Collect from operational.jsonl (feedback log entries)
1709
- const jsonlPath = path.join(fbDir, "operational.jsonl");
1710
- try {
1711
- if (fs.existsSync(jsonlPath)) {
1712
- const lines = fs.readFileSync(jsonlPath, "utf8").trim().split("\n").filter(Boolean);
1713
- for (const line of lines) {
1714
- try { items.push({ type: "log", data: JSON.parse(line) }); } catch {}
1715
- }
1716
- }
1717
- } catch {}
1718
-
1719
- if (!items.length) {
1720
- log("no feedback found");
1721
- console.log(` ${D}Log feedback first: 0dai feedback log --type suggestion --detail '...'${R}`);
1722
- return;
1723
- }
1724
-
1725
- // Push all items
1726
- const report = {
1727
- project: path.basename(target),
1728
- entries: items.map(i => i.data),
1729
- count: items.length,
1730
- submitted_at: new Date().toISOString(),
1731
- };
1732
- log(`pushing ${items.length} feedback item(s)...`);
1733
- const result = await apiCall("/v1/feedback", { report });
1734
- if (result.received) {
1735
- log(`received${result.issue ? `: ${result.issue}` : ""}`);
1736
- if (result.bonus) log(`${T}bonus:${R} ${result.bonus}`);
1737
- // Archive pushed entries
1738
- if (fs.existsSync(jsonlPath)) {
1739
- const archivePath = path.join(fbDir, `pushed-${Date.now()}.jsonl`);
1740
- fs.renameSync(jsonlPath, archivePath);
1741
- }
1742
- } else {
1743
- log(`error: ${result.error || "unknown"}`);
1744
- }
1745
- }
1746
-
1747
- // --- Models ---
1748
- function cmdModels(filter) {
1749
- // Scores from benchmark_models.py (3-task: read/count/review, 2026-04-06)
1750
- const MODELS = [
1751
- { name: "Claude Opus 4.6", tier: "deep", score: 95, cli: "claude", flag: "--model opus" },
1752
- { name: "GPT-5.4-mini", tier: "fast", score: 93, cli: "codex", flag: "-m gpt-5.4-mini", tested: true },
1753
- { name: "MiniMax M2.7", tier: "balanced", score: 93, cli: "opencode", flag: "-m opencode-go/minimax-m2.7", tested: true },
1754
- { name: "Claude Sonnet 4.6", tier: "balanced", score: 90, cli: "claude", flag: "--model sonnet" },
1755
- { name: "GPT-5.4", tier: "balanced", score: 90, cli: "codex", flag: "-m gpt-5.4", tested: true },
1756
- { name: "Kimi K2.5", tier: "balanced", score: 88, cli: "opencode", flag: "-m opencode-go/kimi-k2.5", tested: true },
1757
- { name: "Qwen 3.6+ Free", tier: "free", score: 88, cli: "opencode", flag: "-m opencode/qwen3.6-plus-free", tested: true },
1758
- { name: "Gemini 3.1 Pro", tier: "balanced", score: 85, cli: "gemini", flag: "-m gemini-3.1-pro" },
1759
- { name: "GPT-5.3 Codex", tier: "deep", score: 83, cli: "codex", flag: "-m gpt-5.3-codex", tested: true },
1760
- { name: "GPT-5.3 Spark", tier: "fast", score: 82, cli: "codex", flag: "-m gpt-5.3-codex-spark" },
1761
- { name: "Claude Haiku 4.5", tier: "fast", score: 78, cli: "claude", flag: "--model haiku" },
1762
- { name: "Gemini 3 Flash", tier: "fast", score: 77, cli: "gemini", flag: "-m gemini-3-flash" },
1763
- { name: "Mimo v2 Pro", tier: "fast", score: 74, cli: "opencode", flag: "-m opencode-go/mimo-v2-pro", tested: true },
1764
- { name: "GPT-5.4 (opencode)",tier: "fast", score: 74, cli: "opencode", flag: "-m openai/gpt-5.4", tested: true },
1765
- { name: "GPT-5.2", tier: "balanced", score: 87, cli: "codex", flag: "-m gpt-5.2", tested: true },
1766
- { name: "MiniMax M2.5", tier: "slow", score: 57, cli: "opencode", flag: "-m opencode-go/minimax-m2.5", tested: true },
1767
- ];
1768
-
1769
- const { execFileSync } = require("child_process");
1770
- const available = new Set();
1771
- for (const cli of ["claude", "codex", "opencode", "gemini", "aider"]) {
1772
- try { execFileSync("/bin/sh", ["-c", `command -v ${cli}`], { stdio: "ignore" }); available.add(cli); } catch {}
1773
- }
1774
-
1775
- const isTTY = process.stdout.isTTY;
1776
- const Y = isTTY ? "\x1b[33m" : "";
1777
- const G = isTTY ? "\x1b[32m" : "";
1778
- const DIM = isTTY ? "\x1b[2m" : "";
1779
-
1780
- let models = [...MODELS].sort((a, b) => b.score - a.score);
1781
- if (filter === "--fast") models = models.filter(m => m.tier === "fast");
1782
- if (filter === "--balanced") models = models.filter(m => m.tier === "balanced");
1783
- if (filter === "--deep") models = models.filter(m => m.tier === "deep");
1784
- if (filter === "--available") models = models.filter(m => available.has(m.cli));
1785
-
1786
- const tc = (t) => t === "deep" ? T : t === "balanced" ? G : DIM;
1787
- console.log(`\n ${T}0dai${R} model ratings — ${models.length} models\n`);
1788
- console.log(` ${"SCORE".padEnd(6)} ${"MODEL".padEnd(22)} ${"TIER".padEnd(10)} ${"CLI".padEnd(10)} FLAG`);
1789
- console.log(` ${"-".repeat(64)}`);
1790
- for (const m of models) {
1791
- const dim = available.has(m.cli) ? "" : DIM;
1792
- const mark = m.tested ? ` ${G}✓${R}` : "";
1793
- console.log(`${dim} ${Y}${String(m.score).padEnd(6)}${R} ${m.name.padEnd(22)} ${tc(m.tier)}${m.tier.padEnd(10)}${R} ${m.cli.padEnd(10)} ${DIM}${m.flag}${R}${mark}${dim ? R : ""}`);
1794
- }
1795
- console.log(`\n ${DIM}✓ = swarm-benchmarked | dimmed = CLI not installed${R}`);
1796
- console.log(` ${DIM}Filter: --fast --balanced --deep --available${R}`);
1797
- console.log(` ${DIM}Full table: https://0dai.dev/models${R}\n`);
1798
- }
1799
-
1800
- // --- Session (local, file-based) ---
1801
- function cmdSession(target, sub, args) {
1802
- const sessFile = path.join(target, "ai", "sessions", "active.json");
1803
- const sessDir = path.dirname(sessFile);
1804
-
1805
- if (sub === "save") {
1806
- fs.mkdirSync(sessDir, { recursive: true });
1807
- const goal = args.find((_, i) => args[i - 1] === "--goal") || "";
1808
- const summary = args.find((_, i) => args[i - 1] === "--summary") || "";
1809
- const session = {
1810
- id: `sess-${Date.now()}`,
1811
- started: new Date().toISOString(),
1812
- current_agent: "cli",
1813
- task: { goal: goal || summary || "active session", status: "in_progress" },
1814
- handoff_notes: summary,
1815
- context: { files_touched: [] },
1816
- };
1817
- if (fs.existsSync(sessFile)) {
1818
- const existing = JSON.parse(fs.readFileSync(sessFile, "utf8"));
1819
- existing.handoff_notes = summary || existing.handoff_notes;
1820
- if (goal) existing.task.goal = goal;
1821
- existing.updated = new Date().toISOString();
1822
- fs.writeFileSync(sessFile, JSON.stringify(existing, null, 2));
1823
- log("session updated");
1824
- } else {
1825
- fs.writeFileSync(sessFile, JSON.stringify(session, null, 2));
1826
- log(`session started: ${session.id}`);
1827
- }
1828
- return;
1829
- }
1830
- if (sub === "status") {
1831
- if (!fs.existsSync(sessFile)) { log("no active session"); return; }
1832
- const s = JSON.parse(fs.readFileSync(sessFile, "utf8"));
1833
- log(`session: ${(s.task || {}).goal || "?"}`);
1834
- console.log(` agent: ${s.current_agent || "?"}`);
1835
- if (s.handoff_notes) console.log(` handoff: ${s.handoff_notes}`);
1836
- return;
1837
- }
1838
- if (sub === "complete") {
1839
- if (!fs.existsSync(sessFile)) { log("no active session"); return; }
1840
- const archiveDir = path.join(target, "ai", "sessions", "archive");
1841
- fs.mkdirSync(archiveDir, { recursive: true });
1842
- const s = JSON.parse(fs.readFileSync(sessFile, "utf8"));
1843
- fs.writeFileSync(path.join(archiveDir, `${s.id || "session"}.json`), JSON.stringify(s, null, 2));
1844
- fs.unlinkSync(sessFile);
1845
- log(`session ${s.id} archived`);
1846
- return;
1847
- }
1848
- console.log("Usage: 0dai session [save|status|complete] [--goal '...'] [--summary '...']");
1849
- }
1850
-
1851
- // --- Swarm (local, file-based) ---
1852
- function cmdSwarm(target, sub, args) {
1853
- const swarmDir = path.join(target, "ai", "swarm");
1854
- const queueDir = path.join(swarmDir, "queue");
1855
-
1856
- if (sub === "status") {
1857
- const count = (d) => { try { return fs.readdirSync(d).filter(f => f.endsWith(".json")).length; } catch { return 0; } };
1858
- const q = count(path.join(swarmDir, "queue"));
1859
- const a = count(path.join(swarmDir, "active"));
1860
- const d = count(path.join(swarmDir, "done"));
1861
- log(`swarm: ${q} queued, ${a} active, ${d} done`);
1862
- return;
1863
- }
1864
- if (sub === "add" || sub === "delegate") {
1865
- fs.mkdirSync(queueDir, { recursive: true });
1866
- const task = args.find((_, i) => args[i - 1] === "--task") || "untitled";
1867
- const forAgent = args.find((_, i) => ["--for", "--to"].includes(args[i - 1])) || "any";
1868
- const id = `swarm-${Date.now()}`;
1869
- const t = { id, title: task, assigned_to: forAgent, status: "pending", created_at: new Date().toISOString(), created_by: "cli" };
1870
- fs.writeFileSync(path.join(queueDir, `${id}.json`), JSON.stringify(t, null, 2));
1871
- log(`task created: ${id} → ${forAgent}`);
1872
- return;
1873
- }
1874
- if (sub === "webhook") {
1875
- const webhooksFile = path.join(swarmDir, "webhooks.json");
1876
- const loadHooks = () => { try { return JSON.parse(fs.readFileSync(webhooksFile, "utf8")); } catch { return []; } };
1877
- const saveHooks = (h) => { fs.mkdirSync(swarmDir, { recursive: true }); fs.writeFileSync(webhooksFile, JSON.stringify(h, null, 2)); };
1878
- const action = args[2] || "";
1879
-
1880
- if (action === "add") {
1881
- const url = args[3] || args.find((_, i) => args[i-1] === "--url");
1882
- const event = args.find((_, i) => args[i-1] === "--event") || "all";
1883
- const secret = args.find((_, i) => args[i-1] === "--secret") || "";
1884
- if (!url || !url.startsWith("http")) { log("Usage: 0dai swarm webhook add <url> [--event task_done|task_failed|all] [--secret TOKEN]"); return; }
1885
- // MED: SSRF protection — block internal/metadata endpoints
1886
- try {
1887
- const u = new URL(url);
1888
- const host = u.hostname;
1889
- const BLOCKED = /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.|169\.254\.|::1$|fc00:|fe80:|localhost$|0\.0\.0\.0$)/i;
1890
- if (BLOCKED.test(host) || host === "metadata.google.internal") {
1891
- log(`rejected: ${host} is a private/internal address (SSRF protection)`);
1892
- return;
1893
- }
1894
- if (u.protocol !== "https:" && u.protocol !== "http:") {
1895
- log(`rejected: only http/https allowed, got ${u.protocol}`);
1896
- return;
1897
- }
1898
- } catch {
1899
- log(`invalid URL: ${url}`);
1900
- return;
1901
- }
1902
- const hooks = loadHooks();
1903
- if (hooks.find(h => h.url === url)) { log(`already registered: ${url}`); return; }
1904
- hooks.push({ url, event, secret: secret || undefined, added_at: new Date().toISOString() });
1905
- saveHooks(hooks);
1906
- log(`webhook added: ${url} (event: ${event})`);
1907
- return;
1908
- }
1909
- if (action === "list") {
1910
- const hooks = loadHooks();
1911
- if (hooks.length === 0) { log("no webhooks registered. Use: 0dai swarm webhook add <url>"); return; }
1912
- console.log(`\n ${T}Registered webhooks${R}\n`);
1913
- hooks.forEach((h, i) => {
1914
- console.log(` ${i+1}. ${h.url}`);
1915
- console.log(` ${D}event: ${h.event} added: ${h.added_at?.slice(0,10)}${R}`);
1916
- });
1917
- console.log();
1918
- return;
1919
- }
1920
- if (action === "remove") {
1921
- const url = args[3] || "";
1922
- if (!url) { log("Usage: 0dai swarm webhook remove <url>"); return; }
1923
- const hooks = loadHooks().filter(h => h.url !== url);
1924
- saveHooks(hooks);
1925
- log(`removed: ${url}`);
1926
- return;
1927
- }
1928
- if (action === "test") {
1929
- const url = args[3] || loadHooks()[0]?.url;
1930
- if (!url) { log("Usage: 0dai swarm webhook test <url>"); return; }
1931
- const payload = JSON.stringify({ event: "test", task_id: "test-ping", title: "Webhook test from 0dai", status: "done", timestamp: new Date().toISOString() });
1932
- const req = https.request(url, { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": "0dai-swarm/1.0", "Content-Length": Buffer.byteLength(payload) } }, (res) => {
1933
- log(`test sent to ${url} → HTTP ${res.statusCode}`);
1934
- });
1935
- req.on("error", (e) => log(`test failed: ${e.message}`));
1936
- req.setTimeout(5000, () => { req.destroy(); log("test timed out"); });
1937
- req.write(payload);
1938
- req.end();
1939
- return;
1940
- }
1941
- console.log("Usage: 0dai swarm webhook [add|list|remove|test] <url> [--event all|task_done|task_failed] [--secret TOKEN]");
1942
- return;
1943
- }
1944
- if (sub === "budget") {
1945
- const budgetFile = path.join(swarmDir, "budget.json");
1946
- if (!fs.existsSync(budgetFile)) { log("no budget data yet"); return; }
1947
- const b = JSON.parse(fs.readFileSync(budgetFile, "utf8"));
1948
- const B2 = process.stdout.isTTY ? "\x1b[1m" : "";
1949
- const R2 = process.stdout.isTTY ? "\x1b[0m" : "";
1950
- const D2 = process.stdout.isTTY ? "\x1b[2m" : "";
1951
- const G2 = process.stdout.isTTY ? "\x1b[32m" : "";
1952
- const W2 = process.stdout.isTTY ? "\x1b[33m" : "";
1953
- const today = new Date().toISOString().slice(0, 10);
1954
- const sessionKey = process.env.ODAI_SESSION_ID ||
1955
- new Date().toISOString().slice(0, 13).replace("T", "-");
1956
- const dailySpent = b.daily?.[today] || 0;
1957
- const totalSpent = b.total_spent || 0;
1958
- const sess = b.sessions?.[sessionKey];
1959
- // Tier distribution across all tasks
1960
- const tierCount = { fast: 0, balanced: 0, deep: 0 };
1961
- for (const t of Object.values(b.tasks || {})) {
1962
- if (t.tier && tierCount[t.tier] !== undefined) tierCount[t.tier]++;
1963
- }
1964
- const tieredTotal = tierCount.fast + tierCount.balanced + tierCount.deep;
1965
- console.log(`\n ${B2}Swarm Budget${R2}`);
1966
- if (sess && sess.total_cost > 0) {
1967
- const taskCount = (sess.tasks || []).length;
1968
- const avgCost = taskCount > 0 ? (sess.total_cost / taskCount).toFixed(4) : "0";
1969
- console.log(` ${B2}This session${R2} $${sess.total_cost.toFixed(4)} · ${taskCount} tasks · avg $${avgCost}/task`);
1970
- if (sess.tiers) {
1971
- const tiers = Object.entries(sess.tiers).filter(([, n]) => n > 0).map(([t, n]) => `${n}×${t}`).join(" ");
1972
- if (tiers) console.log(` ${D2}Tiers ${tiers}${R2}`);
1973
- }
1974
- } else {
1975
- console.log(` ${D2}This session no tracked spend${R2}`);
1976
- }
1977
- if (dailySpent > 0) {
1978
- const dailyLimit = parseFloat(process.env.ODAI_DAILY_BUDGET || "5");
1979
- const pct = Math.round((dailySpent / dailyLimit) * 100);
1980
- const bar = "█".repeat(Math.round(pct / 5)).padEnd(20, "░");
1981
- const col = pct < 50 ? G2 : pct < 80 ? W2 : "\x1b[31m";
1982
- console.log(` ${B2}Today${R2} ${col}$${dailySpent.toFixed(4)}${R2} / $${dailyLimit.toFixed(2)} ${D2}${bar} ${pct}%${R2}`);
1983
- }
1984
- console.log(` ${B2}All time${R2} ${D2}$${totalSpent.toFixed(4)} (${Object.keys(b.tasks || {}).length} tasks)${R2}`);
1985
- if (tieredTotal > 0) {
1986
- const fastPct = Math.round((tierCount.fast / tieredTotal) * 100);
1987
- console.log(` ${D2}Model routing ${tierCount.fast}×fast ${tierCount.balanced}×balanced ${tierCount.deep}×deep (${fastPct}% cheap)${R2}`);
1988
- }
1989
- // Recent sessions (last 5)
1990
- const sessions = Object.entries(b.sessions || {})
1991
- .sort(([a], [bb]) => bb.localeCompare(a))
1992
- .slice(0, 5);
1993
- if (sessions.length > 1) {
1994
- console.log(` ${D2}Recent sessions:${R2}`);
1995
- for (const [key, s] of sessions) {
1996
- const tasks = (s.tasks || []).length;
1997
- console.log(` ${D2}${key} $${(s.total_cost || 0).toFixed(4)} · ${tasks} tasks${R2}`);
1998
- }
1999
- }
2000
- console.log();
2001
- return;
2002
- }
2003
- console.log("Usage: 0dai swarm [status|add|delegate|budget] [--task '...'] [--to agent]");
2004
- }
2005
-
2006
- // --- Feedback (local + API push) ---
2007
- async function cmdFeedback(target, sub, args) {
2008
- const fbDir = path.join(target, "ai", "feedback");
2009
-
2010
- if (sub === "push") {
2011
- return cmdFeedbackPush(target);
2012
- }
2013
- if (sub === "log") {
2014
- const type = args.find((_, i) => args[i - 1] === "--type") || "suggestion";
2015
- const detail = args.find((_, i) => args[i - 1] === "--detail") || "";
2016
- if (!detail) { console.log("Usage: 0dai feedback log --type bug|suggestion|friction|positive --detail '...'"); return; }
2017
- fs.mkdirSync(fbDir, { recursive: true });
2018
- const entry = JSON.stringify({ ts: new Date().toISOString(), type, detail, agent: "cli" });
2019
- fs.appendFileSync(path.join(fbDir, "operational.jsonl"), entry + "\n");
2020
- log(`logged: [${type}] ${detail.slice(0, 60)}`);
2021
- return;
2022
- }
2023
- if (sub === "list") {
2024
- try {
2025
- const files = fs.readdirSync(fbDir).filter(f => f.endsWith("-report.json"));
2026
- if (!files.length) { log("no reports"); return; }
2027
- for (const f of files) {
2028
- try {
2029
- const d = JSON.parse(fs.readFileSync(path.join(fbDir, f), "utf8"));
2030
- console.log(` ${f}: ${d.verdict || "?"} (${d.project || "?"})`);
2031
- } catch {}
2032
- }
2033
- } catch { log("no feedback directory"); }
2034
- return;
2035
- }
2036
- console.log("Usage: 0dai feedback [push|log|list] [--type ...] [--detail '...']");
2037
- }
2038
-
2039
- function cmdWatch(target, args) {
2040
- const isTTY = process.stdout.isTTY;
2041
- const B2 = isTTY ? "\x1b[1m" : "";
2042
- const DIM = isTTY ? "\x1b[2m" : "";
2043
- const G = isTTY ? "\x1b[32m" : "";
2044
- const Y = isTTY ? "\x1b[33m" : "";
2045
- const C = isTTY ? "\x1b[36m" : "";
2046
- const M = isTTY ? "\x1b[35m" : "";
2047
- const R2 = isTTY ? "\x1b[0m" : "";
2048
-
2049
- const swarmDir = path.join(target, "ai", "swarm");
2050
- const interval = parseInt(args.find((_, i) => args[i - 1] === "--interval") || "3", 10) * 1000;
2051
-
2052
- function readDir(dir) {
2053
- try {
2054
- return fs.readdirSync(dir)
2055
- .filter(f => f.endsWith(".json"))
2056
- .map(f => { try { return JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); } catch { return null; } })
2057
- .filter(Boolean);
2058
- } catch { return []; }
2059
- }
2060
-
2061
- function ago(ts) {
2062
- if (!ts) return "—";
2063
- const s = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
2064
- if (s < 60) return `${s}s`;
2065
- if (s < 3600) return `${Math.floor(s / 60)}m`;
2066
- return `${Math.floor(s / 3600)}h`;
2067
- }
2068
-
2069
- function tierColor(tier) {
2070
- if (tier === "deep") return M;
2071
- if (tier === "fast") return C;
2072
- return G;
2073
- }
2074
-
2075
- function statusColor(status) {
2076
- if (status === "done") return G;
2077
- if (status === "active" || status === "running") return Y;
2078
- if (status === "failed") return "\x1b[31m";
2079
- return DIM;
2080
- }
2081
-
2082
- function render() {
2083
- const queue = readDir(path.join(swarmDir, "queue"));
2084
- const active = readDir(path.join(swarmDir, "active"));
2085
- const done = readDir(path.join(swarmDir, "done")).sort(
2086
- (a, b) => new Date(b.completed_at || b.created_at) - new Date(a.completed_at || a.created_at)
2087
- ).slice(0, 8);
2088
-
2089
- const lines = [];
2090
- const w = process.stdout.columns || 100;
2091
- const sep = DIM + "─".repeat(Math.min(w, 96)) + R2;
2092
-
2093
- lines.push(`\n ${B2}${T}0dai watch${R2} ${DIM}${new Date().toLocaleTimeString()} (q to quit)${R2}`);
2094
- lines.push(sep);
2095
-
2096
- const header = ` ${"STATUS".padEnd(9)} ${"AGENT".padEnd(10)} ${"TIER".padEnd(9)} ${"AGE".padEnd(6)} TITLE`;
2097
- lines.push(DIM + header + R2);
2098
- lines.push(sep);
2099
-
2100
- const printTask = (t, statusOverride) => {
2101
- const status = statusOverride || t.status || "queued";
2102
- const sc = statusColor(status);
2103
- const tc = tierColor(t.model_tier);
2104
- const title = (t.title || "—").slice(0, Math.max(20, w - 42));
2105
- const age = ago(t.created_at);
2106
- lines.push(
2107
- ` ${sc}${status.padEnd(9)}${R2} ` +
2108
- `${DIM}${(t.assigned_to || "—").padEnd(10)}${R2} ` +
2109
- `${tc}${(t.model_tier || "—").padEnd(9)}${R2} ` +
2110
- `${DIM}${age.padEnd(6)}${R2} ${title}`
2111
- );
2112
- };
2113
-
2114
- if (active.length) {
2115
- active.forEach(t => printTask(t, "active"));
2116
- }
2117
- if (queue.length) {
2118
- queue.forEach(t => printTask(t, "queued"));
2119
- }
2120
- if (!active.length && !queue.length) {
2121
- lines.push(` ${DIM}No tasks in queue or active.${R2}`);
2122
- }
2123
-
2124
- lines.push(sep);
2125
- if (done.length) {
2126
- lines.push(` ${DIM}Recently done:${R2}`);
2127
- done.forEach(t => printTask(t, "done"));
2128
- }
2129
-
2130
- lines.push(`\n ${DIM}queue: ${queue.length} active: ${active.length} done (total): ${readDir(path.join(swarmDir, "done")).length}${R2}\n`);
2131
-
2132
- if (isTTY) process.stdout.write("\x1b[2J\x1b[H");
2133
- console.log(lines.join("\n"));
2134
- }
2135
-
2136
- render();
2137
- const timer = setInterval(render, interval);
2138
-
2139
- // Exit on 'q' or Ctrl+C
2140
- if (isTTY && process.stdin.setRawMode) {
2141
- process.stdin.setRawMode(true);
2142
- process.stdin.resume();
2143
- process.stdin.once("data", (key) => {
2144
- clearInterval(timer);
2145
- process.stdin.setRawMode(false);
2146
- process.exit(0);
2147
- });
2148
- }
2149
- process.on("SIGINT", () => { clearInterval(timer); process.exit(0); });
2150
- }
4
+ /**
5
+ * 0dai CLI entry point — routing only.
6
+ *
7
+ * All command implementations live in lib/commands/*.js.
8
+ * Shared config and utilities live in lib/shared.js.
9
+ */
10
+
11
+ const shared = require("../lib/shared");
12
+ const { T, R, D, log, VERSION, fs, path, spawnSync, findRepoScript, checkVersion } = shared;
13
+
14
+ // --- Command imports ---
15
+ const { cmdAuthLogin, cmdAuthLogout, cmdRedeem, cmdAuthStatus, cmdActivateFree, cmdActivateStatus } = require("../lib/commands/auth");
16
+ const { cmdInit, cmdSync } = require("../lib/commands/init");
17
+ const { cmdDetect } = require("../lib/commands/detect");
18
+ const { cmdAudit } = require("../lib/commands/audit");
19
+ const { cmdDoctor } = require("../lib/commands/doctor");
20
+ const { cmdValidate } = require("../lib/commands/validate");
21
+ const { cmdUpdate } = require("../lib/commands/update");
22
+ const { cmdReflect } = require("../lib/commands/reflect");
23
+ const { cmdMetrics } = require("../lib/commands/metrics");
24
+ const { cmdStatus } = require("../lib/commands/status");
25
+ const { cmdPortfolio } = require("../lib/commands/portfolio");
26
+ const { cmdRun } = require("../lib/commands/run");
27
+ const { cmdWatch } = require("../lib/commands/watch");
28
+ const { cmdModels } = require("../lib/commands/models");
29
+ const { cmdSession } = require("../lib/commands/session");
30
+ const { cmdSwarm } = require("../lib/commands/swarm");
31
+ const { cmdFeedback, cmdFeedbackPush } = require("../lib/commands/feedback");
32
+ const { cmdGraph } = require("../lib/commands/graph");
33
+ const { cmdReport } = require("../lib/commands/report");
34
+ const { cmdExperience } = require("../lib/commands/experience");
2151
35
 
2152
36
  async function main() {
2153
37
  const args = process.argv.slice(2);
@@ -2161,14 +45,59 @@ async function main() {
2161
45
  // Non-blocking version check (runs in background, once per day)
2162
46
  checkVersion();
2163
47
 
48
+ // Track first run for time-to-init telemetry
49
+ try { require("../lib/onboarding").trackFirstRun(target); } catch {}
50
+
51
+ // First-run wizard prompt for commands that need ai/
52
+ if (["status", "doctor", "sync", "swarm", "detect", "validate", "reflect", "metrics", "experience", "graph", "session", "report"].includes(cmd)) {
53
+ try {
54
+ const { maybeWizard } = require("../lib/wizard");
55
+ const handled = await maybeWizard(target, cmd);
56
+ if (handled) return;
57
+ } catch {}
58
+ }
59
+
2164
60
  switch (cmd) {
61
+ case "quickstart": {
62
+ const ob = require("../lib/onboarding");
63
+ await ob.cmdQuickstart(target, { cmdDoctor, cmdStatus, cmdInit: (t, a) => cmdInit(t, a || []), log, ensureAuthenticated: shared.makeEnsureAuthenticated(cmdAuthLogin) });
64
+ break;
65
+ }
2165
66
  case "run": await cmdRun(args[1] || "", target, args.slice(2)); break;
2166
67
  case "watch": cmdWatch(target, args.slice(1)); break;
2167
68
  case "audit": cmdAudit(target); break;
69
+ case "security": {
70
+ const secScript = findRepoScript(target, "scan_secrets.py");
71
+ if (!secScript) { log("secret scanner unavailable"); break; }
72
+ const fwd = [secScript, "--target", target];
73
+ if (args.includes("--json")) fwd.push("--json");
74
+ if (args.includes("--fix")) fwd.push("--fix");
75
+ const sr = spawnSync("python3", fwd, { stdio: "inherit" });
76
+ if (typeof sr.status === "number") process.exit(sr.status);
77
+ break;
78
+ }
2168
79
  case "init": await cmdInit(target, args); break;
2169
80
  case "sync": await cmdSync(target, args); break;
2170
81
  case "detect": await cmdDetect(target); break;
2171
- case "doctor": cmdDoctor(target); break;
82
+ case "doctor":
83
+ cmdDoctor(target);
84
+ if (args.includes("--drift")) {
85
+ const ds = findRepoScript(target, "drift_detector.py");
86
+ if (ds) spawnSync("python3", [ds, "report", "--target", target], { stdio: "inherit" });
87
+ }
88
+ break;
89
+ case "drift": {
90
+ const ds = findRepoScript(target, "drift_detector.py");
91
+ if (!ds) { log("drift detector unavailable"); break; }
92
+ if (sub === "accept" && args[2]) {
93
+ spawnSync("python3", [ds, "accept", args[2], "--target", target], { stdio: "inherit" });
94
+ } else if (sub === "show" && args[2]) {
95
+ spawnSync("python3", [ds, "show", args[2], "--target", target], { stdio: "inherit" });
96
+ } else {
97
+ spawnSync("python3", [ds, "report", "--target", target], { stdio: "inherit" });
98
+ }
99
+ break;
100
+ }
2172
101
  case "validate": cmdValidate(target); break;
2173
102
  case "reflect": cmdReflect(target, args); break;
2174
103
  case "update": cmdUpdate(args); break;
@@ -2179,13 +108,19 @@ async function main() {
2179
108
  if (sub === "login") await cmdAuthLogin();
2180
109
  else if (sub === "logout") cmdAuthLogout();
2181
110
  else if (sub === "status") await cmdAuthStatus();
2182
- else {
2183
- console.log("Usage: 0dai auth [login|logout|status]");
2184
- }
111
+ else console.log("Usage: 0dai auth [login|logout|status]");
112
+ break;
113
+ case "activate":
114
+ if (sub === "free" || !sub) await cmdActivateFree();
115
+ else if (sub === "status") await cmdActivateStatus();
116
+ else console.log("Usage: 0dai activate [free|status]");
2185
117
  break;
2186
118
  case "session": cmdSession(target, sub, args); break;
2187
119
  case "swarm": cmdSwarm(target, sub, args); break;
2188
120
  case "feedback": await cmdFeedback(target, sub, args); break;
121
+ case "report": cmdReport(target, sub, args); break;
122
+ case "experience": cmdExperience(target, sub, args); break;
123
+ case "graph": await cmdGraph(target, sub, args); break;
2189
124
  case "models": cmdModels(sub || args[1]); break;
2190
125
  case "redeem": await cmdRedeem(sub || args[1]); break;
2191
126
  case "terminal": case "term":
@@ -2207,7 +142,6 @@ async function main() {
2207
142
  log(`unknown tool: ${tool}. Available: ${Object.keys(TOOL_CMDS).join(", ")}`);
2208
143
  break;
2209
144
  }
2210
- // Pass initial prompt if provided after --
2211
145
  const dashIdx = args.indexOf("--");
2212
146
  const prompt = dashIdx >= 0 ? args.slice(dashIdx + 1).join(" ") : "";
2213
147
  const spawnArgs = [...toolConfig.args];
@@ -2236,7 +170,6 @@ async function main() {
2236
170
  const prefix = args[1] || "";
2237
171
  if (!prefix) { log("usage: 0dai terminal kill <session-id-prefix>"); break; }
2238
172
  const all = Array.from(sm.sessions || []);
2239
- // Kill by prefix match
2240
173
  let found = false;
2241
174
  for (const [id] of all) {
2242
175
  if (id.startsWith(prefix)) { sm.kill(id); log(`killed ${id.slice(0, 8)}`); found = true; }
@@ -2274,11 +207,21 @@ async function main() {
2274
207
  console.log(" swarm webhook list Show registered webhooks");
2275
208
  console.log(" swarm webhook test Send test ping to a webhook URL");
2276
209
  console.log(" feedback push Send feedback to 0dai");
210
+ console.log(" report preview Preview privacy-safe project report");
211
+ console.log(" report push Send report to 0dai (with offline queue)");
212
+ console.log(" report status Show last report, queue, and auto-report status");
213
+ console.log(" experience list Show recent structured experience events");
214
+ console.log(" experience stats Show success and cost stats by agent/model/type");
215
+ console.log(" graph push Upload local graph to server (Pro: edges, Free: nodes)");
216
+ console.log(" graph pull Download server graph and merge locally");
217
+ console.log(" graph status Show local graph stats and sync state");
2277
218
  console.log(" models Show model ratings (--fast/--balanced/--deep/--available)");
2278
219
  console.log(" terminal Launch interactive agent session");
2279
220
  console.log(" auth login Authenticate (device code flow)");
2280
221
  console.log(" auth logout Remove credentials");
2281
222
  console.log(" auth status Show account and usage");
223
+ console.log(" activate free Claim free activation license");
224
+ console.log(" activate status Show activation and bound-project status");
2282
225
  console.log(" redeem <CODE> Redeem a plan upgrade code");
2283
226
  console.log(" --version\n");
2284
227
  console.log("https://0dai.dev");