@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/bin/cli.mjs
ADDED
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `npx extuitive install | update | uninstall | doctor`
|
|
4
|
+
*
|
|
5
|
+
* Reached through the `extuitive` launcher package (`packages/extuitive`), whose only job is
|
|
6
|
+
* to import this file; `npx github:fl100inc/extuitive-skill` runs it straight from the repo.
|
|
7
|
+
*
|
|
8
|
+
* Argument parsing, prompting, and printing. All the decisions live in `src/`; this file
|
|
9
|
+
* exists to turn them into something readable in a terminal — and, increasingly, readable by
|
|
10
|
+
* an agent that will summarise it for a person. That second reader is why the completion
|
|
11
|
+
* output is a fixed-shape block with one line per fact (skill, server, sign-in) rather than
|
|
12
|
+
* prose: an agent can repeat three labelled lines faithfully and will paraphrase a paragraph.
|
|
13
|
+
* `--json` exists for the same reader.
|
|
14
|
+
*
|
|
15
|
+
* One rule worth stating: with no `--host` and no TTY, this refuses rather than guessing.
|
|
16
|
+
* Writing into someone's home directory from a CI job that meant to do nothing of the sort
|
|
17
|
+
* is not a mistake worth being convenient about.
|
|
18
|
+
*/
|
|
19
|
+
import { createInterface } from "node:readline/promises";
|
|
20
|
+
import process from "node:process";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_MCP_ENDPOINT,
|
|
24
|
+
EXAMPLE_PROMPTS,
|
|
25
|
+
NPX_COMMAND,
|
|
26
|
+
PACKAGE_NAME,
|
|
27
|
+
SKILL_COMMANDS,
|
|
28
|
+
} from "../src/constants.mjs";
|
|
29
|
+
import { detectHosts, displayPath, getHost, HOST_IDS } from "../src/hosts.mjs";
|
|
30
|
+
import {
|
|
31
|
+
backupsRoot,
|
|
32
|
+
buildSkillBundles,
|
|
33
|
+
inspectInstalledSkills,
|
|
34
|
+
inspectSkillBundles,
|
|
35
|
+
installSkills,
|
|
36
|
+
removeSkillBundles,
|
|
37
|
+
uninstallSkills,
|
|
38
|
+
} from "../src/install.mjs";
|
|
39
|
+
import {
|
|
40
|
+
authInstructions,
|
|
41
|
+
manualSteps,
|
|
42
|
+
registerMcpServer,
|
|
43
|
+
serverAvailabilityNotice,
|
|
44
|
+
skillAvailabilityNotice,
|
|
45
|
+
unregisterMcpServer,
|
|
46
|
+
} from "../src/mcp-setup.mjs";
|
|
47
|
+
import { diagnose, readHostServerStatus } from "../src/doctor.mjs";
|
|
48
|
+
|
|
49
|
+
const USAGE = `
|
|
50
|
+
${PACKAGE_NAME} — install the Extuitive agent skill and connect it to the Extuitive MCP server.
|
|
51
|
+
|
|
52
|
+
Usage
|
|
53
|
+
${NPX_COMMAND} install [options]
|
|
54
|
+
${NPX_COMMAND} update [options]
|
|
55
|
+
${NPX_COMMAND} uninstall [options]
|
|
56
|
+
${NPX_COMMAND} doctor [options]
|
|
57
|
+
|
|
58
|
+
Hosts
|
|
59
|
+
claude The claude CLI, and the Code tab of the Claude Desktop app.
|
|
60
|
+
codex The Codex CLI, the Codex desktop app, and the IDE extension,
|
|
61
|
+
which share one config and one skills directory.
|
|
62
|
+
claude-desktop The Chat and Cowork tabs of the Claude Desktop app. Skills there
|
|
63
|
+
are uploaded to your account rather than copied to disk, so this
|
|
64
|
+
builds a .zip and tells you where to add it.
|
|
65
|
+
|
|
66
|
+
Options
|
|
67
|
+
--host <name|all> Which host to set up. Required without a TTY.
|
|
68
|
+
--scope <user|project> Install for every project or just this one. Default: user.
|
|
69
|
+
--dir <path> Install into this directory instead of the host's default.
|
|
70
|
+
--endpoint <url> MCP endpoint. Default: ${DEFAULT_MCP_ENDPOINT}
|
|
71
|
+
--keep-server Leave the MCP server registered (uninstall only).
|
|
72
|
+
--dry-run Report what would change without changing anything.
|
|
73
|
+
--yes, -y Accept defaults; never prompt.
|
|
74
|
+
--json Machine-readable output.
|
|
75
|
+
--help, -h Show this message.
|
|
76
|
+
|
|
77
|
+
Where the skill goes
|
|
78
|
+
Claude Code ~/.claude/skills/extuitive
|
|
79
|
+
Codex $CODEX_HOME/skills/extuitive (~/.codex/skills/extuitive)
|
|
80
|
+
Claude Desktop ~/.extuitive-skill/bundles/extuitive.zip, for you to upload
|
|
81
|
+
|
|
82
|
+
update refreshes an install already on this machine and stays quiet when there is
|
|
83
|
+
nothing to do. uninstall removes the skill and the MCP registration; it never
|
|
84
|
+
touches your backups.
|
|
85
|
+
|
|
86
|
+
Installs one skill, "extuitive", invoked with a command:
|
|
87
|
+
${SKILL_COMMANDS.map((command) => `/extuitive ${command}`).join("\n ")}
|
|
88
|
+
|
|
89
|
+
No Extuitive account needed in advance — you can create one during sign-in.
|
|
90
|
+
`.trim();
|
|
91
|
+
|
|
92
|
+
function parseArgs(argv) {
|
|
93
|
+
const options = {
|
|
94
|
+
command: null,
|
|
95
|
+
host: null,
|
|
96
|
+
scope: "user",
|
|
97
|
+
dir: null,
|
|
98
|
+
endpoint: DEFAULT_MCP_ENDPOINT,
|
|
99
|
+
writeConfig: false,
|
|
100
|
+
keepServer: false,
|
|
101
|
+
dryRun: false,
|
|
102
|
+
yes: false,
|
|
103
|
+
json: false,
|
|
104
|
+
help: false,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const rest = [...argv];
|
|
108
|
+
while (rest.length > 0) {
|
|
109
|
+
const arg = rest.shift();
|
|
110
|
+
|
|
111
|
+
if (arg === "--help" || arg === "-h") {
|
|
112
|
+
options.help = true;
|
|
113
|
+
} else if (arg === "--dry-run") {
|
|
114
|
+
options.dryRun = true;
|
|
115
|
+
} else if (arg === "--yes" || arg === "-y") {
|
|
116
|
+
options.yes = true;
|
|
117
|
+
} else if (arg === "--write-config") {
|
|
118
|
+
// Accepted and ignored. It used to permit enabling Codex's `[features] skills` flag,
|
|
119
|
+
// which Codex no longer has. Rejecting it would break every script and README that
|
|
120
|
+
// still passes it.
|
|
121
|
+
options.writeConfig = true;
|
|
122
|
+
} else if (arg === "--keep-server") {
|
|
123
|
+
options.keepServer = true;
|
|
124
|
+
} else if (arg === "--json") {
|
|
125
|
+
options.json = true;
|
|
126
|
+
} else if (arg === "--host") {
|
|
127
|
+
options.host = rest.shift() ?? null;
|
|
128
|
+
} else if (arg === "--scope") {
|
|
129
|
+
options.scope = rest.shift() ?? "user";
|
|
130
|
+
} else if (arg === "--dir") {
|
|
131
|
+
options.dir = rest.shift() ?? null;
|
|
132
|
+
} else if (arg === "--endpoint") {
|
|
133
|
+
options.endpoint = rest.shift() ?? DEFAULT_MCP_ENDPOINT;
|
|
134
|
+
} else if (arg.startsWith("-") === true) {
|
|
135
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
136
|
+
} else if (options.command === null) {
|
|
137
|
+
options.command = arg;
|
|
138
|
+
} else {
|
|
139
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return options;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const COMMANDS = ["install", "update", "uninstall", "doctor"];
|
|
147
|
+
|
|
148
|
+
function validate(options) {
|
|
149
|
+
if (options.command !== null && COMMANDS.includes(options.command) === false) {
|
|
150
|
+
throw new Error(`Unknown command: ${options.command}`);
|
|
151
|
+
}
|
|
152
|
+
if (["user", "project"].includes(options.scope) === false) {
|
|
153
|
+
throw new Error(`--scope must be user or project, got: ${options.scope}`);
|
|
154
|
+
}
|
|
155
|
+
// `both` predates there being three hosts. Kept working rather than rejected, because it
|
|
156
|
+
// is in the README, in shell history, and in whatever notes people wrote down — and it
|
|
157
|
+
// has only ever meant "all of them".
|
|
158
|
+
if (options.host !== null && [...HOST_IDS, "all", "both"].includes(options.host) === false) {
|
|
159
|
+
throw new Error(`--host must be one of ${HOST_IDS.join(", ")}, or all — got: ${options.host}`);
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
new URL(options.endpoint);
|
|
163
|
+
} catch {
|
|
164
|
+
throw new Error(`--endpoint is not a valid URL: ${options.endpoint}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const BULLET = " •";
|
|
169
|
+
|
|
170
|
+
function heading(text) {
|
|
171
|
+
console.log(`\n${text}`);
|
|
172
|
+
console.log("─".repeat(Math.min(text.length, 72)));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function resolveHostIds(options, detections) {
|
|
176
|
+
if (options.host === "all" || options.host === "both") {
|
|
177
|
+
return [...HOST_IDS];
|
|
178
|
+
}
|
|
179
|
+
if (options.host !== null) {
|
|
180
|
+
return [options.host];
|
|
181
|
+
}
|
|
182
|
+
return detections.filter((detection) => detection.installed === true).map((d) => d.host.id);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Which hosts to set up, asked rather than assumed.
|
|
187
|
+
*
|
|
188
|
+
* Every option carries what it covers, because two of the three overlap on one machine: the
|
|
189
|
+
* Claude Desktop app appears twice, once as its Code tab under Claude Code and once as its
|
|
190
|
+
* Chat and Cowork tabs. Someone shown a bare "Claude Code / Claude Desktop" pair has no way
|
|
191
|
+
* to know that, and picking one when they needed both is the mistake this list exists to
|
|
192
|
+
* prevent.
|
|
193
|
+
*/
|
|
194
|
+
async function promptForHosts(detections) {
|
|
195
|
+
const installed = detections.filter((detection) => detection.installed === true);
|
|
196
|
+
|
|
197
|
+
if (installed.length === 0) {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
if (installed.length === 1) {
|
|
201
|
+
console.log(`Found ${installed[0].host.label} — ${installed[0].host.surfaces}.`);
|
|
202
|
+
return [installed[0].host.id];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
206
|
+
try {
|
|
207
|
+
const labels = installed.map(
|
|
208
|
+
(detection, index) =>
|
|
209
|
+
` ${index + 1}) ${detection.host.label} — ${detection.host.surfaces}`,
|
|
210
|
+
);
|
|
211
|
+
console.log("Found more than one host:");
|
|
212
|
+
console.log(labels.join("\n"));
|
|
213
|
+
console.log(` ${installed.length + 1}) All of them`);
|
|
214
|
+
const answer = (await rl.question("Install into which? [all] ")).trim();
|
|
215
|
+
|
|
216
|
+
if (answer === "" || answer === String(installed.length + 1)) {
|
|
217
|
+
return installed.map((detection) => detection.host.id);
|
|
218
|
+
}
|
|
219
|
+
const index = Number.parseInt(answer, 10) - 1;
|
|
220
|
+
if (Number.isInteger(index) === true && index >= 0 && index < installed.length) {
|
|
221
|
+
return [installed[index].host.id];
|
|
222
|
+
}
|
|
223
|
+
return installed.map((detection) => detection.host.id);
|
|
224
|
+
} finally {
|
|
225
|
+
rl.close();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* ------------------------------------------------------------------------------------------ */
|
|
230
|
+
/* Setting a host up, as data */
|
|
231
|
+
/* ------------------------------------------------------------------------------------------ */
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Sign-in, as something we can print without lying about it.
|
|
235
|
+
*
|
|
236
|
+
* Install cannot see the host's credential store, so the only states it can honestly claim
|
|
237
|
+
* are the ones the host reports (`connected`, `needs_auth`) and "we just registered this, so
|
|
238
|
+
* nobody has signed in yet". Everything else is `unverified`, which is printed with the
|
|
239
|
+
* sign-in step and a note that it may already be done.
|
|
240
|
+
*/
|
|
241
|
+
function signInState(host, { registration, server }) {
|
|
242
|
+
const auth = authInstructions(host);
|
|
243
|
+
const instruction =
|
|
244
|
+
auth.inSession === true
|
|
245
|
+
? `In a new ${host.label} session: ${auth.primary}`
|
|
246
|
+
: auth.primary;
|
|
247
|
+
|
|
248
|
+
if (registration.status === "manual_only") {
|
|
249
|
+
// Adding the connector and signing in are one flow in the app, so the instruction is
|
|
250
|
+
// the sign-in half of a step the person has not taken yet.
|
|
251
|
+
return { state: "in_app", instruction, inSession: false };
|
|
252
|
+
}
|
|
253
|
+
if (["skipped_dry_run", "cli_missing", "cli_broken", "failed"].includes(registration.status)) {
|
|
254
|
+
return { state: "after_registration", instruction, inSession: auth.inSession };
|
|
255
|
+
}
|
|
256
|
+
if (server?.state === "connected") {
|
|
257
|
+
return { state: "connected", instruction, inSession: auth.inSession };
|
|
258
|
+
}
|
|
259
|
+
if (server?.state === "needs_auth") {
|
|
260
|
+
return { state: "needed", instruction, inSession: auth.inSession };
|
|
261
|
+
}
|
|
262
|
+
if (registration.status === "registered" && server === null) {
|
|
263
|
+
// Just registered and no way to ask: nobody has signed in to a server that did not
|
|
264
|
+
// exist a moment ago.
|
|
265
|
+
return { state: "needed", instruction, inSession: auth.inSession };
|
|
266
|
+
}
|
|
267
|
+
return { state: "unverified", instruction, inSession: auth.inSession };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Put the skill where the host will find it, whichever of the two things that means.
|
|
272
|
+
*
|
|
273
|
+
* The returned `bundle` is the archive to be uploaded, or null for a host that reads a
|
|
274
|
+
* directory. Callers use its presence rather than the host id to decide what to print.
|
|
275
|
+
*/
|
|
276
|
+
async function placeSkills(host, options) {
|
|
277
|
+
if (host.skillDelivery === "bundle") {
|
|
278
|
+
const result = await buildSkillBundles(host, {
|
|
279
|
+
dir: options.dir,
|
|
280
|
+
dryRun: options.dryRun,
|
|
281
|
+
});
|
|
282
|
+
return { ...result, bundle: result.skills[0]?.destination ?? null };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const result = await installSkills(host, {
|
|
286
|
+
scope: options.scope,
|
|
287
|
+
dir: options.dir,
|
|
288
|
+
dryRun: options.dryRun,
|
|
289
|
+
});
|
|
290
|
+
return { ...result, bundle: null };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Everything install or update does for one host, returned rather than printed.
|
|
295
|
+
*
|
|
296
|
+
* `update` differs from `install` in one decision only: it asks the host whether the server
|
|
297
|
+
* is registered before registering it, because it runs repeatedly and re-adding a server
|
|
298
|
+
* that exists is a refusal both CLIs print as an error. Install registers unconditionally
|
|
299
|
+
* and treats "already exists" as success.
|
|
300
|
+
*/
|
|
301
|
+
async function setupHost(host, detection, options, { mode }) {
|
|
302
|
+
const cliAvailable = detection?.cliAvailable ?? false;
|
|
303
|
+
|
|
304
|
+
const skills = await placeSkills(host, options);
|
|
305
|
+
|
|
306
|
+
let server = null;
|
|
307
|
+
let registration;
|
|
308
|
+
|
|
309
|
+
if (host.mcpSetup === "connector-ui") {
|
|
310
|
+
// No side effects on either path: registration is a list of steps and the status is
|
|
311
|
+
// "cannot be read from here". Asked the same way in both modes so the summary is too.
|
|
312
|
+
registration = await registerMcpServer(host, { endpoint: options.endpoint, scope: options.scope });
|
|
313
|
+
server = readHostServerStatus(host, { cliAvailable });
|
|
314
|
+
} else if (mode === "update") {
|
|
315
|
+
server = readHostServerStatus(host, { cliAvailable });
|
|
316
|
+
if (server.state === "absent") {
|
|
317
|
+
registration = await registerMcpServer(host, {
|
|
318
|
+
endpoint: options.endpoint,
|
|
319
|
+
scope: options.scope,
|
|
320
|
+
dryRun: options.dryRun,
|
|
321
|
+
cliAvailable,
|
|
322
|
+
});
|
|
323
|
+
} else {
|
|
324
|
+
registration = { status: server.state === "unknown" ? "unknown" : "already_registered", command: null };
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
registration = await registerMcpServer(host, {
|
|
328
|
+
endpoint: options.endpoint,
|
|
329
|
+
scope: options.scope,
|
|
330
|
+
dryRun: options.dryRun,
|
|
331
|
+
cliAvailable,
|
|
332
|
+
});
|
|
333
|
+
// Asked even after a fresh registration, because a token from an earlier install may
|
|
334
|
+
// still be in the host's credential store — Codex keeps them in the keychain, keyed by
|
|
335
|
+
// server, and removing the server does not remove the token. Saying "sign in" to someone
|
|
336
|
+
// who is signed in is the small lie that makes people distrust the rest.
|
|
337
|
+
if (["registered", "already_registered"].includes(registration.status) && cliAvailable === true) {
|
|
338
|
+
server = readHostServerStatus(host, { cliAvailable });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const signIn = signInState(host, { registration, server });
|
|
343
|
+
const changed = skills.skills.some(
|
|
344
|
+
(skill) => skill.action !== "unchanged" || (skill.migrated ?? []).length > 0,
|
|
345
|
+
);
|
|
346
|
+
const registeredNow = registration.status === "registered";
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
host: host.id,
|
|
350
|
+
label: host.label,
|
|
351
|
+
cli: host.cliResolution,
|
|
352
|
+
delivery: host.skillDelivery,
|
|
353
|
+
skillsRoot: skills.destinationRoot,
|
|
354
|
+
skills: skills.skills,
|
|
355
|
+
bundle: skills.bundle,
|
|
356
|
+
registration,
|
|
357
|
+
server,
|
|
358
|
+
signIn,
|
|
359
|
+
availability: {
|
|
360
|
+
skill: skillAvailabilityNotice(host),
|
|
361
|
+
server: serverAvailabilityNotice(host),
|
|
362
|
+
},
|
|
363
|
+
examples: EXAMPLE_PROMPTS,
|
|
364
|
+
changed,
|
|
365
|
+
registeredNow,
|
|
366
|
+
dryRun: options.dryRun,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/* ------------------------------------------------------------------------------------------ */
|
|
371
|
+
/* Printing */
|
|
372
|
+
/* ------------------------------------------------------------------------------------------ */
|
|
373
|
+
|
|
374
|
+
const LABEL_WIDTH = 12;
|
|
375
|
+
const STATE_WIDTH = 15;
|
|
376
|
+
|
|
377
|
+
function row(label, state, rest = "") {
|
|
378
|
+
const lead = ` ${label.padEnd(LABEL_WIDTH)}${state.padEnd(STATE_WIDTH)}`;
|
|
379
|
+
console.log(rest === "" ? lead.trimEnd() : `${lead}${rest}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** A continuation line under a row, aligned with the row's third column. */
|
|
383
|
+
function cont(text) {
|
|
384
|
+
console.log(` ${"".padEnd(LABEL_WIDTH + STATE_WIDTH)}${text}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function skillStateWord(skill, dryRun, bundled) {
|
|
388
|
+
if (skill.action === "unchanged") {
|
|
389
|
+
return "up to date";
|
|
390
|
+
}
|
|
391
|
+
const words = bundled === true
|
|
392
|
+
? { created: ["built", "would build"], replaced: ["rebuilt", "would rebuild"] }
|
|
393
|
+
: { created: ["installed", "would install"], replaced: ["updated", "would update"] };
|
|
394
|
+
return words[skill.action][dryRun === true ? 1 : 0];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function printSkillRows(host, report, options) {
|
|
398
|
+
const bundled = report.delivery === "bundle";
|
|
399
|
+
|
|
400
|
+
// Said rather than passed over. A flag that was accepted and then had no effect is the
|
|
401
|
+
// kind of thing someone finds out about weeks later, by wondering why a skill they scoped
|
|
402
|
+
// to one project is available in all of them.
|
|
403
|
+
if (options.scope === "project" && host.supportsScope === false) {
|
|
404
|
+
console.log(` Ignoring --scope project: a ${host.label} skill belongs to your account,`);
|
|
405
|
+
console.log(" not to a directory, so there is no per-project install to make.");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
for (const skill of report.skills) {
|
|
409
|
+
const size = typeof skill.bytes === "number" ? ` (${describeSize(skill.bytes)})` : "";
|
|
410
|
+
row(bundled === true ? "Bundle" : "Skill", skillStateWord(skill, report.dryRun, bundled), `${displayPath(skill.destination)}${size}`);
|
|
411
|
+
if (bundled === false) {
|
|
412
|
+
cont(skill.skillFile);
|
|
413
|
+
}
|
|
414
|
+
if (skill.backup !== null) {
|
|
415
|
+
cont(`previous copy kept at ${displayPath(skill.backup)}`);
|
|
416
|
+
}
|
|
417
|
+
for (const move of skill.migrated ?? []) {
|
|
418
|
+
if (move.action === "removed") {
|
|
419
|
+
cont(`${report.dryRun ? "would move" : "moved"} from ${displayPath(move.from)}`);
|
|
420
|
+
} else if (move.action === "backed_up") {
|
|
421
|
+
cont(
|
|
422
|
+
`${report.dryRun ? "would move" : "moved"} from ${displayPath(move.from)}` +
|
|
423
|
+
(move.backup === null ? " (that copy differed; it would be kept in backups)" : ` (that copy differed; kept at ${displayPath(move.backup)})`),
|
|
424
|
+
);
|
|
425
|
+
} else {
|
|
426
|
+
cont(`left a copy at ${displayPath(move.from)} — the new copy could not be verified`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (skill.verified === false) {
|
|
430
|
+
cont("the installed files do not match the bundled skill; rerun install");
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function printServerRow(host, report) {
|
|
436
|
+
const { registration } = report;
|
|
437
|
+
|
|
438
|
+
if (registration.status === "manual_only") {
|
|
439
|
+
// Not a fallback. This host has no CLI and no config file we may write, so the panel
|
|
440
|
+
// is the install. Saying so keeps it from reading as a refusal.
|
|
441
|
+
row("Connector", "add in app", "Settings > Connectors > Add custom connector");
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (registration.status === "registered") {
|
|
446
|
+
row("MCP server", "registered", registration.command);
|
|
447
|
+
} else if (registration.status === "already_registered") {
|
|
448
|
+
row("MCP server", "registered", registration.command === null ? "already there" : `already there (${registration.command})`);
|
|
449
|
+
} else if (registration.status === "skipped_dry_run") {
|
|
450
|
+
row("MCP server", "would add", registration.command);
|
|
451
|
+
} else if (registration.status === "unknown") {
|
|
452
|
+
row("MCP server", "unknown", report.server?.detail ?? "could not ask the host");
|
|
453
|
+
} else if (registration.status === "cli_missing") {
|
|
454
|
+
row("MCP server", "not added", `${host.cli} is not on PATH`);
|
|
455
|
+
} else if (registration.status === "cli_broken") {
|
|
456
|
+
row("MCP server", "not added", `${host.cli} does not run — ${registration.detail}`);
|
|
457
|
+
} else {
|
|
458
|
+
row("MCP server", "not added", registration.detail);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (["cli_missing", "cli_broken", "failed"].includes(registration.status)) {
|
|
462
|
+
cont(`Register it yourself: ${registration.command}`);
|
|
463
|
+
if (registration.manual !== undefined) {
|
|
464
|
+
cont(`or add to ${displayPath(registration.manual.path)}:`);
|
|
465
|
+
for (const line of registration.manual.body.trimEnd().split("\n")) {
|
|
466
|
+
cont(` ${line}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function printSignInRow(report) {
|
|
473
|
+
const { signIn } = report;
|
|
474
|
+
if (signIn.state === "connected") {
|
|
475
|
+
row("Sign-in", "connected", report.server?.detail ?? "");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (signIn.state === "needed") {
|
|
479
|
+
row("Sign-in", "needed", signIn.instruction);
|
|
480
|
+
} else if (signIn.state === "in_app") {
|
|
481
|
+
row("Sign-in", "in the app", signIn.instruction);
|
|
482
|
+
} else if (signIn.state === "after_registration") {
|
|
483
|
+
row("Sign-in", "needed", `after registering: ${signIn.instruction}`);
|
|
484
|
+
} else {
|
|
485
|
+
row("Sign-in", "unverified", signIn.instruction);
|
|
486
|
+
cont("If the Extuitive tools already work for you, this is done.");
|
|
487
|
+
}
|
|
488
|
+
cont("Opens a browser; only you can complete it.");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The block an installing agent is expected to relay. Three facts, then when each becomes
|
|
493
|
+
* usable, then what to type. Ordered so the true part ("the skill is available") comes first
|
|
494
|
+
* and the conditional part ("once you sign in") is attached to the thing it conditions.
|
|
495
|
+
*/
|
|
496
|
+
function printSummary(host, report, options) {
|
|
497
|
+
printSkillRows(host, report, options);
|
|
498
|
+
printServerRow(host, report);
|
|
499
|
+
printSignInRow(report);
|
|
500
|
+
|
|
501
|
+
console.log("");
|
|
502
|
+
const serverRegistered = ["registered", "already_registered"].includes(report.registration.status);
|
|
503
|
+
if (report.dryRun === true) {
|
|
504
|
+
console.log(" Nothing was changed.");
|
|
505
|
+
} else if (report.registration.status === "manual_only") {
|
|
506
|
+
console.log(` ${report.availability.skill} ${report.availability.server}`);
|
|
507
|
+
} else if (report.signIn.state === "connected") {
|
|
508
|
+
console.log(` ${report.availability.skill} The Extuitive tools are connected.`);
|
|
509
|
+
} else if (serverRegistered === true) {
|
|
510
|
+
console.log(` ${report.availability.skill} ${report.availability.server}`);
|
|
511
|
+
} else {
|
|
512
|
+
console.log(` ${report.availability.skill} The Extuitive tools need the MCP server registered first (above).`);
|
|
513
|
+
}
|
|
514
|
+
if (host.invocationNote !== null) {
|
|
515
|
+
// Worth stating because the other hosts train the opposite habit: there is no
|
|
516
|
+
// `/extuitive` here, so the examples below are the whole invocation syntax.
|
|
517
|
+
console.log(` ${host.invocationNote}`);
|
|
518
|
+
}
|
|
519
|
+
console.log(` Try: ${report.examples.map((prompt) => `"${prompt}"`).join(" · ")}`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* What is left for the person to do, when anything is.
|
|
524
|
+
*
|
|
525
|
+
* Two different reasons lead here and they are framed differently. A CLI host lands here
|
|
526
|
+
* after something went wrong, and "finish this by hand" is the right frame. A connector-UI
|
|
527
|
+
* host lands here every time, because the panel is the install — and that frame would read
|
|
528
|
+
* as a failure of an install that worked exactly as designed.
|
|
529
|
+
*/
|
|
530
|
+
function printRemainingSteps(host, report, options) {
|
|
531
|
+
const manualOptions = { endpoint: options.endpoint, scope: options.scope, bundle: report.bundle };
|
|
532
|
+
if (report.registration.status === "manual_only") {
|
|
533
|
+
printManualSteps(host, manualOptions, `Do the rest in ${host.label}:`);
|
|
534
|
+
} else if (["cli_missing", "cli_broken", "failed"].includes(report.registration.status)) {
|
|
535
|
+
printManualSteps(host, manualOptions);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* The remaining steps, numbered.
|
|
541
|
+
*
|
|
542
|
+
* `lead` is a parameter because the same list arrives two ways. For a CLI host it is a
|
|
543
|
+
* fallback after something went wrong, and "finish this by hand" is the right frame. For
|
|
544
|
+
* Claude Desktop it is the entire supported route, and that frame would read as a failure
|
|
545
|
+
* of an install that worked exactly as designed.
|
|
546
|
+
*/
|
|
547
|
+
function printManualSteps(host, options, lead = `Finish setting up ${host.label} by hand:`) {
|
|
548
|
+
console.log(`\n ${lead}`);
|
|
549
|
+
for (const [index, step] of manualSteps(host, options).entries()) {
|
|
550
|
+
console.log(`\n ${index + 1}. ${step.title}`);
|
|
551
|
+
for (const line of step.body.split("\n")) {
|
|
552
|
+
console.log(` ${line}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** A bundle size, in the units a person reads a download in. */
|
|
558
|
+
function describeSize(bytes) {
|
|
559
|
+
if (typeof bytes !== "number") {
|
|
560
|
+
return "";
|
|
561
|
+
}
|
|
562
|
+
return bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 102.4) / 10} KB`;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function emitJson(command, options, reports) {
|
|
566
|
+
console.log(JSON.stringify({ command, dryRun: options.dryRun, hosts: reports }, null, 2));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/* ------------------------------------------------------------------------------------------ */
|
|
570
|
+
/* Commands */
|
|
571
|
+
/* ------------------------------------------------------------------------------------------ */
|
|
572
|
+
|
|
573
|
+
async function commandInstall(options) {
|
|
574
|
+
const detections = detectHosts();
|
|
575
|
+
let hostIds = resolveHostIds(options, detections);
|
|
576
|
+
|
|
577
|
+
const interactive = process.stdin.isTTY === true && options.yes === false && options.json === false;
|
|
578
|
+
if (options.host === null) {
|
|
579
|
+
if (interactive === true) {
|
|
580
|
+
hostIds = await promptForHosts(detections);
|
|
581
|
+
} else if (hostIds.length === 0) {
|
|
582
|
+
throw new Error(
|
|
583
|
+
`No host detected and no --host given. Pass --host ${HOST_IDS.join(", --host ")}, or --host all.`,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (hostIds.length === 0) {
|
|
589
|
+
console.log("Could not find Claude Code, Codex, or Claude Desktop on this machine.");
|
|
590
|
+
console.log(`Install one, then run: ${NPX_COMMAND} install`);
|
|
591
|
+
return 1;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const reports = [];
|
|
595
|
+
for (const hostId of hostIds) {
|
|
596
|
+
const host = getHost(hostId);
|
|
597
|
+
const detection = detections.find((candidate) => candidate.host.id === hostId);
|
|
598
|
+
reports.push({ host, report: await setupHost(host, detection, options, { mode: "install" }) });
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (options.json === true) {
|
|
602
|
+
emitJson("install", options, reports.map((entry) => entry.report));
|
|
603
|
+
return 0;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (options.dryRun === true) {
|
|
607
|
+
console.log("Dry run — nothing will be written.");
|
|
608
|
+
}
|
|
609
|
+
if (options.writeConfig === true) {
|
|
610
|
+
console.log("Note: --write-config is no longer needed; Codex skills are on by default.");
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
for (const { host, report } of reports) {
|
|
614
|
+
heading(`${host.label} — ${host.surfaces}`);
|
|
615
|
+
printSummary(host, report, options);
|
|
616
|
+
printRemainingSteps(host, report, options);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
console.log("\nNo Extuitive account yet? The sign-in page has a Sign up button —");
|
|
620
|
+
console.log("create one there with a one-time email code, in the same step.");
|
|
621
|
+
console.log(`\nCheck everything with: ${NPX_COMMAND} doctor`);
|
|
622
|
+
return 0;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Hosts that already have a copy of the skill, which is not the same set as hosts that exist.
|
|
627
|
+
*
|
|
628
|
+
* Update refreshes what is there rather than installing anywhere new: someone who chose
|
|
629
|
+
* Claude Code only should not acquire a Codex install by running update. An explicit `--host`
|
|
630
|
+
* overrides that, since naming a host is asking for it. A copy at the previous location
|
|
631
|
+
* counts — that is exactly the person update exists to move.
|
|
632
|
+
*/
|
|
633
|
+
async function resolveUpdateTargets(options, detections) {
|
|
634
|
+
if (options.host !== null) {
|
|
635
|
+
return resolveHostIds(options, detections);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const targets = [];
|
|
639
|
+
for (const detection of detections.filter((candidate) => candidate.installed === true)) {
|
|
640
|
+
// For a bundle host "already installed" can only mean "a bundle was built here". The
|
|
641
|
+
// uploaded copy is in an account and cannot be seen from a shell, so a machine that
|
|
642
|
+
// built one and then had its bundle directory cleared drops out of update — which is
|
|
643
|
+
// the same conservative answer this gives a host whose skills were deleted by hand.
|
|
644
|
+
const inspection =
|
|
645
|
+
detection.host.skillDelivery === "bundle"
|
|
646
|
+
? await inspectSkillBundles(detection.host, { dir: options.dir })
|
|
647
|
+
: await inspectInstalledSkills(detection.host, { scope: options.scope, dir: options.dir });
|
|
648
|
+
|
|
649
|
+
const present = inspection.skills.some((skill) => skill.present === true);
|
|
650
|
+
if (present === true || (inspection.previous ?? []).length > 0) {
|
|
651
|
+
targets.push(detection.host.id);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return targets;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Refresh an existing install in place.
|
|
659
|
+
*
|
|
660
|
+
* `install` already does the file work correctly and idempotently, so this is deliberately
|
|
661
|
+
* thin. What makes it a separate command is what it does *not* print: an update is a routine
|
|
662
|
+
* thing to run repeatedly, and re-issuing the sign-up pitch every time trains people to
|
|
663
|
+
* ignore all of it. The summary block is short enough to print every time; the extras are
|
|
664
|
+
* not.
|
|
665
|
+
*/
|
|
666
|
+
async function commandUpdate(options) {
|
|
667
|
+
const detections = detectHosts();
|
|
668
|
+
const hostIds = await resolveUpdateTargets(options, detections);
|
|
669
|
+
|
|
670
|
+
if (hostIds.length === 0) {
|
|
671
|
+
if (options.json === true) {
|
|
672
|
+
emitJson("update", options, []);
|
|
673
|
+
} else {
|
|
674
|
+
console.log("The Extuitive skill is not installed here yet.");
|
|
675
|
+
console.log(`Install it with: ${NPX_COMMAND} install`);
|
|
676
|
+
}
|
|
677
|
+
return 1;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const reports = [];
|
|
681
|
+
for (const hostId of hostIds) {
|
|
682
|
+
const host = getHost(hostId);
|
|
683
|
+
const detection = detections.find((candidate) => candidate.host.id === hostId);
|
|
684
|
+
reports.push({ host, report: await setupHost(host, detection, options, { mode: "update" }) });
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (options.json === true) {
|
|
688
|
+
emitJson("update", options, reports.map((entry) => entry.report));
|
|
689
|
+
return 0;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (options.dryRun === true) {
|
|
693
|
+
console.log("Dry run — nothing will be written.");
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
for (const { host, report } of reports) {
|
|
697
|
+
heading(host.label);
|
|
698
|
+
printSummary(host, report, options);
|
|
699
|
+
if (report.changed === false && report.registeredNow === false) {
|
|
700
|
+
console.log("\n Already up to date.");
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
// A rebuilt bundle changes nothing until it is uploaded again, so this is the one host
|
|
704
|
+
// where a successful update still leaves the person with something to do.
|
|
705
|
+
if (report.bundle !== null && report.changed === true && options.dryRun === false) {
|
|
706
|
+
console.log(`\n Upload the new bundle to pick this up: ${displayPath(report.bundle)}`);
|
|
707
|
+
console.log(` In ${host.label}: Customize > Skills. Uploading again replaces the old copy.`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return 0;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async function commandUninstall(options) {
|
|
715
|
+
const detections = detectHosts();
|
|
716
|
+
const hostIds = resolveHostIds(options, detections);
|
|
717
|
+
|
|
718
|
+
if (hostIds.length === 0) {
|
|
719
|
+
console.log("No host detected. Nothing to remove.");
|
|
720
|
+
return 0;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (options.dryRun === true) {
|
|
724
|
+
console.log("Dry run — nothing will be removed.\n");
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
for (const hostId of hostIds) {
|
|
728
|
+
const host = getHost(hostId);
|
|
729
|
+
const detection = detections.find((candidate) => candidate.host.id === hostId);
|
|
730
|
+
heading(host.label);
|
|
731
|
+
|
|
732
|
+
const bundled = host.skillDelivery === "bundle";
|
|
733
|
+
const result = bundled === true
|
|
734
|
+
? await removeSkillBundles(host, { dir: options.dir, dryRun: options.dryRun })
|
|
735
|
+
: await uninstallSkills(host, { scope: options.scope, dir: options.dir, dryRun: options.dryRun });
|
|
736
|
+
|
|
737
|
+
for (const skill of result.skills) {
|
|
738
|
+
const name = bundled === true ? `${skill.name}.zip` : skill.name;
|
|
739
|
+
const where = skill.previousLocation === true ? ` from previous location ${displayPath(skill.destination)}` : ` (${displayPath(skill.destination)})`;
|
|
740
|
+
const verb = skill.action === "removed" && options.dryRun === true ? "would be removed" : skill.action;
|
|
741
|
+
console.log(`${BULLET} ${name} ${verb}${where}`);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (options.keepServer === true) {
|
|
745
|
+
console.log(`${BULLET} Left the MCP server registered (--keep-server).`);
|
|
746
|
+
} else {
|
|
747
|
+
const removal = await unregisterMcpServer(host, {
|
|
748
|
+
scope: options.scope,
|
|
749
|
+
dryRun: options.dryRun,
|
|
750
|
+
cliAvailable: detection?.cliAvailable ?? false,
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
if (removal.status === "unregistered") {
|
|
754
|
+
console.log(`${BULLET} Unregistered the MCP server (${removal.command})`);
|
|
755
|
+
} else if (removal.status === "already_absent") {
|
|
756
|
+
console.log(`${BULLET} MCP server was not registered`);
|
|
757
|
+
} else if (removal.status === "skipped_dry_run") {
|
|
758
|
+
console.log(`${BULLET} Would run: ${removal.command}`);
|
|
759
|
+
} else if (removal.status === "manual_only") {
|
|
760
|
+
console.log(`${BULLET} The connector is still there — removing it is a click, not a command:`);
|
|
761
|
+
for (const step of removal.steps) {
|
|
762
|
+
console.log(` ${step}`);
|
|
763
|
+
}
|
|
764
|
+
} else if (removal.status === "cli_missing") {
|
|
765
|
+
console.log(`${BULLET} ${host.cli} is not on PATH, so the server is still registered.`);
|
|
766
|
+
console.log(` Remove it yourself: ${removal.command}`);
|
|
767
|
+
} else if (removal.status === "cli_broken") {
|
|
768
|
+
console.log(`${BULLET} ${host.cli} does not run (${removal.detail}), so the server is still registered.`);
|
|
769
|
+
console.log(` Remove it yourself: ${removal.command}`);
|
|
770
|
+
} else {
|
|
771
|
+
console.log(`${BULLET} Could not unregister the MCP server: ${removal.detail}`);
|
|
772
|
+
console.log(` Remove it yourself: ${removal.command}`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Deleting the bundle removed the archive, not the skill. The skill went to an account
|
|
777
|
+
// when it was uploaded and is still there, on every device signed into it — so an
|
|
778
|
+
// uninstall that stopped at the file would be describing a removal that did not happen.
|
|
779
|
+
if (bundled === true) {
|
|
780
|
+
console.log(`\n The uploaded copy is in your Anthropic account, not on this machine.`);
|
|
781
|
+
console.log(` Remove it in ${host.label}: Customize > Skills, open extuitive,`);
|
|
782
|
+
console.log(" then the ... menu and Delete.");
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Said plainly because it is the one thing an uninstall cannot finish. The token lives
|
|
786
|
+
// in the host's own credential store, which is not ours to read or clear, so claiming a
|
|
787
|
+
// clean removal without mentioning it would be a claim we cannot back.
|
|
788
|
+
console.log(`\n Your sign-in is still in ${host.label}'s credential store; that is its`);
|
|
789
|
+
console.log(" to hold, not ours to clear. Revoking access is done from Extuitive.");
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
console.log(`\nBackups were left alone at ${displayPath(backupsRoot())}`);
|
|
793
|
+
console.log("They exist because an install found a skill it did not write. Delete them");
|
|
794
|
+
console.log("yourself once you are sure you do not want them.");
|
|
795
|
+
|
|
796
|
+
return 0;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function describeProbe(probe) {
|
|
800
|
+
if (probe.state === "reachable") {
|
|
801
|
+
return "reachable, awaiting sign-in";
|
|
802
|
+
}
|
|
803
|
+
if (probe.state === "authenticated") {
|
|
804
|
+
return "reachable and accepting requests";
|
|
805
|
+
}
|
|
806
|
+
if (probe.state === "unreachable") {
|
|
807
|
+
return `unreachable — ${probe.detail}`;
|
|
808
|
+
}
|
|
809
|
+
return probe.detail;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
async function commandDoctor(options) {
|
|
813
|
+
const everyHost = options.host === "all" || options.host === "both" || options.host === null;
|
|
814
|
+
const hostFilter = everyHost === true ? null : [options.host];
|
|
815
|
+
const report = await diagnose({
|
|
816
|
+
hosts: hostFilter,
|
|
817
|
+
scope: options.scope,
|
|
818
|
+
dir: options.dir,
|
|
819
|
+
endpoint: options.endpoint,
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
if (options.json === true) {
|
|
823
|
+
const serializable = {
|
|
824
|
+
...report,
|
|
825
|
+
hosts: report.hosts.map((entry) => ({ ...entry, host: entry.host.id })),
|
|
826
|
+
};
|
|
827
|
+
console.log(JSON.stringify(serializable, null, 2));
|
|
828
|
+
return report.hosts.some((entry) =>
|
|
829
|
+
entry.problems.some((problem) => problem.advisory !== true),
|
|
830
|
+
)
|
|
831
|
+
? 1
|
|
832
|
+
: 0;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
heading("Endpoint");
|
|
836
|
+
console.log(` ${report.endpoint}`);
|
|
837
|
+
console.log(` ${describeProbe(report.probe)}`);
|
|
838
|
+
|
|
839
|
+
if (report.anyHostDetected === false) {
|
|
840
|
+
console.log("\nNo Claude Code, Codex, or Claude Desktop installation found.");
|
|
841
|
+
console.log(`Install one, then run: ${NPX_COMMAND} install`);
|
|
842
|
+
return 1;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
let blocking = 0;
|
|
846
|
+
|
|
847
|
+
for (const entry of report.hosts) {
|
|
848
|
+
const bundled = entry.host.skillDelivery === "bundle";
|
|
849
|
+
heading(`${entry.host.label} — ${entry.host.surfaces}`);
|
|
850
|
+
const cli = entry.cliResolution;
|
|
851
|
+
if (cli.state !== "none") {
|
|
852
|
+
console.log(
|
|
853
|
+
` ${"CLI".padEnd(10)}${cli.state === "available" ? `${cli.path} (${cli.detail})` : cli.state === "broken" ? `broken — ${cli.detail}` : "not found"}`,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
console.log(` ${(bundled === true ? "Bundle" : "Skills").padEnd(10)}${displayPath(entry.skillsRoot)}`);
|
|
857
|
+
|
|
858
|
+
for (const skill of entry.inspection.skills) {
|
|
859
|
+
// The two hosts fail differently and so are marked differently. A copied skill is
|
|
860
|
+
// wrong when its declared name and its directory disagree; a bundle is wrong when it
|
|
861
|
+
// was built from an older version of the skill than the one in this package.
|
|
862
|
+
const healthy =
|
|
863
|
+
skill.present === true && (bundled === true ? skill.current : skill.nameMatches) === true;
|
|
864
|
+
console.log(`${BULLET} [${healthy === true ? "ok" : "--"}] ${skill.name}`);
|
|
865
|
+
}
|
|
866
|
+
for (const copy of entry.previous ?? []) {
|
|
867
|
+
console.log(`${BULLET} [--] ${copy.name} also at previous location ${displayPath(copy.path)}`);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
console.log(
|
|
871
|
+
` ${(bundled === true ? "Connector" : "Server").padEnd(10)}${entry.server.state} — ${entry.server.detail}`,
|
|
872
|
+
);
|
|
873
|
+
|
|
874
|
+
const real = entry.problems.filter((problem) => problem.advisory !== true);
|
|
875
|
+
const advisories = entry.problems.filter((problem) => problem.advisory === true);
|
|
876
|
+
blocking += real.length;
|
|
877
|
+
|
|
878
|
+
if (real.length === 0) {
|
|
879
|
+
console.log("\n Nothing blocking.");
|
|
880
|
+
}
|
|
881
|
+
for (const problem of real) {
|
|
882
|
+
console.log(`\n ${problem.what}`);
|
|
883
|
+
for (const line of problem.fix.split("\n")) {
|
|
884
|
+
console.log(` ${line}`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
for (const advisory of advisories) {
|
|
888
|
+
console.log(`\n Note: ${advisory.what}`);
|
|
889
|
+
console.log(` ${advisory.fix}`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
console.log("");
|
|
894
|
+
return blocking === 0 ? 0 : 1;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function main() {
|
|
898
|
+
let options;
|
|
899
|
+
try {
|
|
900
|
+
options = parseArgs(process.argv.slice(2));
|
|
901
|
+
validate(options);
|
|
902
|
+
} catch (error) {
|
|
903
|
+
console.error(`${error.message}\n`);
|
|
904
|
+
console.error(USAGE);
|
|
905
|
+
return 2;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (options.help === true || options.command === null) {
|
|
909
|
+
console.log(USAGE);
|
|
910
|
+
return options.command === null && options.help === false ? 2 : 0;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Dispatched by name with no fallback. A default of `doctor` would answer a mistyped
|
|
914
|
+
// command by silently running something else and reporting success.
|
|
915
|
+
const commands = {
|
|
916
|
+
install: commandInstall,
|
|
917
|
+
update: commandUpdate,
|
|
918
|
+
uninstall: commandUninstall,
|
|
919
|
+
doctor: commandDoctor,
|
|
920
|
+
};
|
|
921
|
+
|
|
922
|
+
return commands[options.command](options);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
main()
|
|
926
|
+
.then((code) => {
|
|
927
|
+
process.exitCode = code;
|
|
928
|
+
})
|
|
929
|
+
.catch((error) => {
|
|
930
|
+
console.error(`\n${error.message}`);
|
|
931
|
+
process.exitCode = 1;
|
|
932
|
+
});
|