@0dai-dev/cli 4.3.6 → 4.3.8
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/README.md +12 -11
- package/bin/0dai.js +133 -33
- package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
- package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
- package/lib/ai/registry/mcp-catalog.json +98 -0
- package/lib/commands/auth.js +2 -1
- package/lib/commands/compliance.js +1 -1
- package/lib/commands/doctor.js +707 -12
- package/lib/commands/experience.js +40 -5
- package/lib/commands/feedback.js +157 -15
- package/lib/commands/gh.js +26 -0
- package/lib/commands/graph.js +9 -4
- package/lib/commands/heatmap.js +1 -1
- package/lib/commands/init.js +298 -27
- package/lib/commands/mcp.js +111 -33
- package/lib/commands/models.js +138 -41
- package/lib/commands/play.js +20 -4
- package/lib/commands/provider.js +30 -59
- package/lib/commands/quota.js +1 -1
- package/lib/commands/receipt.js +1 -1
- package/lib/commands/run.js +14 -6
- package/lib/commands/runner.js +31 -1
- package/lib/commands/status.js +176 -11
- package/lib/commands/swarm.js +130 -12
- package/lib/commands/trust.js +1 -1
- package/lib/commands/update.js +184 -38
- package/lib/commands/usage.js +1 -1
- package/lib/commands/validate.js +32 -3
- package/lib/commands/vault.js +43 -8
- package/lib/python/__init__.py +0 -0
- package/lib/python/agent_quotas.py +525 -0
- package/lib/python/anomaly_alert.py +397 -0
- package/lib/python/anti_pattern_detector.py +799 -0
- package/lib/python/auth.py +443 -0
- package/lib/python/capi_profile_guard.py +477 -0
- package/lib/python/compliance_report.py +581 -0
- package/lib/python/drift_detector.py +388 -0
- package/lib/python/experience_pipeline.py +1130 -0
- package/lib/python/graph.py +19 -0
- package/lib/python/graph_core.py +293 -0
- package/lib/python/graph_io.py +179 -0
- package/lib/python/graph_legacy.py +2052 -0
- package/lib/python/graph_legacy_helpers.py +221 -0
- package/lib/python/graph_outcomes_core.py +85 -0
- package/lib/python/graph_queries.py +171 -0
- package/lib/python/graph_slice.py +198 -0
- package/lib/python/graph_slicer.py +576 -0
- package/lib/python/graph_slicer_cli.py +60 -0
- package/lib/python/graph_validation.py +64 -0
- package/lib/python/heatmap.py +943 -0
- package/lib/python/json_utils.py +193 -0
- package/lib/python/mcp_exposure_check.py +247 -0
- package/lib/python/model_router.py +1434 -0
- package/lib/python/project_manager.py +621 -0
- package/lib/python/provider_profiles.py +1618 -0
- package/lib/python/provider_registry.py +1211 -0
- package/lib/python/provider_registry_cli.py +125 -0
- package/lib/python/receipt_png.py +727 -0
- package/lib/python/structural_memory.py +325 -0
- package/lib/python/swarm_cost.py +177 -0
- package/lib/python/usage_ledger.py +569 -0
- package/lib/scripts/mcp_tier_config.py +240 -0
- package/lib/shared.js +96 -12
- package/lib/tui/index.mjs +35174 -0
- package/lib/utils/activation_telemetry.js +1 -4
- package/lib/utils/constants.js +7 -1
- package/lib/utils/identity.js +184 -0
- package/lib/utils/mcp-auth.js +81 -15
- package/lib/utils/plan.js +1 -1
- package/lib/vault/index.js +19 -3
- package/lib/vault/storage.js +21 -2
- package/lib/wizard.js +5 -2
- package/package.json +9 -3
- package/scripts/build-python-bundle.js +106 -0
- package/scripts/build-tui.js +14 -1
- package/scripts/harvest_experience.py +523 -0
- package/scripts/postinstall.js +15 -9
package/lib/commands/doctor.js
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
const shared = require("../shared");
|
|
3
|
-
const {
|
|
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;
|
|
4
18
|
|
|
5
19
|
const LOCAL_LAYER_CHECKS = Object.freeze([
|
|
6
20
|
["ai/VERSION", "ai/VERSION", "error"],
|
|
7
21
|
["ai/manifest/project.yaml", "ai/manifest/project.yaml", "error"],
|
|
8
22
|
["ai/manifest/discovery.json", "ai/manifest/discovery.json", "warn"],
|
|
9
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"],
|
|
10
26
|
[".claude/settings.json", ".claude/settings.json", "warn"],
|
|
11
27
|
["AGENTS.md", "AGENTS.md", "warn"],
|
|
12
28
|
]);
|
|
@@ -25,7 +41,7 @@ function nodePtyProbe() {
|
|
|
25
41
|
name: "node-pty",
|
|
26
42
|
present: false,
|
|
27
43
|
sev: "warn",
|
|
28
|
-
hint: "install node-pty
|
|
44
|
+
hint: "install node-pty in the 0dai CLI package for terminal sessions",
|
|
29
45
|
};
|
|
30
46
|
}
|
|
31
47
|
}
|
|
@@ -48,6 +64,149 @@ function ghostAuthStatus(home = process.env.HOME || process.env.USERPROFILE || "
|
|
|
48
64
|
};
|
|
49
65
|
}
|
|
50
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
|
+
|
|
51
210
|
function collectLayerChecks(target) {
|
|
52
211
|
return LOCAL_LAYER_CHECKS.map(([name, relPath, missingSev]) => {
|
|
53
212
|
const fullPath = path.join(target, relPath);
|
|
@@ -61,19 +220,489 @@ function collectLayerChecks(target) {
|
|
|
61
220
|
});
|
|
62
221
|
}
|
|
63
222
|
|
|
64
|
-
function
|
|
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
|
+
// --- security mode (0dai doctor --security) -------------------------------
|
|
485
|
+
//
|
|
486
|
+
// Static, code-derived disclosure of the data boundary so a reviewer can see,
|
|
487
|
+
// without reading source, what leaves the box and which routes need operator
|
|
488
|
+
// authZ. Three sections:
|
|
489
|
+
// 1. egress — network endpoints the CLI may call (reused verbatim from
|
|
490
|
+
// trust.js _buildColdPayload so there is ONE egress list).
|
|
491
|
+
// 2. local sinks — files the CLI writes locally (NOT egress) + the host-side
|
|
492
|
+
// secret vault dir, live-detected and labeled.
|
|
493
|
+
// 3. authZ — deny-by-default operator-gate summary, enforced server-side
|
|
494
|
+
// (web/src/lib/auth.ts requireOperator); described, not probed.
|
|
495
|
+
//
|
|
496
|
+
// This mode performs NO network calls and reads NO secret values — it only
|
|
497
|
+
// reports paths and presence. Run `0dai trust` for the live enforced scope.
|
|
498
|
+
|
|
499
|
+
// authZ summary mirrors web/src/lib/auth.ts. Tiers are described, not probed —
|
|
500
|
+
// the CLI cannot reach into the deployment's route guards at runtime.
|
|
501
|
+
const AUTHZ_SUMMARY = Object.freeze({
|
|
502
|
+
enforced_by: "web/src/lib/auth.ts (Next.js API routes)",
|
|
503
|
+
default: "deny",
|
|
504
|
+
note:
|
|
505
|
+
"Operator-state routes require authN + authZ. With ODAI_OPERATOR_EMAILS unset, "
|
|
506
|
+
+ "every authenticated user is denied (403) — default-deny, not default-allow.",
|
|
507
|
+
tiers: Object.freeze([
|
|
508
|
+
{
|
|
509
|
+
level: "operator",
|
|
510
|
+
guard: "requireOperator",
|
|
511
|
+
rule: "authN + email in ODAI_OPERATOR_EMAILS allowlist; unset allowlist = deny all",
|
|
512
|
+
routes: "swarm/*, cli/[action], team",
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
level: "authenticated",
|
|
516
|
+
guard: "requireAuth",
|
|
517
|
+
rule: "any valid token (401 if absent/invalid)",
|
|
518
|
+
routes: "plugins",
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
level: "auth-forward",
|
|
522
|
+
guard: "forward",
|
|
523
|
+
rule: "web layer forwards any Authorization header to the Python API; downstream authZ enforced there (verify)",
|
|
524
|
+
routes: "admin/* (adminForwardAuth), events",
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
level: "open",
|
|
528
|
+
guard: "none",
|
|
529
|
+
rule: "intentionally unauthenticated public endpoints",
|
|
530
|
+
routes: "contact, public/*",
|
|
531
|
+
},
|
|
532
|
+
]),
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
function _secretVaultProbe(home = process.env.HOME || process.env.USERPROFILE || "") {
|
|
536
|
+
// Host-side secret vault written by scripts/telegram_secrets.py and
|
|
537
|
+
// scripts/secrets_decrypt.sh — NOT by this CLI. We only report presence.
|
|
538
|
+
const dir = home ? path.join(home, ".config", "secrets") : "";
|
|
539
|
+
const present = Boolean(dir && fs.existsSync(dir));
|
|
540
|
+
return {
|
|
541
|
+
name: "~/.config/secrets",
|
|
542
|
+
path: dir,
|
|
543
|
+
present,
|
|
544
|
+
written_by_cli: false,
|
|
545
|
+
note: present
|
|
546
|
+
? "present on this host; populated by server-side secret sync, not the CLI"
|
|
547
|
+
: "absent on this host; populated by server-side secret sync when configured (verify)",
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function _mcpEgressEntry(target, env = process.env) {
|
|
552
|
+
// MCP cloud transport host — DEFAULT_MCP_HOST from lib/utils/mcp-auth.js,
|
|
553
|
+
// overridable via MCP_HOST / ODAI_MCP_HOST. The local 0dai + filesystem MCP
|
|
554
|
+
// servers run as on-box subprocesses (no egress); only the cloud server
|
|
555
|
+
// ("claude_ai_0dai") talks to the network. We also surface the configured
|
|
556
|
+
// server names from .mcp.json if present (no values, just names).
|
|
557
|
+
const host = env.MCP_HOST || env.ODAI_MCP_HOST || "https://mcp.0dai.dev";
|
|
558
|
+
let configured = [];
|
|
559
|
+
try {
|
|
560
|
+
configured = _readMcpConfigSummary(target).servers || [];
|
|
561
|
+
} catch { configured = []; }
|
|
562
|
+
return {
|
|
563
|
+
endpoint: `${host}/mcp`,
|
|
564
|
+
trigger: "MCP cloud server (claude_ai_0dai) when an agent CLI connects to it",
|
|
565
|
+
data: "MCP tool calls over an authenticated session; local 0dai + filesystem servers are on-box subprocesses (no egress)",
|
|
566
|
+
configured_servers: configured,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function collectSecurityPayload(target, options = {}) {
|
|
571
|
+
const home = options.home || process.env.HOME || process.env.USERPROFILE || "";
|
|
572
|
+
const env = options.env || process.env;
|
|
573
|
+
let egress;
|
|
574
|
+
try {
|
|
575
|
+
egress = require("./trust")._buildColdPayload(target).egress;
|
|
576
|
+
} catch {
|
|
577
|
+
egress = { status: "unavailable", note: "trust.js egress facts not loadable" };
|
|
578
|
+
}
|
|
579
|
+
// Augment the trust.js subset with the two surfaces the issue names that are
|
|
580
|
+
// not in that subset: the MCP cloud transport and the activation telemetry
|
|
581
|
+
// POST. Both are derived from real CLI code (mcp-auth.js, auth.js).
|
|
582
|
+
egress = JSON.parse(JSON.stringify(egress));
|
|
583
|
+
egress.note =
|
|
584
|
+
"Egress surfaces relevant to this disclosure (MCP, telemetry, on-command "
|
|
585
|
+
+ "pushes). Not an exhaustive list of every /v1/* endpoint the CLI can reach.";
|
|
586
|
+
egress.mcp = _mcpEgressEntry(target, env);
|
|
587
|
+
if (Array.isArray(egress.on_explicit_command_only)) {
|
|
588
|
+
egress.on_explicit_command_only.push({
|
|
589
|
+
endpoint: "POST /v1/events (api.0dai.dev)",
|
|
590
|
+
trigger: "0dai activate free (best-effort, on successful activation)",
|
|
591
|
+
data: "activation event {event, timestamp, path, source}; no file contents, no secrets",
|
|
592
|
+
});
|
|
593
|
+
}
|
|
65
594
|
return {
|
|
595
|
+
target,
|
|
596
|
+
generated_at: new Date().toISOString().replace(/\.\d+Z$/, "Z"),
|
|
597
|
+
note:
|
|
598
|
+
"Static, code-derived data boundary. This mode makes NO network calls and "
|
|
599
|
+
+ "reads NO secret values. Run `0dai trust` for the live enforced scope.",
|
|
600
|
+
egress,
|
|
601
|
+
local_sinks: [
|
|
602
|
+
{
|
|
603
|
+
name: "ai/meta/telemetry/activation.jsonl",
|
|
604
|
+
kind: "local file write",
|
|
605
|
+
note: "activation funnel timing written to disk; a summary event is POSTed to /v1/events only on `0dai activate free` (see egress)",
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
name: "ai/meta/telemetry/*.jsonl",
|
|
609
|
+
kind: "local file write",
|
|
610
|
+
note: "MCP tool usage, operator-ack, and other local telemetry; written to disk, not auto-POSTed",
|
|
611
|
+
},
|
|
612
|
+
],
|
|
613
|
+
secret_vault: _secretVaultProbe(home),
|
|
614
|
+
authz: AUTHZ_SUMMARY,
|
|
615
|
+
docs: "docs/data-boundary.md",
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function printSecurityReport(target, options = {}) {
|
|
620
|
+
const payload = collectSecurityPayload(target, options);
|
|
621
|
+
const W = process.stdout.isTTY ? "\x1b[33m" : "";
|
|
622
|
+
const G = process.stdout.isTTY ? "\x1b[32m" : "";
|
|
623
|
+
const R2 = process.stdout.isTTY ? "\x1b[0m" : "";
|
|
624
|
+
|
|
625
|
+
console.log("\n 0dai doctor --security — data boundary");
|
|
626
|
+
console.log(` ${D}${payload.note}${R2}`);
|
|
627
|
+
|
|
628
|
+
console.log("\n egress (network):");
|
|
629
|
+
const eg = payload.egress || {};
|
|
630
|
+
if (Array.isArray(eg.unconditional)) {
|
|
631
|
+
console.log(" unconditional (every CLI run):");
|
|
632
|
+
for (const ep of eg.unconditional) {
|
|
633
|
+
console.log(` * ${ep.endpoint}`);
|
|
634
|
+
console.log(` ${D}trigger: ${ep.trigger}${R2}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (eg.mcp) {
|
|
638
|
+
console.log(" MCP:");
|
|
639
|
+
console.log(` * ${eg.mcp.endpoint}`);
|
|
640
|
+
console.log(` ${D}trigger: ${eg.mcp.trigger}${R2}`);
|
|
641
|
+
if (Array.isArray(eg.mcp.configured_servers) && eg.mcp.configured_servers.length) {
|
|
642
|
+
console.log(` ${D}configured servers: ${eg.mcp.configured_servers.join(", ")}${R2}`);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (Array.isArray(eg.on_explicit_command_only)) {
|
|
646
|
+
console.log(" on explicit command only:");
|
|
647
|
+
for (const ep of eg.on_explicit_command_only) {
|
|
648
|
+
console.log(` * ${ep.endpoint} ${D}(${ep.trigger})${R2}`);
|
|
649
|
+
console.log(` ${D}data: ${ep.data}${R2}`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (!eg.unconditional && !eg.on_explicit_command_only) {
|
|
653
|
+
console.log(` ${D}${eg.note || "egress facts unavailable"}${R2}`);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
console.log("\n local sinks (no egress):");
|
|
657
|
+
for (const sink of payload.local_sinks) {
|
|
658
|
+
console.log(` * ${sink.name} ${D}(${sink.note})${R2}`);
|
|
659
|
+
}
|
|
660
|
+
const vault = payload.secret_vault;
|
|
661
|
+
const vaultMark = vault.present ? `${G}present${R2}` : `${D}absent${R2}`;
|
|
662
|
+
console.log(` ${vaultMark} ${vault.name} ${D}(${vault.note})${R2}`);
|
|
663
|
+
|
|
664
|
+
console.log("\n authZ (enforced server-side, deny-by-default):");
|
|
665
|
+
console.log(` ${W}default: ${payload.authz.default}${R2} ${D}— ${payload.authz.note}${R2}`);
|
|
666
|
+
for (const tier of payload.authz.tiers) {
|
|
667
|
+
console.log(` * ${tier.level.padEnd(13)} ${tier.guard} ${D}→ ${tier.routes}${R2}`);
|
|
668
|
+
console.log(` ${D}${tier.rule}${R2}`);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
console.log(`\n ${D}Full doc: ${payload.docs} Live enforced scope: 0dai trust${R2}`);
|
|
672
|
+
return payload;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function collectDoctorPayload(target, options = {}) {
|
|
676
|
+
const payload = {
|
|
66
677
|
binary: "node",
|
|
67
678
|
binary_version: shared.VERSION,
|
|
68
679
|
checks: collectLayerChecks(target),
|
|
680
|
+
layer_version: collectLayerVersionFreshness(target),
|
|
681
|
+
mcp_exposure: collectMcpExposurePayload(target, options.mcpExposureOptions || {}),
|
|
682
|
+
node_pty: nodePtyProbe(),
|
|
69
683
|
};
|
|
684
|
+
if (options.drift) {
|
|
685
|
+
payload.drift = collectDriftPayload(target, options.driftOptions || {});
|
|
686
|
+
}
|
|
687
|
+
return payload;
|
|
70
688
|
}
|
|
71
689
|
|
|
72
690
|
function cmdDoctor(target, options = {}) {
|
|
73
691
|
const ai = path.join(target, "ai");
|
|
74
692
|
if (!fs.existsSync(ai)) { log("No 0dai config found. Run: 0dai init"); return; }
|
|
693
|
+
if (options.security) {
|
|
694
|
+
if (options.json) {
|
|
695
|
+
const payload = collectSecurityPayload(target, options.securityOptions || {});
|
|
696
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
697
|
+
return payload;
|
|
698
|
+
}
|
|
699
|
+
return printSecurityReport(target, options.securityOptions || {});
|
|
700
|
+
}
|
|
75
701
|
if (options.json) {
|
|
76
|
-
const payload = collectDoctorPayload(target
|
|
702
|
+
const payload = collectDoctorPayload(target, {
|
|
703
|
+
drift: options.drift,
|
|
704
|
+
driftOptions: options.driftOptions,
|
|
705
|
+
});
|
|
77
706
|
console.log(JSON.stringify(payload, null, 2));
|
|
78
707
|
return payload;
|
|
79
708
|
}
|
|
@@ -152,6 +781,12 @@ function cmdDoctor(target, options = {}) {
|
|
|
152
781
|
const mark = check.ok ? `${G}ok${R2}` : check.sev === "error" ? `${E}MISSING${R2}` : `${W}missing${R2}`;
|
|
153
782
|
console.log(` ${mark.padEnd(22)} ${check.name}`);
|
|
154
783
|
}
|
|
784
|
+
const layerVersion = collectLayerVersionFreshness(target);
|
|
785
|
+
if (layerVersion.status === "stale") {
|
|
786
|
+
warnings++;
|
|
787
|
+
console.log(` ${W}warn${R2}`.padEnd(22) + ` ai/VERSION stale ${D}${layerVersion.current} → ${layerVersion.expected}${R2}`);
|
|
788
|
+
console.log(` ${D}→ run: 0dai sync${R2}`);
|
|
789
|
+
}
|
|
155
790
|
// Explain WHY native configs are missing and what to do
|
|
156
791
|
if (missingConfigs.length > 0) {
|
|
157
792
|
const hasDiscovery = fs.existsSync(path.join(ai, "manifest", "discovery.json"));
|
|
@@ -171,9 +806,48 @@ function cmdDoctor(target, options = {}) {
|
|
|
171
806
|
console.log(` ${mark.padEnd(22)} ${c.name}${hint}`);
|
|
172
807
|
}
|
|
173
808
|
|
|
809
|
+
const githubIdentities = collectGitHubIdentityPayload(target, options.githubIdentityOptions || {});
|
|
810
|
+
console.log("\n GitHub identities:");
|
|
811
|
+
for (const check of githubIdentities.checks) {
|
|
812
|
+
if (!check.ok && check.sev === "warn") warnings++;
|
|
813
|
+
const mark = check.ok
|
|
814
|
+
? `${G}ok${R2}`
|
|
815
|
+
: check.sev === "warn"
|
|
816
|
+
? `${W}warn${R2}`
|
|
817
|
+
: `${D}not set${R2}`;
|
|
818
|
+
const expected = check.expected_login ? ` ${D}(expected ${check.expected_login})${R2}` : "";
|
|
819
|
+
const detail = check.ok && check.login ? ` ${D}(${check.login})${R2}` : "";
|
|
820
|
+
const hint = check.ok ? "" : `\n ${D}→ ${check.hint}${R2}`;
|
|
821
|
+
console.log(` ${mark.padEnd(22)} ${check.name}${expected}${detail}${hint}`);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const mcpExposure = collectMcpExposurePayload(target);
|
|
825
|
+
console.log("\n MCP exposure:");
|
|
826
|
+
const mcpMark = mcpExposure.status === "green"
|
|
827
|
+
? `${G}ok${R2}`
|
|
828
|
+
: mcpExposure.status === "red"
|
|
829
|
+
? `${E}missing${R2}`
|
|
830
|
+
: mcpExposure.status === "yellow"
|
|
831
|
+
? `${W}partial${R2}`
|
|
832
|
+
: `${D}unknown${R2}`;
|
|
833
|
+
console.log(` ${mcpMark.padEnd(22)} ${formatMcpExposureLine(mcpExposure)}`);
|
|
834
|
+
if (mcpExposure.next_step && mcpExposure.status !== "green") {
|
|
835
|
+
console.log(` ${D}→ ${mcpExposure.next_step}${R2}`);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const nodePty = nodePtyProbe();
|
|
839
|
+
if (!nodePty.present && nodePty.sev === "warn") warnings++;
|
|
840
|
+
console.log("\n terminal sessions:");
|
|
841
|
+
const terminalMark = nodePty.sev === "ok"
|
|
842
|
+
? `${G}ok${R2}`
|
|
843
|
+
: nodePty.sev === "warn"
|
|
844
|
+
? `${W}warn${R2}`
|
|
845
|
+
: `${D}${nodePty.sev}${R2}`;
|
|
846
|
+
console.log(` ${terminalMark.padEnd(22)} ${nodePty.name} ${D}(${nodePty.hint})${R2}`);
|
|
847
|
+
|
|
174
848
|
console.log("\n provider profiles:");
|
|
175
849
|
try {
|
|
176
|
-
const providerScript =
|
|
850
|
+
const providerScript = resolvePythonScript(target, "provider_profiles.py");
|
|
177
851
|
if (!providerScript) {
|
|
178
852
|
console.log(` ${D}—${R2} unavailable in this environment`);
|
|
179
853
|
} else {
|
|
@@ -206,6 +880,7 @@ function cmdDoctor(target, options = {}) {
|
|
|
206
880
|
let updatesAvailable = 0;
|
|
207
881
|
console.log("\n agent CLIs:");
|
|
208
882
|
for (const cli of SUPPORTED_CLIS) {
|
|
883
|
+
const label = cliDisplayName(cli);
|
|
209
884
|
let installed = false, ver = null;
|
|
210
885
|
try {
|
|
211
886
|
const out = _ef2(cli.bin, ["--version"], { timeout: 8000 }).toString().trim();
|
|
@@ -224,13 +899,13 @@ function cmdDoctor(target, options = {}) {
|
|
|
224
899
|
}
|
|
225
900
|
if (latest && ver && latest !== ver) {
|
|
226
901
|
updatesAvailable++;
|
|
227
|
-
console.log(` ${W}update${R2} ${
|
|
902
|
+
console.log(` ${W}update${R2} ${label} ${D}${ver} → ${latest}${R2}`);
|
|
228
903
|
console.log(` ${D}→ ${cli.install}${R2}`);
|
|
229
904
|
} else {
|
|
230
|
-
console.log(` ${G}ok${R2} ${
|
|
905
|
+
console.log(` ${G}ok${R2} ${label}${ver ? ` ${D}v${ver}${R2}` : ""}`);
|
|
231
906
|
}
|
|
232
907
|
} else {
|
|
233
|
-
console.log(` ${D}—${R2} ${
|
|
908
|
+
console.log(` ${D}—${R2} ${label} ${D}not installed${R2}`);
|
|
234
909
|
console.log(` ${D}→ ${cli.install}${cli.altAuth ? ` (or ${cli.altAuth})` : ""}${R2}`);
|
|
235
910
|
}
|
|
236
911
|
}
|
|
@@ -267,10 +942,15 @@ function cmdDoctor(target, options = {}) {
|
|
|
267
942
|
});
|
|
268
943
|
if (errors && !options.suppressExitCode) process.exitCode = 1;
|
|
269
944
|
|
|
270
|
-
|
|
271
|
-
|
|
945
|
+
if (options.drift) {
|
|
946
|
+
const drift = printDriftReport(target, options.driftOptions || {});
|
|
947
|
+
if (drift.status === "error" && typeof drift.exit_code === "number" && drift.exit_code !== 0) {
|
|
948
|
+
process.exitCode = drift.exit_code;
|
|
949
|
+
}
|
|
950
|
+
} else {
|
|
951
|
+
// Drift summary (lightweight — full report via --drift flag)
|
|
272
952
|
try {
|
|
273
|
-
const ds =
|
|
953
|
+
const ds = resolvePythonScript(target, "drift_detector.py");
|
|
274
954
|
if (ds) {
|
|
275
955
|
const dr = spawnSync("python3", [ds, "report", "--target", target],
|
|
276
956
|
{ stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", timeout: 5000 });
|
|
@@ -300,4 +980,19 @@ function cmdDoctor(target, options = {}) {
|
|
|
300
980
|
}
|
|
301
981
|
}
|
|
302
982
|
|
|
303
|
-
module.exports = {
|
|
983
|
+
module.exports = {
|
|
984
|
+
cmdDoctor,
|
|
985
|
+
ghostAuthStatus,
|
|
986
|
+
nodePtyProbe,
|
|
987
|
+
collectDoctorPayload,
|
|
988
|
+
collectLayerChecks,
|
|
989
|
+
collectLayerVersionFreshness,
|
|
990
|
+
compareReleaseVersions,
|
|
991
|
+
collectDriftPayload,
|
|
992
|
+
printDriftReport,
|
|
993
|
+
collectGitHubIdentityPayload,
|
|
994
|
+
collectMcpExposurePayload,
|
|
995
|
+
formatMcpExposureLine,
|
|
996
|
+
collectSecurityPayload,
|
|
997
|
+
printSecurityReport,
|
|
998
|
+
};
|