@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 +42 -7
- package/dist/cli.d.ts +231 -3
- package/dist/cli.js +693 -14
- package/dist/showly-publish-skill.d.ts +2 -0
- package/dist/showly-publish-skill.js +3 -0
- package/manifest.json +63 -5
- package/package.json +8 -7
- package/skills/showly-publish/SKILL.md +38 -0
- package/skills/showly-publish/agents/openai.yaml +4 -0
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
|
|
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`)
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
40
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
}
|
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.
|
|
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": "
|
|
70
|
-
"scopes": ["
|
|
71
|
-
"mcp_origin_blocked":
|
|
72
|
-
"note": "
|
|
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.
|
|
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.
|
|
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.
|
|
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": "^
|
|
74
|
-
"eslint": "^
|
|
75
|
-
"tsx": "^4.
|
|
76
|
-
"typescript": "^
|
|
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.
|