@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/README.md +45 -7
- package/dist/cli.d.ts +291 -3
- package/dist/cli.js +905 -43
- package/dist/showly-hosting-skill.d.ts +14 -0
- package/dist/showly-hosting-skill.js +15 -0
- package/manifest.json +63 -5
- package/package.json +8 -7
- package/skills/showly-hosting/SKILL.md +67 -0
- package/skills/showly-hosting/agents/openai.yaml +4 -0
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
40
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
165
|
-
|
|
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 (
|
|
169
|
-
|
|
170
|
-
return;
|
|
986
|
+
if (command === "manifest") {
|
|
987
|
+
io.out(JSON.stringify(loadManifest(), null, 2));
|
|
988
|
+
return 0;
|
|
171
989
|
}
|
|
172
|
-
if (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
1021
|
+
finally {
|
|
1022
|
+
cancel.release();
|
|
192
1023
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
1090
|
+
void runCli(process.argv).then((code) => {
|
|
1091
|
+
process.exitCode = code;
|
|
1092
|
+
});
|
|
231
1093
|
}
|