@0dai-dev/cli 4.3.5 → 4.3.7

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 (79) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +214 -40
  3. package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
  4. package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
  5. package/lib/ai/registry/mcp-catalog.json +98 -0
  6. package/lib/commands/auth.js +55 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/detect.js +10 -4
  9. package/lib/commands/doctor.js +545 -26
  10. package/lib/commands/experience.js +40 -5
  11. package/lib/commands/export.js +73 -0
  12. package/lib/commands/feedback.js +157 -15
  13. package/lib/commands/gh.js +26 -0
  14. package/lib/commands/graph.js +9 -4
  15. package/lib/commands/heatmap.js +1 -1
  16. package/lib/commands/init.js +222 -30
  17. package/lib/commands/mcp.js +129 -21
  18. package/lib/commands/models.js +138 -41
  19. package/lib/commands/provider.js +30 -59
  20. package/lib/commands/quota.js +1 -1
  21. package/lib/commands/receipt.js +1 -1
  22. package/lib/commands/run.js +18 -7
  23. package/lib/commands/runner.js +31 -1
  24. package/lib/commands/status.js +44 -11
  25. package/lib/commands/swarm.js +130 -12
  26. package/lib/commands/trust.js +286 -0
  27. package/lib/commands/update.js +184 -38
  28. package/lib/commands/usage.js +1 -1
  29. package/lib/commands/validate.js +32 -3
  30. package/lib/commands/vault.js +46 -9
  31. package/lib/python/__init__.py +0 -0
  32. package/lib/python/agent_quotas.py +525 -0
  33. package/lib/python/anomaly_alert.py +397 -0
  34. package/lib/python/anti_pattern_detector.py +799 -0
  35. package/lib/python/auth.py +443 -0
  36. package/lib/python/capi_profile_guard.py +477 -0
  37. package/lib/python/compliance_report.py +581 -0
  38. package/lib/python/drift_detector.py +388 -0
  39. package/lib/python/experience_pipeline.py +1130 -0
  40. package/lib/python/graph.py +19 -0
  41. package/lib/python/graph_core.py +293 -0
  42. package/lib/python/graph_io.py +179 -0
  43. package/lib/python/graph_legacy.py +2052 -0
  44. package/lib/python/graph_legacy_helpers.py +221 -0
  45. package/lib/python/graph_outcomes_core.py +85 -0
  46. package/lib/python/graph_queries.py +171 -0
  47. package/lib/python/graph_slice.py +198 -0
  48. package/lib/python/graph_slicer.py +576 -0
  49. package/lib/python/graph_slicer_cli.py +60 -0
  50. package/lib/python/graph_validation.py +64 -0
  51. package/lib/python/heatmap.py +934 -0
  52. package/lib/python/json_utils.py +193 -0
  53. package/lib/python/mcp_exposure_check.py +247 -0
  54. package/lib/python/model_router.py +1434 -0
  55. package/lib/python/project_manager.py +621 -0
  56. package/lib/python/provider_profiles.py +1618 -0
  57. package/lib/python/provider_registry.py +1211 -0
  58. package/lib/python/provider_registry_cli.py +125 -0
  59. package/lib/python/receipt_png.py +727 -0
  60. package/lib/python/structural_memory.py +325 -0
  61. package/lib/python/swarm_cost.py +177 -0
  62. package/lib/python/usage_ledger.py +569 -0
  63. package/lib/scripts/mcp_tier_config.py +240 -0
  64. package/lib/shared.js +97 -14
  65. package/lib/tui/index.mjs +35174 -0
  66. package/lib/utils/activation_telemetry.js +230 -11
  67. package/lib/utils/constants.js +7 -1
  68. package/lib/utils/export-bundler.js +285 -0
  69. package/lib/utils/identity.js +198 -1
  70. package/lib/utils/mcp-auth.js +81 -15
  71. package/lib/utils/plan.js +1 -1
  72. package/lib/vault/index.js +19 -3
  73. package/lib/vault/storage.js +21 -2
  74. package/lib/wizard.js +5 -2
  75. package/package.json +9 -3
  76. package/scripts/build-python-bundle.js +106 -0
  77. package/scripts/build-tui.js +14 -1
  78. package/scripts/harvest_experience.py +523 -0
  79. package/scripts/postinstall.js +15 -9
@@ -1,6 +1,31 @@
1
1
  "use strict";
2
2
  const shared = require("../shared");
3
- const { log, T, R, D, fs, path, spawnSync, findRepoScript, SUPPORTED_CLIS, recordExperienceEvent } = shared;
3
+ const {
4
+ log,
5
+ T,
6
+ R,
7
+ D,
8
+ fs,
9
+ path,
10
+ spawnSync,
11
+ findRepoScript,
12
+ repoScriptCandidates,
13
+ resolvePythonScript,
14
+ SUPPORTED_CLIS,
15
+ cliDisplayName,
16
+ recordExperienceEvent,
17
+ } = shared;
18
+
19
+ const LOCAL_LAYER_CHECKS = Object.freeze([
20
+ ["ai/VERSION", "ai/VERSION", "error"],
21
+ ["ai/manifest/project.yaml", "ai/manifest/project.yaml", "error"],
22
+ ["ai/manifest/discovery.json", "ai/manifest/discovery.json", "warn"],
23
+ ["ai/manifest/commands.yaml", "ai/manifest/commands.yaml", "warn"],
24
+ ["ai/manifest/current_state.json", "ai/manifest/current_state.json", "warn"],
25
+ ["ai/manifest/current_task.json", "ai/manifest/current_task.json", "warn"],
26
+ [".claude/settings.json", ".claude/settings.json", "warn"],
27
+ ["AGENTS.md", "AGENTS.md", "warn"],
28
+ ]);
4
29
 
5
30
  function nodePtyProbe() {
6
31
  try {
@@ -16,7 +41,7 @@ function nodePtyProbe() {
16
41
  name: "node-pty",
17
42
  present: false,
18
43
  sev: "warn",
19
- hint: "install node-pty for terminal sessions: npm i -g node-pty",
44
+ hint: "install node-pty in the 0dai CLI package for terminal sessions",
20
45
  };
21
46
  }
22
47
  }
@@ -39,9 +64,449 @@ function ghostAuthStatus(home = process.env.HOME || process.env.USERPROFILE || "
39
64
  };
40
65
  }
41
66
 
67
+ function _ghProbeEnv(env, configDir = "", unsetConfigDir = false) {
68
+ const next = { ...process.env, ...(env || {}) };
69
+ if (unsetConfigDir) delete next.GH_CONFIG_DIR;
70
+ else if (configDir) next.GH_CONFIG_DIR = configDir;
71
+ return next;
72
+ }
73
+
74
+ function _probeGhIdentity(spec, options = {}) {
75
+ const runner = options.spawnSync || spawnSync;
76
+ const env = options.env || process.env;
77
+ const timeout = options.timeout || 5000;
78
+ const configDir = spec.configDir || "";
79
+ const expectedLogin = spec.expectedLogin || "";
80
+ const required = !!spec.required;
81
+ const check = {
82
+ name: spec.name,
83
+ role: spec.role,
84
+ ok: false,
85
+ sev: "info",
86
+ status: "unknown",
87
+ login: "",
88
+ expected_login: expectedLogin,
89
+ config_dir: configDir,
90
+ configured: true,
91
+ hint: "",
92
+ };
93
+
94
+ if (configDir && !fs.existsSync(configDir)) {
95
+ check.configured = false;
96
+ check.status = "missing_config";
97
+ check.sev = required ? "warn" : "info";
98
+ check.hint = required
99
+ ? `configured gh identity dir is missing: ${configDir}`
100
+ : `not configured: ${configDir}`;
101
+ return check;
102
+ }
103
+
104
+ let result;
105
+ try {
106
+ result = runner("gh", ["api", "user", "--jq", ".login"], {
107
+ stdio: ["ignore", "pipe", "pipe"],
108
+ encoding: "utf8",
109
+ timeout,
110
+ env: _ghProbeEnv(env, configDir, !!spec.unsetConfigDir),
111
+ });
112
+ } catch (error) {
113
+ check.status = "probe_failed";
114
+ check.sev = configDir || required ? "warn" : "info";
115
+ check.hint = String(error && error.message ? error.message : error).split("\n").slice(-1)[0] || "gh probe failed";
116
+ return check;
117
+ }
118
+
119
+ if (result && result.error && result.error.code === "ENOENT") {
120
+ check.status = "gh_missing";
121
+ check.sev = configDir || required ? "warn" : "info";
122
+ check.hint = "gh CLI not installed";
123
+ return check;
124
+ }
125
+
126
+ const exitCode = result && typeof result.status === "number" ? result.status : null;
127
+ const login = String((result && result.stdout) || "").trim();
128
+ if (exitCode !== 0 || !login) {
129
+ check.status = "not_authenticated";
130
+ check.sev = configDir || required ? "warn" : "info";
131
+ check.hint = configDir
132
+ ? `token invalid or expired for ${configDir}; re-auth ${expectedLogin || "the configured review identity"} with gh`
133
+ : "default gh CLI identity is not authenticated; run: gh auth login";
134
+ if (exitCode !== null) check.exit_code = exitCode;
135
+ return check;
136
+ }
137
+
138
+ check.login = login;
139
+ if (expectedLogin && login !== expectedLogin) {
140
+ check.status = "login_mismatch";
141
+ check.sev = "warn";
142
+ check.hint = `resolves to '${login}', expected '${expectedLogin}'; re-auth the fenced gh config dir`;
143
+ return check;
144
+ }
145
+
146
+ check.ok = true;
147
+ check.sev = "ok";
148
+ check.status = "ok";
149
+ check.hint = expectedLogin
150
+ ? `authenticated as expected ${expectedLogin}`
151
+ : `authenticated as ${login}`;
152
+ return check;
153
+ }
154
+
155
+ function collectGitHubIdentityPayload(target, options = {}) {
156
+ const env = options.env || process.env;
157
+ const primaryDir = env.OPERATOR_GH_REVIEW_CONFIG_DIR || path.join(target, ".0dai", "gh-lead-0dai-review");
158
+ const primaryLogin = env.OPERATOR_GH_REVIEW_EXPECTED_LOGIN || "pq1-dev";
159
+ const fallbackDir = env.OPERATOR_GH_REVIEW_FALLBACK_CONFIG_DIR || "";
160
+ const fallbackLogin = env.OPERATOR_GH_REVIEW_FALLBACK_LOGIN || "";
161
+ const legacyPq1Dir = "/root/.config/gh-pq1";
162
+ const specs = [
163
+ {
164
+ role: "default",
165
+ name: "default gh identity",
166
+ unsetConfigDir: true,
167
+ },
168
+ ];
169
+ const seenDirs = new Set();
170
+
171
+ function addConfig(role, name, configDir, expectedLogin, required = false) {
172
+ if (!configDir || seenDirs.has(configDir)) return;
173
+ seenDirs.add(configDir);
174
+ specs.push({ role, name, configDir, expectedLogin, required });
175
+ }
176
+
177
+ addConfig(
178
+ "env-gh-config",
179
+ "GH_CONFIG_DIR identity",
180
+ env.GH_CONFIG_DIR || "",
181
+ env.GH_CONFIG_DIR === legacyPq1Dir ? "pq1-dev" : "",
182
+ true,
183
+ );
184
+ addConfig(
185
+ "review-primary",
186
+ "pq1-dev review identity",
187
+ primaryDir,
188
+ primaryLogin,
189
+ Boolean(env.OPERATOR_GH_REVIEW_CONFIG_DIR),
190
+ );
191
+ if (fallbackDir || fallbackLogin) {
192
+ addConfig("review-fallback", "fallback review identity", fallbackDir, fallbackLogin, true);
193
+ }
194
+ if (legacyPq1Dir !== primaryDir && (env.GH_CONFIG_DIR === legacyPq1Dir || fs.existsSync(legacyPq1Dir))) {
195
+ addConfig("review-legacy-pq1", "legacy pq1-dev review identity", legacyPq1Dir, "pq1-dev", true);
196
+ }
197
+
198
+ const checks = specs.map((spec) => _probeGhIdentity(spec, options));
199
+ const warnings = checks.filter((check) => check.sev === "warn" && !check.ok).length;
200
+ return {
201
+ status: warnings ? "warn" : "ok",
202
+ warnings,
203
+ checks,
204
+ next_step: warnings
205
+ ? "Rotate or remove stale fenced gh review credentials; default gh identity is separate from pq1-dev review identity."
206
+ : "GitHub identity diagnostics are non-blocking.",
207
+ };
208
+ }
209
+
210
+ function collectLayerChecks(target) {
211
+ return LOCAL_LAYER_CHECKS.map(([name, relPath, missingSev]) => {
212
+ const fullPath = path.join(target, relPath);
213
+ const present = fs.existsSync(fullPath);
214
+ return {
215
+ name,
216
+ ok: present,
217
+ sev: present ? "ok" : missingSev,
218
+ hint: present ? "present" : `missing: ${fullPath}`,
219
+ };
220
+ });
221
+ }
222
+
223
+ function parseReleaseVersion(value) {
224
+ const match = String(value || "").trim().match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
225
+ if (!match) return null;
226
+ return match.slice(1, 4).map((part) => Number(part));
227
+ }
228
+
229
+ function compareReleaseVersions(a, b) {
230
+ const left = parseReleaseVersion(a);
231
+ const right = parseReleaseVersion(b);
232
+ if (!left || !right) return null;
233
+ return left[0] - right[0] || left[1] - right[1] || left[2] - right[2];
234
+ }
235
+
236
+ function collectLayerVersionFreshness(target, cliVersion = shared.VERSION) {
237
+ const versionPath = path.join(target, "ai", "VERSION");
238
+ let current = "";
239
+ try { current = fs.readFileSync(versionPath, "utf8").trim(); } catch {}
240
+ const cmp = compareReleaseVersions(current, cliVersion);
241
+ if (!current) {
242
+ return {
243
+ status: "missing",
244
+ ok: false,
245
+ sev: "error",
246
+ current: "",
247
+ expected: cliVersion,
248
+ hint: `missing: ${versionPath}`,
249
+ };
250
+ }
251
+ if (cmp === null) {
252
+ return {
253
+ status: "unknown",
254
+ ok: true,
255
+ sev: "info",
256
+ current,
257
+ expected: cliVersion,
258
+ hint: "ai/VERSION is not a release semver; skipping freshness check",
259
+ };
260
+ }
261
+ if (cmp < 0) {
262
+ return {
263
+ status: "stale",
264
+ ok: false,
265
+ sev: "warn",
266
+ current,
267
+ expected: cliVersion,
268
+ hint: `ai/VERSION stale: ${current} -> ${cliVersion}; run: 0dai sync`,
269
+ };
270
+ }
271
+ if (cmp > 0) {
272
+ return {
273
+ status: "newer",
274
+ ok: true,
275
+ sev: "info",
276
+ current,
277
+ expected: cliVersion,
278
+ hint: `project layer ${current} is newer than CLI ${cliVersion}; not recommending sync`,
279
+ };
280
+ }
281
+ return {
282
+ status: "ok",
283
+ ok: true,
284
+ sev: "ok",
285
+ current,
286
+ expected: cliVersion,
287
+ hint: "ai/VERSION matches installed CLI",
288
+ };
289
+ }
290
+
291
+ function collectDriftPayload(target, options = {}) {
292
+ const scriptName = "drift_detector.py";
293
+ const candidates = options.candidates || repoScriptCandidates(target, scriptName);
294
+ const findScript = options.findRepoScript || resolvePythonScript;
295
+ const script = Object.prototype.hasOwnProperty.call(options, "script")
296
+ ? options.script
297
+ : findScript(target, scriptName);
298
+
299
+ if (!script) {
300
+ return {
301
+ status: "unavailable",
302
+ available: false,
303
+ helper: scriptName,
304
+ reason: "helper_not_found",
305
+ message: "drift detector unavailable in this environment",
306
+ checked_paths: candidates,
307
+ next_step: "Run from a 0dai repo checkout or sync/install the managed project helper scripts, then rerun: 0dai doctor --drift --json",
308
+ };
309
+ }
310
+
311
+ const runner = options.spawnSync || spawnSync;
312
+ const result = runner("python3", [script, "report", "--target", target], {
313
+ stdio: ["ignore", "pipe", "pipe"],
314
+ encoding: "utf8",
315
+ timeout: options.timeout || 15000,
316
+ });
317
+ const exitCode = typeof result.status === "number" ? result.status : null;
318
+ const stdout = String(result.stdout || "").trim();
319
+ const stderr = String(result.stderr || "").trim();
320
+ return {
321
+ status: exitCode === 0 ? "ok" : "error",
322
+ available: true,
323
+ helper: scriptName,
324
+ script,
325
+ command: `python3 ${script} report --target ${target}`,
326
+ exit_code: exitCode,
327
+ report: stdout,
328
+ ...(stderr ? { stderr } : {}),
329
+ };
330
+ }
331
+
332
+ function _detectAgentKind(env = process.env) {
333
+ return env.ODAI_AGENT_KIND || env.ODAI_AGENT || env.CLAUDE_AGENT_KIND || "default";
334
+ }
335
+
336
+ function _readMcpConfigSummary(target) {
337
+ const configPath = path.join(target, ".mcp.json");
338
+ const summary = {
339
+ path: "",
340
+ configured: false,
341
+ servers: [],
342
+ odai_servers: [],
343
+ };
344
+ if (!fs.existsSync(configPath)) return summary;
345
+ summary.path = configPath;
346
+ try {
347
+ const data = JSON.parse(fs.readFileSync(configPath, "utf8"));
348
+ const servers = data && data.mcpServers && typeof data.mcpServers === "object"
349
+ ? Object.keys(data.mcpServers)
350
+ : [];
351
+ summary.servers = servers;
352
+ summary.odai_servers = servers.filter((name) => name.toLowerCase().includes("0dai"));
353
+ summary.configured = summary.odai_servers.length > 0;
354
+ } catch {
355
+ summary.configured = false;
356
+ }
357
+ return summary;
358
+ }
359
+
360
+ function _exposureNextStep(payload) {
361
+ if (!payload.configured) return "run: 0dai sync --target .";
362
+ if (payload.status === "green") return "0dai MCP tools exposed";
363
+ const agent = String(payload.agent || "agent").toLowerCase();
364
+ const reconnect = agent.includes("codex")
365
+ ? "restart/reconnect Codex CLI"
366
+ : agent.includes("claude")
367
+ ? "restart/reconnect Claude Code"
368
+ : "restart/reconnect active agent CLI";
369
+ if (payload.status === "yellow" || payload.status === "red") {
370
+ return `${reconnect}; fallback: 0dai mcp doctor --json`;
371
+ }
372
+ return `set ODAI_EXPOSED_MCP_TOOLS; fallback: 0dai mcp doctor --json`;
373
+ }
374
+
375
+ function collectMcpExposurePayload(target, options = {}) {
376
+ const env = options.env || process.env;
377
+ const agent = options.agent || _detectAgentKind(env);
378
+ const config = _readMcpConfigSummary(target);
379
+ const scriptName = "mcp_exposure_check.py";
380
+ const script = Object.prototype.hasOwnProperty.call(options, "script")
381
+ ? options.script
382
+ : resolvePythonScript(target, scriptName);
383
+ const observedRaw = String(env.ODAI_EXPOSED_MCP_TOOLS || "").trim();
384
+ const payload = {
385
+ agent,
386
+ status: "unknown",
387
+ configured: config.configured,
388
+ config_path: config.path,
389
+ servers: config.servers,
390
+ odai_servers: config.odai_servers,
391
+ observed_count: 0,
392
+ expected_tool_count: 0,
393
+ missing_required: [],
394
+ missing_recommended: [],
395
+ helper: script || "",
396
+ reason: observedRaw ? "" : "runtime_tool_list_not_reported",
397
+ };
398
+
399
+ if (!script) {
400
+ payload.reason = "helper_not_found";
401
+ payload.next_step = _exposureNextStep(payload);
402
+ return payload;
403
+ }
404
+
405
+ const runner = options.spawnSync || spawnSync;
406
+ let result;
407
+ try {
408
+ result = runner("python3", [script, "--agent", agent], {
409
+ stdio: ["ignore", "pipe", "pipe"],
410
+ encoding: "utf8",
411
+ timeout: options.timeout || 5000,
412
+ env: { ...process.env, ...env },
413
+ });
414
+ } catch (error) {
415
+ payload.reason = "helper_failed";
416
+ payload.stderr = String(error && error.message ? error.message : error).split("\n").slice(-1)[0] || "";
417
+ payload.next_step = _exposureNextStep(payload);
418
+ return payload;
419
+ }
420
+ const stdout = String(result.stdout || "").trim();
421
+ const stderr = String(result.stderr || "").trim();
422
+ if (result.status !== 0 && !stdout) {
423
+ payload.reason = "helper_failed";
424
+ payload.stderr = stderr.split("\n").slice(-1)[0] || "";
425
+ payload.next_step = _exposureNextStep(payload);
426
+ return payload;
427
+ }
428
+ try {
429
+ const checked = JSON.parse(stdout);
430
+ Object.assign(payload, {
431
+ status: checked.status || "unknown",
432
+ blocking: !!checked.blocking,
433
+ anomaly_type: checked.anomaly_type || "",
434
+ observed_count: Number(checked.observed_count || 0),
435
+ expected_tool_count: Number(checked.expected_tool_count || 0),
436
+ required: checked.required || [],
437
+ recommended: checked.recommended || [],
438
+ missing_required: checked.missing_required || [],
439
+ missing_recommended: checked.missing_recommended || [],
440
+ observed_required: checked.observed_required || [],
441
+ observed_recommended: checked.observed_recommended || [],
442
+ contract_tool_count: Number(checked.contract_tool_count || 0),
443
+ tier_config_tool_count: Number(checked.tier_config_tool_count || 0),
444
+ contract_tier_count_match: checked.contract_tier_count_match,
445
+ reason: checked.status === "unknown" && !observedRaw ? "runtime_tool_list_not_reported" : "",
446
+ });
447
+ } catch {
448
+ payload.reason = "helper_output_unparseable";
449
+ }
450
+ payload.next_step = _exposureNextStep(payload);
451
+ return payload;
452
+ }
453
+
454
+ function formatMcpExposureLine(payload) {
455
+ const serverText = payload.configured
456
+ ? `configured: ${payload.odai_servers.join(", ")}`
457
+ : "not configured";
458
+ const observed = Number(payload.observed_count || 0);
459
+ const expected = Number(payload.expected_tool_count || 0);
460
+ const observedText = observed ? `${observed}/${expected || "?"} observed` : "not reported by runtime";
461
+ return `${payload.status} (${payload.agent}) — ${serverText}; ${observedText}`;
462
+ }
463
+
464
+ function printDriftReport(target, options = {}) {
465
+ const payload = collectDriftPayload(target, options);
466
+ console.log("\n drift report:");
467
+ if (!payload.available) {
468
+ console.log(` ${D}${payload.message}${R}`);
469
+ console.log(` ${D}reason: ${payload.helper} was not found in the script lookup paths${R}`);
470
+ console.log(` ${D}checked:${R}`);
471
+ for (const candidate of payload.checked_paths) console.log(` ${D}- ${candidate}${R}`);
472
+ console.log(` ${D}next: ${payload.next_step}${R}`);
473
+ return payload;
474
+ }
475
+ if (payload.report) console.log(payload.report);
476
+ else console.log(` ${D}drift detector returned no output${R}`);
477
+ if (payload.status === "error") {
478
+ console.log(` ${D}drift detector exited with status ${payload.exit_code ?? "unknown"}${R}`);
479
+ if (payload.stderr) console.log(` ${D}${payload.stderr}${R}`);
480
+ }
481
+ return payload;
482
+ }
483
+
484
+ function collectDoctorPayload(target, options = {}) {
485
+ const payload = {
486
+ binary: "node",
487
+ binary_version: shared.VERSION,
488
+ checks: collectLayerChecks(target),
489
+ layer_version: collectLayerVersionFreshness(target),
490
+ mcp_exposure: collectMcpExposurePayload(target, options.mcpExposureOptions || {}),
491
+ node_pty: nodePtyProbe(),
492
+ };
493
+ if (options.drift) {
494
+ payload.drift = collectDriftPayload(target, options.driftOptions || {});
495
+ }
496
+ return payload;
497
+ }
498
+
42
499
  function cmdDoctor(target, options = {}) {
43
500
  const ai = path.join(target, "ai");
44
501
  if (!fs.existsSync(ai)) { log("No 0dai config found. Run: 0dai init"); return; }
502
+ if (options.json) {
503
+ const payload = collectDoctorPayload(target, {
504
+ drift: options.drift,
505
+ driftOptions: options.driftOptions,
506
+ });
507
+ console.log(JSON.stringify(payload, null, 2));
508
+ return payload;
509
+ }
45
510
  let v = "?", stack = "generic";
46
511
  try { v = fs.readFileSync(path.join(ai, "VERSION"), "utf8").trim(); } catch {}
47
512
  try { stack = JSON.parse(fs.readFileSync(path.join(ai, "manifest", "discovery.json"), "utf8")).stack || "generic"; } catch {}
@@ -52,15 +517,6 @@ function cmdDoctor(target, options = {}) {
52
517
  const R2 = process.stdout.isTTY ? "\x1b[0m" : "";
53
518
 
54
519
  // --- ai/ layer checks ---
55
- const layerChecks = {
56
- "ai/VERSION": { path: path.join(ai, "VERSION"), sev: "error" },
57
- "ai/manifest/project.yaml": { path: path.join(ai, "manifest", "project.yaml"), sev: "error" },
58
- "ai/manifest/commands.yaml": { path: path.join(ai, "manifest", "commands.yaml"), sev: "warn" },
59
- "ai/manifest/discovery.json": { path: path.join(ai, "manifest", "discovery.json"),sev: "warn" },
60
- ".claude/settings.json": { path: path.join(target, ".claude", "settings.json"), sev: "warn" },
61
- "AGENTS.md": { path: path.join(target, "AGENTS.md"), sev: "warn" },
62
- };
63
-
64
520
  // --- credentials checklist ---
65
521
  // Detect subscription-based auth (not just env API keys)
66
522
  const { execFileSync: _execFile } = require("child_process");
@@ -118,14 +574,19 @@ function cmdDoctor(target, options = {}) {
118
574
 
119
575
  const missingConfigs = [];
120
576
  console.log(" ai/ layer:");
121
- for (const [name, { path: p, sev }] of Object.entries(layerChecks)) {
122
- const exists = fs.existsSync(p);
123
- if (!exists) {
124
- sev === "error" ? errors++ : warnings++;
125
- if (sev === "warn") missingConfigs.push(name);
577
+ for (const check of collectLayerChecks(target)) {
578
+ if (!check.ok) {
579
+ check.sev === "error" ? errors++ : warnings++;
580
+ if (check.sev === "warn") missingConfigs.push(check.name);
126
581
  }
127
- const mark = exists ? `${G}ok${R2}` : sev === "error" ? `${E}MISSING${R2}` : `${W}missing${R2}`;
128
- console.log(` ${mark.padEnd(22)} ${name}`);
582
+ const mark = check.ok ? `${G}ok${R2}` : check.sev === "error" ? `${E}MISSING${R2}` : `${W}missing${R2}`;
583
+ console.log(` ${mark.padEnd(22)} ${check.name}`);
584
+ }
585
+ const layerVersion = collectLayerVersionFreshness(target);
586
+ if (layerVersion.status === "stale") {
587
+ warnings++;
588
+ console.log(` ${W}warn${R2}`.padEnd(22) + ` ai/VERSION stale ${D}${layerVersion.current} → ${layerVersion.expected}${R2}`);
589
+ console.log(` ${D}→ run: 0dai sync${R2}`);
129
590
  }
130
591
  // Explain WHY native configs are missing and what to do
131
592
  if (missingConfigs.length > 0) {
@@ -146,9 +607,48 @@ function cmdDoctor(target, options = {}) {
146
607
  console.log(` ${mark.padEnd(22)} ${c.name}${hint}`);
147
608
  }
148
609
 
610
+ const githubIdentities = collectGitHubIdentityPayload(target, options.githubIdentityOptions || {});
611
+ console.log("\n GitHub identities:");
612
+ for (const check of githubIdentities.checks) {
613
+ if (!check.ok && check.sev === "warn") warnings++;
614
+ const mark = check.ok
615
+ ? `${G}ok${R2}`
616
+ : check.sev === "warn"
617
+ ? `${W}warn${R2}`
618
+ : `${D}not set${R2}`;
619
+ const expected = check.expected_login ? ` ${D}(expected ${check.expected_login})${R2}` : "";
620
+ const detail = check.ok && check.login ? ` ${D}(${check.login})${R2}` : "";
621
+ const hint = check.ok ? "" : `\n ${D}→ ${check.hint}${R2}`;
622
+ console.log(` ${mark.padEnd(22)} ${check.name}${expected}${detail}${hint}`);
623
+ }
624
+
625
+ const mcpExposure = collectMcpExposurePayload(target);
626
+ console.log("\n MCP exposure:");
627
+ const mcpMark = mcpExposure.status === "green"
628
+ ? `${G}ok${R2}`
629
+ : mcpExposure.status === "red"
630
+ ? `${E}missing${R2}`
631
+ : mcpExposure.status === "yellow"
632
+ ? `${W}partial${R2}`
633
+ : `${D}unknown${R2}`;
634
+ console.log(` ${mcpMark.padEnd(22)} ${formatMcpExposureLine(mcpExposure)}`);
635
+ if (mcpExposure.next_step && mcpExposure.status !== "green") {
636
+ console.log(` ${D}→ ${mcpExposure.next_step}${R2}`);
637
+ }
638
+
639
+ const nodePty = nodePtyProbe();
640
+ if (!nodePty.present && nodePty.sev === "warn") warnings++;
641
+ console.log("\n terminal sessions:");
642
+ const terminalMark = nodePty.sev === "ok"
643
+ ? `${G}ok${R2}`
644
+ : nodePty.sev === "warn"
645
+ ? `${W}warn${R2}`
646
+ : `${D}${nodePty.sev}${R2}`;
647
+ console.log(` ${terminalMark.padEnd(22)} ${nodePty.name} ${D}(${nodePty.hint})${R2}`);
648
+
149
649
  console.log("\n provider profiles:");
150
650
  try {
151
- const providerScript = findRepoScript(target, "provider_profiles.py");
651
+ const providerScript = resolvePythonScript(target, "provider_profiles.py");
152
652
  if (!providerScript) {
153
653
  console.log(` ${D}—${R2} unavailable in this environment`);
154
654
  } else {
@@ -181,6 +681,7 @@ function cmdDoctor(target, options = {}) {
181
681
  let updatesAvailable = 0;
182
682
  console.log("\n agent CLIs:");
183
683
  for (const cli of SUPPORTED_CLIS) {
684
+ const label = cliDisplayName(cli);
184
685
  let installed = false, ver = null;
185
686
  try {
186
687
  const out = _ef2(cli.bin, ["--version"], { timeout: 8000 }).toString().trim();
@@ -199,13 +700,13 @@ function cmdDoctor(target, options = {}) {
199
700
  }
200
701
  if (latest && ver && latest !== ver) {
201
702
  updatesAvailable++;
202
- console.log(` ${W}update${R2} ${cli.name} ${D}${ver} → ${latest}${R2}`);
703
+ console.log(` ${W}update${R2} ${label} ${D}${ver} → ${latest}${R2}`);
203
704
  console.log(` ${D}→ ${cli.install}${R2}`);
204
705
  } else {
205
- console.log(` ${G}ok${R2} ${cli.name}${ver ? ` ${D}v${ver}${R2}` : ""}`);
706
+ console.log(` ${G}ok${R2} ${label}${ver ? ` ${D}v${ver}${R2}` : ""}`);
206
707
  }
207
708
  } else {
208
- console.log(` ${D}—${R2} ${cli.name} ${D}not installed${R2}`);
709
+ console.log(` ${D}—${R2} ${label} ${D}not installed${R2}`);
209
710
  console.log(` ${D}→ ${cli.install}${cli.altAuth ? ` (or ${cli.altAuth})` : ""}${R2}`);
210
711
  }
211
712
  }
@@ -242,10 +743,15 @@ function cmdDoctor(target, options = {}) {
242
743
  });
243
744
  if (errors && !options.suppressExitCode) process.exitCode = 1;
244
745
 
245
- // Drift summary (lightweight — full report via --drift flag)
246
- if (!options.drift) {
746
+ if (options.drift) {
747
+ const drift = printDriftReport(target, options.driftOptions || {});
748
+ if (drift.status === "error" && typeof drift.exit_code === "number" && drift.exit_code !== 0) {
749
+ process.exitCode = drift.exit_code;
750
+ }
751
+ } else {
752
+ // Drift summary (lightweight — full report via --drift flag)
247
753
  try {
248
- const ds = findRepoScript(target, "drift_detector.py");
754
+ const ds = resolvePythonScript(target, "drift_detector.py");
249
755
  if (ds) {
250
756
  const dr = spawnSync("python3", [ds, "report", "--target", target],
251
757
  { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000 });
@@ -275,4 +781,17 @@ function cmdDoctor(target, options = {}) {
275
781
  }
276
782
  }
277
783
 
278
- module.exports = { cmdDoctor, ghostAuthStatus, nodePtyProbe };
784
+ module.exports = {
785
+ cmdDoctor,
786
+ ghostAuthStatus,
787
+ nodePtyProbe,
788
+ collectDoctorPayload,
789
+ collectLayerChecks,
790
+ collectLayerVersionFreshness,
791
+ compareReleaseVersions,
792
+ collectDriftPayload,
793
+ printDriftReport,
794
+ collectGitHubIdentityPayload,
795
+ collectMcpExposurePayload,
796
+ formatMcpExposureLine,
797
+ };