@showly/mcp-server 0.1.0 → 0.2.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 CHANGED
@@ -6,37 +6,72 @@ you can preview and deploy a site directly from your terminal AI.
6
6
  ## Quickstart
7
7
 
8
8
  ```sh
9
- npx @showly/mcp-server install --to claude-code # or --to codex
9
+ npx @showly/mcp-server install --to codex --with-skill
10
+ # or: npx @showly/mcp-server install --to claude-code --with-skill
10
11
  ```
11
12
 
12
13
  The installer writes the Showly MCP server block to `~/.claude.json`
13
- (or `~/.codex/config.toml`). No token to paste: the next time you call a Showly
14
+ (or `~/.codex/config.toml`) and installs the reusable `showly-publish` skill.
15
+ The skill activates when you ask to share, preview, deploy, host, or publish a
16
+ web project; merely asking to build or edit a page does not deploy it.
17
+
18
+ No token to paste: the next time you call a Showly
14
19
  tool, your agent discovers Showly's OAuth authorization server from the
15
20
  endpoint and opens a browser tab for you to sign in and approve the scopes
16
21
  (standard MCP authorization).
17
22
 
18
23
  ## What you get
19
24
 
25
+ `manifest.json` in this package is the authoritative list — the server
26
+ derives `INVOKABLE_TOOLS` and `MCP_BLOCKED_TOOLS` from it, so read it
27
+ rather than trusting a hand-kept summary. It currently ships 33 tools,
28
+ grouped below by its own `kind` field.
29
+
20
30
  Read-only tools available to any token:
21
31
 
22
32
  - `list_projects`, `list_sites`, `get_site_context`, `list_templates`
23
33
  - `get_preview_status`, `get_deployment_logs`, `diagnose_deployment`
34
+ - `list_deployments`, `list_site_versions`, `diff_site_versions`, `get_site_files`
35
+ - `list_site_domains`, `request_download_url`
24
36
 
25
37
  Write tools that stage and materialise previews:
26
38
 
27
39
  - `create_change_plan` — turn a natural-language request into a plan
28
40
  - `apply_site_patch` — stage one or more file edits as a _changeset_
29
41
  - `create_preview` — build the changeset and return a real preview URL
42
+ - `create_github_preview` — build a private preview from the latest commit on a connected GitHub branch (static targets; dynamic container builds fail before enqueue)
30
43
  - `retry_deployment` — re-run a failed preview build
31
44
  - `run_checks` — read the lint / typecheck / build status of a preview
32
45
  - `create_site_from_template` — bootstrap a new site from a template
46
+ - `create_site_from_html` — create a site from inline HTML
47
+ - `request_upload_url` — signed URL for a file too large to pass inline
48
+ - `set_preview_access` — change who can open a preview URL
33
49
  - `request_publish` — open a publish approval (completed in the web UI)
34
50
 
35
- Production paths (`publish_site`, `rollback_deployment`) are deliberately
36
- not exposed via MCP at all — they don't appear in `tools/list`. The agent's
37
- path to production is `request_publish`, whose success response includes a
38
- `webApprovalUrl` deep link; the user opens that link to complete approval
39
- and step-up MFA in the Showly web UI.
51
+ Write tools for custom domains (ADR-0015):
52
+
53
+ - `add_custom_domain`, `verify_custom_domain`
54
+
55
+ Write tools for version history and deletion:
56
+
57
+ - `rollback_to_version` — restore a previous site version
58
+ - `delete_preview`, `delete_site`
59
+
60
+ Write tools for the guest trial flow:
61
+
62
+ - `create_trial_site`, `claim_trial_site`
63
+
64
+ `publish_site` is exposed through a narrower `publish:confirm` scope and never
65
+ publishes on its first call: it returns a short-lived, deployment-bound token
66
+ that the agent may echo only after the user confirms. Team and Enterprise
67
+ workspaces use `request_publish` and its `webApprovalUrl` for second-person
68
+ approval. Ordinary Live publishing does not require OTP/MFA. The legacy
69
+ `rollback_deployment` path remains Web-only.
70
+
71
+ Both publish paths require the human user bound to the MCP token to have a
72
+ verified Showly account email. An `email_verification_required` result includes
73
+ `webVerificationUrl`; the agent must direct the user there and must not report
74
+ the site as live until verification succeeds and publishing is retried.
40
75
 
41
76
  ## Environment
42
77
 
package/dist/cli.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- type Target = "claude-code" | "codex" | "stdout";
2
+ export type Target = "claude-code" | "codex" | "stdout";
3
3
  export declare function buildClaudeCodeSnippet(opts: {
4
4
  url: string;
5
5
  apiUrl?: string;
@@ -18,8 +18,237 @@ export type InstallResult = {
18
18
  snippet: string;
19
19
  wrote: boolean;
20
20
  alreadyConfigured: boolean;
21
+ skill: SkillInstallResult | null;
21
22
  };
22
- export declare function performInstall(target: Target, env?: NodeJS.ProcessEnv): InstallResult;
23
+ export type SkillInstallResult = {
24
+ path: string;
25
+ wrote: boolean;
26
+ alreadyConfigured: boolean;
27
+ };
28
+ export type InstallOptions = {
29
+ withSkill?: boolean;
30
+ };
31
+ export declare function performInstall(target: Target, env?: NodeJS.ProcessEnv, options?: InstallOptions): InstallResult;
32
+ export declare function performSkillInstall(target: Exclude<Target, "stdout">, env?: NodeJS.ProcessEnv): SkillInstallResult;
33
+ /**
34
+ * The client_id this CLI starts device flows under.
35
+ *
36
+ * Deliberately NOT a first-party id (the API rejects those on /oauth/device
37
+ * precisely so nobody can wear the product's identity on a consent screen),
38
+ * and deliberately not registered: a device client_id is free text, so the
39
+ * consent page treats whatever name it carries as self-declared and asks the
40
+ * human to check the code echo instead.
41
+ */
42
+ export declare const LOGIN_CLIENT_ID = "showly-mcp-cli";
43
+ /** The env var name emitted into config snippets that must not hold a secret. */
44
+ export declare const TOKEN_ENV_VAR = "SHOWLY_TOKEN";
45
+ export type DeviceStart = {
46
+ device_code: string;
47
+ user_code: string;
48
+ verification_uri: string;
49
+ verification_uri_complete: string;
50
+ expires_in: number;
51
+ interval: number;
52
+ };
53
+ export type DeviceToken = {
54
+ access_token: string;
55
+ scope: string;
56
+ expires_in?: number;
57
+ };
58
+ /**
59
+ * The exact text `login` prints while it waits. Pure, and pinned by a test,
60
+ * because this block is the entire user interface of headless sign-in — the
61
+ * docs quote it verbatim so the page and the binary cannot drift.
62
+ *
63
+ * Reading order is not cosmetic:
64
+ * • The bare URL plus the separately-printed code comes FIRST, because that
65
+ * is the pair that works when the human is on a phone or another machine —
66
+ * which is the entire reason this flow exists. The one-click deep link is
67
+ * offered second, for the case where the browser is on this box.
68
+ * • The code is repeated three times on purpose. RFC 8628 §3.3.1's code echo
69
+ * is the ONLY anti-phishing signal that survives an attacker-controlled
70
+ * client_name (Storm-2372 phished 340+ M365 tenants on exactly this flow),
71
+ * so the human has to be told what they will see and what to do if it
72
+ * differs.
73
+ * • The expiry is a wall-clock time, not a duration. A human who wanders off
74
+ * to create an account and verify an email cannot subtract "15 minutes"
75
+ * from a moment they have forgotten.
76
+ */
77
+ export declare function buildLoginPrompt(input: {
78
+ verificationUri: string;
79
+ verificationUriComplete: string;
80
+ userCode: string;
81
+ expiresAt: Date;
82
+ }): string;
83
+ /** Claude Code reads a `headers` map on an http MCP server entry. */
84
+ export declare function buildClaudeCodeAuthSnippet(opts: {
85
+ url: string;
86
+ token: string;
87
+ }): Record<string, unknown>;
88
+ export declare function buildCodexAuthSnippet(opts: {
89
+ url: string;
90
+ tokenEnvVar?: string;
91
+ }): string;
92
+ export type LoginDeps = {
93
+ fetchImpl?: typeof fetch;
94
+ /**
95
+ * Injectable wait. Takes the cancellation signal too, so the real one can
96
+ * return the instant Ctrl+C arrives instead of finishing a five-second nap
97
+ * first — a cancel the user has to wait out does not read as a cancel.
98
+ */
99
+ sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
100
+ /** Injectable clock (ms). Lets the poll deadline be tested without waiting. */
101
+ now?: () => number;
102
+ /** stderr sink. Keeps stdout clean for --print-token. */
103
+ log?: (line: string) => void;
104
+ /**
105
+ * Cancellation from outside — Ctrl+C, in production (see createCancelScope).
106
+ *
107
+ * Without it there was no way out of this command but killing the process,
108
+ * which is a poor deal for the human who is already stuck: `login` blocks for
109
+ * a quarter of an hour by design, and the whole reason a reader reaches it is
110
+ * that something else (their editor's browser window) failed to appear.
111
+ */
112
+ signal?: AbortSignal;
113
+ /**
114
+ * Per-request deadline override, in ms. Production uses the constants below;
115
+ * the tests use a tiny value so a hung server can be exercised in
116
+ * milliseconds rather than in the fifteen seconds a real one gets.
117
+ */
118
+ requestTimeoutMs?: number;
119
+ };
120
+ /**
121
+ * How long ONE HTTP request may take before it is abandoned.
122
+ *
123
+ * Neither fetch had any bound at all, and both are inside the flow whose entire
124
+ * job is to unstick a human: a TCP connection that opens and then goes quiet
125
+ * (a captive portal, a proxy that swallows the response, a machine suspended
126
+ * mid-poll) leaves `login` hanging with no output and no deadline — the same
127
+ * silent wait, one layer down, that this command exists to replace.
128
+ *
129
+ * The start POST is short because nothing has been printed yet: until it
130
+ * returns there is no code, no URL, and nothing on screen, so the reader cannot
131
+ * tell a slow network from a dead one. A poll gets longer, because by then the
132
+ * prompt is on screen and the loop can absorb a slow answer without the reader
133
+ * seeing anything at all.
134
+ */
135
+ export declare const START_REQUEST_TIMEOUT_MS = 15000;
136
+ export declare const POLL_REQUEST_TIMEOUT_MS = 30000;
137
+ /** Thrown when the human cancels; the CLI exits 130 rather than 1 on it. */
138
+ export declare class LoginCancelledError extends Error {
139
+ constructor();
140
+ }
141
+ /**
142
+ * Wire Ctrl+C (and SIGTERM) to an AbortSignal the login flow watches.
143
+ *
144
+ * Registering a SIGINT listener also SUPPRESSES Node's default "die now", which
145
+ * is the point: the flow gets to unwind, say what happened, and exit with the
146
+ * conventional 130 instead of leaving the reader guessing whether anything was
147
+ * half-written. Exported so the wiring itself is testable — `main` is not.
148
+ */
149
+ export type SignalTarget = {
150
+ on(event: string, listener: () => void): unknown;
151
+ off(event: string, listener: () => void): unknown;
152
+ };
153
+ export declare function createCancelScope(target?: SignalTarget): {
154
+ signal: AbortSignal;
155
+ release: () => void;
156
+ };
157
+ /**
158
+ * How far PAST the expiry we were handed at start the poll keeps going.
159
+ *
160
+ * `expires_in` from POST /oauth/device is a floor, not a deadline: when a
161
+ * signed-in human lands on the consent page the server pushes expires_at out
162
+ * (see DEVICE_LOOKUP_EXTENSION_MS), so a client that stopped at the advertised
163
+ * expiry would hang up on the human mid-approval. It is still bounded — the
164
+ * server caps a device flow at 30 minutes from creation whatever happens — so
165
+ * one absolute cap past the advertised expiry is comfortably beyond any answer
166
+ * that could still become a token, and stopping there is what keeps a deploy
167
+ * that stops resolving dead rows from turning this loop into a permanent 300
168
+ * requests/min against the token endpoint.
169
+ */
170
+ export declare const SERVER_EXTENSION_ALLOWANCE_MS: number;
171
+ export declare function startDeviceFlow(apiUrl: string, deps?: LoginDeps): Promise<DeviceStart>;
172
+ /**
173
+ * Poll /oauth/token until the human decides, per RFC 8628 §3.4-3.5.
174
+ *
175
+ * Every terminal answer is turned into a sentence a human can act on. The
176
+ * server sends `error_description` for exactly this reason, so we prefer it
177
+ * over anything we could invent, and fall back only when an older deploy
178
+ * sends the bare code.
179
+ *
180
+ * `expiresAt` is the client-side backstop, not the contract: the server's own
181
+ * `expired_token` / `invalid_grant` is what normally ends the loop, and this
182
+ * only fires when no answer ever arrives at all. It sits a full
183
+ * SERVER_EXTENSION_ALLOWANCE_MS past the advertised expiry so an arrival
184
+ * extension is never cut short.
185
+ */
186
+ export declare function pollForDeviceToken(input: {
187
+ apiUrl: string;
188
+ deviceCode: string;
189
+ intervalSec: number;
190
+ expiresAt: Date;
191
+ }, deps?: LoginDeps): Promise<DeviceToken>;
192
+ export type LoginResult = {
193
+ target: Target;
194
+ token: string;
195
+ scope: string;
196
+ expiresAt: Date | null;
197
+ path: string | null;
198
+ wrote: boolean;
199
+ snippet: string;
200
+ };
201
+ /**
202
+ * Run the whole headless sign-in and put the credential where the host will
203
+ * find it.
204
+ *
205
+ * No secret is ever accepted on argv — there is no `--token` flag, and the
206
+ * only credential this command handles is the one it just fetched. Anything
207
+ * pasted from /app/admin/mcp-tokens goes in through the environment
208
+ * (SHOWLY_TOKEN) or the host config by hand, so it stays out of shell history
209
+ * and out of every `ps` listing on the machine.
210
+ */
211
+ export declare function performLogin(opts: {
212
+ target: Target;
213
+ env?: NodeJS.ProcessEnv;
214
+ }, deps?: LoginDeps): Promise<LoginResult>;
215
+ /**
216
+ * The env var names a host config tells its client to read the credential
217
+ * from: Codex's `bearer_token_env_var = "NAME"`, and the `${NAME}` placeholder
218
+ * in the pasteable snippet.
219
+ *
220
+ * Deliberately derived FROM the config text rather than hardcoded, because the
221
+ * bug this closes was a config and an output that disagreed. Whatever a future
222
+ * host schema calls its indirection, the name it references is what the human
223
+ * has to be handed a value for.
224
+ */
225
+ export declare function envVarsReferencedBy(configText: string): string[];
226
+ export type LoginOutputLine = {
227
+ stream: "out" | "err";
228
+ line: string;
229
+ };
230
+ /**
231
+ * Everything `login` says after the token lands. Pure, so the one property
232
+ * that matters can be tested: a human who ran this command ends up CONNECTED.
233
+ *
234
+ * `--to codex` used to break that. It writes `bearer_token_env_var =
235
+ * "SHOWLY_TOKEN"` — Codex reads the credential from the environment, never
236
+ * from the file — and then printed `Connected. Wrote <path>` and dropped the
237
+ * token on the floor. Nothing on the machine ever set SHOWLY_TOKEN, so every
238
+ * tool call went out with no Authorization header and 401'd, after the human
239
+ * had already spent their one approval; recovering meant a second device flow
240
+ * and a second approval, which is the dead end this whole command exists to
241
+ * remove. `--to stdout` had the same hole with a sentence over it ("re-run
242
+ * with --print-token"), which is also a second approval.
243
+ *
244
+ * So the value is supplied here, once, on stderr — the only place it can go
245
+ * without landing in a file. The config keeps the env indirection, which is
246
+ * what makes it safe to commit; stdout keeps carrying only the snippet (or,
247
+ * under --print-token, only the token) so both remain pipeable.
248
+ */
249
+ export declare function buildLoginOutput(result: LoginResult, opts?: {
250
+ printToken?: boolean;
251
+ }): LoginOutputLine[];
23
252
  /**
24
253
  * True when this module is the program entrypoint.
25
254
  *
@@ -35,4 +264,3 @@ export declare function performInstall(target: Target, env?: NodeJS.ProcessEnv):
35
264
  * exercised from an in-process test runner otherwise).
36
265
  */
37
266
  export declare function isMainModule(argv1: string | undefined, metaUrl: string): boolean;
38
- export {};
package/dist/cli.js CHANGED
@@ -4,29 +4,51 @@
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-publish
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
11
  // showly-mcp --help
10
12
  //
11
- // The CLI only writes the MCP server config block — it doesn't touch network.
13
+ // `install` only writes the MCP server config block — it doesn't touch network.
12
14
  // The agent discovers OAuth from the server and runs the browser sign-in on
13
15
  // first use (standard MCP authorization).
16
+ //
17
+ // `login` is the headless path, and it exists because nothing else could start
18
+ // one. The API has implemented RFC 8628 the whole time, but no product surface
19
+ // ever kicked it off: this CLI had `install` and `manifest` and no `login`, and
20
+ // MCP clients will not drive it either — the MCP Authorization spec never
21
+ // mentions RFC 8628, and Claude Code ignores `device_authorization_endpoint`
22
+ // even when it is advertised. So a human whose agent runs on a box with no
23
+ // browser, or who is holding a phone rather than sitting at the machine, had a
24
+ // working server-side flow and no way to reach it.
14
25
  import { readFileSync, mkdirSync, writeFileSync, existsSync, realpathSync, } from "node:fs";
15
26
  import { dirname, join } from "node:path";
16
27
  import { homedir } from "node:os";
17
28
  import { pathToFileURL } from "node:url";
18
29
  import { loadManifest } from "./index.js";
30
+ import { SHOWLY_PUBLISH_SKILL_MARKDOWN, SHOWLY_PUBLISH_SKILL_NAME, } from "./showly-publish-skill.js";
19
31
  function usage() {
20
32
  return [
21
33
  "Showly MCP server installer",
22
34
  "",
23
35
  "Usage:",
24
- " showly-mcp install --to <claude-code|codex|stdout>",
36
+ " showly-mcp install --to <claude-code|codex|stdout> [--with-skill]",
37
+ " showly-mcp login [--to <claude-code|codex|stdout>] [--print-token]",
25
38
  " showly-mcp manifest",
26
39
  "",
40
+ "login authorizes this machine without a browser on it: it prints a short",
41
+ "code, you approve it on any device, and the credential lands in your host",
42
+ "config. --to codex writes a config that reads the token from SHOWLY_TOKEN,",
43
+ "so login also prints the export line that sets it. --print-token writes",
44
+ "ONLY the token to stdout (everything else goes to stderr) so CI can",
45
+ "capture it without it touching a file.",
46
+ "",
27
47
  "Environment overrides:",
28
48
  " SHOWLY_MCP_URL full URL to your MCP endpoint (default https://mcp.showly.ai)",
29
49
  " SHOWLY_API_URL full URL to your API (default https://api.showly.ai)",
50
+ "",
51
+ "--with-skill installs a reusable showly-publish skill for Claude Code or Codex.",
30
52
  ].join("\n");
31
53
  }
32
54
  // The MCP server config is intentionally MINIMAL: just the transport + URL.
@@ -36,8 +58,8 @@ function usage() {
36
58
  // authorization-server metadata, dynamically registers, and runs
37
59
  // authorization_code + PKCE in the browser. There is no client-readable
38
60
  // `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.
61
+ // did) was a no-op that misled rather than helped. `apiUrl` is threaded through
62
+ // for the stdout help text and for `login`, which POSTs the device flow there.
41
63
  export function buildClaudeCodeSnippet(opts) {
42
64
  return {
43
65
  mcpServers: {
@@ -71,7 +93,7 @@ export function resolveUrls(env = process.env) {
71
93
  apiUrl: env[manifest.mcp.endpoints.api_url_env] ?? "https://api.showly.ai",
72
94
  };
73
95
  }
74
- export function performInstall(target, env = process.env) {
96
+ export function performInstall(target, env = process.env, options = {}) {
75
97
  const { url, apiUrl } = resolveUrls(env);
76
98
  if (target === "stdout") {
77
99
  const obj = buildClaudeCodeSnippet({ url, apiUrl });
@@ -84,8 +106,10 @@ export function performInstall(target, env = process.env) {
84
106
  buildCodexSnippet({ url, apiUrl }),
85
107
  wrote: false,
86
108
  alreadyConfigured: false,
109
+ skill: null,
87
110
  };
88
111
  }
112
+ const installSkill = () => options.withSkill ? performSkillInstall(target, env) : null;
89
113
  if (target === "claude-code") {
90
114
  // User-scope MCP servers live in ~/.claude.json (top-level `mcpServers`),
91
115
  // NOT ~/.claude/settings.json — Claude Code never reads mcpServers from
@@ -114,16 +138,18 @@ export function performInstall(target, env = process.env) {
114
138
  snippet: JSON.stringify(merged, null, 2),
115
139
  wrote: false,
116
140
  alreadyConfigured: true,
141
+ skill: installSkill(),
117
142
  };
118
143
  }
119
144
  mkdirSync(dirname(path), { recursive: true });
120
- writeFileSync(path, JSON.stringify(merged, null, 2) + "\n", "utf8");
145
+ writeCredentialFile(path, JSON.stringify(merged, null, 2) + "\n");
121
146
  return {
122
147
  target,
123
148
  path,
124
149
  snippet: JSON.stringify(merged, null, 2),
125
150
  wrote: true,
126
151
  alreadyConfigured: false,
152
+ skill: installSkill(),
127
153
  };
128
154
  }
129
155
  // codex
@@ -137,6 +163,7 @@ export function performInstall(target, env = process.env) {
137
163
  snippet,
138
164
  wrote: false,
139
165
  alreadyConfigured: true,
166
+ skill: installSkill(),
140
167
  };
141
168
  }
142
169
  mkdirSync(dirname(path), { recursive: true });
@@ -149,8 +176,23 @@ export function performInstall(target, env = process.env) {
149
176
  snippet,
150
177
  wrote: true,
151
178
  alreadyConfigured: false,
179
+ skill: installSkill(),
152
180
  };
153
181
  }
182
+ export function performSkillInstall(target, env = process.env) {
183
+ const codexHome = env.CODEX_HOME?.trim();
184
+ const hostDirectory = target === "codex"
185
+ ? codexHome || join(homedir(), ".codex")
186
+ : join(homedir(), ".claude");
187
+ const path = join(hostDirectory, "skills", SHOWLY_PUBLISH_SKILL_NAME, "SKILL.md");
188
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : null;
189
+ if (existing === SHOWLY_PUBLISH_SKILL_MARKDOWN) {
190
+ return { path, wrote: false, alreadyConfigured: true };
191
+ }
192
+ mkdirSync(dirname(path), { recursive: true });
193
+ writeFileSync(path, SHOWLY_PUBLISH_SKILL_MARKDOWN, "utf8");
194
+ return { path, wrote: true, alreadyConfigured: false };
195
+ }
154
196
  function safeReadJson(path) {
155
197
  try {
156
198
  return JSON.parse(readFileSync(path, "utf8"));
@@ -159,7 +201,596 @@ function safeReadJson(path) {
159
201
  return {};
160
202
  }
161
203
  }
162
- function main(argv) {
204
+ /**
205
+ * Write a file that holds (or will hold) a credential, 0600 on creation.
206
+ *
207
+ * `login` puts a raw 90-day bearer into ~/.claude.json, and this command
208
+ * exists for exactly the hosts where "another local user" is not hypothetical:
209
+ * shared build boxes, container images, CI runners. writeFileSync's default is
210
+ * 0666 & ~umask — 0644 under the usual umask, i.e. world-readable.
211
+ *
212
+ * `mode` is consulted only when the file is CREATED (it goes to open(2) with
213
+ * O_CREAT), so an existing file keeps whatever mode its owner chose: this
214
+ * never widens a mode and never silently narrows one that was set on purpose.
215
+ * `install` writes through here too, so the common install-then-login sequence
216
+ * does not leave the file created loose before the token arrives.
217
+ */
218
+ function writeCredentialFile(path, contents) {
219
+ writeFileSync(path, contents, { encoding: "utf8", mode: 0o600 });
220
+ }
221
+ // ───────────────────────────────────────────────────────────────────────────
222
+ // login — RFC 8628 device authorization
223
+ // ───────────────────────────────────────────────────────────────────────────
224
+ /**
225
+ * The client_id this CLI starts device flows under.
226
+ *
227
+ * Deliberately NOT a first-party id (the API rejects those on /oauth/device
228
+ * precisely so nobody can wear the product's identity on a consent screen),
229
+ * and deliberately not registered: a device client_id is free text, so the
230
+ * consent page treats whatever name it carries as self-declared and asks the
231
+ * human to check the code echo instead.
232
+ */
233
+ export const LOGIN_CLIENT_ID = "showly-mcp-cli";
234
+ /** The env var name emitted into config snippets that must not hold a secret. */
235
+ export const TOKEN_ENV_VAR = "SHOWLY_TOKEN";
236
+ /**
237
+ * The exact text `login` prints while it waits. Pure, and pinned by a test,
238
+ * because this block is the entire user interface of headless sign-in — the
239
+ * docs quote it verbatim so the page and the binary cannot drift.
240
+ *
241
+ * Reading order is not cosmetic:
242
+ * • The bare URL plus the separately-printed code comes FIRST, because that
243
+ * is the pair that works when the human is on a phone or another machine —
244
+ * which is the entire reason this flow exists. The one-click deep link is
245
+ * offered second, for the case where the browser is on this box.
246
+ * • The code is repeated three times on purpose. RFC 8628 §3.3.1's code echo
247
+ * is the ONLY anti-phishing signal that survives an attacker-controlled
248
+ * client_name (Storm-2372 phished 340+ M365 tenants on exactly this flow),
249
+ * so the human has to be told what they will see and what to do if it
250
+ * differs.
251
+ * • The expiry is a wall-clock time, not a duration. A human who wanders off
252
+ * to create an account and verify an email cannot subtract "15 minutes"
253
+ * from a moment they have forgotten.
254
+ */
255
+ export function buildLoginPrompt(input) {
256
+ const clock = input.expiresAt.toLocaleTimeString(undefined, {
257
+ hour: "2-digit",
258
+ minute: "2-digit",
259
+ hour12: false,
260
+ });
261
+ // Three things this wording deliberately does NOT do, each from a review of
262
+ // the first shipped draft:
263
+ // • no "takes about a minute". The population this path exists for is the
264
+ // cold-start user who must still sign up and verify an email — exactly the
265
+ // people who used to run the clock out. A minute is a promise we break at
266
+ // the worst possible moment; naming the signup sets the real expectation.
267
+ // • no button label. The consent page is localized (拒绝 / 拒否 / 거부 /
268
+ // Denegar / Refuser), so "press Deny" names a control five readers in six
269
+ // never see — and this sentence is the ONE human check standing between
270
+ // them and a device-code phish. It must not depend on an English label.
271
+ // • no first person. An agent relays this block verbatim to a human who is
272
+ // talking to that agent, so "I will continue" reads as the agent speaking.
273
+ // Name the actor instead.
274
+ return [
275
+ "Showly needs one approval from you. If you do not have a Showly",
276
+ "account yet, you will be asked to create one first.",
277
+ "",
278
+ ` 1. Open this page: ${input.verificationUri}`,
279
+ ` 2. Enter this code: ${input.userCode}`,
280
+ "",
281
+ "Same machine as your browser? Use the direct link instead:",
282
+ ` ${input.verificationUriComplete}`,
283
+ "",
284
+ `The page will show the code ${input.userCode} before you approve.`,
285
+ "Approve ONLY if it matches the code above. If it shows a",
286
+ "different code, someone else is trying to get in - refuse it.",
287
+ "",
288
+ `Waiting for approval until ${clock} local. Once you approve, this`,
289
+ "command picks it up on its own - no need to come back and tell it.",
290
+ "",
291
+ // The last line of the block is the one thing it never said: how to stop.
292
+ // This command blocks for up to fifteen minutes, and the population it
293
+ // exists for arrived here because a sign-in window did not open — so the
294
+ // reader is already unsure whether anything is happening. Without an exit
295
+ // that is named, "wait" and "give up on the whole session" are the only two
296
+ // moves visible, and the second is the one the report describes people
297
+ // taking. Ctrl+C is safe to name because nothing is connected until the
298
+ // approval lands, and the CLI now handles the signal rather than dying on
299
+ // it mid-write.
300
+ "Changed your mind? Press Ctrl+C to stop waiting. Nothing is",
301
+ "connected until you approve, and the command can be run again.",
302
+ ].join("\n");
303
+ }
304
+ /** Claude Code reads a `headers` map on an http MCP server entry. */
305
+ export function buildClaudeCodeAuthSnippet(opts) {
306
+ return {
307
+ mcpServers: {
308
+ showly: {
309
+ type: "http",
310
+ url: opts.url,
311
+ headers: { Authorization: `Bearer ${opts.token}` },
312
+ },
313
+ },
314
+ };
315
+ }
316
+ // Codex's streamable-HTTP MCP config takes `bearer_token_env_var` — the name
317
+ // of an env var to read, never the token itself. Verified against the
318
+ // installed binary rather than assumed: `codex mcp add --url` exposes
319
+ // `--bearer-token-env-var`, the config struct carries
320
+ // `bearer_token_env_var` / `http_headers` / `env_http_headers`, and the loader
321
+ // rejects a literal `bearer_token` with "uses unsupported `bearer_token`; set
322
+ // `bearer_token_env_var`". That check mattered — Codex's TOML deserialization
323
+ // refuses unknown keys, so guessing at a `headers` table here would not just
324
+ // fail to authorize, it would make the user's whole config.toml unloadable.
325
+ //
326
+ // The env indirection is also the right shape for CI: the credential lives in
327
+ // the secret store, and the file we write is safe to commit.
328
+ export function buildCodexAuthSnippet(opts) {
329
+ return [
330
+ "# Added by @showly/mcp-server login",
331
+ "",
332
+ "[mcp_servers.showly]",
333
+ `url = "${opts.url}"`,
334
+ `bearer_token_env_var = "${opts.tokenEnvVar ?? TOKEN_ENV_VAR}"`,
335
+ "",
336
+ ].join("\n");
337
+ }
338
+ /**
339
+ * How long ONE HTTP request may take before it is abandoned.
340
+ *
341
+ * Neither fetch had any bound at all, and both are inside the flow whose entire
342
+ * job is to unstick a human: a TCP connection that opens and then goes quiet
343
+ * (a captive portal, a proxy that swallows the response, a machine suspended
344
+ * mid-poll) leaves `login` hanging with no output and no deadline — the same
345
+ * silent wait, one layer down, that this command exists to replace.
346
+ *
347
+ * The start POST is short because nothing has been printed yet: until it
348
+ * returns there is no code, no URL, and nothing on screen, so the reader cannot
349
+ * tell a slow network from a dead one. A poll gets longer, because by then the
350
+ * prompt is on screen and the loop can absorb a slow answer without the reader
351
+ * seeing anything at all.
352
+ */
353
+ export const START_REQUEST_TIMEOUT_MS = 15_000;
354
+ export const POLL_REQUEST_TIMEOUT_MS = 30_000;
355
+ /** Thrown when the human cancels; the CLI exits 130 rather than 1 on it. */
356
+ export class LoginCancelledError extends Error {
357
+ constructor() {
358
+ super("Cancelled. Nothing was connected — run `npx @showly/mcp-server login` again when you are ready.");
359
+ this.name = "LoginCancelled";
360
+ }
361
+ }
362
+ /**
363
+ * A wait that also ends on cancellation.
364
+ *
365
+ * Resolves rather than rejects on abort: the caller re-checks the signal
366
+ * immediately after, so there is exactly one place that decides what a cancel
367
+ * means and one error to throw for it.
368
+ */
369
+ const defaultSleep = (ms, signal) => new Promise((resolve) => {
370
+ if (signal?.aborted)
371
+ return resolve();
372
+ const finish = () => {
373
+ clearTimeout(timer);
374
+ signal?.removeEventListener("abort", finish);
375
+ resolve();
376
+ };
377
+ const timer = setTimeout(finish, ms);
378
+ signal?.addEventListener("abort", finish, { once: true });
379
+ });
380
+ /**
381
+ * One signal per HTTP request: the request deadline OR the human's Ctrl+C,
382
+ * whichever lands first, plus the cleanup that keeps neither leaking.
383
+ *
384
+ * Hand-rolled instead of `AbortSignal.any()` because this package publishes
385
+ * `engines.node: ">=18"` and `any()` only exists from 20.3 — a published CLI
386
+ * that throws TypeError on a supported runtime would replace the hang with a
387
+ * crash rather than a fix.
388
+ */
389
+ function requestSignal(timeoutMs, external) {
390
+ const controller = new AbortController();
391
+ const timer = setTimeout(() => controller.abort(new DOMException("timeout", "TimeoutError")), timeoutMs);
392
+ const onAbort = () => controller.abort(external?.reason);
393
+ if (external?.aborted)
394
+ onAbort();
395
+ else
396
+ external?.addEventListener("abort", onAbort, { once: true });
397
+ return {
398
+ signal: controller.signal,
399
+ release: () => {
400
+ clearTimeout(timer);
401
+ external?.removeEventListener("abort", onAbort);
402
+ },
403
+ };
404
+ }
405
+ export function createCancelScope(target = process) {
406
+ const controller = new AbortController();
407
+ const onSignal = () => controller.abort();
408
+ target.on("SIGINT", onSignal);
409
+ target.on("SIGTERM", onSignal);
410
+ return {
411
+ signal: controller.signal,
412
+ release: () => {
413
+ target.off("SIGINT", onSignal);
414
+ target.off("SIGTERM", onSignal);
415
+ },
416
+ };
417
+ }
418
+ /**
419
+ * How far PAST the expiry we were handed at start the poll keeps going.
420
+ *
421
+ * `expires_in` from POST /oauth/device is a floor, not a deadline: when a
422
+ * signed-in human lands on the consent page the server pushes expires_at out
423
+ * (see DEVICE_LOOKUP_EXTENSION_MS), so a client that stopped at the advertised
424
+ * expiry would hang up on the human mid-approval. It is still bounded — the
425
+ * server caps a device flow at 30 minutes from creation whatever happens — so
426
+ * one absolute cap past the advertised expiry is comfortably beyond any answer
427
+ * that could still become a token, and stopping there is what keeps a deploy
428
+ * that stops resolving dead rows from turning this loop into a permanent 300
429
+ * requests/min against the token endpoint.
430
+ */
431
+ export const SERVER_EXTENSION_ALLOWANCE_MS = 30 * 60 * 1000;
432
+ /**
433
+ * Did this request die on its own deadline?
434
+ *
435
+ * undici reports an aborted fetch as a DOMException on `cause`, not as the
436
+ * thrown error itself, so the name has to be read through both.
437
+ */
438
+ function isTimeoutError(error) {
439
+ if (typeof error !== "object" || error === null)
440
+ return false;
441
+ const named = error;
442
+ return named.name === "TimeoutError" || named.cause?.name === "TimeoutError";
443
+ }
444
+ /** One short clause naming why a request failed, for the deadline message. */
445
+ function errorSummary(error, timeoutMs) {
446
+ if (isTimeoutError(error))
447
+ return `no answer within ${timeoutMs}ms`;
448
+ return error instanceof Error ? error.message : String(error);
449
+ }
450
+ /** Said on both routes to a dead code: the server's answer, and our deadline. */
451
+ const EXPIRED_MESSAGE = "The code expired before it was approved. Run this command again for a fresh one.";
452
+ export async function startDeviceFlow(apiUrl, deps = {}) {
453
+ const doFetch = deps.fetchImpl ?? fetch;
454
+ // `scope` is optional on /oauth/device, and this used to send none at all.
455
+ // The consent screen then listed no permissions, the human approved that,
456
+ // and the token that came back could invoke none of the tools this package
457
+ // exists to reach — while `showly-mcp login` printed "Connected" and the
458
+ // connect screen agreed, because a call denied for insufficient_scope still
459
+ // stamps last_used_at. The API now applies its own default to clients that
460
+ // omit `scope`, but a client that knows what it needs should say so. The
461
+ // manifest is the one place this list is written down and is the same list
462
+ // the API falls back to; asking through it is what keeps the request, the
463
+ // package's own advertised scopes, and the server default from drifting.
464
+ const manifest = loadManifest();
465
+ const timeoutMs = deps.requestTimeoutMs ?? START_REQUEST_TIMEOUT_MS;
466
+ const { signal, release } = requestSignal(timeoutMs, deps.signal);
467
+ let res;
468
+ try {
469
+ res = await doFetch(`${apiUrl}/oauth/device`, {
470
+ method: "POST",
471
+ headers: { "content-type": "application/json" },
472
+ body: JSON.stringify({
473
+ client_id: LOGIN_CLIENT_ID,
474
+ scope: manifest.mcp.auth.default_scopes.join(" "),
475
+ }),
476
+ signal,
477
+ });
478
+ }
479
+ catch (error) {
480
+ // Nothing is on screen yet at this point, so an unexplained hang here is
481
+ // indistinguishable from a broken install. Name which of the two ended it.
482
+ if (deps.signal?.aborted)
483
+ throw new LoginCancelledError();
484
+ if (isTimeoutError(error)) {
485
+ 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.`);
486
+ }
487
+ throw error;
488
+ }
489
+ finally {
490
+ release();
491
+ }
492
+ const body = (await res.json().catch(() => null));
493
+ if (!res.ok || !body?.data) {
494
+ throw new Error(`Could not start Showly sign-in (${res.status}). ${body?.error_description ?? "Check SHOWLY_API_URL and your network."}`);
495
+ }
496
+ return body.data;
497
+ }
498
+ /**
499
+ * Poll /oauth/token until the human decides, per RFC 8628 §3.4-3.5.
500
+ *
501
+ * Every terminal answer is turned into a sentence a human can act on. The
502
+ * server sends `error_description` for exactly this reason, so we prefer it
503
+ * over anything we could invent, and fall back only when an older deploy
504
+ * sends the bare code.
505
+ *
506
+ * `expiresAt` is the client-side backstop, not the contract: the server's own
507
+ * `expired_token` / `invalid_grant` is what normally ends the loop, and this
508
+ * only fires when no answer ever arrives at all. It sits a full
509
+ * SERVER_EXTENSION_ALLOWANCE_MS past the advertised expiry so an arrival
510
+ * extension is never cut short.
511
+ */
512
+ export async function pollForDeviceToken(input, deps = {}) {
513
+ const doFetch = deps.fetchImpl ?? fetch;
514
+ const sleep = deps.sleep ?? defaultSleep;
515
+ const now = deps.now ?? (() => Date.now());
516
+ const cancel = deps.signal;
517
+ const timeoutMs = deps.requestTimeoutMs ?? POLL_REQUEST_TIMEOUT_MS;
518
+ let intervalMs = Math.max(1, input.intervalSec) * 1000;
519
+ const stopAt = input.expiresAt.getTime() + SERVER_EXTENSION_ALLOWANCE_MS;
520
+ // Kept for the deadline message. A loop that quietly swallowed every network
521
+ // failure and then said only "the code expired" would send the human to look
522
+ // at their approval when the fault was never on their side.
523
+ let lastTransportError;
524
+ for (;;) {
525
+ if (cancel?.aborted)
526
+ throw new LoginCancelledError();
527
+ if (now() >= stopAt) {
528
+ throw new Error(lastTransportError
529
+ ? `${EXPIRED_MESSAGE} (the last attempt to reach Showly failed: ${lastTransportError})`
530
+ : EXPIRED_MESSAGE);
531
+ }
532
+ await sleep(intervalMs, cancel);
533
+ if (cancel?.aborted)
534
+ throw new LoginCancelledError();
535
+ const attempt = requestSignal(timeoutMs, cancel);
536
+ let res;
537
+ try {
538
+ res = await doFetch(`${input.apiUrl}/oauth/token`, {
539
+ method: "POST",
540
+ headers: { "content-type": "application/json" },
541
+ body: JSON.stringify({
542
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
543
+ device_code: input.deviceCode,
544
+ client_id: LOGIN_CLIENT_ID,
545
+ }),
546
+ signal: attempt.signal,
547
+ });
548
+ }
549
+ catch (error) {
550
+ if (cancel?.aborted)
551
+ throw new LoginCancelledError();
552
+ // A stalled or failed request is NOT a terminal answer. The human may
553
+ // already be approving on their phone, and their one approval is spent
554
+ // either way — throwing on a dropped packet would burn it and make them
555
+ // start over. Fall through to the next tick; `stopAt` still bounds this.
556
+ lastTransportError = errorSummary(error, timeoutMs);
557
+ continue;
558
+ }
559
+ finally {
560
+ attempt.release();
561
+ }
562
+ lastTransportError = undefined;
563
+ const body = (await res.json().catch(() => ({})));
564
+ if (res.ok && body.access_token) {
565
+ return {
566
+ access_token: body.access_token,
567
+ scope: body.scope ?? "",
568
+ expires_in: body.expires_in,
569
+ };
570
+ }
571
+ switch (body.error) {
572
+ case "authorization_pending":
573
+ continue;
574
+ // RFC 8628 §3.5: back off by 5 seconds and keep going. This is the one
575
+ // error that is not terminal and not a no-op.
576
+ case "slow_down":
577
+ intervalMs += 5_000;
578
+ continue;
579
+ case "access_denied":
580
+ throw new Error(body.error_description ??
581
+ "Approval was denied. Nothing was connected.");
582
+ case "expired_token":
583
+ throw new Error(body.error_description ?? EXPIRED_MESSAGE);
584
+ default:
585
+ throw new Error(body.error_description ??
586
+ `Sign-in failed (${body.error ?? res.status}).`);
587
+ }
588
+ }
589
+ }
590
+ /**
591
+ * Run the whole headless sign-in and put the credential where the host will
592
+ * find it.
593
+ *
594
+ * No secret is ever accepted on argv — there is no `--token` flag, and the
595
+ * only credential this command handles is the one it just fetched. Anything
596
+ * pasted from /app/admin/mcp-tokens goes in through the environment
597
+ * (SHOWLY_TOKEN) or the host config by hand, so it stays out of shell history
598
+ * and out of every `ps` listing on the machine.
599
+ */
600
+ export async function performLogin(opts, deps = {}) {
601
+ const env = opts.env ?? process.env;
602
+ const { url, apiUrl } = resolveUrls(env);
603
+ const log = deps.log ?? ((line) => console.error(line));
604
+ const started = await startDeviceFlow(apiUrl, deps);
605
+ const expiresAt = new Date(Date.now() + started.expires_in * 1000);
606
+ log(buildLoginPrompt({
607
+ verificationUri: started.verification_uri,
608
+ verificationUriComplete: started.verification_uri_complete,
609
+ userCode: started.user_code,
610
+ expiresAt,
611
+ }));
612
+ const token = await pollForDeviceToken({
613
+ apiUrl,
614
+ deviceCode: started.device_code,
615
+ intervalSec: started.interval,
616
+ expiresAt,
617
+ }, deps);
618
+ // Showly issues no refresh token, so this date is the moment a working agent
619
+ // stops working and a human has to approve again. Say it out loud now, while
620
+ // there is context, instead of leaving a 401 to be diagnosed in 90 days.
621
+ const tokenExpiresAt = token.expires_in
622
+ ? new Date(Date.now() + token.expires_in * 1000)
623
+ : null;
624
+ if (opts.target === "claude-code") {
625
+ const path = join(homedir(), ".claude.json");
626
+ const existing = existsSync(path)
627
+ ? safeReadJson(path)
628
+ : { mcpServers: {} };
629
+ const snippet = buildClaudeCodeAuthSnippet({
630
+ url,
631
+ token: token.access_token,
632
+ });
633
+ const merged = {
634
+ ...existing,
635
+ mcpServers: {
636
+ ...(typeof existing.mcpServers === "object" && existing.mcpServers
637
+ ? existing.mcpServers
638
+ : {}),
639
+ showly: snippet.mcpServers.showly,
640
+ },
641
+ };
642
+ mkdirSync(dirname(path), { recursive: true });
643
+ writeCredentialFile(path, JSON.stringify(merged, null, 2) + "\n");
644
+ return {
645
+ target: opts.target,
646
+ token: token.access_token,
647
+ scope: token.scope,
648
+ expiresAt: tokenExpiresAt,
649
+ path,
650
+ wrote: true,
651
+ snippet: JSON.stringify(snippet, null, 2),
652
+ };
653
+ }
654
+ if (opts.target === "codex") {
655
+ // Append only when there is no [mcp_servers.showly] block yet. Rewriting
656
+ // an existing one would mean editing TOML in place, and a bad edit there
657
+ // takes down the user's entire config rather than just our entry — so
658
+ // when the block exists we print the single line to add and touch
659
+ // nothing.
660
+ const path = join(homedir(), ".codex", "config.toml");
661
+ const snippet = buildCodexAuthSnippet({ url });
662
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
663
+ if (existing.includes("[mcp_servers.showly]")) {
664
+ return {
665
+ target: opts.target,
666
+ token: token.access_token,
667
+ scope: token.scope,
668
+ expiresAt: tokenExpiresAt,
669
+ path,
670
+ wrote: false,
671
+ snippet: `bearer_token_env_var = "${TOKEN_ENV_VAR}"`,
672
+ };
673
+ }
674
+ mkdirSync(dirname(path), { recursive: true });
675
+ writeFileSync(path, existing.length > 0 && !existing.endsWith("\n")
676
+ ? `${existing}\n${snippet}`
677
+ : `${existing}${snippet}`, "utf8");
678
+ return {
679
+ target: opts.target,
680
+ token: token.access_token,
681
+ scope: token.scope,
682
+ expiresAt: tokenExpiresAt,
683
+ path,
684
+ wrote: true,
685
+ snippet,
686
+ };
687
+ }
688
+ return {
689
+ target: opts.target,
690
+ token: token.access_token,
691
+ scope: token.scope,
692
+ expiresAt: tokenExpiresAt,
693
+ path: null,
694
+ wrote: false,
695
+ snippet: `# claude-code (~/.claude.json):\n` +
696
+ JSON.stringify(buildClaudeCodeAuthSnippet({
697
+ url,
698
+ token: `\${${TOKEN_ENV_VAR}}`,
699
+ }), null, 2) +
700
+ `\n\n# codex (~/.codex/config.toml):\n` +
701
+ buildCodexAuthSnippet({ url }),
702
+ };
703
+ }
704
+ /**
705
+ * The env var names a host config tells its client to read the credential
706
+ * from: Codex's `bearer_token_env_var = "NAME"`, and the `${NAME}` placeholder
707
+ * in the pasteable snippet.
708
+ *
709
+ * Deliberately derived FROM the config text rather than hardcoded, because the
710
+ * bug this closes was a config and an output that disagreed. Whatever a future
711
+ * host schema calls its indirection, the name it references is what the human
712
+ * has to be handed a value for.
713
+ */
714
+ export function envVarsReferencedBy(configText) {
715
+ const names = new Set();
716
+ for (const m of configText.matchAll(/_env_var\s*=\s*"([A-Za-z_][A-Za-z0-9_]*)"/g)) {
717
+ names.add(m[1]);
718
+ }
719
+ for (const m of configText.matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) {
720
+ names.add(m[1]);
721
+ }
722
+ return [...names];
723
+ }
724
+ /**
725
+ * Everything `login` says after the token lands. Pure, so the one property
726
+ * that matters can be tested: a human who ran this command ends up CONNECTED.
727
+ *
728
+ * `--to codex` used to break that. It writes `bearer_token_env_var =
729
+ * "SHOWLY_TOKEN"` — Codex reads the credential from the environment, never
730
+ * from the file — and then printed `Connected. Wrote <path>` and dropped the
731
+ * token on the floor. Nothing on the machine ever set SHOWLY_TOKEN, so every
732
+ * tool call went out with no Authorization header and 401'd, after the human
733
+ * had already spent their one approval; recovering meant a second device flow
734
+ * and a second approval, which is the dead end this whole command exists to
735
+ * remove. `--to stdout` had the same hole with a sentence over it ("re-run
736
+ * with --print-token"), which is also a second approval.
737
+ *
738
+ * So the value is supplied here, once, on stderr — the only place it can go
739
+ * without landing in a file. The config keeps the env indirection, which is
740
+ * what makes it safe to commit; stdout keeps carrying only the snippet (or,
741
+ * under --print-token, only the token) so both remain pipeable.
742
+ */
743
+ export function buildLoginOutput(result, opts = {}) {
744
+ // stdout carries the token and NOTHING else, so
745
+ // `TOKEN=$(showly-mcp login --print-token)` is correct.
746
+ if (opts.printToken)
747
+ return [{ stream: "out", line: result.token }];
748
+ const lines = [];
749
+ if (result.wrote) {
750
+ lines.push({ stream: "err", line: `Connected. Wrote ${result.path}` });
751
+ }
752
+ else if (result.path) {
753
+ lines.push({
754
+ stream: "err",
755
+ line: `Connected. ${result.path} already has a [mcp_servers.showly] block — add this line to it:\n${result.snippet}`,
756
+ });
757
+ }
758
+ else {
759
+ lines.push({
760
+ stream: "err",
761
+ line: "Connected. Add this to your host config:",
762
+ });
763
+ lines.push({ stream: "out", line: result.snippet });
764
+ }
765
+ for (const name of envVarsReferencedBy(result.snippet)) {
766
+ lines.push({
767
+ stream: "err",
768
+ line: `That config reads the token from ${name}, and nothing sets it yet. Put this in the environment your agent starts in:`,
769
+ });
770
+ lines.push({ stream: "err", line: ` export ${name}=${result.token}` });
771
+ lines.push({
772
+ stream: "err",
773
+ 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.",
774
+ });
775
+ }
776
+ if (result.expiresAt) {
777
+ lines.push({
778
+ stream: "err",
779
+ 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.`,
780
+ });
781
+ }
782
+ return lines;
783
+ }
784
+ function parseTarget(rest, fallback) {
785
+ const toIdx = rest.indexOf("--to");
786
+ if (toIdx === -1)
787
+ return fallback;
788
+ const value = rest[toIdx + 1];
789
+ if (!value || !["claude-code", "codex", "stdout"].includes(value))
790
+ return null;
791
+ return value;
792
+ }
793
+ async function main(argv) {
163
794
  const [cmd, ...rest] = argv.slice(2);
164
795
  if (!cmd || cmd === "--help" || cmd === "-h") {
165
796
  console.log(usage());
@@ -169,6 +800,42 @@ function main(argv) {
169
800
  console.log(JSON.stringify(loadManifest(), null, 2));
170
801
  return;
171
802
  }
803
+ if (cmd === "login") {
804
+ // --to defaults to stdout: printing a snippet can never corrupt a config
805
+ // file the user did not ask us to touch.
806
+ const target = parseTarget(rest, "stdout");
807
+ if (!target) {
808
+ console.error("login: --to must be claude-code, codex or stdout");
809
+ process.exitCode = 2;
810
+ return;
811
+ }
812
+ const printToken = rest.includes("--print-token");
813
+ // This command blocks for up to fifteen minutes waiting on a human, so
814
+ // Ctrl+C has to mean something here. Handling the signal (rather than
815
+ // letting Node's default kill the process) is what turns "the terminal went
816
+ // quiet and I don't know what happened" into one sentence and exit 130.
817
+ const cancel = createCancelScope();
818
+ try {
819
+ const result = await performLogin({ target }, { signal: cancel.signal });
820
+ for (const { stream, line } of buildLoginOutput(result, { printToken })) {
821
+ if (stream === "out")
822
+ console.log(line);
823
+ else
824
+ console.error(line);
825
+ }
826
+ }
827
+ catch (error) {
828
+ console.error(error instanceof Error ? error.message : String(error));
829
+ // 130 is the shell's own "terminated by SIGINT". A cancel is not a
830
+ // failure of the command, and a script wrapping it should be able to
831
+ // tell the two apart.
832
+ process.exitCode = error instanceof LoginCancelledError ? 130 : 1;
833
+ }
834
+ finally {
835
+ cancel.release();
836
+ }
837
+ return;
838
+ }
172
839
  if (cmd === "install") {
173
840
  const toIdx = rest.indexOf("--to");
174
841
  if (toIdx === -1 || !rest[toIdx + 1]) {
@@ -183,17 +850,29 @@ function main(argv) {
183
850
  process.exitCode = 2;
184
851
  return;
185
852
  }
186
- const result = performInstall(target);
853
+ const withSkill = rest.includes("--with-skill");
854
+ if (withSkill && target === "stdout") {
855
+ console.error("install: --with-skill requires --to claude-code or --to codex");
856
+ process.exitCode = 2;
857
+ return;
858
+ }
859
+ const result = performInstall(target, process.env, { withSkill });
187
860
  if (target === "stdout") {
188
861
  console.log(result.snippet);
189
862
  }
190
- else if (result.alreadyConfigured) {
191
- console.log(`Already configured at ${result.path}`);
192
- }
193
863
  else {
194
- console.log(`Wrote ${result.path}`);
864
+ console.log(result.alreadyConfigured
865
+ ? `Already configured at ${result.path}`
866
+ : `Wrote ${result.path}`);
867
+ if (result.skill) {
868
+ console.log(result.skill.alreadyConfigured
869
+ ? `Skill already installed at ${result.skill.path}`
870
+ : `Installed reusable skill at ${result.skill.path}`);
871
+ }
195
872
  console.log("");
196
- console.log("Next: open Claude Code / Codex and run any read tool (e.g. list_sites).");
873
+ console.log(withSkill
874
+ ? "Next: open your agent and ask “publish this site as a private preview”."
875
+ : "Next: open Claude Code / Codex and run any read tool (e.g. list_sites).");
197
876
  console.log("The agent will pop a browser tab for you to authorize the connection.");
198
877
  }
199
878
  return;
@@ -227,5 +906,5 @@ export function isMainModule(argv1, metaUrl) {
227
906
  }
228
907
  }
229
908
  if (isMainModule(process.argv[1], import.meta.url)) {
230
- main(process.argv);
909
+ void main(process.argv);
231
910
  }
@@ -0,0 +1,2 @@
1
+ export declare const SHOWLY_PUBLISH_SKILL_NAME = "showly-publish";
2
+ export declare const SHOWLY_PUBLISH_SKILL_MARKDOWN: string;
@@ -0,0 +1,3 @@
1
+ import { readFileSync } from "node:fs";
2
+ export const SHOWLY_PUBLISH_SKILL_NAME = "showly-publish";
3
+ export const SHOWLY_PUBLISH_SKILL_MARKDOWN = readFileSync(new URL("../skills/showly-publish/SKILL.md", import.meta.url), "utf8");
package/manifest.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://showly.ai/schemas/skill-manifest-v1.json",
3
3
  "name": "showly",
4
4
  "displayName": "Showly",
5
- "version": "0.1.0",
5
+ "version": "0.2.0",
6
6
  "description": "Deploy and manage Showly sites from inside Claude Code / Codex.",
7
7
  "homepage": "https://showly.ai/docs/skills",
8
8
  "publisher": "Showly",
@@ -18,6 +18,7 @@
18
18
  "preview:create",
19
19
  "checks:run",
20
20
  "publish:request",
21
+ "publish:confirm",
21
22
  "logs:read",
22
23
  "template:read",
23
24
  "template:create"
@@ -48,6 +49,38 @@
48
49
  { "name": "create_change_plan", "kind": "write", "scopes": ["site:read"] },
49
50
  { "name": "apply_site_patch", "kind": "write", "scopes": ["site:write"] },
50
51
  { "name": "create_preview", "kind": "write", "scopes": ["preview:create"] },
52
+ {
53
+ "name": "create_github_preview",
54
+ "kind": "write",
55
+ "scopes": ["preview:create"]
56
+ },
57
+ {
58
+ "name": "set_preview_access",
59
+ "kind": "write",
60
+ "scopes": ["preview:create"]
61
+ },
62
+ { "name": "delete_preview", "kind": "write", "scopes": ["preview:create"] },
63
+ { "name": "delete_site", "kind": "write", "scopes": ["site:delete"] },
64
+ { "name": "list_site_domains", "kind": "read", "scopes": ["site:read"] },
65
+ {
66
+ "name": "add_custom_domain",
67
+ "kind": "write",
68
+ "scopes": ["site:write"]
69
+ },
70
+ {
71
+ "name": "verify_custom_domain",
72
+ "kind": "write",
73
+ "scopes": ["site:write"]
74
+ },
75
+ { "name": "list_site_versions", "kind": "read", "scopes": ["site:read"] },
76
+ { "name": "list_deployments", "kind": "read", "scopes": ["site:read"] },
77
+ { "name": "get_site_files", "kind": "read", "scopes": ["site:read"] },
78
+ { "name": "diff_site_versions", "kind": "read", "scopes": ["site:read"] },
79
+ {
80
+ "name": "rollback_to_version",
81
+ "kind": "write",
82
+ "scopes": ["rollback:confirm"]
83
+ },
51
84
  {
52
85
  "name": "retry_deployment",
53
86
  "kind": "write",
@@ -64,12 +97,37 @@
64
97
  "kind": "write",
65
98
  "scopes": ["template:create", "site:write"]
66
99
  },
100
+ {
101
+ "name": "create_site_from_html",
102
+ "kind": "write",
103
+ "scopes": ["site:write", "preview:create"]
104
+ },
105
+ {
106
+ "name": "request_upload_url",
107
+ "kind": "write",
108
+ "scopes": ["site:write", "preview:create"]
109
+ },
110
+ {
111
+ "name": "request_download_url",
112
+ "kind": "read",
113
+ "scopes": ["site:read"]
114
+ },
115
+ {
116
+ "name": "create_trial_site",
117
+ "kind": "write",
118
+ "scopes": ["site:write", "preview:create"]
119
+ },
120
+ {
121
+ "name": "claim_trial_site",
122
+ "kind": "write",
123
+ "scopes": ["site:write"]
124
+ },
67
125
  {
68
126
  "name": "publish_site",
69
- "kind": "production",
70
- "scopes": ["production:deploy"],
71
- "mcp_origin_blocked": true,
72
- "note": "Production-side deploy. MCP-origin calls are rejected; trigger from the web UI with step-up MFA + approval."
127
+ "kind": "confirm-publish",
128
+ "scopes": ["publish:confirm"],
129
+ "mcp_origin_blocked": false,
130
+ "note": "Two-step direct production publish: step 1 returns a summary + short-lived confirmation token; after the user confirms in the conversation, step 2 publishes. The MCP-bound user must have a verified email; email_verification_required returns webVerificationUrl. Solo/non-approval plans; Team/Enterprise use request_publish."
73
131
  },
74
132
  {
75
133
  "name": "rollback_deployment",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@showly/mcp-server",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Connect Claude Code / Codex to the Showly MCP server — preview and deploy sites from your agent.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  },
20
20
  "files": [
21
21
  "dist",
22
+ "skills",
22
23
  "manifest.json",
23
24
  "README.md"
24
25
  ],
@@ -51,7 +52,7 @@
51
52
  },
52
53
  "claude-code-skill": {
53
54
  "name": "showly",
54
- "version": "0.1.0",
55
+ "version": "0.2.0",
55
56
  "description": "Deploy and manage Showly sites from inside Claude Code.",
56
57
  "mcp-server": {
57
58
  "url-env": "SHOWLY_MCP_URL",
@@ -64,15 +65,15 @@
64
65
  },
65
66
  "codex-plugin": {
66
67
  "name": "showly",
67
- "version": "0.1.0",
68
+ "version": "0.2.0",
68
69
  "type": "mcp-server",
69
70
  "manifest": "manifest.json"
70
71
  },
71
72
  "devDependencies": {
72
73
  "@showly/eslint-config": "workspace:^",
73
- "@types/node": "^25.9.1",
74
- "eslint": "^9.18.0",
75
- "tsx": "^4.22.4",
76
- "typescript": "^6.0.3"
74
+ "@types/node": "^26.1.1",
75
+ "eslint": "^10.7.0",
76
+ "tsx": "^4.23.10",
77
+ "typescript": "^7.0.2"
77
78
  }
78
79
  }
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: showly-publish
3
+ description: Publish, deploy, host, share, or put a website online with Showly, and create private preview links for web projects. Use when the user asks for a shareable URL, a private preview, hosting, deployment, or publishing. Do not trigger when the user only asks to build or edit a page without asking to share or put it online.
4
+ ---
5
+
6
+ # Publish web projects with Showly
7
+
8
+ Use Showly when the user wants a web project deployed, hosted, shared, put online, or turned into a preview URL.
9
+
10
+ ## Default behavior
11
+
12
+ - Treat “preview”, “share”, “deploy”, “host”, and “put it online” as a request for a **private Preview**, not a public production release.
13
+ - Build or validate the project before deploying it. Preserve the user's existing framework and files.
14
+ - If Showly asks for authorization, tell the user to complete the browser sign-in, then retry the interrupted tool call.
15
+ - Return the Preview URL and password together. Also report whether the Preview expires or is permanent.
16
+ - Never claim a site is online until the Showly tool reports a successful deployment.
17
+
18
+ ## New sites
19
+
20
+ For a simple new static site, call `create_site_from_html` with the completed HTML, CSS, and JavaScript. For larger projects, use the upload or repository workflow exposed by the available Showly tools.
21
+
22
+ Do not call `create_trial_site` here. It builds a throwaway site owned by the shared guest organization, not by the connected account, and it refuses a connected caller with `authenticated_account_present`. Reaching Showly's tools at all means an account is connected, so `create_site_from_html` is the create path even when the user says "just a trial" — a private Preview is already reversible and costs nothing.
23
+
24
+ ## Existing Showly sites
25
+
26
+ 1. Call `list_sites` and identify the intended site. Ask only if more than one site is a plausible match.
27
+ 2. Use `create_change_plan` when the change is substantial or ambiguous.
28
+ 3. Stage edits with `apply_site_patch`.
29
+ 4. Call `create_preview` and return the private Preview URL and password.
30
+
31
+ ## Public production publishing
32
+
33
+ A private Preview is reversible; a public production publish is not the default.
34
+
35
+ - Publish publicly only when the user explicitly asks for a public or production release.
36
+ - Use `publish_site` for solo publishing. Its first call only returns a summary and confirmation token. Show that summary to the user and call it a second time only after the user explicitly confirms.
37
+ - If the workspace requires approval, use `request_publish` and return its approval URL.
38
+ - If email verification is required, return the verification URL and do not say the site is live until verification and publishing succeed.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Showly Publish"
3
+ short_description: "Publish web projects as private Showly previews"
4
+ default_prompt: "Publish this web project as a private Showly preview and return the URL and password."