@0dai-dev/cli 4.0.0 → 4.2.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.
@@ -0,0 +1,416 @@
1
+ "use strict";
2
+
3
+ const shared = require("../shared");
4
+ const { https, http, fs, path, os, API_URL, AUTH_FILE, log } = shared;
5
+ const { cmdAuthLogin } = require("./auth");
6
+
7
+ const BEGIN_MARKER = "# BEGIN 0dai managed";
8
+ const END_MARKER = "# END 0dai managed";
9
+
10
+ function _apiRequest(method, endpoint, payload, extraHeaders = {}) {
11
+ return new Promise((resolve, reject) => {
12
+ const url = new URL(endpoint, API_URL);
13
+ const mod = url.protocol === "https:" ? https : http;
14
+ const body = payload === undefined ? null : JSON.stringify(payload);
15
+ const headers = {
16
+ Accept: "application/json",
17
+ "User-Agent": "0dai-cli/ssh",
18
+ ...extraHeaders,
19
+ };
20
+ if (body !== null) {
21
+ headers["Content-Type"] = "application/json";
22
+ headers["Content-Length"] = Buffer.byteLength(body);
23
+ }
24
+ try {
25
+ const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
26
+ const token = auth.api_key || auth.access_token || auth.token;
27
+ if (token && !headers.Authorization) headers.Authorization = `Bearer ${token}`;
28
+ } catch {}
29
+
30
+ const req = mod.request(
31
+ {
32
+ hostname: url.hostname,
33
+ port: url.port || (url.protocol === "https:" ? 443 : 80),
34
+ path: url.pathname,
35
+ method,
36
+ headers,
37
+ timeout: 60000,
38
+ },
39
+ (res) => {
40
+ const chunks = [];
41
+ res.on("data", (chunk) => chunks.push(chunk));
42
+ res.on("end", () => {
43
+ const raw = Buffer.concat(chunks).toString("utf8");
44
+ let parsed = {};
45
+ try {
46
+ parsed = raw ? JSON.parse(raw) : {};
47
+ } catch {
48
+ parsed = { error: raw || `HTTP ${res.statusCode}` };
49
+ }
50
+ if (res.statusCode >= 200 && res.statusCode < 300) {
51
+ resolve(parsed);
52
+ return;
53
+ }
54
+ reject(new Error(parsed.error || parsed.message || `HTTP ${res.statusCode}`));
55
+ });
56
+ }
57
+ );
58
+ req.on("error", (err) => reject(err));
59
+ req.on("timeout", () => {
60
+ req.destroy();
61
+ reject(new Error("request timed out after 60s"));
62
+ });
63
+ if (body !== null) req.write(body);
64
+ req.end();
65
+ });
66
+ }
67
+
68
+ async function _ensureAuthenticated() {
69
+ let authed = false;
70
+ try {
71
+ const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf8"));
72
+ authed = Boolean(auth?.api_key || auth?.access_token || auth?.token);
73
+ } catch {}
74
+ if (!authed) {
75
+ if (process.stdout.isTTY && process.stdin.isTTY) {
76
+ await cmdAuthLogin();
77
+ return;
78
+ }
79
+ throw new Error("authentication required — run '0dai auth login' first");
80
+ }
81
+ }
82
+
83
+ function _parseArgs(args) {
84
+ const options = {
85
+ name: "",
86
+ file: "",
87
+ publicKey: "",
88
+ keyId: "",
89
+ grantId: "",
90
+ hostId: "",
91
+ environment: "",
92
+ userEmail: "",
93
+ unixUser: "",
94
+ tokenEnv: "ODAI_SSH_SYNC_TOKEN",
95
+ homeRoot: "",
96
+ json: false,
97
+ apply: false,
98
+ };
99
+ for (let i = 0; i < args.length; i += 1) {
100
+ const arg = args[i];
101
+ if (arg === "--name" && args[i + 1]) {
102
+ options.name = args[++i];
103
+ } else if (arg === "--file" && args[i + 1]) {
104
+ options.file = args[++i];
105
+ } else if (arg === "--public-key" && args[i + 1]) {
106
+ options.publicKey = args[++i];
107
+ } else if (arg === "--key-id" && args[i + 1]) {
108
+ options.keyId = args[++i];
109
+ } else if (arg === "--grant-id" && args[i + 1]) {
110
+ options.grantId = args[++i];
111
+ } else if (arg === "--host-id" && args[i + 1]) {
112
+ options.hostId = args[++i];
113
+ } else if (arg === "--env" && args[i + 1]) {
114
+ options.environment = args[++i];
115
+ } else if (arg === "--user-email" && args[i + 1]) {
116
+ options.userEmail = args[++i];
117
+ } else if (arg === "--unix-user" && args[i + 1]) {
118
+ options.unixUser = args[++i];
119
+ } else if (arg === "--token-env" && args[i + 1]) {
120
+ options.tokenEnv = args[++i];
121
+ } else if (arg === "--home-root" && args[i + 1]) {
122
+ options.homeRoot = args[++i];
123
+ } else if (arg === "--json") {
124
+ options.json = true;
125
+ } else if (arg === "--apply") {
126
+ options.apply = true;
127
+ }
128
+ }
129
+ return options;
130
+ }
131
+
132
+ function _require(value, label) {
133
+ if (!value) throw new Error(`${label} required`);
134
+ }
135
+
136
+ function _readPublicKey(options) {
137
+ if (options.publicKey) return options.publicKey.trim();
138
+ if (options.file) return fs.readFileSync(options.file, "utf8").trim();
139
+ throw new Error("--file or --public-key required");
140
+ }
141
+
142
+ function _printJson(payload) {
143
+ console.log(JSON.stringify(payload, null, 2));
144
+ }
145
+
146
+ function _printKeys(keys) {
147
+ if (!Array.isArray(keys) || keys.length === 0) {
148
+ console.log("No SSH keys yet. Add one with: 0dai ssh key add --name laptop --file ~/.ssh/id_ed25519.pub");
149
+ return;
150
+ }
151
+ console.log(`SSH Keys (${keys.length})`);
152
+ console.log("ID Name Algorithm Status Fingerprint");
153
+ console.log("-".repeat(96));
154
+ for (const key of keys) {
155
+ console.log(
156
+ `${String(key.key_id || "").slice(0, 16).padEnd(16)} ` +
157
+ `${String(key.name || "").slice(0, 14).padEnd(14)} ` +
158
+ `${String(key.algorithm || "").slice(0, 25).padEnd(25)} ` +
159
+ `${String(key.status || "").padEnd(8)} ` +
160
+ `${key.fingerprint_sha256 || ""}`
161
+ );
162
+ }
163
+ }
164
+
165
+ function _printHosts(ownedHosts, accessibleHosts) {
166
+ console.log(`Owned Hosts (${ownedHosts.length})`);
167
+ if (!ownedHosts.length) {
168
+ console.log(" none");
169
+ } else {
170
+ for (const host of ownedHosts) {
171
+ console.log(` ${host.host_id} ${host.name} [${host.environment}] token:${host.token_hint || "—"} last-sync:${host.last_sync_at || "never"}`);
172
+ }
173
+ }
174
+ console.log("");
175
+ console.log(`Accessible Hosts (${accessibleHosts.length})`);
176
+ if (!accessibleHosts.length) {
177
+ console.log(" none");
178
+ } else {
179
+ for (const host of accessibleHosts) {
180
+ console.log(` ${host.name} unix:${host.unix_user} owner:${host.owner_email || "—"}`);
181
+ }
182
+ }
183
+ }
184
+
185
+ function _printGrants(grants) {
186
+ if (!Array.isArray(grants) || grants.length === 0) {
187
+ console.log("No grants yet.");
188
+ return;
189
+ }
190
+ console.log(`SSH Grants (${grants.length})`);
191
+ for (const grant of grants) {
192
+ console.log(` ${grant.grant_id} host:${grant.host_name || grant.host_id} user:${grant.user_email} unix:${grant.unix_user} ${grant.state}`);
193
+ }
194
+ }
195
+
196
+ function _printAudit(audit) {
197
+ if (!Array.isArray(audit) || audit.length === 0) {
198
+ console.log("No SSH audit events yet.");
199
+ return;
200
+ }
201
+ console.log(`SSH Audit (${audit.length})`);
202
+ for (const entry of audit.slice(-20).reverse()) {
203
+ console.log(` ${entry.created_at} ${entry.kind} ${entry.subject_id}`);
204
+ }
205
+ }
206
+
207
+ function _resolveAuthorizedKeysPath(unixUser, homeRoot) {
208
+ const currentUser = process.env.USER || process.env.LOGNAME || "";
209
+ if (unixUser === currentUser) {
210
+ return path.join(os.homedir(), ".ssh", "authorized_keys");
211
+ }
212
+ const base = homeRoot || "/home";
213
+ return path.join(base, unixUser, ".ssh", "authorized_keys");
214
+ }
215
+
216
+ function _mergeManagedBlock(existingText, managedText) {
217
+ const lines = String(existingText || "").split(/\r?\n/);
218
+ const kept = [];
219
+ let insideManaged = false;
220
+ for (const line of lines) {
221
+ if (line.trim() === BEGIN_MARKER) {
222
+ insideManaged = true;
223
+ continue;
224
+ }
225
+ if (line.trim() === END_MARKER) {
226
+ insideManaged = false;
227
+ continue;
228
+ }
229
+ if (!insideManaged) kept.push(line);
230
+ }
231
+ const unmanaged = kept.join("\n").trim();
232
+ return unmanaged ? `${unmanaged}\n\n${managedText}\n` : `${managedText}\n`;
233
+ }
234
+
235
+ function _buildManagedBlock(hostId, unixUser, authorizedKeys) {
236
+ const lines = [
237
+ BEGIN_MARKER,
238
+ `# host_id=${hostId} unix_user=${unixUser}`,
239
+ ...authorizedKeys,
240
+ END_MARKER,
241
+ ];
242
+ return lines.join("\n");
243
+ }
244
+
245
+ function _applySyncState(state, options) {
246
+ const results = [];
247
+ for (const entry of state.unix_users || []) {
248
+ const unixUser = entry.unix_user;
249
+ const filePath = _resolveAuthorizedKeysPath(unixUser, options.homeRoot);
250
+ const dirPath = path.dirname(filePath);
251
+ fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
252
+ let existing = "";
253
+ if (fs.existsSync(filePath)) {
254
+ existing = fs.readFileSync(filePath, "utf8");
255
+ const backupPath = `${filePath}.bak.${Date.now()}`;
256
+ fs.copyFileSync(filePath, backupPath);
257
+ }
258
+ const nextContent = _mergeManagedBlock(
259
+ existing,
260
+ _buildManagedBlock(state.host.host_id, unixUser, entry.authorized_keys || [])
261
+ );
262
+ const tmpPath = `${filePath}.tmp`;
263
+ fs.writeFileSync(tmpPath, nextContent, { mode: 0o600 });
264
+ fs.renameSync(tmpPath, filePath);
265
+ results.push({ unix_user: unixUser, path: filePath, key_count: (entry.authorized_keys || []).length });
266
+ }
267
+ return results;
268
+ }
269
+
270
+ async function cmdSsh(_target, sub, args) {
271
+ const entity = sub || "help";
272
+ const action = args[2] || "";
273
+ const options = _parseArgs(args);
274
+
275
+ try {
276
+ if (entity === "sync") {
277
+ _require(options.hostId, "--host-id");
278
+ const syncToken = String(process.env[options.tokenEnv] || "").trim();
279
+ if (!syncToken) throw new Error(`${options.tokenEnv} is empty`);
280
+ const state = await _apiRequest("POST", "/v1/ssh/sync", { host_id: options.hostId }, { "X-SSH-Sync-Token": syncToken });
281
+ if (options.json) {
282
+ _printJson(state);
283
+ return;
284
+ }
285
+ console.log(`SSH sync state ${state.state_version} for ${state.host.name}`);
286
+ for (const entry of state.unix_users || []) {
287
+ console.log(` ${entry.unix_user}: ${(entry.authorized_keys || []).length} keys`);
288
+ }
289
+ if (!options.apply) {
290
+ console.log("");
291
+ console.log("Dry run only. Re-run with --apply to update authorized_keys.");
292
+ return;
293
+ }
294
+ const results = _applySyncState(state, options);
295
+ console.log("");
296
+ console.log("Applied managed authorized_keys blocks:");
297
+ for (const item of results) {
298
+ console.log(` ${item.unix_user} → ${item.path} (${item.key_count} keys)`);
299
+ }
300
+ return;
301
+ }
302
+
303
+ await _ensureAuthenticated();
304
+
305
+ if (entity === "key") {
306
+ if (action === "add") {
307
+ const payload = { name: options.name, public_key: _readPublicKey(options) };
308
+ const result = await _apiRequest("POST", "/v1/ssh/keys/add", payload);
309
+ if (options.json) return _printJson(result);
310
+ console.log(`Added SSH key ${result.key.key_id}`);
311
+ console.log(` ${result.key.fingerprint_sha256}`);
312
+ return;
313
+ }
314
+ if (action === "revoke") {
315
+ _require(options.keyId, "--key-id");
316
+ const result = await _apiRequest("POST", "/v1/ssh/keys/revoke", { key_id: options.keyId });
317
+ if (options.json) return _printJson(result);
318
+ console.log(`Revoked SSH key ${result.key.key_id}`);
319
+ return;
320
+ }
321
+ const result = await _apiRequest("POST", "/v1/ssh/keys/list", {});
322
+ if (options.json) return _printJson(result);
323
+ _printKeys(result.keys || []);
324
+ return;
325
+ }
326
+
327
+ if (entity === "host") {
328
+ if (action === "register") {
329
+ _require(options.name, "--name");
330
+ const result = await _apiRequest("POST", "/v1/ssh/hosts/register", { name: options.name, environment: options.environment });
331
+ if (options.json) return _printJson(result);
332
+ console.log(`Registered host ${result.host.host_id} (${result.host.name})`);
333
+ console.log(` export ODAI_SSH_SYNC_TOKEN=${result.sync_token}`);
334
+ console.log(` 0dai ssh sync --host-id ${result.host.host_id} --apply`);
335
+ return;
336
+ }
337
+ if (action === "rotate-token") {
338
+ _require(options.hostId, "--host-id");
339
+ const result = await _apiRequest("POST", "/v1/ssh/hosts/rotate-token", { host_id: options.hostId });
340
+ if (options.json) return _printJson(result);
341
+ console.log(`Rotated sync token for ${result.host.name}`);
342
+ console.log(` export ODAI_SSH_SYNC_TOKEN=${result.sync_token}`);
343
+ return;
344
+ }
345
+ const result = await _apiRequest("POST", "/v1/ssh/hosts/list", {});
346
+ if (options.json) return _printJson(result);
347
+ _printHosts(result.owned_hosts || [], result.accessible_hosts || []);
348
+ return;
349
+ }
350
+
351
+ if (entity === "grant") {
352
+ if (action === "add") {
353
+ _require(options.hostId, "--host-id");
354
+ _require(options.userEmail, "--user-email");
355
+ _require(options.unixUser, "--unix-user");
356
+ const result = await _apiRequest("POST", "/v1/ssh/grants/add", {
357
+ host_id: options.hostId,
358
+ user_email: options.userEmail,
359
+ unix_user: options.unixUser,
360
+ });
361
+ if (options.json) return _printJson(result);
362
+ console.log(`Added SSH grant ${result.grant.grant_id}`);
363
+ return;
364
+ }
365
+ if (action === "revoke") {
366
+ const grantId = options.grantId || args[3];
367
+ _require(grantId, "--grant-id");
368
+ const result = await _apiRequest("POST", "/v1/ssh/grants/revoke", { grant_id: grantId });
369
+ if (options.json) return _printJson(result);
370
+ console.log(`Revoked SSH grant ${result.grant.grant_id}`);
371
+ return;
372
+ }
373
+ const result = await _apiRequest("POST", "/v1/ssh/overview", {});
374
+ if (options.json) return _printJson(result);
375
+ _printGrants(result.grants || []);
376
+ return;
377
+ }
378
+
379
+ if (entity === "audit") {
380
+ const result = await _apiRequest("POST", "/v1/ssh/overview", {});
381
+ if (options.json) return _printJson(result);
382
+ _printAudit(result.audit || []);
383
+ return;
384
+ }
385
+
386
+ if (entity === "status" || entity === "overview") {
387
+ const result = await _apiRequest("POST", "/v1/ssh/overview", {});
388
+ if (options.json) return _printJson(result);
389
+ _printKeys(result.keys || []);
390
+ console.log("");
391
+ _printHosts(result.owned_hosts || [], result.accessible_hosts || []);
392
+ console.log("");
393
+ _printGrants(result.grants || []);
394
+ console.log("");
395
+ _printAudit(result.audit || []);
396
+ return;
397
+ }
398
+
399
+ console.log("Usage:");
400
+ console.log(" 0dai ssh key add --name laptop --file ~/.ssh/id_ed25519.pub");
401
+ console.log(" 0dai ssh key list");
402
+ console.log(" 0dai ssh key revoke --key-id <sshk_...>");
403
+ console.log(" 0dai ssh host register --name prod-api --env production");
404
+ console.log(" 0dai ssh host list");
405
+ console.log(" 0dai ssh host rotate-token --host-id <sshh_...>");
406
+ console.log(" 0dai ssh grant add --host-id <sshh_...> --user-email dev@example.com --unix-user deploy");
407
+ console.log(" 0dai ssh grant revoke --grant-id <sshg_...>");
408
+ console.log(" 0dai ssh audit");
409
+ console.log(" 0dai ssh sync --host-id <sshh_...> --token-env ODAI_SSH_SYNC_TOKEN [--apply] [--home-root /home]");
410
+ } catch (err) {
411
+ log(`error: ${err.message}`);
412
+ process.exit(1);
413
+ }
414
+ }
415
+
416
+ module.exports = { cmdSsh };
@@ -1,43 +1,40 @@
1
1
  "use strict";
2
2
  const shared = require("../shared");
3
- const { log, T, R, D, fs, path, spawnSync, findRepoScript, getSwarmQuotaLocal, _detectPlanLocal, PLAN_LEVELS } = shared;
3
+ const {
4
+ log, T, R, D, fs, path, spawnSync, findRepoScript,
5
+ getSwarmQuotaLocal, _detectPlanLocal, PLAN_LEVELS, loadAuthState,
6
+ } = shared;
4
7
 
5
- function cmdStatus(target) {
8
+ function countJson(dir) {
9
+ try { return fs.readdirSync(dir).filter(f => f.endsWith(".json")).length; } catch { return 0; }
10
+ }
11
+
12
+ function loadJson(file) {
13
+ try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
14
+ }
15
+
16
+ function collectStatusPayload(target) {
6
17
  const ai = path.join(target, "ai");
7
18
  let v = "?", stack = "?";
8
19
  try { v = fs.readFileSync(path.join(ai, "VERSION"), "utf8").trim(); } catch {}
9
20
  try { stack = JSON.parse(fs.readFileSync(path.join(ai, "manifest", "discovery.json"), "utf8")).stack || "?"; } catch {}
10
- log(`v${v} | stack: ${stack}`);
11
21
 
12
- const count = (dir) => { try { return fs.readdirSync(dir).filter(f => f.endsWith(".json")).length; } catch { return 0; } };
13
- const q = count(path.join(ai, "swarm", "queue"));
14
- const a = count(path.join(ai, "swarm", "active"));
15
- const d = count(path.join(ai, "swarm", "done"));
16
- if (q || a || d) console.log(` swarm: ${q} queued, ${a} active, ${d} done`);
22
+ const q = countJson(path.join(ai, "swarm", "queue"));
23
+ const a = countJson(path.join(ai, "swarm", "active"));
24
+ const d = countJson(path.join(ai, "swarm", "done"));
17
25
 
18
- // Swarm quota
19
26
  const quota = getSwarmQuotaLocal(target);
20
- if (quota.plan === "free") {
21
- console.log(` swarm quota: ${D}locked (Free) — upgrade for ${quota.daily_limit} tasks/day${R}`);
22
- } else {
23
- console.log(` swarm quota: ${quota.used_today}/${quota.daily_limit} tasks today (${quota.plan})`);
24
- }
25
-
26
- // Session roaming status
27
27
  const sessPlan = _detectPlanLocal(target);
28
28
  const sessLocked = PLAN_LEVELS[sessPlan] < PLAN_LEVELS["pro"];
29
- if (sessLocked) {
30
- console.log(` session roaming: ${D}locked (Free) — upgrade to save/resume sessions${R}`);
31
- } else {
32
- console.log(` session roaming: ${T}available (${sessPlan})${R}`);
33
- }
34
29
 
35
- try {
36
- const s = JSON.parse(fs.readFileSync(path.join(ai, "sessions", "active.json"), "utf8"));
37
- console.log(` session: ${(s.task || {}).goal || "?"} (agent: ${s.current_agent || "?"})`);
38
- } catch {}
30
+ const session = loadJson(path.join(ai, "sessions", "active.json"));
31
+ const auth = loadAuthState() || {};
32
+ const budget = loadJson(path.join(ai, "swarm", "budget.json")) || {};
33
+ const today = new Date().toISOString().slice(0, 10);
34
+ const budgetDaily = budget.daily && typeof budget.daily === "object" ? budget.daily : {};
39
35
 
40
- // Anti-pattern warnings count
36
+ let driftDetected = false;
37
+ let warningCount = 0;
41
38
  try {
42
39
  const ds = findRepoScript(target, "anti_pattern_detector.py");
43
40
  if (ds) {
@@ -45,25 +42,98 @@ function cmdStatus(target) {
45
42
  { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000 });
46
43
  if (wr.status === 0 && wr.stdout) {
47
44
  const wc = JSON.parse(wr.stdout.trim());
48
- if (wc.count > 0) console.log(` warnings: ${wc.count} active — run: 0dai experience warnings`);
45
+ warningCount = Number(wc.count || 0);
49
46
  }
50
47
  }
51
48
  } catch {}
52
49
 
53
- // First-status tip (shows once after init)
54
- try { require("../onboarding").showFirstStatusTip(target); } catch {}
55
-
56
- // Drift warning (lightweight)
57
50
  try {
58
51
  const ds = findRepoScript(target, "drift_detector.py");
59
52
  if (ds) {
60
53
  const dr = spawnSync("python3", [ds, "report", "--target", target],
61
54
  { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000 });
62
- if (dr.stdout && (dr.stdout.includes("MODIFIED") || dr.stdout.includes("CONTRADICTS"))) {
63
- console.log(` drift: config changes detected — run: 0dai doctor --drift`);
64
- }
55
+ driftDetected = !!(dr.stdout && (dr.stdout.includes("MODIFIED") || dr.stdout.includes("CONTRADICTS")));
65
56
  }
66
57
  } catch {}
58
+
59
+ return {
60
+ version: v,
61
+ stack,
62
+ swarm: {
63
+ queued: q,
64
+ active: a,
65
+ done: d,
66
+ quota_state: quota.plan === "free" ? "locked" : "available",
67
+ quota_plan: quota.plan,
68
+ used_today: Number(quota.used_today || 0),
69
+ daily_limit: Number(quota.daily_limit || 0),
70
+ },
71
+ session: {
72
+ id: session ? (session.id || null) : null,
73
+ name: session ? ((session.task || {}).goal || null) : null,
74
+ agent: session ? (session.current_agent || null) : null,
75
+ archive_count: countJson(path.join(ai, "sessions", "archive")),
76
+ roaming_state: sessLocked ? "locked" : "available",
77
+ roaming_plan: sessPlan,
78
+ },
79
+ auth: {
80
+ logged_in: !!(auth.api_key || auth.access_token || auth.token),
81
+ email: auth.email || auth.user || null,
82
+ tier: auth.plan || sessPlan || "free",
83
+ activation: ((auth.license || {}).status) || "inactive",
84
+ activation_id: ((auth.license || {}).activation_id) || null,
85
+ },
86
+ budget: {
87
+ today: Number(budgetDaily[today] || 0),
88
+ total_spent: Number(budget.total_spent || 0),
89
+ },
90
+ drift: {
91
+ detected: driftDetected,
92
+ },
93
+ warnings: warningCount,
94
+ };
95
+ }
96
+
97
+ function cmdStatus(target, options = {}) {
98
+ const payload = collectStatusPayload(target);
99
+ if (options.json) {
100
+ console.log(JSON.stringify(payload, null, 2));
101
+ return payload;
102
+ }
103
+
104
+ log(`v${payload.version} | stack: ${payload.stack}`);
105
+
106
+ if (payload.swarm.queued || payload.swarm.active || payload.swarm.done) {
107
+ console.log(` swarm: ${payload.swarm.queued} queued, ${payload.swarm.active} active, ${payload.swarm.done} done`);
108
+ }
109
+
110
+ if (payload.swarm.quota_state === "locked") {
111
+ console.log(` swarm quota: ${D}locked (Free) — upgrade for ${payload.swarm.daily_limit} tasks/day${R}`);
112
+ } else {
113
+ console.log(` swarm quota: ${payload.swarm.used_today}/${payload.swarm.daily_limit} tasks today (${payload.swarm.quota_plan})`);
114
+ }
115
+
116
+ if (payload.session.roaming_state === "locked") {
117
+ console.log(` session roaming: ${D}locked (Free) — upgrade to save/resume sessions${R}`);
118
+ } else {
119
+ console.log(` session roaming: ${T}available (${payload.session.roaming_plan})${R}`);
120
+ }
121
+
122
+ if (payload.session.name) {
123
+ console.log(` session: ${payload.session.name} (agent: ${payload.session.agent || "?"})`);
124
+ }
125
+
126
+ if (payload.warnings > 0) {
127
+ console.log(` warnings: ${payload.warnings} active — run: 0dai experience warnings`);
128
+ }
129
+
130
+ try { require("../onboarding").showFirstStatusTip(target); } catch {}
131
+
132
+ if (payload.drift.detected) {
133
+ console.log(` drift: config changes detected — run: 0dai doctor --drift`);
134
+ }
135
+
136
+ return payload;
67
137
  }
68
138
 
69
- module.exports = { cmdStatus };
139
+ module.exports = { cmdStatus, collectStatusPayload };
@@ -155,7 +155,45 @@ function cmdSwarm(target, sub, args) {
155
155
  console.log();
156
156
  return;
157
157
  }
158
- console.log("Usage: 0dai swarm [status|add|delegate|budget] [--task '...'] [--to agent]");
158
+ if (sub === "estimate") {
159
+ const gate = requirePlan("pro", "Swarm Estimate", target);
160
+ if (gate) { log(gate.error); log(gate.hint); return; }
161
+ const goal = args.find((_, i) => args[i - 1] === "--goal") || "";
162
+ if (!goal) { console.log("Usage: 0dai swarm estimate --goal '...' [--agent claude|codex] [--model tier] [--json]"); return; }
163
+ const agent = args.find((_, i) => args[i - 1] === "--agent") || "";
164
+ const model = args.find((_, i) => args[i - 1] === "--model") || "";
165
+ const asJson = args.includes("--json");
166
+ // Call API for cost estimate
167
+ const identity = shared.buildProjectIdentity(target, shared.collectMetadata(target));
168
+ shared.apiCall("/v1/swarm/estimate", { goal, agent, model_tier: model, project_id: identity.project_id }).then((result) => {
169
+ if (result.error) { log(`error: ${result.error}`); return; }
170
+ if (asJson) { console.log(JSON.stringify(result, null, 2)); return; }
171
+ log(`Cost estimate for: ${goal}`);
172
+ console.log(` ${D}Estimated: $${(result.estimated_cost_usd || 0).toFixed(4)} · ${result.estimated_tokens || "?"} tokens · ${result.estimated_time_s || "?"}s${R}`);
173
+ if (result.model_recommendation) console.log(` ${T}Recommended: ${result.model_recommendation}${R}`);
174
+ if (result.tier_breakdown) {
175
+ for (const [tier, cost] of Object.entries(result.tier_breakdown)) {
176
+ console.log(` ${tier}: $${cost.toFixed(4)}`);
177
+ }
178
+ }
179
+ });
180
+ return;
181
+ }
182
+ if (sub === "quality") {
183
+ const scorerScript = findRepoScript(target, "quality_scorer.py");
184
+ if (!scorerScript) { log("quality scorer unavailable"); return; }
185
+ const fwd = [scorerScript, "--target", target];
186
+ if (args.includes("--json")) fwd.push("--json");
187
+ for (let i = 2; i < args.length; i++) {
188
+ if (args[i] === "--last" && args[i + 1]) { fwd.push("--last", args[i + 1]); i++; }
189
+ else if (args[i] === "--details" && args[i + 1]) { fwd.push("--details", args[i + 1]); i++; }
190
+ }
191
+ const result = spawnSync("python3", fwd, { stdio: "inherit", timeout: 15000 });
192
+ if (typeof result.status === "number" && result.status !== 0) process.exit(result.status);
193
+ return;
194
+ }
195
+ console.log("Usage: 0dai swarm [status|add|delegate|budget|estimate|quality] [--task '...'] [--to agent]");
196
+ console.log(" quality Show quality scores for recent tasks (--last N / --details TASK_ID)");
159
197
  }
160
198
 
161
199
  module.exports = { cmdSwarm };
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ const path = require("path");
3
+ const shared = require("../shared");
4
+ const { log, D, R, VERSION } = shared;
5
+
6
+ /**
7
+ * `0dai tui` — read-only dashboard (issue #373).
8
+ *
9
+ * Lazy-loads lib/tui/index.js (pre-built by scripts/build-tui.js at prepack
10
+ * time). Degrades gracefully if the bundle / peer deps aren't available so
11
+ * non-TUI users on minimal installs still get a useful message.
12
+ */
13
+ async function cmdTui(target, args = []) {
14
+ if (!process.stdout.isTTY) {
15
+ console.error("0dai tui requires an interactive TTY. Try: 0dai status");
16
+ process.exit(2);
17
+ }
18
+
19
+ // Ink 5.x is ESM; bundle is .mjs so we load it via dynamic import
20
+ // from this CJS entry.
21
+ const bundlePath = path.join(__dirname, "..", "tui", "index.mjs");
22
+ let tui;
23
+ try {
24
+ // Use the file:// URL form so Windows paths with drive letters load too.
25
+ const url = require("url").pathToFileURL(bundlePath).href;
26
+ tui = await import(url);
27
+ } catch (err) {
28
+ const code = err && err.code;
29
+ const msg = String(err && err.message || "");
30
+ if (code === "ERR_MODULE_NOT_FOUND" || msg.includes("Cannot find module")) {
31
+ log("TUI bundle missing. Rebuild with:");
32
+ console.log(` ${D}cd $(npm root -g)/@0dai-dev/cli && node scripts/build-tui.js${R}`);
33
+ process.exit(1);
34
+ }
35
+ throw err;
36
+ }
37
+
38
+ let plan = "free";
39
+ try {
40
+ const auth = shared.loadAuthState();
41
+ if (auth && auth.plan) plan = auth.plan;
42
+ } catch {
43
+ // keep default plan
44
+ }
45
+
46
+ await tui.run({ target, version: VERSION, plan });
47
+ }
48
+
49
+ module.exports = { cmdTui };