@extuitive/skill 0.1.0 → 0.1.1
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/README.md +32 -8
- package/bin/cli.mjs +64 -14
- package/package.json +1 -1
- package/src/exec.mjs +115 -1
- package/src/mcp-setup.mjs +151 -4
package/README.md
CHANGED
|
@@ -45,20 +45,31 @@ Codex — the Codex CLI, the Codex desktop app, and the IDE extension
|
|
|
45
45
|
Skill installed ~/.codex/skills/extuitive
|
|
46
46
|
/Users/you/.codex/skills/extuitive/SKILL.md
|
|
47
47
|
MCP server registered codex mcp add extuitive --url https://www.extuitive.com/mcp
|
|
48
|
-
Sign-in
|
|
49
|
-
Opens a browser; only you can complete it.
|
|
48
|
+
Sign-in connected signed in during registration
|
|
50
49
|
|
|
51
|
-
The skill is available on your next turn.
|
|
52
|
-
|
|
50
|
+
The skill is available on your next turn. You are signed in; the Extuitive tools appear
|
|
51
|
+
in a new Codex session.
|
|
53
52
|
Try: "Check my Extuitive connection" · "Upload these images to Extuitive"
|
|
54
53
|
```
|
|
55
54
|
|
|
55
|
+
On Codex the sign-in is part of the `codex mcp add` step — it opens the person's browser and
|
|
56
|
+
the installer waits for them — so `Sign-in` usually reads `connected` by the time the block
|
|
57
|
+
prints. When the browser flow was closed or ran out of time it reads `needed` instead, with the
|
|
58
|
+
command that finishes it:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
Sign-in needed codex mcp login extuitive
|
|
62
|
+
codex opened a browser sign-in during registration, but it
|
|
63
|
+
was not completed within 10 minutes.
|
|
64
|
+
Opens a browser; only you can complete it.
|
|
65
|
+
```
|
|
66
|
+
|
|
56
67
|
Three facts on three lines, each with its own state, so the summary an agent gives back can be
|
|
57
68
|
short and still true:
|
|
58
69
|
|
|
59
70
|
> Installed [Extuitive](/Users/you/.codex/skills/extuitive/SKILL.md) in `~/.codex/skills/extuitive`.
|
|
60
71
|
> The skill will be available on your next turn.
|
|
61
|
-
>
|
|
72
|
+
> You are signed in; start a new session for the Extuitive tools.
|
|
62
73
|
> Try: "Check my Extuitive connection."
|
|
63
74
|
|
|
64
75
|
If you are the agent doing the install, four rules:
|
|
@@ -68,6 +79,11 @@ If you are the agent doing the install, four rules:
|
|
|
68
79
|
- **Report the `Sign-in` line as printed and never run it.** `codex mcp login` opens a browser
|
|
69
80
|
and waits for a redirect your shell cannot receive; `/mcp` belongs to the person's session.
|
|
70
81
|
When it reads `connected`, say so and skip the instruction.
|
|
82
|
+
- **On Codex, expect the install to wait for a browser sign-in.** `codex mcp add` opens the
|
|
83
|
+
person's browser and does not return until they have signed in, and the installer waits up to
|
|
84
|
+
ten minutes for that — so tell the person a browser tab is coming and do not kill the command
|
|
85
|
+
for being slow. If it is cut off anyway, the server is still registered; run
|
|
86
|
+
`npx extuitive doctor` to see where things stand rather than re-running `install`.
|
|
71
87
|
- **Say what is available when.** The skill is live now (Codex: next turn; Claude Code:
|
|
72
88
|
immediately; Claude Desktop: once uploaded, in new chats). The Extuitive *tools* are not:
|
|
73
89
|
every host connects MCP servers when a session starts, so they appear in a new session, and
|
|
@@ -169,9 +185,17 @@ Which does:
|
|
|
169
185
|
codex mcp add extuitive --url https://www.extuitive.com/mcp
|
|
170
186
|
```
|
|
171
187
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
188
|
+
**The sign-in happens inside that `add`.** Codex writes the config, notices the endpoint
|
|
189
|
+
supports OAuth, and opens your browser on the spot — there is no flag to add without logging
|
|
190
|
+
in. The installer says so before it runs, echoes Codex's output as it goes (including the
|
|
191
|
+
authorize URL, in case no browser opens), and waits up to ten minutes for you to finish: long
|
|
192
|
+
enough to create an Extuitive account and connect Meta on the way. Once you are back in the
|
|
193
|
+
terminal the `Sign-in` line reads `connected`. If the browser flow was closed or timed out,
|
|
194
|
+
the server is still registered — the config was written in the first second — and the line
|
|
195
|
+
reads `needed` with `codex mcp login extuitive` to finish the sign-in on its own.
|
|
196
|
+
|
|
197
|
+
The skill itself is picked up on your next turn; the Extuitive **tools** appear in a new Codex
|
|
198
|
+
session, because MCP servers are connected when a session starts.
|
|
175
199
|
|
|
176
200
|
**This is one install for three programs.** The Codex desktop app, the CLI and the IDE
|
|
177
201
|
extension share `~/.codex/config.toml` for MCP and the same skills directories, so there is
|
package/bin/cli.mjs
CHANGED
|
@@ -38,9 +38,12 @@ import {
|
|
|
38
38
|
} from "../src/install.mjs";
|
|
39
39
|
import {
|
|
40
40
|
authInstructions,
|
|
41
|
+
describeDuration,
|
|
41
42
|
manualSteps,
|
|
42
43
|
registerMcpServer,
|
|
44
|
+
registrationIncludesSignIn,
|
|
43
45
|
serverAvailabilityNotice,
|
|
46
|
+
SIGN_IN_TIMEOUT_MS,
|
|
44
47
|
skillAvailabilityNotice,
|
|
45
48
|
unregisterMcpServer,
|
|
46
49
|
} from "../src/mcp-setup.mjs";
|
|
@@ -253,6 +256,15 @@ function signInState(host, { registration, server }) {
|
|
|
253
256
|
if (["skipped_dry_run", "cli_missing", "cli_broken", "failed"].includes(registration.status)) {
|
|
254
257
|
return { state: "after_registration", instruction, inSession: auth.inSession };
|
|
255
258
|
}
|
|
259
|
+
// What we watched happen outranks what the host's list command says afterwards. Codex
|
|
260
|
+
// signs in as part of `mcp add`, and its `mcp list` may still say "Unknown" for a token it
|
|
261
|
+
// wrote a moment ago; a "Successfully logged in." we saw printed is the better witness.
|
|
262
|
+
if (registration.signIn?.state === "completed") {
|
|
263
|
+
return { state: "connected", instruction, inSession: auth.inSession, detail: registration.signIn.detail };
|
|
264
|
+
}
|
|
265
|
+
if (registration.signIn?.state === "interrupted" || typeof registration.signIn?.detail === "string") {
|
|
266
|
+
return { state: "needed", instruction, inSession: auth.inSession, detail: registration.signIn.detail };
|
|
267
|
+
}
|
|
256
268
|
if (server?.state === "connected") {
|
|
257
269
|
return { state: "connected", instruction, inSession: auth.inSession };
|
|
258
270
|
}
|
|
@@ -290,6 +302,40 @@ async function placeSkills(host, options) {
|
|
|
290
302
|
return { ...result, bundle: null };
|
|
291
303
|
}
|
|
292
304
|
|
|
305
|
+
/**
|
|
306
|
+
* What to say, and what to echo, while a registration that opens a browser is running.
|
|
307
|
+
*
|
|
308
|
+
* Codex's `mcp add --url` writes the config and then opens the person's browser to sign in,
|
|
309
|
+
* and does not return until they have. That has to be announced *before* it runs — a browser
|
|
310
|
+
* tab appearing mid-install with no explanation reads as something going wrong — and Codex's
|
|
311
|
+
* own output has to be shown as it arrives, because it contains the authorize URL for anyone
|
|
312
|
+
* whose browser did not open, and the "Successfully logged in." that tells them they can
|
|
313
|
+
* come back to the terminal.
|
|
314
|
+
*
|
|
315
|
+
* Nothing is printed in `--json` mode (the output must stay parseable) or in a dry run
|
|
316
|
+
* (nothing opens), and the announcement is skipped when the CLI is missing, since then
|
|
317
|
+
* nothing will be run.
|
|
318
|
+
*/
|
|
319
|
+
function registrationProgress(host, options, cliAvailable) {
|
|
320
|
+
if (
|
|
321
|
+
registrationIncludesSignIn(host) === false ||
|
|
322
|
+
options.json === true ||
|
|
323
|
+
options.dryRun === true ||
|
|
324
|
+
cliAvailable === false
|
|
325
|
+
) {
|
|
326
|
+
return {};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
console.log(`\nRegistering the MCP server with ${host.label}.`);
|
|
330
|
+
console.log(`${host.label} will open your browser to sign in to Extuitive as part of this step.`);
|
|
331
|
+
console.log("Finish there — create an account and connect Meta if you need to — then come back.");
|
|
332
|
+
console.log(`Waiting up to ${describeDuration(SIGN_IN_TIMEOUT_MS)} for that. If nothing opens, use the URL ${host.cli} prints below.\n`);
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
onLine: (line) => console.log(` ${host.cli} │ ${line}`),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
293
339
|
/**
|
|
294
340
|
* Everything install or update does for one host, returned rather than printed.
|
|
295
341
|
*
|
|
@@ -306,6 +352,15 @@ async function setupHost(host, detection, options, { mode }) {
|
|
|
306
352
|
let server = null;
|
|
307
353
|
let registration;
|
|
308
354
|
|
|
355
|
+
const register = () =>
|
|
356
|
+
registerMcpServer(host, {
|
|
357
|
+
endpoint: options.endpoint,
|
|
358
|
+
scope: options.scope,
|
|
359
|
+
dryRun: options.dryRun,
|
|
360
|
+
cliAvailable,
|
|
361
|
+
...registrationProgress(host, options, cliAvailable),
|
|
362
|
+
});
|
|
363
|
+
|
|
309
364
|
if (host.mcpSetup === "connector-ui") {
|
|
310
365
|
// No side effects on either path: registration is a list of steps and the status is
|
|
311
366
|
// "cannot be read from here". Asked the same way in both modes so the summary is too.
|
|
@@ -314,22 +369,12 @@ async function setupHost(host, detection, options, { mode }) {
|
|
|
314
369
|
} else if (mode === "update") {
|
|
315
370
|
server = readHostServerStatus(host, { cliAvailable });
|
|
316
371
|
if (server.state === "absent") {
|
|
317
|
-
registration = await
|
|
318
|
-
endpoint: options.endpoint,
|
|
319
|
-
scope: options.scope,
|
|
320
|
-
dryRun: options.dryRun,
|
|
321
|
-
cliAvailable,
|
|
322
|
-
});
|
|
372
|
+
registration = await register();
|
|
323
373
|
} else {
|
|
324
374
|
registration = { status: server.state === "unknown" ? "unknown" : "already_registered", command: null };
|
|
325
375
|
}
|
|
326
376
|
} else {
|
|
327
|
-
registration = await
|
|
328
|
-
endpoint: options.endpoint,
|
|
329
|
-
scope: options.scope,
|
|
330
|
-
dryRun: options.dryRun,
|
|
331
|
-
cliAvailable,
|
|
332
|
-
});
|
|
377
|
+
registration = await register();
|
|
333
378
|
// Asked even after a fresh registration, because a token from an earlier install may
|
|
334
379
|
// still be in the host's credential store — Codex keeps them in the keychain, keyed by
|
|
335
380
|
// server, and removing the server does not remove the token. Saying "sign in" to someone
|
|
@@ -472,11 +517,16 @@ function printServerRow(host, report) {
|
|
|
472
517
|
function printSignInRow(report) {
|
|
473
518
|
const { signIn } = report;
|
|
474
519
|
if (signIn.state === "connected") {
|
|
475
|
-
row("Sign-in", "connected", report.server?.detail ?? "");
|
|
520
|
+
row("Sign-in", "connected", signIn.detail ?? report.server?.detail ?? "");
|
|
476
521
|
return;
|
|
477
522
|
}
|
|
478
523
|
if (signIn.state === "needed") {
|
|
479
524
|
row("Sign-in", "needed", signIn.instruction);
|
|
525
|
+
if (signIn.detail !== undefined) {
|
|
526
|
+
// The registration started this sign-in and it did not finish. Said explicitly, since
|
|
527
|
+
// the person just watched a browser open and may reasonably think it worked.
|
|
528
|
+
cont(signIn.detail);
|
|
529
|
+
}
|
|
480
530
|
} else if (signIn.state === "in_app") {
|
|
481
531
|
row("Sign-in", "in the app", signIn.instruction);
|
|
482
532
|
} else if (signIn.state === "after_registration") {
|
|
@@ -505,7 +555,7 @@ function printSummary(host, report, options) {
|
|
|
505
555
|
} else if (report.registration.status === "manual_only") {
|
|
506
556
|
console.log(` ${report.availability.skill} ${report.availability.server}`);
|
|
507
557
|
} else if (report.signIn.state === "connected") {
|
|
508
|
-
console.log(` ${report.availability.skill}
|
|
558
|
+
console.log(` ${report.availability.skill} You are signed in; the Extuitive tools appear in a new ${host.label} ${host.sessionNoun}.`);
|
|
509
559
|
} else if (serverRegistered === true) {
|
|
510
560
|
console.log(` ${report.availability.skill} ${report.availability.server}`);
|
|
511
561
|
} else {
|
package/package.json
CHANGED
package/src/exec.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* calls, and an argument array cannot be talked into running a second command the way a
|
|
7
7
|
* shell string can.
|
|
8
8
|
*/
|
|
9
|
-
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
10
10
|
import { existsSync } from "node:fs";
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -130,6 +130,120 @@ export function run(command, args, options = {}) {
|
|
|
130
130
|
};
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Run a command that a person may be part of, watching its output as it goes.
|
|
135
|
+
*
|
|
136
|
+
* `run` is right for commands that finish on their own. This exists for the one that does
|
|
137
|
+
* not: `codex mcp add --url` writes the config and then opens a browser and waits for the
|
|
138
|
+
* person to sign in, however long that takes. Buffering that behind `spawnSync` hides the
|
|
139
|
+
* authorize URL until the command ends, and ending it on a short timeout kills the local
|
|
140
|
+
* callback listener the browser is about to redirect to — the sign-in the person just
|
|
141
|
+
* completed fails at the last step, and the installer reports the registration as failed
|
|
142
|
+
* when the config was written in the first second.
|
|
143
|
+
*
|
|
144
|
+
* So output is handed to `onLine` as it arrives, for the caller to echo, and is also
|
|
145
|
+
* collected so the caller can read what happened afterwards. The timeout is the caller's to
|
|
146
|
+
* size to the slowest thing inside the command, which here is a person. On timeout the
|
|
147
|
+
* process gets SIGTERM and, if it ignores that, SIGKILL shortly after.
|
|
148
|
+
*
|
|
149
|
+
* Same result shape as `run`, so callers can inspect either the same way.
|
|
150
|
+
*/
|
|
151
|
+
export function runStreaming(command, args, options = {}) {
|
|
152
|
+
const { timeoutMs = 30_000, onLine = null, env = process.env } = options;
|
|
153
|
+
|
|
154
|
+
return new Promise((resolve) => {
|
|
155
|
+
let child;
|
|
156
|
+
try {
|
|
157
|
+
child = spawn(command, args, { shell: false, stdio: ["ignore", "pipe", "pipe"], env });
|
|
158
|
+
} catch (error) {
|
|
159
|
+
resolve({
|
|
160
|
+
ok: false,
|
|
161
|
+
status: null,
|
|
162
|
+
stdout: "",
|
|
163
|
+
stderr: String(error.message ?? error),
|
|
164
|
+
spawnError: true,
|
|
165
|
+
timedOut: false,
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let timedOut = false;
|
|
171
|
+
let settled = false;
|
|
172
|
+
|
|
173
|
+
const timer = setTimeout(() => {
|
|
174
|
+
timedOut = true;
|
|
175
|
+
child.kill("SIGTERM");
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
if (settled === false) {
|
|
178
|
+
child.kill("SIGKILL");
|
|
179
|
+
}
|
|
180
|
+
}, 2_000).unref();
|
|
181
|
+
}, timeoutMs);
|
|
182
|
+
|
|
183
|
+
// Line-buffered per stream so a chunk boundary in the middle of a URL does not hand the
|
|
184
|
+
// caller half of it.
|
|
185
|
+
const watch = (stream, stash) => {
|
|
186
|
+
let pending = "";
|
|
187
|
+
stream.setEncoding("utf8");
|
|
188
|
+
stream.on("data", (chunk) => {
|
|
189
|
+
stash.push(chunk);
|
|
190
|
+
pending += chunk;
|
|
191
|
+
let newline = pending.indexOf("\n");
|
|
192
|
+
while (newline !== -1) {
|
|
193
|
+
const line = pending.slice(0, newline).replace(/\r$/, "");
|
|
194
|
+
pending = pending.slice(newline + 1);
|
|
195
|
+
if (onLine !== null && line.trim() !== "") {
|
|
196
|
+
onLine(line);
|
|
197
|
+
}
|
|
198
|
+
newline = pending.indexOf("\n");
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
stream.on("end", () => {
|
|
202
|
+
if (onLine !== null && pending.trim() !== "") {
|
|
203
|
+
onLine(pending.replace(/\r$/, ""));
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const outChunks = [];
|
|
209
|
+
const errChunks = [];
|
|
210
|
+
watch(child.stdout, outChunks);
|
|
211
|
+
watch(child.stderr, errChunks);
|
|
212
|
+
|
|
213
|
+
const finish = (result) => {
|
|
214
|
+
if (settled === true) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
settled = true;
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
resolve({
|
|
220
|
+
...result,
|
|
221
|
+
stdout: outChunks.join(""),
|
|
222
|
+
stderr: result.stderr ?? errChunks.join(""),
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
child.on("error", (error) => {
|
|
227
|
+
finish({
|
|
228
|
+
ok: false,
|
|
229
|
+
status: null,
|
|
230
|
+
stderr: String(error.message ?? error),
|
|
231
|
+
spawnError: true,
|
|
232
|
+
timedOut: false,
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
child.on("close", (status) => {
|
|
237
|
+
finish({
|
|
238
|
+
ok: status === 0 && timedOut === false,
|
|
239
|
+
status,
|
|
240
|
+
spawnError: false,
|
|
241
|
+
timedOut,
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
133
247
|
/** A shell-ready rendering of a command, for printing a step the user has to run by hand. */
|
|
134
248
|
export function formatCommand(command, args) {
|
|
135
249
|
const parts = [command, ...args].map((part) =>
|
package/src/mcp-setup.mjs
CHANGED
|
@@ -24,7 +24,32 @@
|
|
|
24
24
|
* `connectorSteps` for why its config file is left alone even as a fallback.
|
|
25
25
|
*/
|
|
26
26
|
import { DEFAULT_MCP_ENDPOINT, MCP_SERVER_NAME, NPX_COMMAND } from "./constants.mjs";
|
|
27
|
-
import { formatCommand, run } from "./exec.mjs";
|
|
27
|
+
import { formatCommand, run, runStreaming } from "./exec.mjs";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How long to wait for a registration that includes a browser sign-in.
|
|
31
|
+
*
|
|
32
|
+
* Sized to a person, not a process. Someone signing in for the first time is creating an
|
|
33
|
+
* Extuitive account, connecting Meta, and picking an ad account before the browser ever
|
|
34
|
+
* redirects back — a 30-second limit was cut mid-flow and reported "not added" over a
|
|
35
|
+
* registration that had already been written. Ten minutes is long enough that hitting it
|
|
36
|
+
* means the tab was closed, and short enough that a closed tab is not an install that never
|
|
37
|
+
* ends.
|
|
38
|
+
*/
|
|
39
|
+
export const SIGN_IN_TIMEOUT_MS = 10 * 60_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether registering the server on this host also signs the person in, in the same command.
|
|
43
|
+
*
|
|
44
|
+
* True for Codex: `codex mcp add --url` writes the config, discovers that the endpoint
|
|
45
|
+
* supports OAuth, and starts the login on the spot — it opens a browser and waits for the
|
|
46
|
+
* redirect. There is no flag to add without logging in. Callers use this to warn before
|
|
47
|
+
* running that a browser is about to open, and to keep the command running while the person
|
|
48
|
+
* is in it.
|
|
49
|
+
*/
|
|
50
|
+
export function registrationIncludesSignIn(host) {
|
|
51
|
+
return host.id === "codex" && host.mcpSetup === "cli";
|
|
52
|
+
}
|
|
28
53
|
|
|
29
54
|
/**
|
|
30
55
|
* The command that registers the server.
|
|
@@ -272,9 +297,24 @@ export function manualConfigSnippet(host, { endpoint = DEFAULT_MCP_ENDPOINT } =
|
|
|
272
297
|
* `cli_missing` and `cli_broken` are different statuses because they have different fixes:
|
|
273
298
|
* one person has to install the CLI, the other has one that does not run and should be told
|
|
274
299
|
* which file it is.
|
|
300
|
+
*
|
|
301
|
+
* On a host where registration includes the sign-in (see `registrationIncludesSignIn`), the
|
|
302
|
+
* command is run with `onLine` echoing its output as it goes and a timeout sized for a
|
|
303
|
+
* person, and the result carries a `signIn` field: `completed`, `interrupted` (the browser
|
|
304
|
+
* flow was started but the command ended before it finished), or `not_started`. The
|
|
305
|
+
* registration itself is judged by what the CLI printed, not by whether the whole command
|
|
306
|
+
* exited cleanly — the config is written in the first second, and a sign-in that timed out
|
|
307
|
+
* or was declined does not unwrite it.
|
|
275
308
|
*/
|
|
276
309
|
export async function registerMcpServer(host, options = {}) {
|
|
277
|
-
const {
|
|
310
|
+
const {
|
|
311
|
+
endpoint = DEFAULT_MCP_ENDPOINT,
|
|
312
|
+
scope = "user",
|
|
313
|
+
dryRun = false,
|
|
314
|
+
cliAvailable,
|
|
315
|
+
onLine = null,
|
|
316
|
+
signInTimeoutMs = SIGN_IN_TIMEOUT_MS,
|
|
317
|
+
} = options;
|
|
278
318
|
|
|
279
319
|
// Not a degraded outcome and not a failure — it is how this host is set up, every time.
|
|
280
320
|
// Kept distinct from `cli_missing` so callers can say "here is what to click" instead of
|
|
@@ -299,6 +339,10 @@ export async function registerMcpServer(host, options = {}) {
|
|
|
299
339
|
return { status: "skipped_dry_run", command: rendered };
|
|
300
340
|
}
|
|
301
341
|
|
|
342
|
+
if (registrationIncludesSignIn(host) === true) {
|
|
343
|
+
return registerWithSignIn(host, { command, args, rendered, endpoint, onLine, signInTimeoutMs });
|
|
344
|
+
}
|
|
345
|
+
|
|
302
346
|
const timeoutMs = 30_000;
|
|
303
347
|
const result = run(command, args, { timeoutMs });
|
|
304
348
|
if (result.ok === true) {
|
|
@@ -319,8 +363,7 @@ export async function registerMcpServer(host, options = {}) {
|
|
|
319
363
|
// Adding a server that is already configured is a refusal, not a problem: the desired end
|
|
320
364
|
// state is the one we already have. Detected by message because neither CLI gives it a
|
|
321
365
|
// distinct exit code.
|
|
322
|
-
|
|
323
|
-
if (output.includes("already exists") === true || output.includes("already configured") === true) {
|
|
366
|
+
if (saysAlreadyRegistered(result) === true) {
|
|
324
367
|
return { status: "already_registered", command: rendered };
|
|
325
368
|
}
|
|
326
369
|
|
|
@@ -332,6 +375,110 @@ export async function registerMcpServer(host, options = {}) {
|
|
|
332
375
|
};
|
|
333
376
|
}
|
|
334
377
|
|
|
378
|
+
function saysAlreadyRegistered(result) {
|
|
379
|
+
const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
|
380
|
+
return output.includes("already exists") === true || output.includes("already configured") === true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The phrases Codex prints at each stage of `mcp add --url`, read from its source rather
|
|
385
|
+
* than guessed. Matched case-insensitively and loosely on purpose: the exact wording is
|
|
386
|
+
* Codex's to change, and a missed match here degrades to "unverified", not to a wrong claim.
|
|
387
|
+
*/
|
|
388
|
+
const CODEX_ADDED = /added (global )?mcp server/i;
|
|
389
|
+
const CODEX_SIGN_IN_STARTED = /starting oauth flow|open(ing)? this url in your browser/i;
|
|
390
|
+
const CODEX_SIGNED_IN = /successfully logged in/i;
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Register on a host whose `add` also runs the browser sign-in.
|
|
394
|
+
*
|
|
395
|
+
* Three outcomes have to be told apart, and the exit code alone cannot do it:
|
|
396
|
+
*
|
|
397
|
+
* - Exit 0: registered and signed in, in one go.
|
|
398
|
+
* - Printed "Added" and then timed out, or exited non-zero after starting the sign-in: the
|
|
399
|
+
* registration is on disk; the sign-in is what did not finish. That is a *success* for
|
|
400
|
+
* the registration and a clear next step for the sign-in, not a failure to be fixed by
|
|
401
|
+
* pasting TOML — pasting TOML would produce exactly the state already there.
|
|
402
|
+
* - Never printed "Added": the add itself failed, and the manual snippet is the right offer.
|
|
403
|
+
*/
|
|
404
|
+
async function registerWithSignIn(host, { command, args, rendered, endpoint, onLine, signInTimeoutMs }) {
|
|
405
|
+
const result = await runStreaming(command, args, { timeoutMs: signInTimeoutMs, onLine });
|
|
406
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
407
|
+
|
|
408
|
+
const added = CODEX_ADDED.test(output) === true;
|
|
409
|
+
const signInStarted = CODEX_SIGN_IN_STARTED.test(output) === true;
|
|
410
|
+
const signedIn = CODEX_SIGNED_IN.test(output) === true;
|
|
411
|
+
|
|
412
|
+
if (result.ok === true) {
|
|
413
|
+
return {
|
|
414
|
+
status: "registered",
|
|
415
|
+
command: rendered,
|
|
416
|
+
signIn: signedIn === true
|
|
417
|
+
? { state: "completed", detail: "signed in during registration" }
|
|
418
|
+
: { state: "not_started", detail: null },
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (added === true || (result.timedOut === true && signInStarted === true)) {
|
|
423
|
+
// The detail names what happened and stops; the caller prints the sign-in command
|
|
424
|
+
// beside it, so repeating it here would print it twice.
|
|
425
|
+
const reason = lastLine(result) || `exit ${result.status}`;
|
|
426
|
+
const detail = result.timedOut === true
|
|
427
|
+
? `${host.cli} opened a browser sign-in during registration, but it was not completed within ${describeDuration(signInTimeoutMs)}.`
|
|
428
|
+
: signInStarted === true
|
|
429
|
+
? `${host.cli} opened a browser sign-in during registration, but it did not finish: ${reason}`
|
|
430
|
+
: `${host.cli} registered the server but then exited early: ${reason}`;
|
|
431
|
+
return {
|
|
432
|
+
status: "registered",
|
|
433
|
+
command: rendered,
|
|
434
|
+
signIn: { state: signInStarted === true ? "interrupted" : "not_started", detail },
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (saysAlreadyRegistered(result) === true) {
|
|
439
|
+
return { status: "already_registered", command: rendered, signIn: { state: "not_started", detail: null } };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (result.timedOut === true) {
|
|
443
|
+
return {
|
|
444
|
+
status: "failed",
|
|
445
|
+
command: rendered,
|
|
446
|
+
detail:
|
|
447
|
+
`${host.cli} did not finish within ${describeDuration(signInTimeoutMs)} and was stopped. ` +
|
|
448
|
+
`Run it yourself in a terminal: ${rendered}`,
|
|
449
|
+
manual: manualConfigSnippet(host, { endpoint }),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
status: "failed",
|
|
455
|
+
command: rendered,
|
|
456
|
+
detail: (result.stderr || result.stdout).trim(),
|
|
457
|
+
manual: manualConfigSnippet(host, { endpoint }),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** A wait, in the unit a person would say it in. */
|
|
462
|
+
export function describeDuration(ms) {
|
|
463
|
+
if (ms >= 60_000) {
|
|
464
|
+
const minutes = Math.round(ms / 60_000);
|
|
465
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
466
|
+
}
|
|
467
|
+
const seconds = Math.max(1, Math.round(ms / 1000));
|
|
468
|
+
return `${seconds} second${seconds === 1 ? "" : "s"}`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** The last thing the command said, preferring stderr — that is where an error lands. */
|
|
472
|
+
function lastLine(result) {
|
|
473
|
+
for (const text of [result.stderr, result.stdout]) {
|
|
474
|
+
const lines = text.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
475
|
+
if (lines.length > 0) {
|
|
476
|
+
return lines[lines.length - 1];
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return "";
|
|
480
|
+
}
|
|
481
|
+
|
|
335
482
|
/**
|
|
336
483
|
* Unregister the server, or explain how to.
|
|
337
484
|
*
|