@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 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 needed codex mcp login extuitive
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. Codex connects MCP servers when a session
52
- starts, so the Extuitive tools appear in a new session once you have signed in.
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
- > Sign in with `codex mcp login extuitive`, then start a new session for the Extuitive tools.
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
- Then `codex mcp login extuitive` to sign in. The skill itself is picked up on your next turn;
173
- the Extuitive **tools** appear in a new Codex session, because MCP servers are connected when
174
- a session starts.
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 registerMcpServer(host, {
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 registerMcpServer(host, {
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} The Extuitive tools are connected.`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@extuitive/skill",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The Extuitive agent skill and installer for Claude Code, Codex, and Claude Desktop. Run it with `npx extuitive`.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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 { endpoint = DEFAULT_MCP_ENDPOINT, scope = "user", dryRun = false, cliAvailable } = options;
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
- const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
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
  *