@extuitive/skill 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/doctor.mjs ADDED
@@ -0,0 +1,513 @@
1
+ /**
2
+ * What is wrong, and which step fixes it.
3
+ *
4
+ * Two rules shape everything here.
5
+ *
6
+ * First, prefer the host's own answer. `claude mcp list` and `codex mcp list` distinguish
7
+ * "not registered" from "registered but not signed in", which an outside probe cannot: both
8
+ * look identical from here, because an unauthenticated request gets the same 401 whether or
9
+ * not any host knows the server exists. Our own probe is the fallback for when neither CLI
10
+ * is installed.
11
+ *
12
+ * Second, never claim the sign-in worked. OAuth completes in a browser against the user's
13
+ * own session and the token lands in the host's credential store, which is not ours to read.
14
+ * Doctor reports what the host says and otherwise says "unknown" — an installer that
15
+ * announced success it had not verified would be wrong in exactly the case that matters.
16
+ */
17
+ import { DEFAULT_MCP_ENDPOINT, MCP_SERVER_NAME, NPX_COMMAND } from "./constants.mjs";
18
+ import { detectHosts, displayPath } from "./hosts.mjs";
19
+ import {
20
+ backupsRoot,
21
+ findShadowingBackups,
22
+ inspectInstalledSkills,
23
+ inspectSkillBundles,
24
+ } from "./install.mjs";
25
+ import { authInstructions, serverAvailabilityNotice, statusCommand } from "./mcp-setup.mjs";
26
+ import { run } from "./exec.mjs";
27
+
28
+ /**
29
+ * Ask the endpoint whether it is there.
30
+ *
31
+ * A 401 carrying `WWW-Authenticate` is the healthy answer: the server is reachable and is
32
+ * correctly advertising where its OAuth metadata lives, which is what lets a host register
33
+ * without a client id. Worth stating because lead-magnet's own `.env.example` claims an
34
+ * unconfigured server returns 503 from this path — it does not, only `/oauth/token` does, so
35
+ * a check written against that comment would call a working server broken.
36
+ */
37
+ export async function probeEndpoint(endpoint = DEFAULT_MCP_ENDPOINT, { timeoutMs = 10_000 } = {}) {
38
+ const controller = new AbortController();
39
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
40
+
41
+ try {
42
+ const response = await fetch(endpoint, {
43
+ method: "POST",
44
+ headers: { "content-type": "application/json", accept: "application/json" },
45
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" }),
46
+ // Following the redirect would turn a diagnosable 307 into whatever the login page
47
+ // says about being POSTed to, which is a worse error about a different thing.
48
+ redirect: "manual",
49
+ signal: controller.signal,
50
+ });
51
+
52
+ const challenge = response.headers.get("www-authenticate");
53
+
54
+ if (response.status >= 300 && response.status < 400) {
55
+ const location = response.headers.get("location") ?? "elsewhere";
56
+ return {
57
+ state: "unexpected",
58
+ detail:
59
+ `Redirected to ${location} instead of returning a 401 challenge. The site is up, but /mcp ` +
60
+ "is behind the browser session gate rather than exposed as an MCP endpoint, so no host can " +
61
+ "sign in to it. This is a server-side deployment problem, not something the installer can fix.",
62
+ };
63
+ }
64
+
65
+ if (response.status === 401) {
66
+ return challenge === null
67
+ ? {
68
+ state: "unexpected",
69
+ detail:
70
+ "401 without a WWW-Authenticate header. The server is reachable but is not advertising its OAuth metadata, so hosts cannot discover how to sign in.",
71
+ }
72
+ : { state: "reachable", detail: "Reachable, awaiting sign-in.", challenge };
73
+ }
74
+
75
+ if (response.status === 200) {
76
+ return { state: "authenticated", detail: "Reachable and this request was accepted." };
77
+ }
78
+
79
+ return {
80
+ state: "unexpected",
81
+ detail: `Responded ${response.status}. Expected 401 with a WWW-Authenticate header.`,
82
+ };
83
+ } catch (error) {
84
+ const aborted = error instanceof Error && error.name === "AbortError";
85
+ return {
86
+ state: "unreachable",
87
+ detail: aborted === true ? `No response within ${timeoutMs}ms.` : String(error.message ?? error),
88
+ };
89
+ } finally {
90
+ clearTimeout(timer);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Whether a line is about our server, as opposed to merely mentioning it.
96
+ *
97
+ * Claude Code lists claude.ai connectors alongside registered servers, and people name those
98
+ * after the service — a connector called "Extuitive" prints a line carrying our name, our
99
+ * endpoint and a healthy `✔ Connected`. Matching on substring finds it and reports a
100
+ * registration that does not exist, which is the one wrong answer doctor must not give.
101
+ * The name is the first token on the line: before the `:` Claude Code uses, or the run of
102
+ * spaces Codex's table uses.
103
+ */
104
+ function namesOurServer(line) {
105
+ const [first = ""] = line.trim().split(/[:\s]/, 1);
106
+ return first.toLowerCase() === MCP_SERVER_NAME;
107
+ }
108
+
109
+ /**
110
+ * The columns of a fixed-width table row, cut where the header says they start.
111
+ *
112
+ * `codex mcp list` prints a table (`Name Command Args Env Cwd Status Auth`) whose
113
+ * columns are padded to the widest value, so a row cannot be split on whitespace — an env
114
+ * column full of `KEY=*****, KEY=*****` would swallow the ones after it. The header's
115
+ * column starts are the only reliable cut points. Returns null when there is no header to
116
+ * cut by, and the caller falls back to reading the row as prose.
117
+ */
118
+ function tableColumns(lines, row) {
119
+ const header = lines.find((line) => /^\s*Name\s+/.test(line) && /\bStatus\b/.test(line));
120
+ if (header === undefined) {
121
+ return null;
122
+ }
123
+
124
+ const names = [...header.matchAll(/\S+/g)];
125
+ const columns = {};
126
+ for (const [index, match] of names.entries()) {
127
+ const start = match.index;
128
+ const end = index + 1 < names.length ? names[index + 1].index : row.length;
129
+ columns[match[0].toLowerCase()] = row.slice(start, end).trim();
130
+ }
131
+ return columns;
132
+ }
133
+
134
+ /**
135
+ * Codex's auth vocabulary, from `McpAuthStatus` in its source, mapped onto ours.
136
+ *
137
+ * `OAuth` means a usable token is stored, i.e. signed in — not "uses OAuth". `NotLoggedIn`
138
+ * means the server advertises OAuth and no token is stored. `BearerToken` is a static header
139
+ * and counts as signed in. `Unsupported` is what a server with no auth at all reports, and
140
+ * also what a disabled one reports, so on its own it says nothing about sign-in. Accepts
141
+ * both the table's `OAuth`/`NotLoggedIn` spelling and `--json`'s `o_auth`/`not_logged_in`.
142
+ */
143
+ function describeCodexAuth(raw) {
144
+ const auth = String(raw).toLowerCase().replace(/[\s_-]/g, "");
145
+ if (auth === "notloggedin") {
146
+ return { state: "needs_auth", detail: "registered, not signed in" };
147
+ }
148
+ if (auth === "oauth" || auth === "bearertoken") {
149
+ return { state: "connected", detail: "registered, signed in" };
150
+ }
151
+ return { state: "registered", detail: `registered (auth: ${raw || "unknown"})` };
152
+ }
153
+
154
+ /**
155
+ * `codex mcp list --json`, which is the one host status we can read without guessing.
156
+ *
157
+ * Returns null when the flag is not supported or the output is not JSON, so the caller falls
158
+ * back to the table. Anything else — including "the server is not in the list" — is a real
159
+ * answer and is returned as one.
160
+ */
161
+ function readCodexServerStatusJson(host, command, args) {
162
+ const result = run(command, [...args, "--json"], { timeoutMs: 20_000 });
163
+ if (result.spawnError === true || result.ok === false) {
164
+ return null;
165
+ }
166
+
167
+ let entries;
168
+ try {
169
+ entries = JSON.parse(result.stdout);
170
+ } catch {
171
+ return null;
172
+ }
173
+ if (Array.isArray(entries) === false) {
174
+ return null;
175
+ }
176
+
177
+ const entry = entries.find((candidate) => String(candidate?.name ?? "").toLowerCase() === MCP_SERVER_NAME);
178
+ if (entry === undefined) {
179
+ const lookalike = entries.find((candidate) =>
180
+ String(candidate?.name ?? "").toLowerCase().includes(MCP_SERVER_NAME),
181
+ );
182
+ return {
183
+ state: "absent",
184
+ detail: `${host.label} has no server named ${MCP_SERVER_NAME}.`,
185
+ lookalike: lookalike === undefined ? null : String(lookalike.name),
186
+ };
187
+ }
188
+
189
+ if (entry.enabled === false) {
190
+ return {
191
+ state: "disabled",
192
+ detail: entry.disabled_reason ? `disabled: ${entry.disabled_reason}` : "disabled",
193
+ entry,
194
+ };
195
+ }
196
+ return { ...describeCodexAuth(entry.auth_status ?? ""), entry };
197
+ }
198
+
199
+ /**
200
+ * The host's own view of the server.
201
+ *
202
+ * Parsed from human-readable output, so it is written to degrade rather than mislead: a line
203
+ * naming the server is enough to say it is registered, and only clear signals move it to
204
+ * `needs_auth`, `connected`, `disabled` or `failed`. Anything unrecognized stays
205
+ * `registered`, because "we could not read the status" must not be reported as "it is
206
+ * broken".
207
+ *
208
+ * A CLI that cannot be run at all is `unknown`, never `absent`. The earlier version got this
209
+ * wrong for a broken `codex` shim: the spawn failure printed nothing that named the server,
210
+ * so the server was reported missing and the fix offered was to register it again — with the
211
+ * same broken binary.
212
+ */
213
+ export function readHostServerStatus(host, { cliAvailable }) {
214
+ // Distinct from `unknown`, which means "we could not read it this time". This one will
215
+ // never be readable, so nothing about it is worth suggesting a fix for.
216
+ const status = statusCommand(host);
217
+ if (status === null) {
218
+ return {
219
+ state: "unverifiable",
220
+ detail: `${host.label} has no CLI, so its connectors can only be seen in Settings > Connectors.`,
221
+ };
222
+ }
223
+
224
+ if (cliAvailable === false) {
225
+ return {
226
+ state: "unknown",
227
+ detail:
228
+ host.cliResolution.state === "broken"
229
+ ? host.cliResolution.detail
230
+ : `${host.cli} is not on PATH, so the registration cannot be checked from here.`,
231
+ };
232
+ }
233
+
234
+ const { command, args } = status;
235
+
236
+ if (host.id === "codex") {
237
+ const structured = readCodexServerStatusJson(host, command, args);
238
+ if (structured !== null) {
239
+ return structured;
240
+ }
241
+ }
242
+
243
+ const result = run(command, args, { timeoutMs: 20_000 });
244
+
245
+ if (result.spawnError === true) {
246
+ return { state: "unknown", detail: `Could not run ${command} ${args.join(" ")}.` };
247
+ }
248
+
249
+ const lines = `${result.stdout}\n${result.stderr}`.split("\n");
250
+ const line = lines.find(namesOurServer);
251
+
252
+ if (line === undefined) {
253
+ // Reported rather than passed over, because from the outside it looks like the install
254
+ // did nothing: the host lists something called Extuitive, and doctor says there is no
255
+ // Extuitive server. Naming the other entry is the difference between those two facts.
256
+ const lookalike = lines.find((candidate) => candidate.toLowerCase().includes(MCP_SERVER_NAME));
257
+ return {
258
+ state: "absent",
259
+ detail: `${host.label} has no server named ${MCP_SERVER_NAME}.`,
260
+ lookalike: lookalike === undefined ? null : lookalike.trim(),
261
+ };
262
+ }
263
+
264
+ // Prefer the table's own columns when there are any. `Status` is enabled/disabled and
265
+ // `Auth` is the sign-in state, and reading them by position means an env var containing
266
+ // the word "failed" cannot be mistaken for the server having failed.
267
+ const columns = tableColumns(lines, line);
268
+ if (columns !== null && (columns.status !== undefined || columns.auth !== undefined)) {
269
+ const status = (columns.status ?? "").toLowerCase();
270
+ const summary = [columns.status, columns.auth].filter((part) => part).join(", ");
271
+
272
+ if (status.includes("disabled") === true) {
273
+ return { state: "disabled", detail: summary, columns };
274
+ }
275
+ return { ...describeCodexAuth(columns.auth ?? ""), columns, detail: summary };
276
+ }
277
+
278
+ const lowered = line.toLowerCase();
279
+ if (lowered.includes("needs authentication") === true || lowered.includes("not logged in") === true) {
280
+ return { state: "needs_auth", detail: line.trim() };
281
+ }
282
+ if (lowered.includes("failed") === true) {
283
+ return { state: "failed", detail: line.trim() };
284
+ }
285
+ if (lowered.includes("connected") === true) {
286
+ return { state: "connected", detail: line.trim() };
287
+ }
288
+ return { state: "registered", detail: line.trim() };
289
+ }
290
+
291
+ function skillSummary(inspection) {
292
+ const missing = inspection.skills.filter((skill) => skill.present === false);
293
+ const mismatched = inspection.skills.filter(
294
+ (skill) => skill.present === true && skill.nameMatches === false,
295
+ );
296
+
297
+ if (missing.length === inspection.skills.length) {
298
+ return { state: "absent", missing, mismatched };
299
+ }
300
+ if (missing.length > 0 || mismatched.length > 0) {
301
+ return { state: "partial", missing, mismatched };
302
+ }
303
+ return { state: "installed", missing, mismatched };
304
+ }
305
+
306
+ /**
307
+ * The same summary for a host whose skills are uploaded rather than copied.
308
+ *
309
+ * `built` rather than `installed`, and the difference is the point: an archive on this disk
310
+ * says the upload is possible, not that it happened. Whether the account has the skill is
311
+ * behind a browser session doctor cannot see, so it is never claimed either way.
312
+ */
313
+ function bundleSummary(inspection) {
314
+ const missing = inspection.skills.filter((skill) => skill.present === false);
315
+ const stale = inspection.skills.filter(
316
+ (skill) => skill.present === true && skill.current === false,
317
+ );
318
+
319
+ if (missing.length === inspection.skills.length) {
320
+ return { state: "absent", missing, stale, mismatched: [] };
321
+ }
322
+ if (missing.length > 0 || stale.length > 0) {
323
+ return { state: "partial", missing, stale, mismatched: [] };
324
+ }
325
+ return { state: "built", missing, stale, mismatched: [] };
326
+ }
327
+
328
+ /** Everything doctor knows about one host. */
329
+ export async function diagnoseHost(detection, options = {}) {
330
+ const { scope = "user", dir = null, cwd = process.cwd() } = options;
331
+ const { host, cliAvailable, configPresent } = detection;
332
+
333
+ const bundled = host.skillDelivery === "bundle";
334
+ const inspection = bundled === true
335
+ ? await inspectSkillBundles(host, { dir, cwd })
336
+ : await inspectInstalledSkills(host, { scope, dir, cwd });
337
+ const skills = bundled === true ? bundleSummary(inspection) : skillSummary(inspection);
338
+ const previous = inspection.previous ?? [];
339
+ const shadowingBackups = await findShadowingBackups(host, { scope, dir, cwd });
340
+ const server = readHostServerStatus(host, { cliAvailable });
341
+
342
+ const problems = [];
343
+
344
+ if (skills.state === "absent" && previous.length > 0) {
345
+ // It loads from there, so this is not blocking — but a later install would create a
346
+ // duplicate, and the person should know the location changed before that happens.
347
+ problems.push({
348
+ what: `Extuitive is installed at its previous location (${displayPath(previous[0].path)}). It still loads from there.`,
349
+ fix: `Move it to ${displayPath(inspection.destinationRoot)} with: ${NPX_COMMAND} update`,
350
+ advisory: true,
351
+ });
352
+ } else if (skills.state === "absent") {
353
+ problems.push({
354
+ what: bundled === true
355
+ ? `No skill bundle built for ${host.label} in ${inspection.destinationRoot}.`
356
+ : `No Extuitive skills in ${inspection.destinationRoot}.`,
357
+ fix: `Run: ${NPX_COMMAND} install`,
358
+ });
359
+ } else if (skills.state === "partial") {
360
+ if (skills.missing.length > 0) {
361
+ problems.push({
362
+ what: `Missing skills: ${skills.missing.map((skill) => skill.name).join(", ")}.`,
363
+ fix: `Run: ${NPX_COMMAND} install`,
364
+ });
365
+ }
366
+ for (const skill of skills.stale ?? []) {
367
+ problems.push({
368
+ what: `${skill.destination} was built from an older copy of the skill.`,
369
+ fix: `Rebuild it, then upload the new one: ${NPX_COMMAND} update`,
370
+ });
371
+ }
372
+ for (const skill of skills.mismatched) {
373
+ problems.push({
374
+ what: `${skill.destination} declares name "${skill.declaredName}" but sits in a directory named "${skill.name}". Hosts key a skill on its directory, so this one will not load.`,
375
+ fix: `Reinstall to restore the bundled copy: ${NPX_COMMAND} install`,
376
+ });
377
+ }
378
+ } else if (bundled === true) {
379
+ // The one thing doctor genuinely cannot see, said plainly rather than left as a healthy
380
+ // tick that means less than it looks like. A built bundle is a file on this disk; the
381
+ // skill is in an account.
382
+ problems.push({
383
+ what: `The bundle is current, but whether it has been uploaded is only visible in ${host.label} under Customize > Skills.`,
384
+ fix: `Upload ${inspection.skills[0]?.destination ?? "the bundle"} there if extuitive is not already listed.`,
385
+ advisory: true,
386
+ });
387
+ }
388
+
389
+ for (const path of shadowingBackups) {
390
+ problems.push({
391
+ what: `A backup directory is sitting in the skills root (${path}). ${host.label} scans everything there, so it loads as a second, older skill.`,
392
+ fix: `Move it out of the way: mv ${path} ${backupsRoot()}/\nor delete it if you no longer need it: rm -rf ${path}`,
393
+ });
394
+ }
395
+
396
+ if (skills.state !== "absent") {
397
+ for (const copy of previous) {
398
+ problems.push({
399
+ what: `A second copy of ${copy.name} is at Extuitive's previous install location (${displayPath(copy.path)}). ${host.label} scans both, so the skill appears twice and the older body may be the one read.`,
400
+ fix: `Migrate it (backs up anything that differs): ${NPX_COMMAND} update\nor remove it: rm -rf ${copy.path}`,
401
+ });
402
+ }
403
+ }
404
+
405
+ if (server.state === "unknown" && cliAvailable === false) {
406
+ problems.push({
407
+ what:
408
+ host.cliResolution.state === "broken"
409
+ ? `The ${host.cli} command does not run: ${host.cliResolution.detail}`
410
+ : `The ${host.cli} command is not on PATH, so the MCP registration cannot be checked or changed from here.`,
411
+ fix:
412
+ host.cliResolution.state === "broken"
413
+ ? `Reinstall the ${host.label} CLI, or point at a working one: CODEX_CLI_PATH=/path/to/codex ${NPX_COMMAND} doctor`
414
+ : `Install the ${host.label} CLI, or register the server by hand — see: ${NPX_COMMAND} install`,
415
+ });
416
+ }
417
+
418
+ if (server.state === "absent" && server.lookalike != null) {
419
+ problems.push({
420
+ what: `${host.label} has no server named ${MCP_SERVER_NAME}, but it does list "${server.lookalike}" — a separate entry that happens to share the name.`,
421
+ fix:
422
+ `If the Extuitive tools already work in your session, that entry is providing them and there is nothing to do here.\n` +
423
+ `If they do not, register this one too: ${NPX_COMMAND} install`,
424
+ });
425
+ } else if (server.state === "absent") {
426
+ problems.push({
427
+ what: `The MCP server is not registered with ${host.label}, so none of the Extuitive tools are available.`,
428
+ fix: `Run: ${NPX_COMMAND} install`,
429
+ });
430
+ } else if (server.state === "needs_auth") {
431
+ const auth = authInstructions(host);
432
+ problems.push({
433
+ what: `${host.label} has the server but is not signed in.`,
434
+ // The restart is part of the fix, not a footnote to it: on Claude Code the sign-in
435
+ // panel is only reachable from a session that already connected to the server.
436
+ fix:
437
+ auth.inSession === true
438
+ ? `${serverAvailabilityNotice(host)}\nThen: ${auth.primary}`
439
+ : auth.primary,
440
+ });
441
+ } else if (server.state === "disabled") {
442
+ problems.push({
443
+ what: `${host.label} has the server but it is disabled (${server.detail}).`,
444
+ fix: `Enable it in ${host.configPath}, or remove and re-add it: ${NPX_COMMAND} install`,
445
+ });
446
+ } else if (server.state === "failed") {
447
+ problems.push({
448
+ what: `${host.label} could not connect to the server: ${server.detail}`,
449
+ fix: "Check the endpoint is reachable, then sign in again.",
450
+ });
451
+ } else if (server.state === "unverifiable") {
452
+ // Advisory, not a problem. Nothing is known to be wrong; the place to look is simply
453
+ // not a place a shell can reach, and reporting that as a failure would send someone
454
+ // reinstalling a connector that is already there.
455
+ problems.push({
456
+ what: `Whether ${host.label} has the extuitive connector cannot be read from a shell.`,
457
+ fix: "Check it yourself in Settings > Connectors.",
458
+ advisory: true,
459
+ });
460
+ }
461
+
462
+ // Raised for every host, because the failure it explains is the same everywhere:
463
+ // everything reads healthy and the session still has no Extuitive tools. Doctor cannot
464
+ // tell whether a new session has been started since — it runs in a shell, not in the
465
+ // session — so it says the fact and leaves the "if you have not" to the reader. The skill
466
+ // itself needs no restart on any host, which is why this speaks only of the server.
467
+ // Not raised alongside `needs_auth`, whose fix already says it.
468
+ const settled = ["registered", "connected", "unverifiable"].includes(server.state);
469
+ if (["installed", "built"].includes(skills.state) === true && settled === true) {
470
+ problems.push({
471
+ what:
472
+ server.state === "connected"
473
+ ? `${host.label} connects MCP servers when a ${host.sessionNoun} starts, so a ${host.sessionNoun} older than this registration or sign-in does not have the Extuitive tools.`
474
+ : serverAvailabilityNotice(host),
475
+ fix: `If the Extuitive tools are not in your current ${host.sessionNoun}, start a new ${host.label} ${host.sessionNoun}.`,
476
+ advisory: true,
477
+ });
478
+ }
479
+
480
+ return {
481
+ host,
482
+ cliAvailable,
483
+ cliResolution: host.cliResolution,
484
+ configPresent,
485
+ skillsRoot: inspection.destinationRoot,
486
+ skills,
487
+ inspection,
488
+ previous,
489
+ shadowingBackups,
490
+ server,
491
+ problems,
492
+ };
493
+ }
494
+
495
+ /** The whole picture: every detected host, plus one shared endpoint probe. */
496
+ export async function diagnose(options = {}) {
497
+ const { hosts = null, endpoint = DEFAULT_MCP_ENDPOINT } = options;
498
+ const detections = detectHosts().filter((detection) =>
499
+ hosts === null ? detection.installed === true : hosts.includes(detection.host.id),
500
+ );
501
+
502
+ const reports = [];
503
+ for (const detection of detections) {
504
+ reports.push(await diagnoseHost(detection, options));
505
+ }
506
+
507
+ return {
508
+ endpoint,
509
+ probe: await probeEndpoint(endpoint),
510
+ hosts: reports,
511
+ anyHostDetected: detections.length > 0,
512
+ };
513
+ }
package/src/exec.mjs ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Process helpers.
3
+ *
4
+ * Everything here uses `spawnSync` with an argument array rather than a shell string. The
5
+ * installer passes user-supplied values — an endpoint, a directory — straight into these
6
+ * calls, and an argument array cannot be talked into running a second command the way a
7
+ * shell string can.
8
+ */
9
+ import { spawnSync } from "node:child_process";
10
+ import { existsSync } from "node:fs";
11
+
12
+ /**
13
+ * Whether a command is runnable, by asking the OS rather than guessing from a path.
14
+ *
15
+ * `where` on Windows, `which` elsewhere. A host can be installed without its CLI on PATH,
16
+ * so a false here means "cannot drive it from a script", not "not installed" — callers
17
+ * decide what to do with that distinction.
18
+ */
19
+ export function commandExists(command) {
20
+ const probe = process.platform === "win32" ? "where" : "which";
21
+ const result = spawnSync(probe, [command], {
22
+ stdio: "ignore",
23
+ shell: false,
24
+ });
25
+ return result.status === 0;
26
+ }
27
+
28
+ /**
29
+ * Find a CLI that actually runs, out of several places it might be.
30
+ *
31
+ * "On PATH" is not the same as "works". On this very machine `which codex` finds an npm
32
+ * wrapper whose vendored binary is missing, and every invocation dies with `spawn … ENOENT`
33
+ * while the real, working `codex` sits inside the Codex desktop app bundle. An installer
34
+ * that trusted `which` would register nothing and then report the server as "not
35
+ * registered" rather than "could not ask" — the wrong diagnosis with the wrong fix.
36
+ *
37
+ * So each candidate is run with `--version` first. Bare names are looked up on PATH; absolute
38
+ * paths are checked for existence. The first candidate that exits 0 wins and is what every
39
+ * later command spawns. If none work, the distinction between "found but broken" and "not
40
+ * found anywhere" is preserved, because doctor needs to say which.
41
+ *
42
+ * Returns `{ state: "available" | "broken" | "missing", path, detail, tried }`. For a bare
43
+ * name that works, `path` is the bare name, so printed commands stay short.
44
+ */
45
+ export function resolveCli(candidates, { timeoutMs = 5_000 } = {}) {
46
+ const tried = [];
47
+ let firstBroken = null;
48
+
49
+ for (const candidate of candidates) {
50
+ if (typeof candidate !== "string" || candidate.trim() === "") {
51
+ continue;
52
+ }
53
+
54
+ const isBare = candidate.includes("/") === false && candidate.includes("\\") === false;
55
+ if (isBare === true && commandExists(candidate) === false) {
56
+ tried.push({ path: candidate, state: "missing" });
57
+ continue;
58
+ }
59
+ if (isBare === false && existsSync(candidate) === false) {
60
+ tried.push({ path: candidate, state: "missing" });
61
+ continue;
62
+ }
63
+
64
+ const probe = run(candidate, ["--version"], { timeoutMs });
65
+ if (probe.ok === true) {
66
+ tried.push({ path: candidate, state: "available" });
67
+ return {
68
+ state: "available",
69
+ path: candidate,
70
+ detail: `${probe.stdout.trim() || probe.stderr.trim()}`.split("\n")[0],
71
+ tried,
72
+ };
73
+ }
74
+
75
+ const detail = (probe.stderr || probe.stdout).trim().split("\n")[0] || `exit ${probe.status}`;
76
+ tried.push({ path: candidate, state: "broken", detail });
77
+ if (firstBroken === null) {
78
+ firstBroken = { path: candidate, detail };
79
+ }
80
+ }
81
+
82
+ if (firstBroken !== null) {
83
+ return {
84
+ state: "broken",
85
+ path: firstBroken.path,
86
+ detail: `${firstBroken.path} is present but does not run: ${firstBroken.detail}`,
87
+ tried,
88
+ };
89
+ }
90
+ return { state: "missing", path: null, detail: "Not found on PATH or in any known location.", tried };
91
+ }
92
+
93
+ /**
94
+ * Run a command and hand back its outcome instead of throwing.
95
+ *
96
+ * Callers here are all doing setup that has a sensible fallback — print the manual step,
97
+ * report a degraded check — so a non-zero exit is data, not an exception. A missing binary
98
+ * surfaces as `ok: false` with `spawnError` set, which is different from a binary that ran
99
+ * and refused.
100
+ */
101
+ export function run(command, args, options = {}) {
102
+ const result = spawnSync(command, args, {
103
+ encoding: "utf8",
104
+ shell: false,
105
+ timeout: options.timeoutMs ?? 30_000,
106
+ ...options,
107
+ });
108
+
109
+ if (result.error !== undefined) {
110
+ return {
111
+ ok: false,
112
+ status: null,
113
+ stdout: "",
114
+ stderr: String(result.error.message ?? result.error),
115
+ spawnError: true,
116
+ // Reported separately because it is the one failure with a different cause: the
117
+ // command was found and started fine, it just never finished. Collapsing it into
118
+ // "could not run" would send someone looking for a broken install instead.
119
+ timedOut: result.error.code === "ETIMEDOUT",
120
+ };
121
+ }
122
+
123
+ return {
124
+ ok: result.status === 0,
125
+ status: result.status,
126
+ stdout: result.stdout ?? "",
127
+ stderr: result.stderr ?? "",
128
+ spawnError: false,
129
+ timedOut: false,
130
+ };
131
+ }
132
+
133
+ /** A shell-ready rendering of a command, for printing a step the user has to run by hand. */
134
+ export function formatCommand(command, args) {
135
+ const parts = [command, ...args].map((part) =>
136
+ /[\s"'$`\\]/.test(part) === true ? JSON.stringify(part) : part,
137
+ );
138
+ return parts.join(" ");
139
+ }