@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/LICENSE +21 -0
- package/README.md +514 -0
- package/bin/cli.mjs +932 -0
- package/package.json +40 -0
- package/skills/extuitive/SKILL.md +63 -0
- package/skills/extuitive/references/connect.md +75 -0
- package/skills/extuitive/references/init.md +83 -0
- package/skills/extuitive/references/select.md +89 -0
- package/skills/extuitive/references/tools.md +252 -0
- package/skills/extuitive/references/upload-status.md +102 -0
- package/skills/extuitive/references/upload.md +160 -0
- package/skills/extuitive/scripts/upload.mjs +340 -0
- package/src/constants.mjs +63 -0
- package/src/doctor.mjs +513 -0
- package/src/exec.mjs +139 -0
- package/src/hosts.mjs +350 -0
- package/src/install.mjs +541 -0
- package/src/mcp-setup.mjs +476 -0
- package/src/zip.mjs +246 -0
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
|
+
}
|