@showly/mcp-server 0.1.0 → 0.3.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/dist/cli.js CHANGED
@@ -4,29 +4,85 @@
4
4
  // Usage:
5
5
  // showly-mcp install --to claude-code # writes ~/.claude.json
6
6
  // showly-mcp install --to codex # writes ~/.codex/config.toml
7
+ // showly-mcp install --to codex --with-skill # also installs showly-hosting
7
8
  // showly-mcp install --to stdout # prints the snippet for manual paste
9
+ // showly-mcp login --to claude-code # RFC 8628 device flow, no browser here
8
10
  // showly-mcp manifest # prints manifest.json
9
- // showly-mcp --help
11
+ // showly-mcp --help / --version
12
+ // showly-mcp <command> --help # exits 0 iff this copy has <command>
10
13
  //
11
- // The CLI only writes the MCP server config block it doesn't touch network.
14
+ // Every help screen and every error names the version of THIS copy, and every
15
+ // unrecognized command or flag exits non-zero. Both are here because the
16
+ // opposite shipped: a 0.1.0 copy handed `--with-skill` ignored it, printed
17
+ // "Wrote …" and exited 0, and its command list gave an agent no reason to
18
+ // suspect a newer release existed.
19
+ //
20
+ // `install` only writes the MCP server config block — it doesn't touch network.
12
21
  // The agent discovers OAuth from the server and runs the browser sign-in on
13
22
  // first use (standard MCP authorization).
14
- import { readFileSync, mkdirSync, writeFileSync, existsSync, realpathSync, } from "node:fs";
23
+ //
24
+ // `login` is the headless path, and it exists because nothing else could start
25
+ // one. The API has implemented RFC 8628 the whole time, but no product surface
26
+ // ever kicked it off: this CLI had `install` and `manifest` and no `login`, and
27
+ // MCP clients will not drive it either — the MCP Authorization spec never
28
+ // mentions RFC 8628, and Claude Code ignores `device_authorization_endpoint`
29
+ // even when it is advertised. So a human whose agent runs on a box with no
30
+ // browser, or who is holding a phone rather than sitting at the machine, had a
31
+ // working server-side flow and no way to reach it.
32
+ import { readFileSync, mkdirSync, rmSync, writeFileSync, existsSync, realpathSync, } from "node:fs";
15
33
  import { dirname, join } from "node:path";
16
34
  import { homedir } from "node:os";
17
35
  import { pathToFileURL } from "node:url";
18
36
  import { loadManifest } from "./index.js";
37
+ import { SHOWLY_HOSTING_SKILL_MARKDOWN, SHOWLY_HOSTING_SKILL_NAME, SHOWLY_LEGACY_SKILL_NAME, } from "./showly-hosting-skill.js";
38
+ /**
39
+ * The version of THIS copy of the package, read from the manifest that ships
40
+ * beside it. `manifest.test.ts` pins manifest.json, package.json,
41
+ * claude-code-skill and codex-plugin to one string, so this cannot print a
42
+ * version the package does not actually have.
43
+ *
44
+ * It is printed on every help screen and on every unknown-command error, and
45
+ * that is the whole point. When an agent runs `showly-mcp` from a stale npx
46
+ * cache and sees a command list that is missing what the docs told it to run,
47
+ * the most natural inference is "the docs are wrong" or "I misremembered the
48
+ * name" — because nothing on screen suggests a newer copy exists. A version
49
+ * string plus the `@latest` hint is the one signal that redirects that guess
50
+ * from "the instructions are wrong" to "my copy is old".
51
+ */
52
+ export const CLI_VERSION = loadManifest().version;
53
+ /** The commands this copy dispatches. Anything else is an error, never a no-op. */
54
+ export const KNOWN_COMMANDS = ["install", "login", "manifest"];
55
+ /**
56
+ * The sentence that turns "this tool cannot do that" into "this COPY cannot do
57
+ * that". Printed on unknown commands and on unknown flags, because both are
58
+ * reached by the same route: documentation written against a newer release
59
+ * than the one npx resolved.
60
+ */
61
+ export const UPGRADE_HINT = "If you expected this, your copy is out of date — re-run with `npx @showly/mcp-server@latest`.";
19
62
  function usage() {
20
63
  return [
21
- "Showly MCP server installer",
64
+ `Showly MCP server installer (@showly/mcp-server ${CLI_VERSION})`,
22
65
  "",
23
66
  "Usage:",
24
- " showly-mcp install --to <claude-code|codex|stdout>",
67
+ " showly-mcp install --to <claude-code|codex|stdout> [--with-skill]",
68
+ " showly-mcp login [--to <claude-code|codex|stdout>] [--print-token]",
25
69
  " showly-mcp manifest",
70
+ " showly-mcp --version",
71
+ "",
72
+ "login authorizes this machine without a browser on it: it prints a short",
73
+ "code, you approve it on any device, and the credential lands in your host",
74
+ "config. --to codex writes a config that reads the token from SHOWLY_TOKEN,",
75
+ "so login also prints the export line that sets it. --print-token writes",
76
+ "ONLY the token to stdout (everything else goes to stderr) so CI can",
77
+ "capture it without it touching a file.",
26
78
  "",
27
79
  "Environment overrides:",
28
80
  " SHOWLY_MCP_URL full URL to your MCP endpoint (default https://mcp.showly.ai)",
29
81
  " SHOWLY_API_URL full URL to your API (default https://api.showly.ai)",
82
+ "",
83
+ "--with-skill installs a reusable showly-hosting skill for Claude Code or Codex.",
84
+ "",
85
+ UPGRADE_HINT,
30
86
  ].join("\n");
31
87
  }
32
88
  // The MCP server config is intentionally MINIMAL: just the transport + URL.
@@ -36,8 +92,8 @@ function usage() {
36
92
  // authorization-server metadata, dynamically registers, and runs
37
93
  // authorization_code + PKCE in the browser. There is no client-readable
38
94
  // `oauth` field in the host config schema, so emitting one (as a prior version
39
- // did) was a no-op that misled rather than helped. `apiUrl` is retained in the
40
- // signature for the stdout help text + the device-flow fallback docs.
95
+ // did) was a no-op that misled rather than helped. `apiUrl` is threaded through
96
+ // for the stdout help text and for `login`, which POSTs the device flow there.
41
97
  export function buildClaudeCodeSnippet(opts) {
42
98
  return {
43
99
  mcpServers: {
@@ -71,7 +127,7 @@ export function resolveUrls(env = process.env) {
71
127
  apiUrl: env[manifest.mcp.endpoints.api_url_env] ?? "https://api.showly.ai",
72
128
  };
73
129
  }
74
- export function performInstall(target, env = process.env) {
130
+ export function performInstall(target, env = process.env, options = {}) {
75
131
  const { url, apiUrl } = resolveUrls(env);
76
132
  if (target === "stdout") {
77
133
  const obj = buildClaudeCodeSnippet({ url, apiUrl });
@@ -84,8 +140,10 @@ export function performInstall(target, env = process.env) {
84
140
  buildCodexSnippet({ url, apiUrl }),
85
141
  wrote: false,
86
142
  alreadyConfigured: false,
143
+ skill: null,
87
144
  };
88
145
  }
146
+ const installSkill = () => options.withSkill ? performSkillInstall(target, env) : null;
89
147
  if (target === "claude-code") {
90
148
  // User-scope MCP servers live in ~/.claude.json (top-level `mcpServers`),
91
149
  // NOT ~/.claude/settings.json — Claude Code never reads mcpServers from
@@ -114,16 +172,18 @@ export function performInstall(target, env = process.env) {
114
172
  snippet: JSON.stringify(merged, null, 2),
115
173
  wrote: false,
116
174
  alreadyConfigured: true,
175
+ skill: installSkill(),
117
176
  };
118
177
  }
119
178
  mkdirSync(dirname(path), { recursive: true });
120
- writeFileSync(path, JSON.stringify(merged, null, 2) + "\n", "utf8");
179
+ writeCredentialFile(path, JSON.stringify(merged, null, 2) + "\n");
121
180
  return {
122
181
  target,
123
182
  path,
124
183
  snippet: JSON.stringify(merged, null, 2),
125
184
  wrote: true,
126
185
  alreadyConfigured: false,
186
+ skill: installSkill(),
127
187
  };
128
188
  }
129
189
  // codex
@@ -137,6 +197,7 @@ export function performInstall(target, env = process.env) {
137
197
  snippet,
138
198
  wrote: false,
139
199
  alreadyConfigured: true,
200
+ skill: installSkill(),
140
201
  };
141
202
  }
142
203
  mkdirSync(dirname(path), { recursive: true });
@@ -149,8 +210,58 @@ export function performInstall(target, env = process.env) {
149
210
  snippet,
150
211
  wrote: true,
151
212
  alreadyConfigured: false,
213
+ skill: installSkill(),
152
214
  };
153
215
  }
216
+ export function performSkillInstall(target, env = process.env) {
217
+ const codexHome = env.CODEX_HOME?.trim();
218
+ const hostDirectory = target === "codex"
219
+ ? codexHome || join(homedir(), ".codex")
220
+ : join(homedir(), ".claude");
221
+ const skillsRoot = join(hostDirectory, "skills");
222
+ const path = join(skillsRoot, SHOWLY_HOSTING_SKILL_NAME, "SKILL.md");
223
+ const removedLegacyPath = removeLegacySkill(skillsRoot);
224
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : null;
225
+ if (existing === SHOWLY_HOSTING_SKILL_MARKDOWN) {
226
+ return { path, wrote: false, alreadyConfigured: true, removedLegacyPath };
227
+ }
228
+ mkdirSync(dirname(path), { recursive: true });
229
+ writeFileSync(path, SHOWLY_HOSTING_SKILL_MARKDOWN, "utf8");
230
+ return { path, wrote: true, alreadyConfigured: false, removedLegacyPath };
231
+ }
232
+ /**
233
+ * Delete the superseded showly-publish skill directory, if we wrote it.
234
+ *
235
+ * Leaving it behind is not neutral: hosts list every skill they find, so the
236
+ * old name and its publish-only description would keep competing with the new
237
+ * one at selection time — the exact failure this rename fixes. The guard is
238
+ * the front matter: we only remove a directory whose SKILL.md still declares
239
+ * `name: showly-publish`, so a user's own skill that happens to sit at that
240
+ * path is never touched.
241
+ */
242
+ function removeLegacySkill(skillsRoot) {
243
+ const legacyDir = join(skillsRoot, SHOWLY_LEGACY_SKILL_NAME);
244
+ const legacyFile = join(legacyDir, "SKILL.md");
245
+ if (!existsSync(legacyFile))
246
+ return null;
247
+ let contents;
248
+ try {
249
+ contents = readFileSync(legacyFile, "utf8");
250
+ }
251
+ catch {
252
+ return null;
253
+ }
254
+ if (!new RegExp(`^name:\\s*${SHOWLY_LEGACY_SKILL_NAME}\\s*$`, "m").test(contents)) {
255
+ return null;
256
+ }
257
+ try {
258
+ rmSync(legacyDir, { recursive: true, force: true });
259
+ }
260
+ catch {
261
+ return null;
262
+ }
263
+ return legacyDir;
264
+ }
154
265
  function safeReadJson(path) {
155
266
  try {
156
267
  return JSON.parse(readFileSync(path, "utf8"));
@@ -159,48 +270,797 @@ function safeReadJson(path) {
159
270
  return {};
160
271
  }
161
272
  }
162
- function main(argv) {
273
+ /**
274
+ * Write a file that holds (or will hold) a credential, 0600 on creation.
275
+ *
276
+ * `login` puts a raw 90-day bearer into ~/.claude.json, and this command
277
+ * exists for exactly the hosts where "another local user" is not hypothetical:
278
+ * shared build boxes, container images, CI runners. writeFileSync's default is
279
+ * 0666 & ~umask — 0644 under the usual umask, i.e. world-readable.
280
+ *
281
+ * `mode` is consulted only when the file is CREATED (it goes to open(2) with
282
+ * O_CREAT), so an existing file keeps whatever mode its owner chose: this
283
+ * never widens a mode and never silently narrows one that was set on purpose.
284
+ * `install` writes through here too, so the common install-then-login sequence
285
+ * does not leave the file created loose before the token arrives.
286
+ */
287
+ function writeCredentialFile(path, contents) {
288
+ writeFileSync(path, contents, { encoding: "utf8", mode: 0o600 });
289
+ }
290
+ // ───────────────────────────────────────────────────────────────────────────
291
+ // login — RFC 8628 device authorization
292
+ // ───────────────────────────────────────────────────────────────────────────
293
+ /**
294
+ * The client_id this CLI starts device flows under.
295
+ *
296
+ * Deliberately NOT a first-party id (the API rejects those on /oauth/device
297
+ * precisely so nobody can wear the product's identity on a consent screen),
298
+ * and deliberately not registered: a device client_id is free text, so the
299
+ * consent page treats whatever name it carries as self-declared and asks the
300
+ * human to check the code echo instead.
301
+ */
302
+ export const LOGIN_CLIENT_ID = "showly-mcp-cli";
303
+ /** The env var name emitted into config snippets that must not hold a secret. */
304
+ export const TOKEN_ENV_VAR = "SHOWLY_TOKEN";
305
+ /**
306
+ * The exact text `login` prints while it waits. Pure, and pinned by a test,
307
+ * because this block is the entire user interface of headless sign-in — the
308
+ * docs quote it verbatim so the page and the binary cannot drift.
309
+ *
310
+ * Reading order is not cosmetic:
311
+ * • The bare URL plus the separately-printed code comes FIRST, because that
312
+ * is the pair that works when the human is on a phone or another machine —
313
+ * which is the entire reason this flow exists. The one-click deep link is
314
+ * offered second, for the case where the browser is on this box.
315
+ * • The code is repeated three times on purpose. RFC 8628 §3.3.1's code echo
316
+ * is the ONLY anti-phishing signal that survives an attacker-controlled
317
+ * client_name (Storm-2372 phished 340+ M365 tenants on exactly this flow),
318
+ * so the human has to be told what they will see and what to do if it
319
+ * differs.
320
+ * • The expiry is a wall-clock time, not a duration. A human who wanders off
321
+ * to create an account and verify an email cannot subtract "15 minutes"
322
+ * from a moment they have forgotten.
323
+ */
324
+ export function buildLoginPrompt(input) {
325
+ const clock = input.expiresAt.toLocaleTimeString(undefined, {
326
+ hour: "2-digit",
327
+ minute: "2-digit",
328
+ hour12: false,
329
+ });
330
+ // Three things this wording deliberately does NOT do, each from a review of
331
+ // the first shipped draft:
332
+ // • no "takes about a minute". The population this path exists for is the
333
+ // cold-start user who must still sign up and verify an email — exactly the
334
+ // people who used to run the clock out. A minute is a promise we break at
335
+ // the worst possible moment; naming the signup sets the real expectation.
336
+ // • no button label. The consent page is localized (拒绝 / 拒否 / 거부 /
337
+ // Denegar / Refuser), so "press Deny" names a control five readers in six
338
+ // never see — and this sentence is the ONE human check standing between
339
+ // them and a device-code phish. It must not depend on an English label.
340
+ // • no first person. An agent relays this block verbatim to a human who is
341
+ // talking to that agent, so "I will continue" reads as the agent speaking.
342
+ // Name the actor instead.
343
+ return [
344
+ "Showly needs one approval from you. If you do not have a Showly",
345
+ "account yet, you will be asked to create one first.",
346
+ "",
347
+ ` 1. Open this page: ${input.verificationUri}`,
348
+ ` 2. Enter this code: ${input.userCode}`,
349
+ "",
350
+ "Same machine as your browser? Use the direct link instead:",
351
+ ` ${input.verificationUriComplete}`,
352
+ "",
353
+ `The page will show the code ${input.userCode} before you approve.`,
354
+ "Approve ONLY if it matches the code above. If it shows a",
355
+ "different code, someone else is trying to get in - refuse it.",
356
+ "",
357
+ `Waiting for approval until ${clock} local. Once you approve, this`,
358
+ "command picks it up on its own - no need to come back and tell it.",
359
+ "",
360
+ // The last line of the block is the one thing it never said: how to stop.
361
+ // This command blocks for up to fifteen minutes, and the population it
362
+ // exists for arrived here because a sign-in window did not open — so the
363
+ // reader is already unsure whether anything is happening. Without an exit
364
+ // that is named, "wait" and "give up on the whole session" are the only two
365
+ // moves visible, and the second is the one the report describes people
366
+ // taking. Ctrl+C is safe to name because nothing is connected until the
367
+ // approval lands, and the CLI now handles the signal rather than dying on
368
+ // it mid-write.
369
+ "Changed your mind? Press Ctrl+C to stop waiting. Nothing is",
370
+ "connected until you approve, and the command can be run again.",
371
+ ].join("\n");
372
+ }
373
+ /** Claude Code reads a `headers` map on an http MCP server entry. */
374
+ export function buildClaudeCodeAuthSnippet(opts) {
375
+ return {
376
+ mcpServers: {
377
+ showly: {
378
+ type: "http",
379
+ url: opts.url,
380
+ headers: { Authorization: `Bearer ${opts.token}` },
381
+ },
382
+ },
383
+ };
384
+ }
385
+ // Codex's streamable-HTTP MCP config takes `bearer_token_env_var` — the name
386
+ // of an env var to read, never the token itself. Verified against the
387
+ // installed binary rather than assumed: `codex mcp add --url` exposes
388
+ // `--bearer-token-env-var`, the config struct carries
389
+ // `bearer_token_env_var` / `http_headers` / `env_http_headers`, and the loader
390
+ // rejects a literal `bearer_token` with "uses unsupported `bearer_token`; set
391
+ // `bearer_token_env_var`". That check mattered — Codex's TOML deserialization
392
+ // refuses unknown keys, so guessing at a `headers` table here would not just
393
+ // fail to authorize, it would make the user's whole config.toml unloadable.
394
+ //
395
+ // The env indirection is also the right shape for CI: the credential lives in
396
+ // the secret store, and the file we write is safe to commit.
397
+ export function buildCodexAuthSnippet(opts) {
398
+ return [
399
+ "# Added by @showly/mcp-server login",
400
+ "",
401
+ "[mcp_servers.showly]",
402
+ `url = "${opts.url}"`,
403
+ `bearer_token_env_var = "${opts.tokenEnvVar ?? TOKEN_ENV_VAR}"`,
404
+ "",
405
+ ].join("\n");
406
+ }
407
+ /**
408
+ * How long ONE HTTP request may take before it is abandoned.
409
+ *
410
+ * Neither fetch had any bound at all, and both are inside the flow whose entire
411
+ * job is to unstick a human: a TCP connection that opens and then goes quiet
412
+ * (a captive portal, a proxy that swallows the response, a machine suspended
413
+ * mid-poll) leaves `login` hanging with no output and no deadline — the same
414
+ * silent wait, one layer down, that this command exists to replace.
415
+ *
416
+ * The start POST is short because nothing has been printed yet: until it
417
+ * returns there is no code, no URL, and nothing on screen, so the reader cannot
418
+ * tell a slow network from a dead one. A poll gets longer, because by then the
419
+ * prompt is on screen and the loop can absorb a slow answer without the reader
420
+ * seeing anything at all.
421
+ */
422
+ export const START_REQUEST_TIMEOUT_MS = 15_000;
423
+ export const POLL_REQUEST_TIMEOUT_MS = 30_000;
424
+ /** Thrown when the human cancels; the CLI exits 130 rather than 1 on it. */
425
+ export class LoginCancelledError extends Error {
426
+ constructor() {
427
+ super("Cancelled. Nothing was connected — run `npx @showly/mcp-server login` again when you are ready.");
428
+ this.name = "LoginCancelled";
429
+ }
430
+ }
431
+ /**
432
+ * A wait that also ends on cancellation.
433
+ *
434
+ * Resolves rather than rejects on abort: the caller re-checks the signal
435
+ * immediately after, so there is exactly one place that decides what a cancel
436
+ * means and one error to throw for it.
437
+ */
438
+ const defaultSleep = (ms, signal) => new Promise((resolve) => {
439
+ if (signal?.aborted)
440
+ return resolve();
441
+ const finish = () => {
442
+ clearTimeout(timer);
443
+ signal?.removeEventListener("abort", finish);
444
+ resolve();
445
+ };
446
+ const timer = setTimeout(finish, ms);
447
+ signal?.addEventListener("abort", finish, { once: true });
448
+ });
449
+ /**
450
+ * One signal per HTTP request: the request deadline OR the human's Ctrl+C,
451
+ * whichever lands first, plus the cleanup that keeps neither leaking.
452
+ *
453
+ * Hand-rolled instead of `AbortSignal.any()` because this package publishes
454
+ * `engines.node: ">=18"` and `any()` only exists from 20.3 — a published CLI
455
+ * that throws TypeError on a supported runtime would replace the hang with a
456
+ * crash rather than a fix.
457
+ */
458
+ function requestSignal(timeoutMs, external) {
459
+ const controller = new AbortController();
460
+ const timer = setTimeout(() => controller.abort(new DOMException("timeout", "TimeoutError")), timeoutMs);
461
+ const onAbort = () => controller.abort(external?.reason);
462
+ if (external?.aborted)
463
+ onAbort();
464
+ else
465
+ external?.addEventListener("abort", onAbort, { once: true });
466
+ return {
467
+ signal: controller.signal,
468
+ release: () => {
469
+ clearTimeout(timer);
470
+ external?.removeEventListener("abort", onAbort);
471
+ },
472
+ };
473
+ }
474
+ export function createCancelScope(target = process) {
475
+ const controller = new AbortController();
476
+ const onSignal = () => controller.abort();
477
+ target.on("SIGINT", onSignal);
478
+ target.on("SIGTERM", onSignal);
479
+ return {
480
+ signal: controller.signal,
481
+ release: () => {
482
+ target.off("SIGINT", onSignal);
483
+ target.off("SIGTERM", onSignal);
484
+ },
485
+ };
486
+ }
487
+ /**
488
+ * How far PAST the expiry we were handed at start the poll keeps going.
489
+ *
490
+ * `expires_in` from POST /oauth/device is a floor, not a deadline: when a
491
+ * signed-in human lands on the consent page the server pushes expires_at out
492
+ * (see DEVICE_LOOKUP_EXTENSION_MS), so a client that stopped at the advertised
493
+ * expiry would hang up on the human mid-approval. It is still bounded — the
494
+ * server caps a device flow at 30 minutes from creation whatever happens — so
495
+ * one absolute cap past the advertised expiry is comfortably beyond any answer
496
+ * that could still become a token, and stopping there is what keeps a deploy
497
+ * that stops resolving dead rows from turning this loop into a permanent 300
498
+ * requests/min against the token endpoint.
499
+ */
500
+ export const SERVER_EXTENSION_ALLOWANCE_MS = 30 * 60 * 1000;
501
+ /**
502
+ * Did this request die on its own deadline?
503
+ *
504
+ * undici reports an aborted fetch as a DOMException on `cause`, not as the
505
+ * thrown error itself, so the name has to be read through both.
506
+ */
507
+ function isTimeoutError(error) {
508
+ if (typeof error !== "object" || error === null)
509
+ return false;
510
+ const named = error;
511
+ return named.name === "TimeoutError" || named.cause?.name === "TimeoutError";
512
+ }
513
+ /** One short clause naming why a request failed, for the deadline message. */
514
+ function errorSummary(error, timeoutMs) {
515
+ if (isTimeoutError(error))
516
+ return `no answer within ${timeoutMs}ms`;
517
+ return error instanceof Error ? error.message : String(error);
518
+ }
519
+ /** Said on both routes to a dead code: the server's answer, and our deadline. */
520
+ const EXPIRED_MESSAGE = "The code expired before it was approved. Run this command again for a fresh one.";
521
+ export async function startDeviceFlow(apiUrl, deps = {}) {
522
+ const doFetch = deps.fetchImpl ?? fetch;
523
+ // `scope` is optional on /oauth/device, and this used to send none at all.
524
+ // The consent screen then listed no permissions, the human approved that,
525
+ // and the token that came back could invoke none of the tools this package
526
+ // exists to reach — while `showly-mcp login` printed "Connected" and the
527
+ // connect screen agreed, because a call denied for insufficient_scope still
528
+ // stamps last_used_at. The API now applies its own default to clients that
529
+ // omit `scope`, but a client that knows what it needs should say so. The
530
+ // manifest is the one place this list is written down and is the same list
531
+ // the API falls back to; asking through it is what keeps the request, the
532
+ // package's own advertised scopes, and the server default from drifting.
533
+ const manifest = loadManifest();
534
+ const timeoutMs = deps.requestTimeoutMs ?? START_REQUEST_TIMEOUT_MS;
535
+ const { signal, release } = requestSignal(timeoutMs, deps.signal);
536
+ let res;
537
+ try {
538
+ res = await doFetch(`${apiUrl}/oauth/device`, {
539
+ method: "POST",
540
+ headers: { "content-type": "application/json" },
541
+ body: JSON.stringify({
542
+ client_id: LOGIN_CLIENT_ID,
543
+ scope: manifest.mcp.auth.default_scopes.join(" "),
544
+ }),
545
+ signal,
546
+ });
547
+ }
548
+ catch (error) {
549
+ // Nothing is on screen yet at this point, so an unexplained hang here is
550
+ // indistinguishable from a broken install. Name which of the two ended it.
551
+ if (deps.signal?.aborted)
552
+ throw new LoginCancelledError();
553
+ if (isTimeoutError(error)) {
554
+ throw new Error(`Showly did not answer within ${Math.round(timeoutMs / 1000)}s (${apiUrl}/oauth/device). Check SHOWLY_API_URL and your network, then run the command again.`);
555
+ }
556
+ throw error;
557
+ }
558
+ finally {
559
+ release();
560
+ }
561
+ const body = (await res.json().catch(() => null));
562
+ if (!res.ok || !body?.data) {
563
+ throw new Error(`Could not start Showly sign-in (${res.status}). ${body?.error_description ?? "Check SHOWLY_API_URL and your network."}`);
564
+ }
565
+ return body.data;
566
+ }
567
+ /**
568
+ * Poll /oauth/token until the human decides, per RFC 8628 §3.4-3.5.
569
+ *
570
+ * Every terminal answer is turned into a sentence a human can act on. The
571
+ * server sends `error_description` for exactly this reason, so we prefer it
572
+ * over anything we could invent, and fall back only when an older deploy
573
+ * sends the bare code.
574
+ *
575
+ * `expiresAt` is the client-side backstop, not the contract: the server's own
576
+ * `expired_token` / `invalid_grant` is what normally ends the loop, and this
577
+ * only fires when no answer ever arrives at all. It sits a full
578
+ * SERVER_EXTENSION_ALLOWANCE_MS past the advertised expiry so an arrival
579
+ * extension is never cut short.
580
+ */
581
+ export async function pollForDeviceToken(input, deps = {}) {
582
+ const doFetch = deps.fetchImpl ?? fetch;
583
+ const sleep = deps.sleep ?? defaultSleep;
584
+ const now = deps.now ?? (() => Date.now());
585
+ const cancel = deps.signal;
586
+ const timeoutMs = deps.requestTimeoutMs ?? POLL_REQUEST_TIMEOUT_MS;
587
+ let intervalMs = Math.max(1, input.intervalSec) * 1000;
588
+ const stopAt = input.expiresAt.getTime() + SERVER_EXTENSION_ALLOWANCE_MS;
589
+ // Kept for the deadline message. A loop that quietly swallowed every network
590
+ // failure and then said only "the code expired" would send the human to look
591
+ // at their approval when the fault was never on their side.
592
+ let lastTransportError;
593
+ for (;;) {
594
+ if (cancel?.aborted)
595
+ throw new LoginCancelledError();
596
+ if (now() >= stopAt) {
597
+ throw new Error(lastTransportError
598
+ ? `${EXPIRED_MESSAGE} (the last attempt to reach Showly failed: ${lastTransportError})`
599
+ : EXPIRED_MESSAGE);
600
+ }
601
+ await sleep(intervalMs, cancel);
602
+ if (cancel?.aborted)
603
+ throw new LoginCancelledError();
604
+ const attempt = requestSignal(timeoutMs, cancel);
605
+ let res;
606
+ try {
607
+ res = await doFetch(`${input.apiUrl}/oauth/token`, {
608
+ method: "POST",
609
+ headers: { "content-type": "application/json" },
610
+ body: JSON.stringify({
611
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
612
+ device_code: input.deviceCode,
613
+ client_id: LOGIN_CLIENT_ID,
614
+ }),
615
+ signal: attempt.signal,
616
+ });
617
+ }
618
+ catch (error) {
619
+ if (cancel?.aborted)
620
+ throw new LoginCancelledError();
621
+ // A stalled or failed request is NOT a terminal answer. The human may
622
+ // already be approving on their phone, and their one approval is spent
623
+ // either way — throwing on a dropped packet would burn it and make them
624
+ // start over. Fall through to the next tick; `stopAt` still bounds this.
625
+ lastTransportError = errorSummary(error, timeoutMs);
626
+ continue;
627
+ }
628
+ finally {
629
+ attempt.release();
630
+ }
631
+ lastTransportError = undefined;
632
+ const body = (await res.json().catch(() => ({})));
633
+ if (res.ok && body.access_token) {
634
+ return {
635
+ access_token: body.access_token,
636
+ scope: body.scope ?? "",
637
+ expires_in: body.expires_in,
638
+ };
639
+ }
640
+ switch (body.error) {
641
+ case "authorization_pending":
642
+ continue;
643
+ // RFC 8628 §3.5: back off by 5 seconds and keep going. This is the one
644
+ // error that is not terminal and not a no-op.
645
+ case "slow_down":
646
+ intervalMs += 5_000;
647
+ continue;
648
+ case "access_denied":
649
+ throw new Error(body.error_description ??
650
+ "Approval was denied. Nothing was connected.");
651
+ case "expired_token":
652
+ throw new Error(body.error_description ?? EXPIRED_MESSAGE);
653
+ default:
654
+ throw new Error(body.error_description ??
655
+ `Sign-in failed (${body.error ?? res.status}).`);
656
+ }
657
+ }
658
+ }
659
+ /**
660
+ * Run the whole headless sign-in and put the credential where the host will
661
+ * find it.
662
+ *
663
+ * No secret is ever accepted on argv — there is no `--token` flag, and the
664
+ * only credential this command handles is the one it just fetched. Anything
665
+ * pasted from /app/admin/mcp-tokens goes in through the environment
666
+ * (SHOWLY_TOKEN) or the host config by hand, so it stays out of shell history
667
+ * and out of every `ps` listing on the machine.
668
+ */
669
+ export async function performLogin(opts, deps = {}) {
670
+ const env = opts.env ?? process.env;
671
+ const { url, apiUrl } = resolveUrls(env);
672
+ const log = deps.log ?? ((line) => console.error(line));
673
+ const started = await startDeviceFlow(apiUrl, deps);
674
+ const expiresAt = new Date(Date.now() + started.expires_in * 1000);
675
+ log(buildLoginPrompt({
676
+ verificationUri: started.verification_uri,
677
+ verificationUriComplete: started.verification_uri_complete,
678
+ userCode: started.user_code,
679
+ expiresAt,
680
+ }));
681
+ const token = await pollForDeviceToken({
682
+ apiUrl,
683
+ deviceCode: started.device_code,
684
+ intervalSec: started.interval,
685
+ expiresAt,
686
+ }, deps);
687
+ // Showly issues no refresh token, so this date is the moment a working agent
688
+ // stops working and a human has to approve again. Say it out loud now, while
689
+ // there is context, instead of leaving a 401 to be diagnosed in 90 days.
690
+ const tokenExpiresAt = token.expires_in
691
+ ? new Date(Date.now() + token.expires_in * 1000)
692
+ : null;
693
+ if (opts.target === "claude-code") {
694
+ const path = join(homedir(), ".claude.json");
695
+ const existing = existsSync(path)
696
+ ? safeReadJson(path)
697
+ : { mcpServers: {} };
698
+ const snippet = buildClaudeCodeAuthSnippet({
699
+ url,
700
+ token: token.access_token,
701
+ });
702
+ const merged = {
703
+ ...existing,
704
+ mcpServers: {
705
+ ...(typeof existing.mcpServers === "object" && existing.mcpServers
706
+ ? existing.mcpServers
707
+ : {}),
708
+ showly: snippet.mcpServers.showly,
709
+ },
710
+ };
711
+ mkdirSync(dirname(path), { recursive: true });
712
+ writeCredentialFile(path, JSON.stringify(merged, null, 2) + "\n");
713
+ return {
714
+ target: opts.target,
715
+ token: token.access_token,
716
+ scope: token.scope,
717
+ expiresAt: tokenExpiresAt,
718
+ path,
719
+ wrote: true,
720
+ snippet: JSON.stringify(snippet, null, 2),
721
+ };
722
+ }
723
+ if (opts.target === "codex") {
724
+ // Append only when there is no [mcp_servers.showly] block yet. Rewriting
725
+ // an existing one would mean editing TOML in place, and a bad edit there
726
+ // takes down the user's entire config rather than just our entry — so
727
+ // when the block exists we print the single line to add and touch
728
+ // nothing.
729
+ const path = join(homedir(), ".codex", "config.toml");
730
+ const snippet = buildCodexAuthSnippet({ url });
731
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
732
+ if (existing.includes("[mcp_servers.showly]")) {
733
+ return {
734
+ target: opts.target,
735
+ token: token.access_token,
736
+ scope: token.scope,
737
+ expiresAt: tokenExpiresAt,
738
+ path,
739
+ wrote: false,
740
+ snippet: `bearer_token_env_var = "${TOKEN_ENV_VAR}"`,
741
+ };
742
+ }
743
+ mkdirSync(dirname(path), { recursive: true });
744
+ writeFileSync(path, existing.length > 0 && !existing.endsWith("\n")
745
+ ? `${existing}\n${snippet}`
746
+ : `${existing}${snippet}`, "utf8");
747
+ return {
748
+ target: opts.target,
749
+ token: token.access_token,
750
+ scope: token.scope,
751
+ expiresAt: tokenExpiresAt,
752
+ path,
753
+ wrote: true,
754
+ snippet,
755
+ };
756
+ }
757
+ return {
758
+ target: opts.target,
759
+ token: token.access_token,
760
+ scope: token.scope,
761
+ expiresAt: tokenExpiresAt,
762
+ path: null,
763
+ wrote: false,
764
+ snippet: `# claude-code (~/.claude.json):\n` +
765
+ JSON.stringify(buildClaudeCodeAuthSnippet({
766
+ url,
767
+ token: `\${${TOKEN_ENV_VAR}}`,
768
+ }), null, 2) +
769
+ `\n\n# codex (~/.codex/config.toml):\n` +
770
+ buildCodexAuthSnippet({ url }),
771
+ };
772
+ }
773
+ /**
774
+ * The env var names a host config tells its client to read the credential
775
+ * from: Codex's `bearer_token_env_var = "NAME"`, and the `${NAME}` placeholder
776
+ * in the pasteable snippet.
777
+ *
778
+ * Deliberately derived FROM the config text rather than hardcoded, because the
779
+ * bug this closes was a config and an output that disagreed. Whatever a future
780
+ * host schema calls its indirection, the name it references is what the human
781
+ * has to be handed a value for.
782
+ */
783
+ export function envVarsReferencedBy(configText) {
784
+ const names = new Set();
785
+ for (const m of configText.matchAll(/_env_var\s*=\s*"([A-Za-z_][A-Za-z0-9_]*)"/g)) {
786
+ names.add(m[1]);
787
+ }
788
+ for (const m of configText.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
789
+ names.add(m[1]);
790
+ }
791
+ return [...names];
792
+ }
793
+ /**
794
+ * Everything `login` says after the token lands. Pure, so the one property
795
+ * that matters can be tested: a human who ran this command ends up CONNECTED.
796
+ *
797
+ * `--to codex` used to break that. It writes `bearer_token_env_var =
798
+ * "SHOWLY_TOKEN"` — Codex reads the credential from the environment, never
799
+ * from the file — and then printed `Connected. Wrote <path>` and dropped the
800
+ * token on the floor. Nothing on the machine ever set SHOWLY_TOKEN, so every
801
+ * tool call went out with no Authorization header and 401'd, after the human
802
+ * had already spent their one approval; recovering meant a second device flow
803
+ * and a second approval, which is the dead end this whole command exists to
804
+ * remove. `--to stdout` had the same hole with a sentence over it ("re-run
805
+ * with --print-token"), which is also a second approval.
806
+ *
807
+ * So the value is supplied here, once, on stderr — the only place it can go
808
+ * without landing in a file. The config keeps the env indirection, which is
809
+ * what makes it safe to commit; stdout keeps carrying only the snippet (or,
810
+ * under --print-token, only the token) so both remain pipeable.
811
+ */
812
+ export function buildLoginOutput(result, opts = {}) {
813
+ // stdout carries the token and NOTHING else, so
814
+ // `TOKEN=$(showly-mcp login --print-token)` is correct.
815
+ if (opts.printToken)
816
+ return [{ stream: "out", line: result.token }];
817
+ const lines = [];
818
+ if (result.wrote) {
819
+ lines.push({ stream: "err", line: `Connected. Wrote ${result.path}` });
820
+ }
821
+ else if (result.path) {
822
+ lines.push({
823
+ stream: "err",
824
+ line: `Connected. ${result.path} already has a [mcp_servers.showly] block — add this line to it:\n${result.snippet}`,
825
+ });
826
+ }
827
+ else {
828
+ lines.push({
829
+ stream: "err",
830
+ line: "Connected. Add this to your host config:",
831
+ });
832
+ lines.push({ stream: "out", line: result.snippet });
833
+ }
834
+ for (const name of envVarsReferencedBy(result.snippet)) {
835
+ lines.push({
836
+ stream: "err",
837
+ line: `That config reads the token from ${name}, and nothing sets it yet. Put this in the environment your agent starts in:`,
838
+ });
839
+ lines.push({ stream: "err", line: ` export ${name}=${result.token}` });
840
+ lines.push({
841
+ stream: "err",
842
+ line: "That line is the credential itself. It is printed here once, and kept out of the config file so the file stays safe to commit.",
843
+ });
844
+ }
845
+ if (result.expiresAt) {
846
+ lines.push({
847
+ stream: "err",
848
+ line: `This credential expires ${result.expiresAt.toISOString().slice(0, 10)}. Showly issues no refresh token, so run \`npx @showly/mcp-server login\` again before then — it needs a human approval each time.`,
849
+ });
850
+ }
851
+ return lines;
852
+ }
853
+ /**
854
+ * Which flags each command accepts, and whether the flag consumes the next
855
+ * argv token as its value.
856
+ *
857
+ * This table exists so an UNRECOGNIZED flag can be an error. It used to be
858
+ * impossible for one to be: the old parser only ever asked `rest.indexOf`
859
+ * / `rest.includes` about the flags it knew, so everything else fell through
860
+ * untouched and the command ran to completion, exit 0.
861
+ *
862
+ * That is the worst possible outcome and it shipped. `/drop` hands out
863
+ * `install --to claude-code --with-skill`; every copy older than 0.2.0 ignored
864
+ * `--with-skill`, wrote no skill file, printed "Wrote …" and exited 0. The
865
+ * human was told it worked, the agent had no reason to doubt it, and the
866
+ * feature was simply absent. A non-zero exit with the flag named is strictly
867
+ * better than a success that is not one — a broken command that says so can be
868
+ * retried, a broken command that stays quiet cannot.
869
+ */
870
+ const COMMAND_FLAGS = {
871
+ install: { "--to": true, "--with-skill": false },
872
+ login: { "--to": true, "--print-token": false },
873
+ manifest: {},
874
+ };
875
+ /** Accepted after any command, and handled before the command runs. */
876
+ const HELP_FLAGS = new Set(["--help", "-h"]);
877
+ /**
878
+ * Strict argv parse for one command: every token must be a flag this command
879
+ * declares, a value for a flag that takes one, or `--help`.
880
+ *
881
+ * Bare positionals are rejected too, for the same reason flags are: `install
882
+ * claude-code` (no `--to`) is a plausible typo whose only old outcome was a
883
+ * usage error about the missing `--to`, and `manifest extra` simply printed the
884
+ * manifest as if the word were not there.
885
+ */
886
+ export function parseCommandArgs(command, rest) {
887
+ const spec = COMMAND_FLAGS[command];
888
+ const flags = new Set();
889
+ const values = new Map();
890
+ let help = false;
891
+ for (let i = 0; i < rest.length; i += 1) {
892
+ const token = rest[i];
893
+ if (HELP_FLAGS.has(token)) {
894
+ help = true;
895
+ continue;
896
+ }
897
+ if (!token.startsWith("-")) {
898
+ return {
899
+ ok: false,
900
+ message: `${command}: unexpected argument "${token}". ${UPGRADE_HINT}`,
901
+ };
902
+ }
903
+ // `--to=codex` is a shape the old parser silently ignored (indexOf("--to")
904
+ // never matched), so accept it here rather than leave a second quiet
905
+ // no-op behind while closing the first.
906
+ const eq = token.indexOf("=");
907
+ const name = eq === -1 ? token : token.slice(0, eq);
908
+ if (!(name in spec)) {
909
+ return {
910
+ ok: false,
911
+ message: `${command}: unknown option "${name}". ${UPGRADE_HINT}`,
912
+ };
913
+ }
914
+ if (!spec[name]) {
915
+ if (eq !== -1) {
916
+ return {
917
+ ok: false,
918
+ message: `${command}: "${name}" takes no value.`,
919
+ };
920
+ }
921
+ flags.add(name);
922
+ continue;
923
+ }
924
+ const value = eq === -1 ? rest[++i] : token.slice(eq + 1);
925
+ if (value === undefined || value.length === 0) {
926
+ return { ok: false, message: `${command}: "${name}" needs a value.` };
927
+ }
928
+ values.set(name, value);
929
+ }
930
+ return { ok: true, help, flags, values };
931
+ }
932
+ function parseTarget(parsed, fallback) {
933
+ const value = parsed.values.get("--to");
934
+ if (value === undefined)
935
+ return fallback;
936
+ if (!["claude-code", "codex", "stdout"].includes(value))
937
+ return null;
938
+ return value;
939
+ }
940
+ const consoleIo = {
941
+ out: (line) => console.log(line),
942
+ err: (line) => console.error(line),
943
+ };
944
+ /**
945
+ * The whole command dispatcher, as a function that RETURNS its exit code.
946
+ *
947
+ * It used to be a `main` that assigned `process.exitCode` and was neither
948
+ * exported nor reachable from a test, which is why nothing noticed that the
949
+ * failure paths through it were not failures at all. The code is the contract
950
+ * here — the strings around it are not — so it is what a caller gets back.
951
+ */
952
+ export async function runCli(argv, io = consoleIo, env = process.env) {
163
953
  const [cmd, ...rest] = argv.slice(2);
164
- if (!cmd || cmd === "--help" || cmd === "-h") {
165
- console.log(usage());
166
- return;
954
+ if (!cmd || HELP_FLAGS.has(cmd)) {
955
+ io.out(usage());
956
+ return 0;
957
+ }
958
+ if (cmd === "--version" || cmd === "-v") {
959
+ io.out(CLI_VERSION);
960
+ return 0;
961
+ }
962
+ if (!KNOWN_COMMANDS.includes(cmd)) {
963
+ io.err(`unknown command: ${cmd}`);
964
+ // Said BEFORE the usage block, because the usage block is exactly what
965
+ // misleads here: a reader who is shown a short command list and no version
966
+ // concludes the tool never had the command, not that this copy is behind.
967
+ io.err(`If you expected this command, your copy is out of date — re-run with \`npx @showly/mcp-server@latest\`. This copy is @showly/mcp-server ${CLI_VERSION}.`);
968
+ io.err(usage());
969
+ return 2;
970
+ }
971
+ const command = cmd;
972
+ const parsed = parseCommandArgs(command, rest);
973
+ if (!parsed.ok) {
974
+ io.err(parsed.message);
975
+ io.err(`This copy is @showly/mcp-server ${CLI_VERSION}.`);
976
+ return 2;
977
+ }
978
+ // `<command> --help` exits 0 for every command this copy has. That is a
979
+ // probe a CI gate can run against the tarball npm currently serves as
980
+ // `latest`, so "main documents a subcommand the published package does not
981
+ // have" fails a build instead of failing a user.
982
+ if (parsed.help) {
983
+ io.out(usage());
984
+ return 0;
167
985
  }
168
- if (cmd === "manifest") {
169
- console.log(JSON.stringify(loadManifest(), null, 2));
170
- return;
986
+ if (command === "manifest") {
987
+ io.out(JSON.stringify(loadManifest(), null, 2));
988
+ return 0;
171
989
  }
172
- if (cmd === "install") {
173
- const toIdx = rest.indexOf("--to");
174
- if (toIdx === -1 || !rest[toIdx + 1]) {
175
- console.error("install: --to <target> is required");
176
- process.exitCode = 2;
177
- console.error(usage());
178
- return;
990
+ if (command === "login") {
991
+ // --to defaults to stdout: printing a snippet can never corrupt a config
992
+ // file the user did not ask us to touch.
993
+ const target = parseTarget(parsed, "stdout");
994
+ if (!target) {
995
+ io.err("login: --to must be claude-code, codex or stdout");
996
+ return 2;
179
997
  }
180
- const target = rest[toIdx + 1];
181
- if (!["claude-code", "codex", "stdout"].includes(target)) {
182
- console.error(`install: unknown target "${target}"`);
183
- process.exitCode = 2;
184
- return;
998
+ const printToken = parsed.flags.has("--print-token");
999
+ // This command blocks for up to fifteen minutes waiting on a human, so
1000
+ // Ctrl+C has to mean something here. Handling the signal (rather than
1001
+ // letting Node's default kill the process) is what turns "the terminal went
1002
+ // quiet and I don't know what happened" into one sentence and exit 130.
1003
+ const cancel = createCancelScope();
1004
+ try {
1005
+ const result = await performLogin({ target, env }, { signal: cancel.signal });
1006
+ for (const { stream, line } of buildLoginOutput(result, { printToken })) {
1007
+ if (stream === "out")
1008
+ io.out(line);
1009
+ else
1010
+ io.err(line);
1011
+ }
1012
+ return 0;
185
1013
  }
186
- const result = performInstall(target);
187
- if (target === "stdout") {
188
- console.log(result.snippet);
1014
+ catch (error) {
1015
+ io.err(error instanceof Error ? error.message : String(error));
1016
+ // 130 is the shell's own "terminated by SIGINT". A cancel is not a
1017
+ // failure of the command, and a script wrapping it should be able to
1018
+ // tell the two apart.
1019
+ return error instanceof LoginCancelledError ? 130 : 1;
189
1020
  }
190
- else if (result.alreadyConfigured) {
191
- console.log(`Already configured at ${result.path}`);
1021
+ finally {
1022
+ cancel.release();
192
1023
  }
193
- else {
194
- console.log(`Wrote ${result.path}`);
195
- console.log("");
196
- console.log("Next: open Claude Code / Codex and run any read tool (e.g. list_sites).");
197
- console.log("The agent will pop a browser tab for you to authorize the connection.");
1024
+ }
1025
+ // install
1026
+ const rawTarget = parsed.values.get("--to");
1027
+ if (rawTarget === undefined) {
1028
+ io.err("install: --to <target> is required");
1029
+ io.err(usage());
1030
+ return 2;
1031
+ }
1032
+ if (!["claude-code", "codex", "stdout"].includes(rawTarget)) {
1033
+ io.err(`install: unknown target "${rawTarget}"`);
1034
+ return 2;
1035
+ }
1036
+ const target = rawTarget;
1037
+ const withSkill = parsed.flags.has("--with-skill");
1038
+ if (withSkill && target === "stdout") {
1039
+ io.err("install: --with-skill requires --to claude-code or --to codex");
1040
+ return 2;
1041
+ }
1042
+ const result = performInstall(target, env, { withSkill });
1043
+ if (target === "stdout") {
1044
+ io.out(result.snippet);
1045
+ return 0;
1046
+ }
1047
+ io.out(result.alreadyConfigured
1048
+ ? `Already configured at ${result.path}`
1049
+ : `Wrote ${result.path}`);
1050
+ if (result.skill) {
1051
+ io.out(result.skill.alreadyConfigured
1052
+ ? `Skill already installed at ${result.skill.path}`
1053
+ : `Installed reusable skill at ${result.skill.path}`);
1054
+ if (result.skill.removedLegacyPath) {
1055
+ io.out(`Removed the superseded showly-publish skill at ${result.skill.removedLegacyPath}`);
198
1056
  }
199
- return;
200
1057
  }
201
- console.error(`unknown command: ${cmd}`);
202
- process.exitCode = 2;
203
- console.error(usage());
1058
+ io.out("");
1059
+ io.out(withSkill
1060
+ ? "Next: open your agent and ask “list my Showly sites”."
1061
+ : "Next: open Claude Code / Codex and run any read tool (e.g. list_sites).");
1062
+ io.out("The agent will pop a browser tab for you to authorize the connection.");
1063
+ return 0;
204
1064
  }
205
1065
  /**
206
1066
  * True when this module is the program entrypoint.
@@ -227,5 +1087,7 @@ export function isMainModule(argv1, metaUrl) {
227
1087
  }
228
1088
  }
229
1089
  if (isMainModule(process.argv[1], import.meta.url)) {
230
- main(process.argv);
1090
+ void runCli(process.argv).then((code) => {
1091
+ process.exitCode = code;
1092
+ });
231
1093
  }