@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.
@@ -0,0 +1,476 @@
1
+ /**
2
+ * The one place that knows how each host is told about an MCP server.
3
+ *
4
+ * Every command string for both hosts lives here. Install runs them, `doctor` prints them
5
+ * when something is missing, and the skills reference neither — they tell the agent to run
6
+ * `<npx> doctor` and relay what it says. That indirection exists because these
7
+ * strings are the most likely thing in the package to go stale: they belong to Claude Code
8
+ * and Codex, not to us, and a skill reciting a command that no longer exists is worse than
9
+ * a skill that stays quiet. Keeping them in one module means a host CLI change is a fix
10
+ * here and nowhere else.
11
+ *
12
+ * Registration deliberately shells out to each host's own CLI rather than editing its
13
+ * config file. The file formats differ (JSON for Claude Code, TOML for Codex), both have
14
+ * changed shape across versions, and a hand-written entry that a newer host rejects is
15
+ * harder to diagnose than a command that failed loudly. Hand-editing is the fallback only
16
+ * when the CLI is absent or broken, and then it is printed for the user rather than applied.
17
+ *
18
+ * Commands spawn `host.cliCommand`, which is whichever binary `resolveCli` found working —
19
+ * possibly the one inside the desktop app bundle — and print the same, so a step someone is
20
+ * told to run is the step that will actually run.
21
+ *
22
+ * Claude Desktop has neither a CLI nor a config file we may write, so everything below
23
+ * takes a second path for it, chosen by `mcpSetup` rather than by host id. See
24
+ * `connectorSteps` for why its config file is left alone even as a fallback.
25
+ */
26
+ import { DEFAULT_MCP_ENDPOINT, MCP_SERVER_NAME, NPX_COMMAND } from "./constants.mjs";
27
+ import { formatCommand, run } from "./exec.mjs";
28
+
29
+ /**
30
+ * The command that registers the server.
31
+ *
32
+ * `--scope user` on Claude Code is not a preference. The default is `local`, which binds the
33
+ * server to whichever directory the command ran in, while skills installed to
34
+ * `~/.claude/skills` are available everywhere — so the default would produce a setup that
35
+ * works in one project and looks broken in every other one.
36
+ */
37
+ export function registerCommand(host, { endpoint = DEFAULT_MCP_ENDPOINT, scope = "user" } = {}) {
38
+ if (host.mcpSetup !== "cli") {
39
+ throw new Error(`${host.label} has no CLI to register with; use connectorSteps instead.`);
40
+ }
41
+
42
+ if (host.id === "claude") {
43
+ const args = ["mcp", "add", "--transport", "http", MCP_SERVER_NAME, endpoint];
44
+ if (scope === "project") {
45
+ args.push("--scope", "project");
46
+ } else {
47
+ args.push("--scope", "user");
48
+ }
49
+ return { command: host.cliCommand, args };
50
+ }
51
+
52
+ if (host.id === "codex") {
53
+ return {
54
+ command: host.cliCommand,
55
+ args: ["mcp", "add", MCP_SERVER_NAME, "--url", endpoint],
56
+ };
57
+ }
58
+
59
+ throw new Error(`Unknown host: ${host.id}`);
60
+ }
61
+
62
+ /**
63
+ * The command that takes the server back out.
64
+ *
65
+ * Claude Code needs the scope repeated. Removal looks in one scope at a time and defaults to
66
+ * `local`, so a remove without `--scope user` reports success having deleted nothing — the
67
+ * user-scoped entry install wrote is still there. Codex keeps a single list and takes only a
68
+ * name.
69
+ */
70
+ export function unregisterCommand(host, { scope = "user" } = {}) {
71
+ if (host.mcpSetup !== "cli") {
72
+ throw new Error(`${host.label} has no CLI to unregister with; use connectorSteps instead.`);
73
+ }
74
+
75
+ if (host.id === "claude") {
76
+ return {
77
+ command: host.cliCommand,
78
+ args: ["mcp", "remove", MCP_SERVER_NAME, "--scope", scope === "project" ? "project" : "user"],
79
+ };
80
+ }
81
+
82
+ if (host.id === "codex") {
83
+ return { command: host.cliCommand, args: ["mcp", "remove", MCP_SERVER_NAME] };
84
+ }
85
+
86
+ throw new Error(`Unknown host: ${host.id}`);
87
+ }
88
+
89
+ /**
90
+ * The panel a person adds the server through, for a host with no CLI.
91
+ *
92
+ * This is the whole of Claude Desktop's setup, and it is deliberately not backed by a config
93
+ * file fallback the way the CLI hosts are. `claude_desktop_config.json` validates stdio
94
+ * servers only: an entry carrying a `url` is not ignored but *destructive* — Claude Desktop
95
+ * rewrites the file on next launch with the entire `mcpServers` block stripped out, taking
96
+ * any servers the person added by hand with it. There is a way to reach an HTTPS endpoint
97
+ * from that file, by spawning `npx mcp-remote` as a stdio bridge, and it is left out on
98
+ * purpose: it puts a second OAuth implementation and a background Node process between the
99
+ * app and a server the app can already talk to directly.
100
+ *
101
+ * Written as steps rather than prose because the caller prints them numbered, and because a
102
+ * connector added at the wrong level — the personal panel versus the organisation one — is
103
+ * a failure that looks like the URL being wrong.
104
+ */
105
+ export function connectorSteps(host, { endpoint = DEFAULT_MCP_ENDPOINT } = {}) {
106
+ return [
107
+ "Open Settings, then Connectors.",
108
+ "Click Add custom connector.",
109
+ `Paste this as the remote MCP server URL: ${endpoint}`,
110
+ "Click Add. Claude checks the URL and fills in the authentication settings it finds.",
111
+ ];
112
+ }
113
+
114
+ /**
115
+ * How a person completes OAuth.
116
+ *
117
+ * Never run for them. The flow opens a browser and finishes against their own session, so
118
+ * the honest thing is to name the step and stop.
119
+ *
120
+ * Claude Code gets `/mcp` and nothing else. `claude mcp login extuitive` is a real command
121
+ * on new enough versions, but offering it here was a bug: what reads this output is usually
122
+ * an agent inside a Claude Code session, and the one thing it can do with a shell command is
123
+ * run it — in bash mode, in a session that has no `extuitive` server to log into, on a
124
+ * version that may not have the subcommand at all. Every one of those fails in a way that
125
+ * looks like the install failed. `/mcp` is the person's own panel and cannot be run by
126
+ * mistake on their behalf.
127
+ *
128
+ * `inSession` says where the step happens, which decides the order of everything around it.
129
+ * Claude Code signs in from inside a session and so cannot do it before restarting; Codex
130
+ * signs in from a terminal and so can do it whenever.
131
+ */
132
+ export function authInstructions(host) {
133
+ if (host.id === "claude") {
134
+ return {
135
+ primary: "Run /mcp, choose extuitive, and approve access.",
136
+ alternative: null,
137
+ inSession: true,
138
+ };
139
+ }
140
+
141
+ if (host.id === "codex") {
142
+ return {
143
+ // The desktop app has its own button for this — Settings > MCP servers, then
144
+ // Authenticate — but the command is what gets printed, because it works from every
145
+ // Codex surface including the app, and because one instruction that always applies
146
+ // beats two that each apply sometimes.
147
+ primary: formatCommand(host.cliCommand, ["mcp", "login", MCP_SERVER_NAME]),
148
+ alternative: "In the Codex desktop app: Settings > MCP servers > Authenticate.",
149
+ inSession: false,
150
+ };
151
+ }
152
+
153
+ if (host.id === "claude-desktop") {
154
+ return {
155
+ // Adding the connector and signing in are one continuous flow here — the browser
156
+ // opens off the back of the Add, or off Connect if it was dismissed. Naming the
157
+ // second button matters for the person who closed the window and now sees a
158
+ // connector sitting there doing nothing.
159
+ primary: "Approve access in the browser window that opens after you add the connector.",
160
+ alternative: "If you closed it, click Connect next to extuitive in Settings > Connectors.",
161
+ inSession: false,
162
+ };
163
+ }
164
+
165
+ throw new Error(`Unknown host: ${host.id}`);
166
+ }
167
+
168
+ /**
169
+ * When the skill itself becomes usable, in the host's own terms.
170
+ *
171
+ * Every host now picks up new skills without a restart — Codex between turns, Claude Code as
172
+ * files change, Claude Desktop when a chat starts — so this is one sentence and not an
173
+ * instruction. It exists as a function because it is the sentence an installing agent will
174
+ * repeat to the person, and it must not drift from what the host does: telling someone to
175
+ * restart for a skill that is already live is how they learn to ignore the rest of the
176
+ * output.
177
+ */
178
+ export function skillAvailabilityNotice(host) {
179
+ if (host.skillDelivery === "bundle") {
180
+ // Nothing is live yet. The archive is on disk and the skill reaches the account only
181
+ // once someone uploads it, so the sentence names the upload rather than a wait.
182
+ return `Once uploaded, the skill is available in new ${host.sessionNoun}s.`;
183
+ }
184
+ if (host.loadsSkillsAtStartup === true) {
185
+ return `${host.label} reads skills once at startup, so the skill appears after you start a new ${host.sessionNoun}.`;
186
+ }
187
+ if (host.id === "codex") {
188
+ return "The skill is available on your next turn.";
189
+ }
190
+ return "The skill is available now.";
191
+ }
192
+
193
+ /**
194
+ * When the MCP server — and so the Extuitive tools — becomes usable.
195
+ *
196
+ * Stated as its own sentence everywhere it appears because it is the step people skip and
197
+ * then report as a broken install: the server is registered, the endpoint is fine, `mcp list`
198
+ * agrees, and the session still has no Extuitive tools. On Claude Code it also gates sign-in,
199
+ * so it has to be said before the sign-in step rather than after it.
200
+ */
201
+ export function serverAvailabilityNotice(host) {
202
+ if (host.id === "claude-desktop") {
203
+ // No application restart: a connector and an uploaded skill are both live as soon as
204
+ // they land. The chat is the thing with the stale view, and saying "restart Claude
205
+ // Desktop" would send someone quitting an app that did not need it and still landing
206
+ // back in the same conversation.
207
+ return `${host.label} gives a chat its tools and skills when the chat starts, so neither appears in a conversation that was already open — start a new chat.`;
208
+ }
209
+ if (host.id === "claude") {
210
+ return `${host.label} connects MCP servers when a session starts, so extuitive is not in the session you ran this from — and /mcp cannot sign in to a server that session never connected to.`;
211
+ }
212
+ return `${host.label} connects MCP servers when a session starts, so the Extuitive tools appear in a new session once you have signed in.`;
213
+ }
214
+
215
+ /**
216
+ * The command that reports per-server health, which `doctor` prefers over its own probing.
217
+ *
218
+ * `null` for a host that has no CLI. Doctor reports that as "cannot be checked from here"
219
+ * rather than as a problem, because the alternative — inferring from our own probe — would
220
+ * report every unsigned-in server as missing and every missing one as unsigned-in.
221
+ */
222
+ export function statusCommand(host) {
223
+ if (host.mcpSetup !== "cli") {
224
+ return null;
225
+ }
226
+ return { command: host.cliCommand, args: ["mcp", "list"] };
227
+ }
228
+
229
+ /**
230
+ * What to paste when the host CLI cannot be run and we will not guess at its config.
231
+ *
232
+ * `null` for a connector-UI host. Not because there is no file — there is one, right where
233
+ * you would expect — but because writing a remote server into it makes Claude Desktop
234
+ * delete the whole `mcpServers` block on next launch. Printing a snippet somebody could
235
+ * paste there would be handing them the destructive version of the thing they asked for.
236
+ */
237
+ export function manualConfigSnippet(host, { endpoint = DEFAULT_MCP_ENDPOINT } = {}) {
238
+ if (host.mcpSetup !== "cli") {
239
+ return null;
240
+ }
241
+
242
+ if (host.id === "codex") {
243
+ return {
244
+ path: host.configPath,
245
+ language: "toml",
246
+ body: `[mcp_servers.${MCP_SERVER_NAME}]\nurl = "${endpoint}"\n`,
247
+ };
248
+ }
249
+
250
+ return {
251
+ path: ".mcp.json (project) or ~/.claude.json (user)",
252
+ language: "json",
253
+ body: `${JSON.stringify(
254
+ {
255
+ mcpServers: {
256
+ [MCP_SERVER_NAME]: { type: "http", url: endpoint },
257
+ },
258
+ },
259
+ null,
260
+ 2,
261
+ )}\n`,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Register the server, or explain how to.
267
+ *
268
+ * A failure here is reported, not thrown. Skills are already on disk by this point and are
269
+ * useful the moment the server is registered by any means, so aborting the whole install
270
+ * over a CLI that refused would leave a worse state than finishing and printing the step.
271
+ *
272
+ * `cli_missing` and `cli_broken` are different statuses because they have different fixes:
273
+ * one person has to install the CLI, the other has one that does not run and should be told
274
+ * which file it is.
275
+ */
276
+ export async function registerMcpServer(host, options = {}) {
277
+ const { endpoint = DEFAULT_MCP_ENDPOINT, scope = "user", dryRun = false, cliAvailable } = options;
278
+
279
+ // Not a degraded outcome and not a failure — it is how this host is set up, every time.
280
+ // Kept distinct from `cli_missing` so callers can say "here is what to click" instead of
281
+ // "something went wrong, here is what to click".
282
+ if (host.mcpSetup === "connector-ui") {
283
+ return { status: "manual_only", steps: connectorSteps(host, { endpoint }) };
284
+ }
285
+
286
+ const { command, args } = registerCommand(host, { endpoint, scope });
287
+ const rendered = formatCommand(command, args);
288
+
289
+ if (cliAvailable === false) {
290
+ return {
291
+ status: host.cliResolution.state === "broken" ? "cli_broken" : "cli_missing",
292
+ command: rendered,
293
+ detail: host.cliResolution.detail,
294
+ manual: manualConfigSnippet(host, { endpoint }),
295
+ };
296
+ }
297
+
298
+ if (dryRun === true) {
299
+ return { status: "skipped_dry_run", command: rendered };
300
+ }
301
+
302
+ const timeoutMs = 30_000;
303
+ const result = run(command, args, { timeoutMs });
304
+ if (result.ok === true) {
305
+ return { status: "registered", command: rendered };
306
+ }
307
+
308
+ if (result.timedOut === true) {
309
+ return {
310
+ status: "failed",
311
+ command: rendered,
312
+ detail:
313
+ `${host.cli} did not finish within ${timeoutMs / 1000}s and was stopped. It may be waiting ` +
314
+ `for input, which it cannot receive here. Run it yourself in a terminal: ${rendered}`,
315
+ manual: manualConfigSnippet(host, { endpoint }),
316
+ };
317
+ }
318
+
319
+ // Adding a server that is already configured is a refusal, not a problem: the desired end
320
+ // state is the one we already have. Detected by message because neither CLI gives it a
321
+ // distinct exit code.
322
+ const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
323
+ if (output.includes("already exists") === true || output.includes("already configured") === true) {
324
+ return { status: "already_registered", command: rendered };
325
+ }
326
+
327
+ return {
328
+ status: "failed",
329
+ command: rendered,
330
+ detail: (result.stderr || result.stdout).trim(),
331
+ manual: manualConfigSnippet(host, { endpoint }),
332
+ };
333
+ }
334
+
335
+ /**
336
+ * Unregister the server, or explain how to.
337
+ *
338
+ * Reports rather than throws, for the same reason registration does: by the time this runs
339
+ * the skills are already gone, and aborting on a CLI that refused would leave a half-removed
340
+ * setup with no message about which half.
341
+ */
342
+ export async function unregisterMcpServer(host, options = {}) {
343
+ const { scope = "user", dryRun = false, cliAvailable } = options;
344
+
345
+ if (host.mcpSetup === "connector-ui") {
346
+ return {
347
+ status: "manual_only",
348
+ steps: [
349
+ "Open Settings, then Connectors.",
350
+ `Find ${MCP_SERVER_NAME} and remove it.`,
351
+ ],
352
+ };
353
+ }
354
+
355
+ const { command, args } = unregisterCommand(host, { scope });
356
+ const rendered = formatCommand(command, args);
357
+
358
+ if (cliAvailable === false) {
359
+ return {
360
+ status: host.cliResolution.state === "broken" ? "cli_broken" : "cli_missing",
361
+ command: rendered,
362
+ detail: host.cliResolution.detail,
363
+ };
364
+ }
365
+
366
+ if (dryRun === true) {
367
+ return { status: "skipped_dry_run", command: rendered };
368
+ }
369
+
370
+ const timeoutMs = 30_000;
371
+ const result = run(command, args, { timeoutMs });
372
+
373
+ if (result.timedOut === true) {
374
+ return {
375
+ status: "failed",
376
+ command: rendered,
377
+ detail:
378
+ `${host.cli} did not finish within ${timeoutMs / 1000}s and was stopped. Run it yourself ` +
379
+ `in a terminal: ${rendered}`,
380
+ };
381
+ }
382
+
383
+ // Checked before the exit status, not after, because removing a server that was never
384
+ // there is a *success* for Codex: `codex mcp remove missing` prints "No MCP server named
385
+ // 'missing' found." and exits 0. Reading only the exit code would report having removed
386
+ // something that was not there, which is the one thing an uninstall must not invent.
387
+ const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
388
+ const absent = ["not found", "no such", "does not exist", "no mcp server"].some((phrase) =>
389
+ output.includes(phrase),
390
+ );
391
+ if (absent === true) {
392
+ return { status: "already_absent", command: rendered };
393
+ }
394
+
395
+ if (result.ok === true) {
396
+ return { status: "unregistered", command: rendered };
397
+ }
398
+
399
+ return {
400
+ status: "failed",
401
+ command: rendered,
402
+ detail: (result.stderr || result.stdout).trim(),
403
+ };
404
+ }
405
+
406
+ /**
407
+ * The literal lines a user needs when nothing can be done for them automatically.
408
+ *
409
+ * `bundle` is the path to an archive a bundle-delivery host expects to be uploaded. Passed
410
+ * in rather than derived, because where it was written is the caller's decision — `--dir`
411
+ * moves it — and a list of steps naming a file that is somewhere else is worse than no list.
412
+ */
413
+ export function manualSteps(
414
+ host,
415
+ { endpoint = DEFAULT_MCP_ENDPOINT, scope = "user", bundle = null } = {},
416
+ ) {
417
+ const steps = [];
418
+
419
+ if (host.skillDelivery === "bundle" && bundle !== null) {
420
+ steps.push({
421
+ title: "Upload the skill",
422
+ body: `Settings > Capabilities: turn on code execution and file creation.\nCustomize > Skills: click +, then Create skill, then Upload a skill.\nChoose: ${bundle}`,
423
+ });
424
+ }
425
+
426
+ if (host.mcpSetup === "connector-ui") {
427
+ steps.push({
428
+ title: "Add the connector",
429
+ body: connectorSteps(host, { endpoint })
430
+ .map((step, index) => `${index + 1}. ${step}`)
431
+ .join("\n"),
432
+ });
433
+ } else {
434
+ const { command, args } = registerCommand(host, { endpoint, scope });
435
+ const snippet = manualConfigSnippet(host, { endpoint });
436
+ steps.push({
437
+ title: "Register the MCP server",
438
+ body: `${formatCommand(command, args)}\nor add to ${snippet.path}:\n${snippet.body.trimEnd()}`,
439
+ });
440
+ }
441
+
442
+ // The restart and the sign-in are ordered by where the sign-in happens. On Claude Code it
443
+ // happens inside a session, and `/mcp` offers only servers that session connected to at
444
+ // startup — so a list that signs in first and restarts second describes something nobody
445
+ // can do. Codex signs in from a terminal and opens a new session afterwards for the tools.
446
+ const auth = authInstructions(host);
447
+ const restart = {
448
+ title: `Start a new ${host.label} ${host.sessionNoun}`,
449
+ body: serverAvailabilityNotice(host),
450
+ };
451
+ const signIn = {
452
+ title: "Sign in",
453
+ body: auth.alternative === null ? auth.primary : `${auth.primary}\n${auth.alternative}`,
454
+ };
455
+
456
+ steps.push(...(auth.inSession === true ? [restart, signIn] : [signIn, restart]));
457
+
458
+ steps.push({
459
+ title: "Check it worked",
460
+ body: `${NPX_COMMAND} doctor`,
461
+ });
462
+
463
+ // The prefix is not cosmetic. Codex reserves `/` for its own commands and answers an
464
+ // unknown one with "Unrecognized command", which looks exactly like the skill failing
465
+ // to install, so the working syntax has to be stated rather than inferred. Where there is
466
+ // no prefix at all, saying which one to type would be worse than saying nothing.
467
+ steps.push({
468
+ title: `Use it in ${host.label}`,
469
+ body:
470
+ host.invocationNote === null
471
+ ? `${host.invocationPrefix}extuitive init`
472
+ : host.invocationNote,
473
+ });
474
+
475
+ return steps;
476
+ }