@0dai-dev/cli 4.1.0 → 4.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +30 -5
  2. package/bin/0dai.js +308 -60
  3. package/lib/commands/audit.js +13 -0
  4. package/lib/commands/auth.js +404 -122
  5. package/lib/commands/boneyard.js +44 -0
  6. package/lib/commands/ci.js +329 -0
  7. package/lib/commands/compliance.js +20 -0
  8. package/lib/commands/doctor.js +79 -14
  9. package/lib/commands/experience.js +5 -1
  10. package/lib/commands/feedback.js +92 -5
  11. package/lib/commands/gh.js +506 -0
  12. package/lib/commands/graph.js +78 -10
  13. package/lib/commands/heatmap.js +17 -0
  14. package/lib/commands/import_claude_code_agents.js +367 -0
  15. package/lib/commands/init.js +553 -53
  16. package/lib/commands/loop.js +108 -0
  17. package/lib/commands/mcp.js +410 -0
  18. package/lib/commands/models.js +42 -12
  19. package/lib/commands/paste.js +114 -0
  20. package/lib/commands/persona-simulate.js +19 -0
  21. package/lib/commands/play.js +173 -0
  22. package/lib/commands/provider.js +87 -0
  23. package/lib/commands/quota.js +76 -0
  24. package/lib/commands/receipt.js +53 -0
  25. package/lib/commands/report.js +29 -2
  26. package/lib/commands/run.js +44 -4
  27. package/lib/commands/runner.js +527 -0
  28. package/lib/commands/session.js +1 -7
  29. package/lib/commands/ssh.js +416 -0
  30. package/lib/commands/standup.js +40 -0
  31. package/lib/commands/status.js +131 -36
  32. package/lib/commands/swarm.js +97 -4
  33. package/lib/commands/tui.js +117 -0
  34. package/lib/commands/usage.js +87 -0
  35. package/lib/commands/vault.js +246 -0
  36. package/lib/commands/workspace.js +1 -0
  37. package/lib/onboarding.js +30 -10
  38. package/lib/shared.js +153 -96
  39. package/lib/tui/index.mjs +34994 -0
  40. package/lib/utils/auth.js +1 -0
  41. package/lib/utils/canonical-counts.js +54 -0
  42. package/lib/utils/diff-preview.js +192 -0
  43. package/lib/utils/identity.js +76 -18
  44. package/lib/utils/mcp-auth.js +607 -0
  45. package/lib/utils/model_ratings.js +77 -0
  46. package/lib/utils/plan.js +37 -2
  47. package/lib/vault/cipher.js +125 -0
  48. package/lib/vault/identity.js +122 -0
  49. package/lib/vault/index.js +184 -0
  50. package/lib/vault/storage.js +84 -0
  51. package/lib/wizard.js +19 -12
  52. package/package.json +13 -5
  53. package/scripts/build-tui.js +77 -0
@@ -0,0 +1,607 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const fs = require("fs");
5
+ const http = require("http");
6
+ const https = require("https");
7
+ const os = require("os");
8
+ const path = require("path");
9
+ const { spawnSync } = require("child_process");
10
+
11
+ const DEFAULT_MCP_HOST = "https://mcp.0dai.dev";
12
+ const DEFAULT_CLOUD_SERVER_NAME = "claude.ai 0dai";
13
+ const DEFAULT_CLOUD_SERVER_ID = "claude_ai_0dai";
14
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
15
+
16
+ const EMBEDDED_TEMPLATE = `{
17
+ "mcpServers": {
18
+ "0dai": {
19
+ "command": "python3",
20
+ "args": ["{{ODAI_MCP_SERVER_SCRIPT}}", "--target", "{{PROJECT_PATH}}"],
21
+ "env": {"ODAI_PROJECT_PATH": "{{PROJECT_PATH}}"}
22
+ },
23
+ "filesystem": {
24
+ "command": "npx",
25
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "{{PROJECT_PATH}}"]
26
+ },
27
+ "claude.ai 0dai": {
28
+ "type": "http",
29
+ "url": "{{MCP_HOST}}/mcp"
30
+ }
31
+ }
32
+ }
33
+ `;
34
+
35
+ function argAfter(args, names) {
36
+ const wanted = new Set(Array.isArray(names) ? names : [names]);
37
+ for (let i = 0; i < args.length; i++) {
38
+ const arg = String(args[i] || "");
39
+ if (wanted.has(arg) && args[i + 1]) return String(args[i + 1]);
40
+ for (const name of wanted) {
41
+ if (arg.startsWith(`${name}=`)) return arg.slice(name.length + 1);
42
+ }
43
+ }
44
+ return "";
45
+ }
46
+
47
+ function parseMcpArgs(args = [], env = process.env) {
48
+ return {
49
+ reset: args.includes("--reset"),
50
+ noAuth: args.includes("--no-mcp-auth"),
51
+ yes: args.includes("--yes") || args.includes("-y"),
52
+ host: normalizeMcpHost(argAfter(args, ["--mcp-host"]) || env.ODAI_MCP_HOST || DEFAULT_MCP_HOST),
53
+ };
54
+ }
55
+
56
+ function normalizeMcpHost(raw) {
57
+ let value = String(raw || DEFAULT_MCP_HOST).trim();
58
+ if (!value) value = DEFAULT_MCP_HOST;
59
+ if (!/^https?:\/\//i.test(value)) value = `https://${value}`;
60
+ return value.replace(/\/+$/, "");
61
+ }
62
+
63
+ function escapeTemplateValue(value) {
64
+ return JSON.stringify(String(value)).slice(1, -1);
65
+ }
66
+
67
+ function renderTemplate(template, values) {
68
+ return template.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_m, key) => {
69
+ if (!(key in values)) return "";
70
+ return escapeTemplateValue(values[key]);
71
+ });
72
+ }
73
+
74
+ function repoRootFromPackage() {
75
+ return path.resolve(__dirname, "..", "..", "..", "..");
76
+ }
77
+
78
+ function resolveTemplatePath() {
79
+ const explicit = process.env.ODAI_MCP_TEMPLATE;
80
+ if (explicit) return { path: path.resolve(explicit), required: true };
81
+ const repoRoot = repoRootFromPackage();
82
+ const candidates = [
83
+ path.join(process.cwd(), "templates", "mcp", "mcp.json.template"),
84
+ path.join(repoRoot, "templates", "mcp", "mcp.json.template"),
85
+ path.join(__dirname, "..", "..", "templates", "mcp", "mcp.json.template"),
86
+ ];
87
+ for (const candidate of candidates) {
88
+ if (fs.existsSync(candidate)) return { path: candidate, required: false };
89
+ }
90
+ return { path: "", required: false };
91
+ }
92
+
93
+ function loadTemplate() {
94
+ const found = resolveTemplatePath();
95
+ if (found.path) {
96
+ if (!fs.existsSync(found.path)) {
97
+ throw new Error(`MCP template not found: ${found.path}`);
98
+ }
99
+ return { text: fs.readFileSync(found.path, "utf8"), source: found.path };
100
+ }
101
+ return { text: EMBEDDED_TEMPLATE, source: "embedded" };
102
+ }
103
+
104
+ function resolveMcpServerScript(target) {
105
+ const repoRoot = repoRootFromPackage();
106
+ const candidates = [
107
+ path.join(target, "scripts", "mcp_server.py"),
108
+ path.join(process.cwd(), "scripts", "mcp_server.py"),
109
+ path.join(repoRoot, "scripts", "mcp_server.py"),
110
+ ];
111
+ for (const candidate of candidates) {
112
+ if (fs.existsSync(candidate)) return candidate;
113
+ }
114
+ return "scripts/mcp_server.py";
115
+ }
116
+
117
+ function readJsonFile(filePath) {
118
+ if (!fs.existsSync(filePath)) return {};
119
+ try {
120
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
121
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
122
+ } catch (err) {
123
+ throw new Error(`${filePath} is not valid JSON: ${err.message}`);
124
+ }
125
+ }
126
+
127
+ function writeJsonIfChanged(filePath, value) {
128
+ const text = JSON.stringify(value, null, 2) + "\n";
129
+ if (fs.existsSync(filePath) && fs.readFileSync(filePath, "utf8") === text) {
130
+ return false;
131
+ }
132
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
133
+ fs.writeFileSync(filePath, text, "utf8");
134
+ return true;
135
+ }
136
+
137
+ function targetFromArgs(args) {
138
+ if (!Array.isArray(args)) return "";
139
+ for (let i = 0; i < args.length; i++) {
140
+ if (String(args[i]) === "--target" && args[i + 1]) return String(args[i + 1]);
141
+ }
142
+ return "";
143
+ }
144
+
145
+ function envTarget(config) {
146
+ const env = config && config.env && typeof config.env === "object" && !Array.isArray(config.env)
147
+ ? config.env
148
+ : {};
149
+ return typeof env.ODAI_PROJECT_PATH === "string" ? env.ODAI_PROJECT_PATH : "";
150
+ }
151
+
152
+ function normalizeConfigPath(value, base) {
153
+ const raw = String(value || "").trim();
154
+ if (!raw) return "";
155
+ const expanded = raw === "~" || raw.startsWith("~/")
156
+ ? path.join(os.homedir(), raw.slice(2))
157
+ : raw;
158
+ return path.resolve(path.isAbsolute(expanded) ? expanded : path.join(base, expanded));
159
+ }
160
+
161
+ function pathsDiffer(existingValue, incomingValue, base) {
162
+ if (!existingValue || !incomingValue) return false;
163
+ return normalizeConfigPath(existingValue, base) !== normalizeConfigPath(incomingValue, base);
164
+ }
165
+
166
+ function filesystemTarget(config) {
167
+ const args = Array.isArray(config && config.args) ? config.args : [];
168
+ if (args.length < 1) return "";
169
+ return String(args[args.length - 1] || "");
170
+ }
171
+
172
+ function shouldUpdateManagedServer(name, existingConfig, incomingConfig, options = {}) {
173
+ if (options.reset) return true;
174
+ const base = options.target ? path.resolve(options.target) : process.cwd();
175
+ if (name === "0dai") {
176
+ const existingArgTarget = targetFromArgs(existingConfig && existingConfig.args);
177
+ const incomingArgTarget = targetFromArgs(incomingConfig && incomingConfig.args);
178
+ const existingEnvTarget = envTarget(existingConfig);
179
+ const incomingEnvTarget = envTarget(incomingConfig);
180
+ if ((incomingArgTarget && !existingArgTarget) || pathsDiffer(existingArgTarget, incomingArgTarget, base)) {
181
+ return true;
182
+ }
183
+ if ((incomingEnvTarget && !existingEnvTarget) || pathsDiffer(existingEnvTarget, incomingEnvTarget, base)) {
184
+ return true;
185
+ }
186
+ return false;
187
+ }
188
+ if (name === "filesystem") {
189
+ const existingTarget = filesystemTarget(existingConfig);
190
+ const incomingTarget = filesystemTarget(incomingConfig);
191
+ return (incomingTarget && !existingTarget) || pathsDiffer(existingTarget, incomingTarget, base);
192
+ }
193
+ if (name === DEFAULT_CLOUD_SERVER_NAME) {
194
+ const existingUrl = existingConfig && typeof existingConfig.url === "string" ? existingConfig.url : "";
195
+ const incomingUrl = incomingConfig && typeof incomingConfig.url === "string" ? incomingConfig.url : "";
196
+ return !!incomingUrl && existingUrl !== incomingUrl;
197
+ }
198
+ return false;
199
+ }
200
+
201
+ function mergeMcpConfig(existing, incoming, options = {}) {
202
+ const out = existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
203
+ const existingServers = out.mcpServers && typeof out.mcpServers === "object" && !Array.isArray(out.mcpServers)
204
+ ? { ...out.mcpServers }
205
+ : {};
206
+ const incomingServers = incoming && incoming.mcpServers && typeof incoming.mcpServers === "object"
207
+ ? incoming.mcpServers
208
+ : {};
209
+ const added = [];
210
+ const updated = [];
211
+
212
+ for (const [name, config] of Object.entries(incomingServers)) {
213
+ if (!(name in existingServers)) {
214
+ existingServers[name] = config;
215
+ added.push(name);
216
+ continue;
217
+ }
218
+ if (shouldUpdateManagedServer(name, existingServers[name], config, options)) {
219
+ existingServers[name] = config;
220
+ updated.push(name);
221
+ }
222
+ }
223
+
224
+ out.mcpServers = existingServers;
225
+ return { config: out, added, updated };
226
+ }
227
+
228
+ function ensureMcpConfig(target, options = {}) {
229
+ const resolvedTarget = path.resolve(target);
230
+ const { text, source } = loadTemplate();
231
+ const rendered = renderTemplate(text, {
232
+ PROJECT_PATH: resolvedTarget,
233
+ MCP_HOST: options.host || DEFAULT_MCP_HOST,
234
+ ODAI_MCP_SERVER_SCRIPT: resolveMcpServerScript(resolvedTarget),
235
+ });
236
+ let incoming;
237
+ try {
238
+ incoming = JSON.parse(rendered);
239
+ } catch (err) {
240
+ throw new Error(`MCP template rendered invalid JSON: ${err.message}`);
241
+ }
242
+
243
+ const mcpPath = path.join(resolvedTarget, ".mcp.json");
244
+ const existing = readJsonFile(mcpPath);
245
+ const merged = mergeMcpConfig(existing, incoming, { reset: !!options.reset, target: resolvedTarget });
246
+ const changed = writeJsonIfChanged(mcpPath, merged.config);
247
+ return { path: mcpPath, changed, template: source, added: merged.added, updated: merged.updated };
248
+ }
249
+
250
+ function ensureClaudeSettings(target) {
251
+ const settingsPath = path.join(path.resolve(target), ".claude", "settings.json");
252
+ let settings = {};
253
+ if (fs.existsSync(settingsPath)) {
254
+ settings = readJsonFile(settingsPath);
255
+ }
256
+ if (settings.enableAllProjectMcpServers === true) {
257
+ return { path: settingsPath, changed: false };
258
+ }
259
+ settings.enableAllProjectMcpServers = true;
260
+ const changed = writeJsonIfChanged(settingsPath, settings);
261
+ return { path: settingsPath, changed };
262
+ }
263
+
264
+ function secretConfigHome(env = process.env) {
265
+ return env.XDG_CONFIG_HOME ? path.resolve(env.XDG_CONFIG_HOME) : path.join(os.homedir(), ".config");
266
+ }
267
+
268
+ function mcpTokenPath(serverId = DEFAULT_CLOUD_SERVER_ID, env = process.env) {
269
+ const override = env.ODAI_MCP_TOKEN_PATH;
270
+ if (override) return path.resolve(override);
271
+ return path.join(secretConfigHome(env), "secrets", "mcp", `${serverId}.token`);
272
+ }
273
+
274
+ function hasToken(filePath) {
275
+ try {
276
+ return fs.existsSync(filePath) && fs.statSync(filePath).size > 0;
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+
282
+ function writeTokenFile(filePath, tokenPayload) {
283
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
284
+ const payload = {
285
+ ...tokenPayload,
286
+ updated_at: new Date().toISOString(),
287
+ };
288
+ fs.writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 });
289
+ try { fs.chmodSync(filePath, 0o600); } catch {}
290
+ }
291
+
292
+ function writeMcpAuthTokenFromAccount(accessToken, options = {}) {
293
+ const clean = String(accessToken || "").trim();
294
+ if (!clean) throw new Error("0dai account access token required");
295
+ const tokenPath = options.tokenPath || mcpTokenPath(DEFAULT_CLOUD_SERVER_ID);
296
+ writeTokenFile(tokenPath, {
297
+ server_id: DEFAULT_CLOUD_SERVER_ID,
298
+ server_name: DEFAULT_CLOUD_SERVER_NAME,
299
+ mcp_host: options.host || DEFAULT_MCP_HOST,
300
+ token_type: "Bearer",
301
+ access_token: clean,
302
+ email: options.email || "",
303
+ plan: options.plan || "",
304
+ source: options.source || "0dai-account-token",
305
+ });
306
+ return { status: "written", tokenPath };
307
+ }
308
+
309
+ function base64Url(buffer) {
310
+ return Buffer.from(buffer).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
311
+ }
312
+
313
+ function randomState() {
314
+ return base64Url(crypto.randomBytes(24));
315
+ }
316
+
317
+ function codeChallenge(verifier) {
318
+ return base64Url(crypto.createHash("sha256").update(verifier).digest());
319
+ }
320
+
321
+ function isInteractive() {
322
+ return !!(process.stdin && process.stdin.isTTY && process.stdout && process.stdout.isTTY);
323
+ }
324
+
325
+ function openBrowser(url) {
326
+ const platform = process.platform;
327
+ let command;
328
+ let args;
329
+ if (platform === "darwin") {
330
+ command = "open"; args = [url];
331
+ } else if (platform === "win32") {
332
+ command = "cmd"; args = ["/c", "start", "", url];
333
+ } else {
334
+ command = "xdg-open"; args = [url];
335
+ }
336
+ const result = spawnSync(command, args, { stdio: "ignore", timeout: 5000 });
337
+ return result.status === 0;
338
+ }
339
+
340
+ function requestJson(url, options = {}) {
341
+ return new Promise((resolve, reject) => {
342
+ const parsed = new URL(url);
343
+ const mod = parsed.protocol === "https:" ? https : http;
344
+ const body = options.body || null;
345
+ const req = mod.request({
346
+ method: options.method || (body ? "POST" : "GET"),
347
+ hostname: parsed.hostname,
348
+ port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
349
+ path: parsed.pathname + parsed.search,
350
+ headers: {
351
+ "Accept": "application/json",
352
+ "User-Agent": "0dai-cli-mcp-auth",
353
+ ...(body ? {
354
+ "Content-Type": options.contentType || "application/x-www-form-urlencoded",
355
+ "Content-Length": Buffer.byteLength(body),
356
+ } : {}),
357
+ ...(options.headers || {}),
358
+ },
359
+ timeout: options.timeout || 15000,
360
+ }, (res) => {
361
+ const chunks = [];
362
+ res.on("data", (chunk) => chunks.push(chunk));
363
+ res.on("end", () => {
364
+ const raw = Buffer.concat(chunks).toString("utf8");
365
+ if (res.statusCode < 200 || res.statusCode >= 300) {
366
+ reject(new Error(`HTTP ${res.statusCode}: ${raw.slice(0, 200)}`));
367
+ return;
368
+ }
369
+ try {
370
+ resolve(raw ? JSON.parse(raw) : {});
371
+ } catch (err) {
372
+ reject(new Error(`invalid JSON from ${url}: ${err.message}`));
373
+ }
374
+ });
375
+ });
376
+ req.on("error", reject);
377
+ req.on("timeout", () => {
378
+ req.destroy(new Error(`request timed out: ${url}`));
379
+ });
380
+ if (body) req.write(body);
381
+ req.end();
382
+ });
383
+ }
384
+
385
+ async function discoverOAuth(host) {
386
+ const candidates = [
387
+ `${host}/.well-known/oauth-authorization-server`,
388
+ `${host}/.well-known/oauth-authorization-server/mcp`,
389
+ ];
390
+ for (const url of candidates) {
391
+ try {
392
+ const meta = await requestJson(url, { timeout: 5000 });
393
+ if (meta && meta.authorization_endpoint && meta.token_endpoint) return meta;
394
+ } catch {}
395
+ }
396
+ return {
397
+ authorization_endpoint: `${host}/.well-known/oauth-authorize`,
398
+ token_endpoint: `${host}/.well-known/oauth-token`,
399
+ };
400
+ }
401
+
402
+ async function startCallbackServer(timeoutMs, expectedState) {
403
+ let codeResolve;
404
+ let codeReject;
405
+ const codePromise = new Promise((resolve, reject) => {
406
+ codeResolve = resolve;
407
+ codeReject = reject;
408
+ });
409
+ const server = http.createServer((req, res) => {
410
+ const url = new URL(req.url || "/", "http://127.0.0.1");
411
+ const code = url.searchParams.get("code");
412
+ const state = url.searchParams.get("state");
413
+ if (state !== expectedState) {
414
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
415
+ res.end("State mismatch. You can close this tab and rerun 0dai init --reset.");
416
+ return;
417
+ }
418
+ if (!code) {
419
+ res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
420
+ res.end("No OAuth code was returned.");
421
+ return;
422
+ }
423
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
424
+ res.end("0dai MCP authentication complete. You can close this tab.");
425
+ codeResolve(code);
426
+ });
427
+ const port = await new Promise((resolve, reject) => {
428
+ server.on("error", reject);
429
+ server.listen(0, "127.0.0.1", () => resolve(server.address().port));
430
+ });
431
+ setTimeout(() => codeReject(new Error("MCP OAuth timed out")), timeoutMs).unref();
432
+ return { server, port, codePromise };
433
+ }
434
+
435
+ async function exchangeCodeForToken(tokenEndpoint, params) {
436
+ const body = new URLSearchParams(params).toString();
437
+ return requestJson(tokenEndpoint, { method: "POST", body, timeout: 15000 });
438
+ }
439
+
440
+ async function runOAuthHandshake(options) {
441
+ const host = options.host || DEFAULT_MCP_HOST;
442
+ const state = randomState();
443
+ const verifier = base64Url(crypto.randomBytes(32));
444
+ const challenge = codeChallenge(verifier);
445
+ const callback = await startCallbackServer(options.timeoutMs || DEFAULT_TIMEOUT_MS, state);
446
+ const redirectUri = `http://127.0.0.1:${callback.port}/callback`;
447
+
448
+ try {
449
+ const metadata = await discoverOAuth(host);
450
+ const authUrl = new URL(metadata.authorization_endpoint);
451
+ authUrl.searchParams.set("response_type", "code");
452
+ authUrl.searchParams.set("client_id", options.clientId || "0dai-cli");
453
+ authUrl.searchParams.set("redirect_uri", redirectUri);
454
+ authUrl.searchParams.set("state", state);
455
+ authUrl.searchParams.set("code_challenge", challenge);
456
+ authUrl.searchParams.set("code_challenge_method", "S256");
457
+ authUrl.searchParams.set("resource", `${host}/mcp`);
458
+ if (options.scope) authUrl.searchParams.set("scope", options.scope);
459
+
460
+ if (typeof options.onAuthUrl === "function") options.onAuthUrl(authUrl.toString());
461
+ if (options.openBrowser !== false) {
462
+ const opened = openBrowser(authUrl.toString());
463
+ if (!opened && typeof options.onBrowserOpenFailed === "function") {
464
+ options.onBrowserOpenFailed(authUrl.toString());
465
+ }
466
+ }
467
+
468
+ const code = await callback.codePromise;
469
+ const token = await exchangeCodeForToken(metadata.token_endpoint, {
470
+ grant_type: "authorization_code",
471
+ code,
472
+ redirect_uri: redirectUri,
473
+ client_id: options.clientId || "0dai-cli",
474
+ code_verifier: verifier,
475
+ });
476
+ return token;
477
+ } finally {
478
+ callback.server.close();
479
+ }
480
+ }
481
+
482
+ async function authenticateCloudMcp(options = {}) {
483
+ const tokenPath = options.tokenPath || mcpTokenPath(DEFAULT_CLOUD_SERVER_ID);
484
+ if (!options.reset && hasToken(tokenPath)) {
485
+ return { status: "preserved", tokenPath };
486
+ }
487
+ const envToken = process.env.ODAI_MCP_AUTH_TOKEN || process.env.CLAUDE_AI_0DAI_TOKEN;
488
+ if (envToken) {
489
+ writeTokenFile(tokenPath, {
490
+ server_id: DEFAULT_CLOUD_SERVER_ID,
491
+ server_name: DEFAULT_CLOUD_SERVER_NAME,
492
+ mcp_host: options.host || DEFAULT_MCP_HOST,
493
+ token_type: "Bearer",
494
+ access_token: envToken,
495
+ source: "env",
496
+ });
497
+ return { status: "written", tokenPath };
498
+ }
499
+ if (process.env.ODAI_MCP_AUTH_MOCK === "1") {
500
+ writeTokenFile(tokenPath, {
501
+ server_id: DEFAULT_CLOUD_SERVER_ID,
502
+ server_name: DEFAULT_CLOUD_SERVER_NAME,
503
+ mcp_host: options.host || DEFAULT_MCP_HOST,
504
+ token_type: "Bearer",
505
+ access_token: "mock-mcp-token",
506
+ source: "mock",
507
+ });
508
+ return { status: "written", tokenPath };
509
+ }
510
+ const token = await runOAuthHandshake(options);
511
+ const accessToken = token.access_token || token.token;
512
+ if (!accessToken) throw new Error("OAuth token response did not include access_token");
513
+ writeTokenFile(tokenPath, {
514
+ server_id: DEFAULT_CLOUD_SERVER_ID,
515
+ server_name: DEFAULT_CLOUD_SERVER_NAME,
516
+ mcp_host: options.host || DEFAULT_MCP_HOST,
517
+ ...token,
518
+ });
519
+ return { status: "written", tokenPath };
520
+ }
521
+
522
+ async function shouldAuthenticateCloud(args, options = {}) {
523
+ if (options.noAuth) return false;
524
+ if (process.env.ODAI_MCP_AUTH_TOKEN || process.env.CLAUDE_AI_0DAI_TOKEN || process.env.ODAI_MCP_AUTH_MOCK === "1") {
525
+ return true;
526
+ }
527
+ if (options.yes) return true;
528
+ if (!isInteractive()) return false;
529
+ try {
530
+ const prompts = require("@clack/prompts");
531
+ const answer = await prompts.confirm({
532
+ message: "Authenticate 0dai cloud MCP now?",
533
+ initialValue: true,
534
+ });
535
+ if (prompts.isCancel(answer)) return false;
536
+ return answer !== false;
537
+ } catch {
538
+ return true;
539
+ }
540
+ }
541
+
542
+ async function bootstrapMcp(target, args = [], logger = console.log) {
543
+ const options = parseMcpArgs(args);
544
+ const result = {
545
+ ok: true,
546
+ config: null,
547
+ settings: null,
548
+ auth: null,
549
+ warnings: [],
550
+ };
551
+
552
+ try {
553
+ result.config = ensureMcpConfig(target, { host: options.host, reset: options.reset });
554
+ } catch (err) {
555
+ result.ok = false;
556
+ result.warnings.push(err.message);
557
+ return result;
558
+ }
559
+
560
+ try {
561
+ result.settings = ensureClaudeSettings(target);
562
+ } catch (err) {
563
+ result.warnings.push(`Claude settings MCP enablement skipped: ${err.message}`);
564
+ }
565
+
566
+ const doAuth = await shouldAuthenticateCloud(args, { noAuth: options.noAuth, yes: options.yes });
567
+ if (!doAuth) {
568
+ result.auth = { status: options.noAuth ? "disabled" : "skipped", tokenPath: mcpTokenPath(DEFAULT_CLOUD_SERVER_ID) };
569
+ return result;
570
+ }
571
+
572
+ try {
573
+ if (logger) logger("Authenticating 0dai cloud MCP — browser will open");
574
+ result.auth = await authenticateCloudMcp({
575
+ host: options.host,
576
+ reset: options.reset,
577
+ timeoutMs: DEFAULT_TIMEOUT_MS,
578
+ onAuthUrl: (url) => {
579
+ if (logger) logger(`Open this URL if the browser does not launch: ${url}`);
580
+ },
581
+ onBrowserOpenFailed: () => {},
582
+ });
583
+ } catch (err) {
584
+ result.warnings.push(`cloud MCP auth skipped: ${err.message}`);
585
+ result.auth = { status: "failed", tokenPath: mcpTokenPath(DEFAULT_CLOUD_SERVER_ID), error: err.message };
586
+ }
587
+
588
+ return result;
589
+ }
590
+
591
+ module.exports = {
592
+ DEFAULT_MCP_HOST,
593
+ DEFAULT_CLOUD_SERVER_ID,
594
+ DEFAULT_CLOUD_SERVER_NAME,
595
+ authenticateCloudMcp,
596
+ bootstrapMcp,
597
+ ensureClaudeSettings,
598
+ ensureMcpConfig,
599
+ loadTemplate,
600
+ mcpTokenPath,
601
+ mergeMcpConfig,
602
+ normalizeMcpHost,
603
+ parseMcpArgs,
604
+ renderTemplate,
605
+ runOAuthHandshake,
606
+ writeMcpAuthTokenFromAccount,
607
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Utilities for model table availability and footer rendering.
3
+ */
4
+ "use strict";
5
+
6
+ const { execFileSync } = require("child_process");
7
+ const { SUPPORTED_CLIS } = require("./constants");
8
+
9
+ function buildSupportedCliIndex(supportedClis = SUPPORTED_CLIS) {
10
+ const byName = new Map();
11
+ for (const cli of supportedClis) {
12
+ byName.set(cli.name, cli);
13
+ }
14
+ return byName;
15
+ }
16
+
17
+ function probeInstalledCliNames(supportedClis = SUPPORTED_CLIS, probe = execFileSync) {
18
+ const installed = new Set();
19
+ for (const cli of supportedClis) {
20
+ try {
21
+ probe(cli.bin, ["--version"], {
22
+ stdio: "ignore",
23
+ });
24
+ installed.add(cli.name);
25
+ } catch {}
26
+ }
27
+ return installed;
28
+ }
29
+
30
+ function summarizeModelAvailability(models, supportedClis = SUPPORTED_CLIS, installedCliNames = probeInstalledCliNames(supportedClis)) {
31
+ const supportedByName = buildSupportedCliIndex(supportedClis);
32
+ const referencedCliNames = [];
33
+ const seen = new Set();
34
+
35
+ for (const model of models) {
36
+ if (!model || typeof model.cli !== "string" || seen.has(model.cli)) continue;
37
+ seen.add(model.cli);
38
+ referencedCliNames.push(model.cli);
39
+ }
40
+
41
+ const installed = [];
42
+ const missing = [];
43
+ const unsupported = [];
44
+
45
+ for (const cliName of referencedCliNames) {
46
+ if (!supportedByName.has(cliName)) {
47
+ unsupported.push(cliName);
48
+ continue;
49
+ }
50
+ if (installedCliNames.has(cliName)) installed.push(cliName);
51
+ else missing.push(cliName);
52
+ }
53
+
54
+ return {
55
+ referencedCliNames,
56
+ installedCliNames: installed,
57
+ missingCliNames: missing,
58
+ unsupportedCliNames: unsupported,
59
+ totalModels: models.length,
60
+ availableModels: models.filter((model) => installedCliNames.has(model.cli)),
61
+ };
62
+ }
63
+
64
+ function formatAvailableFooter(summary, visibleCount) {
65
+ const installed = summary.installedCliNames.length ? summary.installedCliNames.join(", ") : "none";
66
+ const missing = summary.missingCliNames.length ? summary.missingCliNames.join(", ") : "none";
67
+ const unsupported = summary.unsupportedCliNames.length ? `; unsupported CLIs: ${summary.unsupportedCliNames.join(", ")}` : "";
68
+ const count = typeof visibleCount === "number" ? visibleCount : summary.availableModels.length;
69
+ return `--available: ${count} of ${summary.totalModels} models (installed CLIs: ${installed}; missing CLIs: ${missing}${unsupported})`;
70
+ }
71
+
72
+ module.exports = {
73
+ buildSupportedCliIndex,
74
+ probeInstalledCliNames,
75
+ summarizeModelAvailability,
76
+ formatAvailableFooter,
77
+ };