superdev-cli 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,519 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Provider detection and readiness engine.
4
+ *
5
+ * READ-ONLY. Bounded. Root-confined. Network-free. It installs, enables,
6
+ * configures, and invokes nothing.
7
+ *
8
+ * Corrections that shape this file:
9
+ * - LIVE STATE FIRST. `claude plugin list --json` (and the Codex / skills
10
+ * equivalents) are the authority; filesystem inspection is a DOCUMENTED
11
+ * FALLBACK, used only when the live listing is unavailable.
12
+ * - FAIL CLOSED. A probe that fails, times out, or returns malformed policy
13
+ * state yields `unknown` / `policy-state-unavailable` - never "not blocked",
14
+ * never a silent ready. An explicit `enabled: false` is respected.
15
+ * - "CLI BINARY EXISTS" IS NEVER "CAPABILITY IS READY". A binary is one
16
+ * component; readiness is composed from every required component.
17
+ * - COMPOSITE PROVIDERS. Find Skills (skill + CLI) and envx (CLI + skill) are
18
+ * modelled component-by-component and report `partially-ready` honestly.
19
+ *
20
+ * Exit codes: 0 all providers ready, 1 one or more not ready, 2 usage.
21
+ */
22
+ import { parseArgs } from "node:util";
23
+ import { execFileSync } from "node:child_process";
24
+ import fs from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import { TalksError, isMain, resolveRoot, usageError, writeReport, redactSensitive } from "../../src/model/toolkit.mjs";
28
+ import { PROVIDERS, PROVIDER_IDS, getProvider, CAPABILITY_STATES, DELIVERY, VERSION_POLICY } from "./registry.mjs";
29
+
30
+ const USAGE = `Usage: node detect.mjs <detect|contract> --root <path> [options]
31
+ detect [--provider <id>] [--home <dir>] capability state for each provider
32
+ contract [--provider <id>] the adapter contract (no detection)
33
+ --json / --out <file>`;
34
+
35
+ const PROBE_TIMEOUT_MS = 5000;
36
+ /** Extra state used alongside the documented capability states. */
37
+ export const PARTIALLY_READY = "partially-ready";
38
+ export const POLICY_STATE_UNAVAILABLE = "policy-state-unavailable";
39
+ export const EXTENDED_STATES = [...CAPABILITY_STATES, PARTIALLY_READY, POLICY_STATE_UNAVAILABLE];
40
+
41
+ export function defaultEnv() {
42
+ return {
43
+ home: os.homedir(),
44
+ pathDirs: (process.env.PATH ?? "").split(path.delimiter).filter(Boolean),
45
+ /** Bounded, non-interactive, network-free command probe. Returns stdout or throws. */
46
+ run: (cmd, args) => execFileSync(cmd, args, {
47
+ encoding: "utf8", timeout: PROBE_TIMEOUT_MS, stdio: ["ignore", "pipe", "ignore"],
48
+ maxBuffer: 8 * 1024 * 1024,
49
+ }),
50
+ };
51
+ }
52
+
53
+ // ---- live harness listings (authority) -----------------------------------
54
+
55
+ /** `claude plugin list --json`. Returns { ok, entries } or { ok:false, reason }. */
56
+ export function liveClaudePlugins(env) {
57
+ let raw;
58
+ try { raw = env.run("claude", ["plugin", "list", "--json"]); }
59
+ catch (e) { return { ok: false, reason: `claude plugin list unavailable (${e.code ?? "error"})` }; }
60
+ let parsed;
61
+ try { parsed = JSON.parse(raw); }
62
+ catch { return { ok: false, reason: "claude plugin list returned unparseable JSON" }; }
63
+ const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed.plugins) ? parsed.plugins : null;
64
+ if (!list) return { ok: false, reason: "claude plugin list JSON had an unrecognized shape" };
65
+ return { ok: true, entries: list };
66
+ }
67
+
68
+ export function liveCodexPlugins(env) {
69
+ let raw;
70
+ try { raw = env.run("codex", ["plugin", "list", "--json"]); }
71
+ catch (e) { return { ok: false, reason: `codex plugin list unavailable (${e.code ?? "error"})` }; }
72
+ try {
73
+ const parsed = JSON.parse(raw);
74
+ const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed.plugins) ? parsed.plugins : null;
75
+ return list ? { ok: true, entries: list } : { ok: false, reason: "codex plugin list JSON had an unrecognized shape" };
76
+ } catch { return { ok: false, reason: "codex plugin list returned unparseable JSON" }; }
77
+ }
78
+
79
+ export function liveSkillsList(env) {
80
+ let raw;
81
+ try { raw = env.run("skills", ["ls", "--json"]); }
82
+ catch (e) { return { ok: false, reason: `skills ls unavailable (${e.code ?? "error"})` }; }
83
+ try {
84
+ const parsed = JSON.parse(raw);
85
+ const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed.skills) ? parsed.skills : null;
86
+ return list ? { ok: true, entries: list } : { ok: false, reason: "skills ls JSON had an unrecognized shape" };
87
+ } catch { return { ok: false, reason: "skills ls returned unparseable JSON" }; }
88
+ }
89
+
90
+ /** Find a plugin in a live listing by its `name@marketplace` key.
91
+ * Verified shape (claude 2.1.220): entries carry a single `id` of the form
92
+ * "name@marketplace" plus `enabled`. Separate name/marketplace fields are also
93
+ * accepted so a listing-shape change degrades to no-match, never a false match. */
94
+ function findLiveEntry(entries, key) {
95
+ const [name, marketplace] = key.split("@");
96
+ const matches = entries.filter((e) => {
97
+ if (typeof e?.id === "string" && e.id === key) return true; // exact qualified id
98
+ const n = e?.name ?? e?.plugin;
99
+ if (n === undefined) return false;
100
+ if (n === key) return true; // name already qualified
101
+ if (n !== name) return false;
102
+ const m = e?.marketplace ?? e?.source ?? e?.marketplaceName;
103
+ return marketplace ? (m === undefined || m === marketplace) : true;
104
+ });
105
+ // Duplicate/conflicting records must not be resolved by picking one.
106
+ if (matches.length > 1) return { conflict: true, matches };
107
+ return { conflict: false, entry: matches[0] ?? null };
108
+ }
109
+
110
+ // ---- filesystem fallback -------------------------------------------------
111
+
112
+ function readInstalledPlugins(env) {
113
+ const file = path.join(env.home, ".claude", "plugins", "installed_plugins.json");
114
+ if (!fs.existsSync(file)) return { ok: false, reason: "no plugin store on this machine" };
115
+ try { return { ok: true, store: JSON.parse(fs.readFileSync(file, "utf8")) }; }
116
+ catch { return { ok: false, reason: "the plugin store is unreadable" }; }
117
+ }
118
+
119
+ /** Blocklist read that FAILS CLOSED: an unreadable policy file is not "empty". */
120
+ function readBlocklist(env) {
121
+ const file = path.join(env.home, ".claude", "plugins", "blocklist.json");
122
+ if (!fs.existsSync(file)) return { ok: true, blocked: [] };
123
+ let text;
124
+ try { text = fs.readFileSync(file, "utf8"); }
125
+ catch { return { ok: false, reason: "the plugin policy file is unreadable" }; }
126
+ try {
127
+ const j = JSON.parse(text);
128
+ const blocked = Array.isArray(j) ? j : Array.isArray(j.blocked) ? j.blocked : (j && typeof j === "object") ? Object.keys(j) : null;
129
+ if (!blocked) return { ok: false, reason: "the plugin policy file has an unrecognized shape" };
130
+ return { ok: true, blocked };
131
+ } catch {
132
+ // A parse failure is NEVER "not blocked".
133
+ return { ok: false, reason: "the plugin policy file is malformed" };
134
+ }
135
+ }
136
+
137
+ /** Resolve a CLI on PATH the way the shell does.
138
+ *
139
+ * A candidate must be an executable REGULAR FILE, or a symlink that resolves to
140
+ * one. The obvious-looking check - "exists and has an execute bit" - is wrong,
141
+ * because directories carry execute bits too: a directory named `skills` on
142
+ * PATH would resolve as the skills CLI, and Superdev would report a provider
143
+ * ready that cannot be invoked. Reported-ready-but-unusable is worse than
144
+ * missing, because the user never learns to install it.
145
+ *
146
+ * statSync follows symlinks, so a valid symlink to an executable file passes
147
+ * (npm, pnpm and Homebrew all install CLIs that way) while a broken symlink or
148
+ * a symlink to a directory throws or fails the file check.
149
+ */
150
+ export function whichCli(env, cli) {
151
+ for (const dir of env.pathDirs ?? []) {
152
+ const p = path.join(dir, cli);
153
+ try {
154
+ const st = fs.statSync(p); // follows symlinks; throws if broken
155
+ if (st.isFile() && (st.mode & 0o111)) return p;
156
+ } catch {
157
+ /* absent, broken symlink, or unreadable directory - not a CLI */
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+
163
+ /** Canonical + agent-specific + project-level skill locations, symlink-safe. */
164
+ export function skillLocations(env, rootReal, name) {
165
+ const bases = [
166
+ path.join(env.home, ".agents", "skills"), // canonical agentskills location
167
+ path.join(env.home, ".claude", "skills"), // Claude-specific
168
+ ...(rootReal ? [path.join(rootReal, ".agents", "skills"), path.join(rootReal, ".claude", "skills")] : []),
169
+ ];
170
+ const found = [];
171
+ const seenReal = new Set();
172
+ for (const base of bases) {
173
+ const dir = path.join(base, name);
174
+ let st;
175
+ try { st = fs.lstatSync(dir); } catch { continue; }
176
+ let real = dir;
177
+ if (st.isSymbolicLink()) {
178
+ // Follow ONE level to deduplicate, but never read outside a real directory.
179
+ try { real = fs.realpathSync(dir); } catch { continue; }
180
+ }
181
+ try { if (!fs.statSync(real).isDirectory()) continue; } catch { continue; }
182
+ if (seenReal.has(real)) continue; // equivalent installation, already counted
183
+ seenReal.add(real);
184
+ found.push({ dir, real, viaSymlink: st.isSymbolicLink() });
185
+ }
186
+ return found;
187
+ }
188
+
189
+ /** A skill is ready only when SKILL.md is readable AND its frontmatter names it. */
190
+ export function inspectSkill(env, rootReal, name) {
191
+ const locations = skillLocations(env, rootReal, name);
192
+ if (!locations.length) return { state: "missing", detail: `no ${name} skill directory found`, evidence: "skill search paths" };
193
+ for (const loc of locations) {
194
+ const manifest = path.join(loc.real, "SKILL.md");
195
+ if (!fs.existsSync(manifest)) continue;
196
+ let text;
197
+ try { text = fs.readFileSync(manifest, "utf8"); }
198
+ catch { return { state: "installed-but-unhealthy", detail: "SKILL.md is unreadable", evidence: manifest }; }
199
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
200
+ if (!fm) return { state: "installed-but-unhealthy", detail: "SKILL.md has no frontmatter block", evidence: manifest };
201
+ const declared = /^name:\s*(.+)$/m.exec(fm[1])?.[1]?.trim();
202
+ if (!declared) return { state: "installed-but-unhealthy", detail: "SKILL.md frontmatter declares no name", evidence: manifest };
203
+ if (declared !== name) return { state: "installed-but-unhealthy", detail: `SKILL.md declares a different identity than its directory (${declared})`, evidence: manifest };
204
+ if (!/^description:\s*\S/m.test(fm[1]) && !/^description:\s*[>|]/m.test(fm[1]))
205
+ return { state: "installed-but-unhealthy", detail: "SKILL.md frontmatter has no description", evidence: manifest };
206
+ return { state: "available-and-ready", detail: "skill installed and well-formed", evidence: manifest, viaSymlink: loc.viaSymlink };
207
+ }
208
+ return { state: "installed-but-unhealthy", detail: "skill directory exists but no readable SKILL.md was found", evidence: locations[0].dir };
209
+ }
210
+
211
+ export function inspectCli(env, cli, versionArgs = ["--version"], minVersion = null) {
212
+ const found = whichCli(env, cli);
213
+ if (!found) return { state: "missing", detail: `${cli} is not on PATH`, evidence: "PATH" };
214
+ let version = null;
215
+ try { version = String(env.run(cli, versionArgs)).trim().split("\n")[0]; }
216
+ catch { return { state: "installed-but-unhealthy", detail: `${cli} is present but its version probe failed`, evidence: found }; }
217
+ if (!version) return { state: "installed-but-unhealthy", detail: `${cli} version probe returned nothing`, evidence: found };
218
+ if (minVersion && !satisfiesMin(version, minVersion))
219
+ return { state: "installed-but-incompatible", version, detail: `installed ${version} is below the required ${minVersion}`, evidence: found };
220
+ return { state: "available-and-ready", version, detail: "cli available", evidence: found, versionResolved: true };
221
+ }
222
+
223
+ export function satisfiesMin(version, min) {
224
+ if (!min) return true;
225
+ const nums = (v) => String(v).trim().replace(/^[^\d]*/, "").split(/[.+-]/).slice(0, 3).map((n) => Number.parseInt(n, 10) || 0);
226
+ const [a, b, c] = nums(version), [x, y, z] = nums(min);
227
+ return a !== x ? a > x : b !== y ? b > y : c >= z;
228
+ }
229
+
230
+ // ---- Claude plugin detection (live first, fallback documented) ------------
231
+
232
+ function detectClaudePlugin(provider, env) {
233
+ const key = provider.detection.key;
234
+ const live = liveClaudePlugins(env);
235
+ if (live.ok) {
236
+ const hit = findLiveEntry(live.entries, key);
237
+ if (hit.conflict)
238
+ return { state: "unknown", source: "live", detail: "the live listing contains conflicting records for this plugin", evidence: "claude plugin list --json" };
239
+ const e = hit.entry;
240
+ if (!e) return { state: "missing", source: "live", detail: `${key} is not installed`, evidence: "claude plugin list --json" };
241
+ // An explicit disable is authoritative.
242
+ if (e.enabled === false) return { state: "installed-but-disabled", source: "live", version: e.version ?? null, detail: `${key} is installed but disabled`, evidence: "claude plugin list --json" };
243
+ if (e.blocked === true || e.status === "blocked") return { state: "policy-blocked", source: "live", version: e.version ?? null, detail: `${key} is blocked by policy`, evidence: "claude plugin list --json" };
244
+ if (e.status === "authentication-required" || e.authRequired === true) return { state: "authentication-required", source: "live", detail: `${key} requires authentication`, evidence: "claude plugin list --json" };
245
+ const version = e.version ?? null;
246
+ if (provider.minVersion && version && version !== "unknown" && !satisfiesMin(version, provider.minVersion))
247
+ return { state: "installed-but-incompatible", source: "live", version, detail: `installed ${version} is below the required ${provider.minVersion}`, evidence: "claude plugin list --json" };
248
+ return {
249
+ state: "available-and-ready", source: "live", version,
250
+ detail: version && version !== "unknown" ? "installed and enabled" : "installed and enabled; version not reported",
251
+ versionResolved: Boolean(version && version !== "unknown"), evidence: "claude plugin list --json",
252
+ };
253
+ }
254
+
255
+ // FALLBACK (documented): filesystem inspection when the live listing is absent.
256
+ const store = readInstalledPlugins(env);
257
+ if (!store.ok) return { state: "unknown", source: "fallback", detail: `${live.reason}; ${store.reason}`, evidence: "plugin store" };
258
+ const entries = store.store.plugins?.[key];
259
+ if (!Array.isArray(entries) || entries.length === 0)
260
+ return { state: "missing", source: "fallback", detail: `no install record for ${key}`, evidence: "installed_plugins.json" };
261
+ if (entries.length > 1 && new Set(entries.map((r) => r.installPath)).size > 1)
262
+ return { state: "unknown", source: "fallback", detail: "duplicate install records disagree; capability cannot be determined", evidence: "installed_plugins.json" };
263
+ const rec = entries[0];
264
+ // Policy state must be READABLE to conclude "not blocked".
265
+ const policy = readBlocklist(env);
266
+ if (!policy.ok) return { state: POLICY_STATE_UNAVAILABLE, source: "fallback", version: rec.version, detail: `${policy.reason}; refusing to assume the plugin is permitted`, evidence: "plugin policy" };
267
+ if (policy.blocked.includes(key))
268
+ return { state: "installed-but-disabled", source: "fallback", version: rec.version, detail: `${key} is disabled by policy`, evidence: "plugin policy" };
269
+ if (rec.enabled === false)
270
+ return { state: "installed-but-disabled", source: "fallback", version: rec.version, detail: `${key} is recorded as disabled`, evidence: "installed_plugins.json" };
271
+ if (rec.installPath && !fs.existsSync(rec.installPath))
272
+ return { state: "installed-but-unhealthy", source: "fallback", version: rec.version, detail: "install record points at a path that no longer exists", evidence: "installed_plugins.json" };
273
+ if (provider.minVersion && rec.version && rec.version !== "unknown" && !satisfiesMin(rec.version, provider.minVersion))
274
+ return { state: "installed-but-incompatible", source: "fallback", version: rec.version, detail: `installed ${rec.version} is below the required ${provider.minVersion}`, evidence: "installed_plugins.json" };
275
+ return {
276
+ state: "available-and-ready", source: "fallback", version: rec.version ?? null,
277
+ detail: rec.version === "unknown" ? "installed; version not recorded by the plugin store" : "installed",
278
+ versionResolved: Boolean(rec.version && rec.version !== "unknown"), evidence: "installed_plugins.json",
279
+ };
280
+ }
281
+
282
+ // ---- composite detection -------------------------------------------------
283
+
284
+ function detectComponent(component, env, rootReal) {
285
+ if (component.kind === "cli") return { id: component.id, kind: "cli", required: component.required !== false, ...inspectCli(env, component.cli, component.versionArgs) };
286
+ if (component.kind === "agent-skill") return { id: component.id, kind: "agent-skill", required: component.required !== false, ...inspectSkill(env, rootReal, component.skill) };
287
+ return { id: component.id, kind: component.kind, required: component.required !== false, state: "unknown", detail: "unrecognized component kind" };
288
+ }
289
+
290
+ function detectComposite(provider, env, rootReal) {
291
+ const components = provider.components.map((c) => detectComponent(c, env, rootReal));
292
+ const required = components.filter((c) => c.required);
293
+ const readyRequired = required.filter((c) => c.state === "available-and-ready");
294
+ if (readyRequired.length === required.length)
295
+ return { state: "available-and-ready", components, detail: "every required component is ready", evidence: components.map((c) => c.evidence).filter(Boolean).join(", ") };
296
+ if (readyRequired.length === 0)
297
+ return { state: "missing", components, detail: `none of the required components are ready (${required.map((c) => `${c.id}: ${c.state}`).join("; ")})`, evidence: "components" };
298
+ // Some but not all - the provider is NOT ready and must never be reported so.
299
+ const missingIds = required.filter((c) => c.state !== "available-and-ready").map((c) => `${c.id} (${c.state})`);
300
+ return { state: PARTIALLY_READY, components, detail: `only some required components are ready; missing: ${missingIds.join(", ")}`, evidence: "components" };
301
+ }
302
+
303
+ /* ------------------------------------------------------------------ *
304
+ * Harness scoping
305
+ *
306
+ * "Installed" and "invocable from where you are standing" are different
307
+ * questions, and conflating them is how a readiness report lies. A Claude
308
+ * plugin listed by `claude plugin list` is invocable from Claude Code and
309
+ * NOWHERE else - reporting Superpowers ready inside a Codex session because
310
+ * Claude has it installed would send specialist work down a route that does not
311
+ * exist on that surface.
312
+ * ------------------------------------------------------------------ */
313
+
314
+ export const HARNESSES = ["claude-code", "codex", "skills.sh"];
315
+
316
+ /** Evidence about which harness we are in, strongest first.
317
+ *
318
+ * Tiering matters because these variables are not equally trustworthy. A user
319
+ * can export CODEX_HOME in a shell profile and never leave Claude Code, while
320
+ * CLAUDECODE and CODEX_SESSION_ID are injected by a live session and cannot be
321
+ * set by accident. A flat first-match-wins check reported `claude-code` inside
322
+ * a Codex-configured shell purely because Claude's variables were listed first
323
+ * - which is exactly how a Claude plugin would get credited on Codex.
324
+ */
325
+ const HARNESS_EVIDENCE = [
326
+ // tier 1 - proves a live session of that harness
327
+ { "claude-code": ["CLAUDECODE"], codex: ["CODEX_SESSION_ID"], "skills.sh": [] },
328
+ // tier 2 - injected by the runtime when it loads a plugin
329
+ { "claude-code": ["CLAUDE_PLUGIN_ROOT", "CLAUDE_PROJECT_DIR"], codex: ["CODEX_PLUGIN_ROOT"], "skills.sh": ["AGENT_SKILLS_ROOT"] },
330
+ // tier 3 - configuration only; a user may export these anywhere
331
+ { "claude-code": ["CLAUDE_CONFIG_DIR"], codex: ["CODEX_HOME"], "skills.sh": ["SKILLS_HOME"] },
332
+ ];
333
+
334
+ /** Which harness is this process actually running under?
335
+ *
336
+ * An explicit override wins. Otherwise the strongest tier that names exactly
337
+ * one harness decides. Evidence for two harnesses in the same tier is
338
+ * AMBIGUOUS, and ambiguity resolves to `unknown` - which grants no provider
339
+ * credit anywhere - rather than to whichever name was checked first.
340
+ */
341
+ export function currentHarness(env = {}) {
342
+ const e = env.vars ?? process.env;
343
+ const override = e.SUPERDEV_HARNESS;
344
+ if (override && HARNESSES.includes(override)) return override;
345
+
346
+ for (const tier of HARNESS_EVIDENCE) {
347
+ const hits = HARNESSES.filter((h) => (tier[h] ?? []).some((v) => e[v]));
348
+ if (hits.length === 1) return hits[0];
349
+ if (hits.length > 1) return "unknown"; // ambiguous: prove nothing
350
+ }
351
+ return "unknown";
352
+ }
353
+
354
+ /** Where a delivery form can actually be reached from, per harness. */
355
+ const REACHABLE = {
356
+ // a Claude plugin is a Claude Code construct and travels nowhere else
357
+ [DELIVERY.CLAUDE_PLUGIN]: { "claude-code": true, codex: false, "skills.sh": false },
358
+ // the canonical ~/.agents/skills location is shared across agents
359
+ [DELIVERY.AGENT_SKILL]: { "claude-code": true, codex: true, "skills.sh": true },
360
+ // PATH is shared by every process on the machine
361
+ [DELIVERY.CLI]: { "claude-code": true, codex: true, "skills.sh": true },
362
+ };
363
+
364
+ const ROUTE = {
365
+ [DELIVERY.CLAUDE_PLUGIN]: (p) => `Claude Code skill namespace \`${p.id}:*\``,
366
+ [DELIVERY.AGENT_SKILL]: (p) => `agent skill \`${p.identity.skill ?? p.id}\``,
367
+ [DELIVERY.CLI]: (p) => `\`${p.identity.cli ?? p.id}\` on PATH`,
368
+ };
369
+
370
+ /**
371
+ * Report a provider's reachability from ONE harness.
372
+ *
373
+ * `installed` is the global detection result; `reachable` answers whether this
374
+ * session can invoke it. A provider that is installed but unreachable here gets
375
+ * an explicit limitation and its truthful unavailable route - never credit.
376
+ */
377
+ export function harnessReadiness(provider, detected, harness) {
378
+ const delivery = provider.identity.delivery;
379
+ const composite = Array.isArray(provider.components) && provider.components.length > 0;
380
+
381
+ // A composite provider is reachable only where EVERY required component is.
382
+ const deliveries = composite
383
+ ? provider.components.filter((c) => c.required !== false).map((c) => (c.kind === "cli" ? DELIVERY.CLI : c.kind === "agent-skill" ? DELIVERY.AGENT_SKILL : delivery))
384
+ : [delivery];
385
+
386
+ const known = HARNESSES.includes(harness);
387
+ const reachable = known && deliveries.every((d) => REACHABLE[d]?.[harness] === true);
388
+ const blockers = known
389
+ ? deliveries.filter((d) => REACHABLE[d]?.[harness] !== true)
390
+ : [];
391
+
392
+ return {
393
+ harness,
394
+ installationSurface: composite ? deliveries.join(" + ") : delivery,
395
+ installed: detected.ready,
396
+ // the question that actually matters: can THIS session invoke it?
397
+ invocableHere: Boolean(detected.ready && reachable),
398
+ reachableFromHarness: reachable,
399
+ invocationRoute: reachable ? (composite ? deliveries.map((d) => ROUTE[d](provider)).join(" + ") : ROUTE[delivery](provider)) : null,
400
+ activationRequired: harness === "codex" && provider.identity.delivery === DELIVERY.AGENT_SKILL ? null
401
+ : harness === "codex" ? "explicit hook/plugin trust" : null,
402
+ limitation: known && !reachable
403
+ ? `${provider.title} is delivered as ${blockers.join(" + ")}, which ${harness} cannot invoke. Installation on another harness does not make it available here.`
404
+ : (!known ? `the current harness could not be identified, so reachability is unproven` : null),
405
+ unavailableBehavior: (detected.ready && reachable) ? null : provider.unavailable,
406
+ };
407
+ }
408
+
409
+ // ---- public API ----------------------------------------------------------
410
+
411
+ export function detectProvider(provider, { env = defaultEnv(), rootReal = null, harness = null } = {}) {
412
+ let result;
413
+ try {
414
+ result = provider.components ? detectComposite(provider, env, rootReal)
415
+ : provider.identity.delivery === DELIVERY.CLAUDE_PLUGIN ? detectClaudePlugin(provider, env)
416
+ : provider.identity.delivery === DELIVERY.AGENT_SKILL ? inspectSkill(env, rootReal, provider.detection.skill)
417
+ : inspectCli(env, provider.detection.cli, provider.readiness?.args);
418
+ } catch (e) {
419
+ result = { state: "unknown", detail: `detection error: ${e.code ?? "error"}`, evidence: null };
420
+ }
421
+ if (!EXTENDED_STATES.includes(result.state)) result = { state: "unknown", detail: "unrecognized detection result" };
422
+ const ready = result.state === "available-and-ready";
423
+ return {
424
+ id: provider.id, title: provider.title, delivery: provider.identity.delivery,
425
+ identity: provider.identity.ref, ready, ...result,
426
+ versionResolution: result.versionResolved === false || result.version === "unknown"
427
+ ? "unresolved" : (provider.versionResolution ?? (result.version ? "resolved" : "not-applicable")),
428
+ versionEnforced: Boolean(provider.minVersion),
429
+ remediation: ready ? null : remediationFor(provider, result),
430
+ unavailableBehavior: ready ? null : provider.unavailable,
431
+ noSubstitution: provider.noSubstitution,
432
+ // Reachability from the harness this session is actually running under.
433
+ harnessReadiness: harnessReadiness(provider, { ready }, harness ?? currentHarness(env)),
434
+ };
435
+ }
436
+
437
+ function remediationFor(provider, result) {
438
+ const install = provider.install ?? {};
439
+ const base = install.command ? `install: \`${install.command}\`` : "install the provider through its own documented flow";
440
+ switch (result.state) {
441
+ case "missing": return `${provider.title} is not installed. With your consent, ${base}.`;
442
+ case PARTIALLY_READY: {
443
+ const missing = (result.components ?? []).filter((c) => c.required && c.state !== "available-and-ready");
444
+ return `${provider.title} is only PARTIALLY ready - ${missing.map((c) => `${c.id} is ${c.state}`).join("; ")}. It must not be treated as available until every required component is ready.`;
445
+ }
446
+ case "installed-but-disabled": return `${provider.title} is installed but disabled. Re-enable it in the harness; Superdev will not enable it for you.`;
447
+ case "policy-blocked": return `${provider.title} is blocked by policy. Resolve the policy with your administrator; Superdev will not override it.`;
448
+ case "authentication-required": return `${provider.title} requires authentication before it can be used.`;
449
+ case POLICY_STATE_UNAVAILABLE: return `${provider.title}'s policy state could not be read, so it is NOT assumed permitted. Repair the plugin policy file, then re-run doctor.`;
450
+ case "installed-but-incompatible": return `${provider.title} is installed at an incompatible version. Update it (${base}), then re-run doctor.`;
451
+ case "installed-but-unhealthy": return `${provider.title} is installed but not usable (${result.detail}). Repair or reinstall it, then re-run doctor.`;
452
+ case "unknown": return `${provider.title} state could not be determined (${result.detail}). Report this rather than assuming availability.`;
453
+ default: return `${provider.title} is not ready (${result.state}).`;
454
+ }
455
+ }
456
+
457
+ export function detectAll({ env = defaultEnv(), rootReal = null, only = null, harness = null } = {}) {
458
+ const list = only ? [getProvider(only)].filter(Boolean) : PROVIDERS;
459
+ if (only && list.length === 0) throw new TalksError("E_PROVIDER_UNKNOWN", `unknown provider "${only}" (known: ${PROVIDER_IDS.join(", ")})`);
460
+ const here = harness ?? currentHarness(env);
461
+ const providers = list.map((p) => detectProvider(p, { env, rootReal, harness: here }));
462
+ const ready = providers.filter((p) => p.ready).length;
463
+ const invocableHere = providers.filter((p) => p.harnessReadiness?.invocableHere).length;
464
+ return {
465
+ version: 1, checked: providers.length, ready, notReady: providers.length - ready,
466
+ note: "read-only detection: no provider was installed, enabled, configured, or invoked",
467
+ // Installed and invocable-from-here are separate numbers on purpose.
468
+ harness: here,
469
+ invocableHere,
470
+ unreachableHere: providers.filter((p) => p.ready && !p.harnessReadiness?.invocableHere)
471
+ .map((p) => ({ id: p.id, limitation: p.harnessReadiness.limitation })),
472
+ versionPolicy: VERSION_POLICY,
473
+ providers,
474
+ };
475
+ }
476
+
477
+ function main() {
478
+ let args, positionals;
479
+ try {
480
+ const parsed = parseArgs({
481
+ options: {
482
+ root: { type: "string", default: "." }, provider: { type: "string" }, home: { type: "string" },
483
+ json: { type: "boolean", default: false }, out: { type: "string" }, help: { type: "boolean", default: false },
484
+ },
485
+ allowPositionals: true,
486
+ });
487
+ args = parsed.values; positionals = parsed.positionals;
488
+ } catch (err) { usageError(String(err.message ?? err), USAGE); }
489
+ if (args.help) { console.log(USAGE); process.exit(0); }
490
+ const op = positionals[0] ?? "detect";
491
+ if (!["detect", "contract"].includes(op)) usageError(`unknown operation: ${op}`, USAGE);
492
+ let rootReal;
493
+ try { rootReal = resolveRoot(args.root); } catch (e) { usageError(e.message, USAGE); }
494
+ try {
495
+ let report, failed = false;
496
+ if (op === "contract") {
497
+ const list = args.provider ? [getProvider(args.provider)].filter(Boolean) : PROVIDERS;
498
+ if (args.provider && !list.length) throw new TalksError("E_PROVIDER_UNKNOWN", `unknown provider "${args.provider}"`);
499
+ report = { count: list.length, versionPolicy: VERSION_POLICY, providers: list };
500
+ } else {
501
+ const env = defaultEnv();
502
+ if (args.home) env.home = args.home;
503
+ report = detectAll({ env, rootReal, only: args.provider });
504
+ failed = report.notReady > 0;
505
+ }
506
+ // Machine paths and any sensitive shape are redacted at the boundary.
507
+ const safe = redactSensitive(report);
508
+ console.log(args.json ? JSON.stringify(safe, null, 2)
509
+ : op === "contract" ? `contract: ${safe.count} provider(s)`
510
+ : `providers: ${safe.ready}/${safe.checked} ready\n${safe.providers.map((p) => ` [${p.ready ? "ready" : p.state}] ${p.title}${p.version ? ` ${p.version}` : ""}${p.ready ? "" : ` - ${p.remediation}`}`).join("\n")}`);
511
+ writeReport(args.out, safe);
512
+ process.exit(failed ? 1 : 0);
513
+ } catch (e) {
514
+ if (e instanceof TalksError) { console.error(`[${e.code}] ${e.message}`); process.exit(e.code === "E_PROVIDER_UNKNOWN" ? 2 : 1); }
515
+ throw e;
516
+ }
517
+ }
518
+
519
+ if (isMain(import.meta.url)) main();