@letterstory/cli 0.2.0 → 0.2.2
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 +85 -16
- package/lib/cli.mjs +50 -4
- package/lib/commands/deploy.mjs +147 -1
- package/lib/commands/discovery.mjs +200 -5
- package/lib/commands/mcp.mjs +52 -0
- package/lib/commands.mjs +1 -0
- package/lib/update-check.mjs +94 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,13 +9,38 @@ manifest an agent sees, so it never drifts from the API.
|
|
|
9
9
|
The CLI is plain ESM with **zero dependencies** and needs no build step (Node ≥ 20).
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
#
|
|
12
|
+
npm install -g @letterstory/cli # puts `letterstory` (and `phantom`) on your PATH
|
|
13
|
+
# …or run it without installing:
|
|
14
|
+
npx @letterstory/cli --help
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Contributing to the CLI itself? Run it straight from a checkout instead:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
13
20
|
cd cli
|
|
14
|
-
npm link # puts `letterstory` (and `phantom`) on your PATH
|
|
21
|
+
npm link # puts `letterstory` (and `phantom`) on your PATH, pointing at this checkout
|
|
15
22
|
# …or run it directly without linking:
|
|
16
23
|
node cli/bin/letterstory.mjs --help
|
|
17
24
|
```
|
|
18
25
|
|
|
26
|
+
## Updating
|
|
27
|
+
|
|
28
|
+
The CLI checks npm for a newer published version at most once every 24 hours (cached
|
|
29
|
+
alongside your config, so most runs make no extra network call) and prints a one-line
|
|
30
|
+
nudge on stderr when one's available:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
Update available: 0.2.1 → 0.3.0. Run `npm install -g @letterstory/cli@latest` to upgrade.
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
To upgrade immediately:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install -g @letterstory/cli@latest
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Set `LETTERSTORY_NO_UPDATE_CHECK=1` to disable the check entirely (e.g. in CI).
|
|
43
|
+
|
|
19
44
|
## `phantom` — the same CLI, Phantomstory-branded
|
|
20
45
|
|
|
21
46
|
`phantom` is a ghost-branded entry point for demos and presentations — same binary, same
|
|
@@ -32,6 +57,22 @@ otherwise runs the exact same code path as `letterstory`.
|
|
|
32
57
|
|
|
33
58
|
## Authenticate
|
|
34
59
|
|
|
60
|
+
Two ways in — pick whichever fits how you're running the CLI.
|
|
61
|
+
|
|
62
|
+
**Sign in via your browser** (the default — best for interactive use on your own machine):
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
letterstory login
|
|
66
|
+
# opens your browser to https://app.letterstory.com, or override with --url
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
This runs a browser-based OAuth 2.1 flow (PKCE, loopback redirect — RFC 8252) against your
|
|
70
|
+
account. It comes back with a full-access session and a refresh token, so there's no API key
|
|
71
|
+
to mint or scope up front. Tokens are saved to `~/.letterstory/config.json` (mode 600) and
|
|
72
|
+
refresh automatically. Run `letterstory logout` to revoke the session and forget it.
|
|
73
|
+
|
|
74
|
+
**Or use a static API key** (for CI/automation, or when you want scoped-down access):
|
|
75
|
+
|
|
35
76
|
You need a Letterstory API key (starts with `ls_`; legacy `lb_` keys still work) with the `deployment:read` and
|
|
36
77
|
`deployment:write` capabilities — mint one in the app under **Settings → API keys**.
|
|
37
78
|
Add `deployment:domain` too if you plan to buy custom domains.
|
|
@@ -44,8 +85,9 @@ letterstory login --key ls_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
|
|
44
85
|
Credentials resolve from `--key`/`--url` flags, then `LETTERSTORY_API_KEY` /
|
|
45
86
|
`LETTERSTORY_API_URL`, then `~/.letterstory/config.json` (written by `login`, mode 600).
|
|
46
87
|
|
|
47
|
-
Run `letterstory whoami` (alias `status`) any time to confirm which key/url
|
|
48
|
-
see your company profile.
|
|
88
|
+
Run `letterstory whoami` (alias `status`) any time to confirm which key/url (or OAuth session)
|
|
89
|
+
resolved and see your company profile. Run `letterstory config` to see the resolved url and
|
|
90
|
+
credential source without making a network call.
|
|
49
91
|
|
|
50
92
|
## Spin up a blog
|
|
51
93
|
|
|
@@ -69,6 +111,24 @@ letterstory deploy delete <deployment-id> --yes
|
|
|
69
111
|
Pass `--no-wait` to return immediately and poll later with `deploy get`, or `--dry-run`
|
|
70
112
|
on `create` to print what would be sent without creating anything.
|
|
71
113
|
|
|
114
|
+
### One-shot: bare `deploy`
|
|
115
|
+
|
|
116
|
+
For the common case — reserve a blog, price (and optionally buy) a domain, and rebuild,
|
|
117
|
+
all in one call — skip the subcommand word entirely:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
letterstory deploy --domain yourverticalreview.com --buy
|
|
121
|
+
# Reserved phantom blog "Yourverticalreview" (7c19c7…)
|
|
122
|
+
# Priced yourverticalreview.com
|
|
123
|
+
# Registered yourverticalreview.com
|
|
124
|
+
# SSL + DNS provisioned · sitemap + schema generated
|
|
125
|
+
#
|
|
126
|
+
# Your phantom blog is live at https://yourverticalreview.com.
|
|
127
|
+
|
|
128
|
+
letterstory deploy --blog 7c19c7… --rebuild # rebuild an existing blog instead of creating one
|
|
129
|
+
letterstory deploy --domain demo.com --dry-run # preview without calling the API
|
|
130
|
+
```
|
|
131
|
+
|
|
72
132
|
## Custom domains
|
|
73
133
|
|
|
74
134
|
```bash
|
|
@@ -163,21 +223,30 @@ Every Letterstory tool is reachable, not just the ones with a dedicated command
|
|
|
163
223
|
|
|
164
224
|
```bash
|
|
165
225
|
letterstory whoami # or: status — verify your key + who it's for
|
|
166
|
-
letterstory tools
|
|
226
|
+
letterstory tools list # list all tools (bare `tools` also works)
|
|
227
|
+
letterstory tools show list_articles # one tool's capability + full argument schema
|
|
167
228
|
letterstory call list_articles --args '{"limit":5}'
|
|
168
229
|
letterstory call ingest_article --args '{"title":"…","content":"…"}'
|
|
230
|
+
|
|
231
|
+
# `tool` is the same idea with schema-coerced arguments instead of raw JSON:
|
|
232
|
+
letterstory tool list_articles --arg limit=5 --arg collection_id=c1
|
|
233
|
+
letterstory tool ingest_article --json-args '{"title":"…"}' --arg content="…"
|
|
234
|
+
cat article.json | letterstory tool ingest_article --stdin
|
|
235
|
+
|
|
236
|
+
letterstory mcp # print MCP server config for an agent
|
|
237
|
+
letterstory mcp --print-key # inline the real key instead of a placeholder
|
|
169
238
|
```
|
|
170
239
|
|
|
171
240
|
## Global flags
|
|
172
241
|
|
|
173
|
-
| Flag | Meaning
|
|
174
|
-
| ------------ |
|
|
175
|
-
| `--json` | Machine-readable output
|
|
176
|
-
| `--quiet` | Suppress success chatter on the new command groups (`--json` implies it)
|
|
177
|
-
| `--verbose` | Log HTTP requests/responses to stderr
|
|
178
|
-
| `--no-color` | Accepted for compatibility; this CLI already prints plain text
|
|
179
|
-
| `--dry-run` | On `deploy`/`blogs create`: print what would
|
|
180
|
-
| `--url` | Override the API base URL for one call
|
|
181
|
-
| `--key` | Override the API key for one call
|
|
182
|
-
| `--help` | Show usage
|
|
183
|
-
| `--version` | Print the CLI version
|
|
242
|
+
| Flag | Meaning |
|
|
243
|
+
| ------------ | ----------------------------------------------------------------------------------------- |
|
|
244
|
+
| `--json` | Machine-readable output |
|
|
245
|
+
| `--quiet` | Suppress success chatter on the new command groups (`--json` implies it) |
|
|
246
|
+
| `--verbose` | Log HTTP requests/responses to stderr |
|
|
247
|
+
| `--no-color` | Accepted for compatibility; this CLI already prints plain text |
|
|
248
|
+
| `--dry-run` | On `deploy`/`blogs create`, or bare `deploy`: print what would happen, don't call the API |
|
|
249
|
+
| `--url` | Override the API base URL for one call |
|
|
250
|
+
| `--key` | Override the API key for one call |
|
|
251
|
+
| `--help` | Show usage |
|
|
252
|
+
| `--version` | Print the CLI version |
|
package/lib/cli.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// never throws for user-facing problems (those become a printed CliError + code 1).
|
|
4
4
|
|
|
5
5
|
import { LetterstoryClient, CliError, resolveConfig, readConfigFile, writeConfigFile } from "./client.mjs";
|
|
6
|
+
import { checkForUpdate } from "./update-check.mjs";
|
|
6
7
|
import {
|
|
7
8
|
cmdLogin,
|
|
8
9
|
cmdLogout,
|
|
@@ -10,6 +11,8 @@ import {
|
|
|
10
11
|
cmdWhoami,
|
|
11
12
|
cmdTools,
|
|
12
13
|
cmdCall,
|
|
14
|
+
cmdTool,
|
|
15
|
+
cmdMcp,
|
|
13
16
|
cmdDeploy,
|
|
14
17
|
cmdDomain,
|
|
15
18
|
cmdBlogs,
|
|
@@ -25,11 +28,25 @@ import {
|
|
|
25
28
|
} from "./commands.mjs";
|
|
26
29
|
|
|
27
30
|
// Keep in sync with cli/package.json.
|
|
28
|
-
export const VERSION = "0.2.
|
|
31
|
+
export const VERSION = "0.2.2";
|
|
29
32
|
|
|
30
33
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
31
34
|
// can't accidentally swallow the id as --json's value.
|
|
32
|
-
const BOOLEAN_FLAGS = new Set([
|
|
35
|
+
const BOOLEAN_FLAGS = new Set([
|
|
36
|
+
"json",
|
|
37
|
+
"yes",
|
|
38
|
+
"no-wait",
|
|
39
|
+
"help",
|
|
40
|
+
"version",
|
|
41
|
+
"quiet",
|
|
42
|
+
"verbose",
|
|
43
|
+
"no-color",
|
|
44
|
+
"dry-run",
|
|
45
|
+
"buy",
|
|
46
|
+
"rebuild",
|
|
47
|
+
"print-key",
|
|
48
|
+
"stdin",
|
|
49
|
+
]);
|
|
33
50
|
|
|
34
51
|
// Tiny argv parser: `--flag value`, `--flag=value`, boolean `--flag`, and positionals.
|
|
35
52
|
// A flag repeated more than once (e.g. `--topic a --topic b`, used by `strategy
|
|
@@ -100,6 +117,10 @@ Auth:
|
|
|
100
117
|
config Show the resolved url + credential source
|
|
101
118
|
|
|
102
119
|
Phantom blogs:
|
|
120
|
+
deploy [--domain <d>] [--name <n>] [--theme <t>] [--collection <uuid>]
|
|
121
|
+
[--blog <id>] [--buy] [--rebuild] [--dry-run]
|
|
122
|
+
One-shot: reserve/locate a blog, price
|
|
123
|
+
the domain, optionally buy it, rebuild
|
|
103
124
|
deploy create --name <name> [--description <text>] [--theme <theme>]
|
|
104
125
|
[--collection <uuid>] [--no-wait] Create a blog; waits until it's live
|
|
105
126
|
deploy list [--limit <n>] List your blogs
|
|
@@ -158,8 +179,13 @@ Insights:
|
|
|
158
179
|
insights top [--period 14d|30d|90d] [--collection <uuid>] [--limit <n>] [--sort clicks|impressions]
|
|
159
180
|
|
|
160
181
|
Anything else:
|
|
161
|
-
tools
|
|
182
|
+
tools list List every tool this server exposes, by name
|
|
183
|
+
tools show <name> Show one tool's capability + full argument schema
|
|
162
184
|
call <tool> [--args '<json>'] [--flag value …] Call any tool directly
|
|
185
|
+
tool <name> [--arg k=v …] [--json-args '<json>'] [--stdin]
|
|
186
|
+
Call any tool with schema-coerced arguments
|
|
187
|
+
mcp [--name <name>] [--print-key] [--json]
|
|
188
|
+
Print MCP server config for Claude Code/Desktop/Cursor
|
|
163
189
|
whoami (alias status) Verify the resolved key and show who it's for
|
|
164
190
|
|
|
165
191
|
Global flags:
|
|
@@ -167,7 +193,7 @@ Global flags:
|
|
|
167
193
|
--quiet Suppress success chatter (new command groups only; --json implies it)
|
|
168
194
|
--verbose Log HTTP requests/responses to stderr
|
|
169
195
|
--no-color Accepted for compatibility; this CLI prints plain text already
|
|
170
|
-
--dry-run For deploy/blogs create: print what would
|
|
196
|
+
--dry-run For deploy/blogs create, or bare deploy: print what would happen, don't call the API
|
|
171
197
|
--url <url> Override the API base URL for this invocation
|
|
172
198
|
--key <ls_…> Override the API key for this invocation
|
|
173
199
|
--help Show this help
|
|
@@ -185,6 +211,10 @@ const CLIENT_COMMANDS = {
|
|
|
185
211
|
status: cmdWhoami,
|
|
186
212
|
tools: cmdTools,
|
|
187
213
|
call: cmdCall,
|
|
214
|
+
// `tool` is Mathew's phantomstory-cli name for `call`, with schema-aware
|
|
215
|
+
// argument coercion (--arg/--json-args/--stdin) added on top.
|
|
216
|
+
tool: cmdTool,
|
|
217
|
+
mcp: cmdMcp,
|
|
188
218
|
deploy: cmdDeploy,
|
|
189
219
|
domain: cmdDomain,
|
|
190
220
|
// Mathew's phantomstory-cli names for the exact same deploy/domain commands.
|
|
@@ -215,6 +245,9 @@ export function defaultIo() {
|
|
|
215
245
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
216
246
|
pollIntervalMs: envInt("LETTERSTORY_POLL_INTERVAL_MS", 4000),
|
|
217
247
|
maxPolls: envInt("LETTERSTORY_MAX_POLLS", 90),
|
|
248
|
+
// Only a *real* CLI invocation (never a test, which always builds its own io)
|
|
249
|
+
// gets the background update-version check — see the `finally` block in run().
|
|
250
|
+
updateCheck: true,
|
|
218
251
|
};
|
|
219
252
|
}
|
|
220
253
|
|
|
@@ -269,5 +302,18 @@ export async function run(argv, io = defaultIo()) {
|
|
|
269
302
|
return 1;
|
|
270
303
|
}
|
|
271
304
|
throw err;
|
|
305
|
+
} finally {
|
|
306
|
+
// Fire-and-await (bounded by checkForUpdate's own short fetch timeout) so the
|
|
307
|
+
// nudge, if any, is printed before the bin's process.exit() — but a warm cache
|
|
308
|
+
// (the common case) resolves with no network call at all, so this is normally
|
|
309
|
+
// instant. --json output stays machine-clean; real errors above already returned.
|
|
310
|
+
if (io.updateCheck && !flags.json) {
|
|
311
|
+
try {
|
|
312
|
+
const notice = await checkForUpdate({ currentVersion: VERSION });
|
|
313
|
+
if (notice) io.error(notice);
|
|
314
|
+
} catch {
|
|
315
|
+
// Never let the update nudge itself become the reason a command fails.
|
|
316
|
+
}
|
|
317
|
+
}
|
|
272
318
|
}
|
|
273
319
|
}
|
package/lib/commands/deploy.mjs
CHANGED
|
@@ -52,6 +52,10 @@ async function pollUntilTerminal(client, id, io, bin) {
|
|
|
52
52
|
|
|
53
53
|
export async function cmdDeploy(ctx) {
|
|
54
54
|
const sub = ctx.positionals[0];
|
|
55
|
+
// Bare `deploy` (no subcommand word) is phantomstory-cli's flagship, flag-only
|
|
56
|
+
// invocation — `phantom deploy --domain x.com --buy` — not one of the create/
|
|
57
|
+
// list/get/… verbs below, so route it to the orchestrator before the switch.
|
|
58
|
+
if (sub === undefined) return deployOrchestrate(ctx);
|
|
55
59
|
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
56
60
|
switch (sub) {
|
|
57
61
|
case "create":
|
|
@@ -76,11 +80,153 @@ export async function cmdDeploy(ctx) {
|
|
|
76
80
|
return deployDelete(rest);
|
|
77
81
|
default:
|
|
78
82
|
throw new CliError(
|
|
79
|
-
`Unknown deploy subcommand: ${sub
|
|
83
|
+
`Unknown deploy subcommand: ${sub}. Try: create, list, get, update, rebuild, diagnostics, delete — or bare ` +
|
|
84
|
+
`"${ctx.bin} deploy --domain <domain>" to reserve+price+build in one step.`
|
|
80
85
|
);
|
|
81
86
|
}
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
// --- one-shot orchestrator: reserve/locate → price domain → optionally buy →
|
|
90
|
+
// rebuild-if-changed. Mirrors phantomstory-cli's flagship `phantom deploy` in this
|
|
91
|
+
// CLI's own plain-text conventions (no spinners/animation, see commands/README notes
|
|
92
|
+
// on the ui/render toolkit not being ported).
|
|
93
|
+
function nameFromDomain(domain) {
|
|
94
|
+
if (!domain) return undefined;
|
|
95
|
+
const host = domain
|
|
96
|
+
.replace(/^https?:\/\//, "")
|
|
97
|
+
.replace(/^www\./, "")
|
|
98
|
+
.split("/")[0];
|
|
99
|
+
const base = host.split(".")[0];
|
|
100
|
+
if (!base) return undefined;
|
|
101
|
+
return base
|
|
102
|
+
.split(/[-_]/)
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
105
|
+
.join(" ");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function pickId(obj) {
|
|
109
|
+
if (obj && typeof obj === "object") {
|
|
110
|
+
for (const key of ["deployment_id", "id", "deploymentId"]) {
|
|
111
|
+
if (typeof obj[key] === "string") return obj[key];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function liveUrl(domain, collected) {
|
|
118
|
+
if (domain) return `https://${domain}`;
|
|
119
|
+
for (const v of Object.values(collected)) {
|
|
120
|
+
if (v && typeof v === "object") {
|
|
121
|
+
const url = v.url ?? v.live_url ?? v.blog_url;
|
|
122
|
+
if (typeof url === "string") return url;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function deployOrchestrate(ctx) {
|
|
129
|
+
const { client, flags, io } = ctx;
|
|
130
|
+
const domainFlag = flagStr(flags.domain);
|
|
131
|
+
const nameFlag = flagStr(flags.name);
|
|
132
|
+
const themeFlag = flagStr(flags.theme);
|
|
133
|
+
const collectionFlag = flagStr(flags.collection);
|
|
134
|
+
const blogFlag = flagStr(flags.blog);
|
|
135
|
+
const buy = Boolean(flags.buy);
|
|
136
|
+
const forceRebuild = Boolean(flags.rebuild);
|
|
137
|
+
const domain = domainFlag !== undefined ? stripScheme(domainFlag) : undefined;
|
|
138
|
+
|
|
139
|
+
if (!domain && !nameFlag && !blogFlag) {
|
|
140
|
+
throw new CliError(
|
|
141
|
+
`Nothing to deploy — pass --domain, --name, or --blog. e.g. "${ctx.bin} deploy --domain yourverticalreview.com"`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (buy && !domain) {
|
|
145
|
+
throw new CliError("--buy needs a --domain to register.");
|
|
146
|
+
}
|
|
147
|
+
if (blogFlag && (nameFlag || themeFlag || collectionFlag)) {
|
|
148
|
+
throw new CliError(
|
|
149
|
+
`--name/--theme/--collection can't be combined with --blog. Use "${ctx.bin} deploy update <id>" to rename, re-theme, or repoint an existing blog.`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (flags["dry-run"]) {
|
|
154
|
+
const name = nameFlag || nameFromDomain(domain) || "Phantom Blog";
|
|
155
|
+
io.log(`Dry run — would deploy with:`);
|
|
156
|
+
io.log(
|
|
157
|
+
JSON.stringify(
|
|
158
|
+
compact({
|
|
159
|
+
blog: blogFlag,
|
|
160
|
+
name: blogFlag ? undefined : name,
|
|
161
|
+
theme: themeFlag,
|
|
162
|
+
collection: collectionFlag,
|
|
163
|
+
domain,
|
|
164
|
+
buy,
|
|
165
|
+
rebuild: forceRebuild,
|
|
166
|
+
}),
|
|
167
|
+
null,
|
|
168
|
+
2
|
|
169
|
+
)
|
|
170
|
+
);
|
|
171
|
+
if (domain) io.log(`\nWould be live at https://${domain}`);
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const collected = {};
|
|
176
|
+
let changed = false;
|
|
177
|
+
let deploymentId = blogFlag;
|
|
178
|
+
|
|
179
|
+
if (deploymentId) {
|
|
180
|
+
collected.blog = await client.callTool("get_deployment", { deployment_id: deploymentId });
|
|
181
|
+
io.log(`Found phantom blog ${deploymentId}`);
|
|
182
|
+
} else {
|
|
183
|
+
const name = nameFlag || nameFromDomain(domain) || "Phantom Blog";
|
|
184
|
+
const created = await client.callTool(
|
|
185
|
+
"create_deployment",
|
|
186
|
+
compact({ name, theme: themeFlag, collection_id: collectionFlag })
|
|
187
|
+
);
|
|
188
|
+
collected.blog = created;
|
|
189
|
+
deploymentId = pickId(created);
|
|
190
|
+
changed = true;
|
|
191
|
+
io.log(`Reserved phantom blog "${name}"${deploymentId ? ` (${deploymentId})` : ""}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (domain) {
|
|
195
|
+
collected.domain = await client.callTool("check_domain", { domain });
|
|
196
|
+
io.log(`Priced ${domain}`);
|
|
197
|
+
|
|
198
|
+
if (buy) {
|
|
199
|
+
if (!deploymentId) throw new CliError("Cannot register a domain without a blog id.");
|
|
200
|
+
collected.purchase = await client.callTool("buy_domain", { deployment_id: deploymentId, domain });
|
|
201
|
+
changed = true;
|
|
202
|
+
io.log(`Registered ${domain}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (deploymentId && (changed || forceRebuild)) {
|
|
207
|
+
collected.build = await client.callTool("rebuild_deployment", { deployment_id: deploymentId });
|
|
208
|
+
io.log(`SSL + DNS provisioned · sitemap + schema generated`);
|
|
209
|
+
try {
|
|
210
|
+
collected.diagnostics = await client.callTool("get_deployment_diagnostics", {
|
|
211
|
+
deployment_id: deploymentId,
|
|
212
|
+
});
|
|
213
|
+
} catch {
|
|
214
|
+
// best-effort — diagnostics may not be ready immediately after a build kicks off
|
|
215
|
+
}
|
|
216
|
+
} else if (deploymentId) {
|
|
217
|
+
io.log(`No changes — skipped rebuild (pass --rebuild to force).`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (flags.json) {
|
|
221
|
+
io.log(JSON.stringify(collected, null, 2));
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const url = liveUrl(domain, collected);
|
|
226
|
+
io.log(`\nYour phantom blog is live${url ? ` at ${url}` : ""}.`);
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
84
230
|
async function deployCreate(ctx) {
|
|
85
231
|
const { client, flags, io } = ctx;
|
|
86
232
|
const name = requireFlag(flags, "name");
|
|
@@ -1,11 +1,29 @@
|
|
|
1
|
-
// Discovery / generic passthrough: `tools` (
|
|
2
|
-
// tool by name).
|
|
3
|
-
//
|
|
1
|
+
// Discovery / generic passthrough: `tools` (browse the manifest — list + show) and
|
|
2
|
+
// `call`/`tool` (invoke any tool by name). This is the escape hatch that keeps every
|
|
3
|
+
// tool reachable even ones with no dedicated command group. `discover()` is the same
|
|
4
|
+
// unauthenticated GET both `tools list` and `tools show` read from — there's no
|
|
5
|
+
// separate bundled/static manifest to keep in sync (unlike phantomstory-cli's
|
|
6
|
+
// manifest.ts), so there's no `--live` flag here: it's always live, by construction.
|
|
4
7
|
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
5
9
|
import { CliError } from "../client.mjs";
|
|
6
|
-
import { flagStr, requirePositional } from "./shared.mjs";
|
|
10
|
+
import { flagStr, flagList, requirePositional } from "./shared.mjs";
|
|
11
|
+
|
|
12
|
+
// --- tools: list / show -----------------------------------------------------
|
|
7
13
|
|
|
8
14
|
export async function cmdTools(ctx) {
|
|
15
|
+
const sub = ctx.positionals[0];
|
|
16
|
+
if (sub === undefined || sub === "list" || sub === "ls") {
|
|
17
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(sub === undefined ? 0 : 1) };
|
|
18
|
+
return toolsList(rest);
|
|
19
|
+
}
|
|
20
|
+
if (sub === "show") {
|
|
21
|
+
return toolsShow({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
22
|
+
}
|
|
23
|
+
throw new CliError(`Unknown tools subcommand: ${sub}. Try: list, show <name>`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function toolsList(ctx) {
|
|
9
27
|
const { client, io, flags } = ctx;
|
|
10
28
|
const doc = await client.discover();
|
|
11
29
|
const tools = doc.tools ?? [];
|
|
@@ -15,12 +33,75 @@ export async function cmdTools(ctx) {
|
|
|
15
33
|
}
|
|
16
34
|
io.log(`${tools.length} tools available at ${client.url}:\n`);
|
|
17
35
|
for (const t of tools) io.log(` ${t.name} — ${t.description ?? ""}`);
|
|
36
|
+
io.log(``);
|
|
37
|
+
io.log(` Run \`${ctx.bin} tools show <name>\` for a tool's full argument schema.`);
|
|
18
38
|
return 0;
|
|
19
39
|
}
|
|
20
40
|
|
|
41
|
+
async function toolsShow(ctx) {
|
|
42
|
+
const { client, positionals, flags, io } = ctx;
|
|
43
|
+
const name = requirePositional(positionals, 0, "name");
|
|
44
|
+
const doc = await client.discover();
|
|
45
|
+
const tool = (doc.tools ?? []).find((t) => t.name === name);
|
|
46
|
+
if (!tool) {
|
|
47
|
+
throw new CliError(`No tool named "${name}". See \`${ctx.bin} tools list\`.`);
|
|
48
|
+
}
|
|
49
|
+
if (flags.json) {
|
|
50
|
+
io.log(JSON.stringify(tool, null, 2));
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
renderTool(io, tool);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderTool(io, tool) {
|
|
58
|
+
io.log(tool.name);
|
|
59
|
+
io.log(` ${tool.description ?? ""}`);
|
|
60
|
+
io.log(``);
|
|
61
|
+
io.log(` capability: ${tool.capability ?? "(none)"}`);
|
|
62
|
+
const props = tool.inputSchema?.properties ?? {};
|
|
63
|
+
const required = new Set(tool.inputSchema?.required ?? []);
|
|
64
|
+
const names = Object.keys(props);
|
|
65
|
+
if (names.length === 0) {
|
|
66
|
+
io.log(` (no arguments)`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
io.log(``);
|
|
70
|
+
io.log(` arguments:`);
|
|
71
|
+
for (const name of names) {
|
|
72
|
+
const req = required.has(name) ? "required" : "optional";
|
|
73
|
+
const detail = detailOf(props[name]);
|
|
74
|
+
io.log(` ${name} (${typeLabel(props[name])}, ${req})${detail ? ` — ${detail}` : ""}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function typeLabel(prop) {
|
|
79
|
+
if (!prop) return "any";
|
|
80
|
+
if (prop.enum) return "enum";
|
|
81
|
+
if (typeof prop.type === "string") return prop.type;
|
|
82
|
+
if (Array.isArray(prop.type)) return prop.type.filter((t) => t !== "null").join("|") || "any";
|
|
83
|
+
if (prop.anyOf) {
|
|
84
|
+
const types = prop.anyOf.map((a) => (typeof a.type === "string" ? a.type : "any")).filter((t) => t !== "null");
|
|
85
|
+
return [...new Set(types)].join("|") || "any";
|
|
86
|
+
}
|
|
87
|
+
return "any";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function detailOf(prop) {
|
|
91
|
+
const parts = [];
|
|
92
|
+
if (prop.description) parts.push(prop.description);
|
|
93
|
+
if (prop.enum) parts.push(`one of: ${prop.enum.map(String).join(", ")}`);
|
|
94
|
+
if (prop.default !== undefined) parts.push(`default: ${JSON.stringify(prop.default)}`);
|
|
95
|
+
return parts.join(" · ");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- call / tool: raw dispatcher --------------------------------------------
|
|
99
|
+
|
|
21
100
|
// Escape hatch: call any tool by name. Args come from --args '<json>', or from
|
|
22
101
|
// individual string flags (everything after the tool name). Keeps the whole
|
|
23
|
-
// manifest reachable without a bespoke subcommand per tool.
|
|
102
|
+
// manifest reachable without a bespoke subcommand per tool. This is the original,
|
|
103
|
+
// stable spelling; `tool` (below) is Mathew's phantomstory-cli name for the same
|
|
104
|
+
// idea, with schema-aware argument coercion added on top.
|
|
24
105
|
export async function cmdCall(ctx) {
|
|
25
106
|
const { client, positionals, flags, io } = ctx;
|
|
26
107
|
const name = requirePositional(positionals, 0, "tool");
|
|
@@ -41,3 +122,117 @@ export async function cmdCall(ctx) {
|
|
|
41
122
|
io.log(typeof result === "string" ? result : JSON.stringify(result, null, 2));
|
|
42
123
|
return 0;
|
|
43
124
|
}
|
|
125
|
+
|
|
126
|
+
// `tool <name> [--arg k=v ...] [--json-args '{…}'] [--stdin]` — phantomstory-cli's
|
|
127
|
+
// raw dispatcher. Unlike `call`, args are coerced toward the tool's real schema
|
|
128
|
+
// (`--arg limit=5` becomes a number, `--arg tags=a,b` an array), and can be piped in
|
|
129
|
+
// as JSON. Precedence low-to-high: --stdin, then --json-args, then --arg (most
|
|
130
|
+
// specific wins) — matches phantomstory-cli's tools.ts.
|
|
131
|
+
export async function cmdTool(ctx) {
|
|
132
|
+
const { client, positionals, flags, io } = ctx;
|
|
133
|
+
const name = requirePositional(positionals, 0, "tool");
|
|
134
|
+
const doc = await client.discover();
|
|
135
|
+
const tool = (doc.tools ?? []).find((t) => t.name === name);
|
|
136
|
+
if (!tool && !flags.quiet) {
|
|
137
|
+
io.error(`Warning: "${name}" is not in the discovered catalog; sending anyway.`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let args = {};
|
|
141
|
+
if (flags.stdin) args = { ...args, ...asObject(readStdinJson()) };
|
|
142
|
+
const jsonArgs = flagStr(flags["json-args"]);
|
|
143
|
+
if (jsonArgs !== undefined) {
|
|
144
|
+
let parsed;
|
|
145
|
+
try {
|
|
146
|
+
parsed = JSON.parse(jsonArgs);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
throw new CliError(`Invalid --json-args: ${err.message}`);
|
|
149
|
+
}
|
|
150
|
+
args = { ...args, ...asObject(parsed) };
|
|
151
|
+
}
|
|
152
|
+
const pairs = coerceToSchema(parseKeyVals(flagList(flags.arg)), tool?.inputSchema);
|
|
153
|
+
args = { ...args, ...pairs };
|
|
154
|
+
|
|
155
|
+
const result = await client.callTool(name, args);
|
|
156
|
+
io.log(typeof result === "string" ? result : JSON.stringify(result, null, 2));
|
|
157
|
+
return 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function asObject(v) {
|
|
161
|
+
if (v && typeof v === "object" && !Array.isArray(v)) return v;
|
|
162
|
+
throw new CliError("Arguments must be a JSON object.");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readStdinJson() {
|
|
166
|
+
if (process.stdin.isTTY) {
|
|
167
|
+
throw new CliError("No data on stdin — pipe input in, or omit --stdin.");
|
|
168
|
+
}
|
|
169
|
+
let raw;
|
|
170
|
+
try {
|
|
171
|
+
raw = readFileSync(0, "utf8");
|
|
172
|
+
} catch (err) {
|
|
173
|
+
throw new CliError(`Could not read stdin: ${err.message}`);
|
|
174
|
+
}
|
|
175
|
+
if (!raw) return {};
|
|
176
|
+
try {
|
|
177
|
+
return JSON.parse(raw);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
throw new CliError(`Invalid JSON on stdin: ${err.message}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseKeyVals(items) {
|
|
184
|
+
const out = {};
|
|
185
|
+
for (const item of items) {
|
|
186
|
+
const eq = item.indexOf("=");
|
|
187
|
+
if (eq === -1) throw new CliError(`Malformed --arg "${item}", expected key=value.`);
|
|
188
|
+
out[item.slice(0, eq)] = item.slice(eq + 1);
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function coerceToSchema(raw, schema) {
|
|
194
|
+
const props = schema?.properties ?? {};
|
|
195
|
+
const out = {};
|
|
196
|
+
for (const [key, value] of Object.entries(raw)) out[key] = coerceValue(value, props[key]);
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function coerceValue(value, prop) {
|
|
201
|
+
switch (resolveType(prop)) {
|
|
202
|
+
case "integer":
|
|
203
|
+
case "number": {
|
|
204
|
+
const n = Number(value);
|
|
205
|
+
return Number.isNaN(n) ? value : n;
|
|
206
|
+
}
|
|
207
|
+
case "boolean":
|
|
208
|
+
if (value === "true") return true;
|
|
209
|
+
if (value === "false") return false;
|
|
210
|
+
return value;
|
|
211
|
+
case "array":
|
|
212
|
+
return value
|
|
213
|
+
.split(",")
|
|
214
|
+
.map((s) => s.trim())
|
|
215
|
+
.filter((s) => s.length > 0);
|
|
216
|
+
case "object":
|
|
217
|
+
try {
|
|
218
|
+
return JSON.parse(value);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
throw new CliError(`Invalid object value for --arg: ${err.message}`);
|
|
221
|
+
}
|
|
222
|
+
default:
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function resolveType(prop) {
|
|
228
|
+
if (!prop) return undefined;
|
|
229
|
+
if (typeof prop.type === "string") return prop.type;
|
|
230
|
+
if (Array.isArray(prop.type)) return prop.type.find((t) => t !== "null");
|
|
231
|
+
if (prop.anyOf) {
|
|
232
|
+
for (const alt of prop.anyOf) {
|
|
233
|
+
const t = resolveType(alt);
|
|
234
|
+
if (t && t !== "null") return t;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// `mcp` — print ready-to-paste configuration for wiring the Letterstory MCP server
|
|
2
|
+
// into an agent (Claude Code, Claude Desktop, Cursor, …). Same endpoint/auth header
|
|
3
|
+
// the CLI itself uses (see client.mjs's `mcpEndpoint` / `x-integrations-key`), just
|
|
4
|
+
// packaged for copy-paste instead of for this process's own requests.
|
|
5
|
+
|
|
6
|
+
import { flagStr } from "./shared.mjs";
|
|
7
|
+
|
|
8
|
+
export function cmdMcp(ctx) {
|
|
9
|
+
const { config, flags, io } = ctx;
|
|
10
|
+
const name = flagStr(flags.name) ?? "letterstory";
|
|
11
|
+
const endpoint = `${config.url}/api/mcp`;
|
|
12
|
+
const header = "x-integrations-key";
|
|
13
|
+
const printKey = Boolean(flags["print-key"]);
|
|
14
|
+
const keyValue = printKey && config.key ? config.key : "${LETTERSTORY_API_KEY}";
|
|
15
|
+
|
|
16
|
+
const jsonConfig = {
|
|
17
|
+
mcpServers: {
|
|
18
|
+
[name]: {
|
|
19
|
+
type: "http",
|
|
20
|
+
url: endpoint,
|
|
21
|
+
headers: { [header]: keyValue },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (flags.json) {
|
|
27
|
+
io.log(JSON.stringify(jsonConfig, null, 2));
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const masked = config.key ? `${config.key.slice(0, 6)}…${config.key.slice(-4)}` : undefined;
|
|
32
|
+
|
|
33
|
+
io.log(`Letterstory MCP`);
|
|
34
|
+
io.log(` endpoint: ${endpoint}`);
|
|
35
|
+
io.log(` transport: streamable-http`);
|
|
36
|
+
io.log(` auth: ${header}: ${masked ?? "(no key found — run `" + ctx.bin + " login` first)"}`);
|
|
37
|
+
|
|
38
|
+
io.log(``);
|
|
39
|
+
io.log(`Claude Code:`);
|
|
40
|
+
io.log(` claude mcp add --transport http ${name} ${endpoint} \\`);
|
|
41
|
+
io.log(` --header "${header}: ${printKey && config.key ? config.key : "YOUR_KEY"}"`);
|
|
42
|
+
|
|
43
|
+
io.log(``);
|
|
44
|
+
io.log(`Claude Desktop / Cursor (mcpServers block):`);
|
|
45
|
+
for (const line of JSON.stringify(jsonConfig, null, 2).split("\n")) io.log(` ${line}`);
|
|
46
|
+
|
|
47
|
+
if (!printKey) {
|
|
48
|
+
io.log(``);
|
|
49
|
+
io.log(`Tip: export LETTERSTORY_API_KEY=…, or re-run with --print-key to inline it.`);
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
package/lib/commands.mjs
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
export * from "./commands/auth.mjs";
|
|
8
8
|
export * from "./commands/discovery.mjs";
|
|
9
9
|
export * from "./commands/deploy.mjs";
|
|
10
|
+
export * from "./commands/mcp.mjs";
|
|
10
11
|
export * from "./commands/posts.mjs";
|
|
11
12
|
export * from "./commands/collections.mjs";
|
|
12
13
|
export * from "./commands/flows.mjs";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Best-effort "a newer version is out" nudge. Checks the npm registry at most once
|
|
2
|
+
// every 24h and caches the result next to the config file, so a warm cache never
|
|
3
|
+
// touches the network and a cold/offline check never slows or breaks a command —
|
|
4
|
+
// every failure mode (no network, slow DNS, registry down, unwritable home) is
|
|
5
|
+
// swallowed and just means no nudge gets printed.
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join, dirname } from "node:path";
|
|
10
|
+
|
|
11
|
+
const REGISTRY_URL = "https://registry.npmjs.org/@letterstory/cli/latest";
|
|
12
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
const FETCH_TIMEOUT_MS = 500;
|
|
14
|
+
|
|
15
|
+
function cachePath() {
|
|
16
|
+
const home = process.env.LETTERSTORY_CONFIG_HOME || homedir();
|
|
17
|
+
return join(home, ".letterstory", "update-check.json");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readCache() {
|
|
21
|
+
try {
|
|
22
|
+
if (!existsSync(cachePath())) return null;
|
|
23
|
+
return JSON.parse(readFileSync(cachePath(), "utf8"));
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeCache(data) {
|
|
30
|
+
try {
|
|
31
|
+
const path = cachePath();
|
|
32
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
33
|
+
writeFileSync(path, JSON.stringify(data));
|
|
34
|
+
} catch {
|
|
35
|
+
// Read-only home, no disk space, etc. — caching is an optimization, not a requirement.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Plain dotted-numeric semver compare — this CLI's versions are always X.Y.Z, no
|
|
40
|
+
// prerelease/build metadata to worry about.
|
|
41
|
+
function isNewer(latest, current) {
|
|
42
|
+
const a = String(latest).split(".").map(Number);
|
|
43
|
+
const b = String(current).split(".").map(Number);
|
|
44
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
45
|
+
const x = a[i] || 0;
|
|
46
|
+
const y = b[i] || 0;
|
|
47
|
+
if (x !== y) return x > y;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function fetchLatestVersion(fetchImpl) {
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetchImpl(REGISTRY_URL, { signal: controller.signal });
|
|
57
|
+
if (!res.ok) return null;
|
|
58
|
+
const body = await res.json();
|
|
59
|
+
return typeof body.version === "string" ? body.version : null;
|
|
60
|
+
} catch {
|
|
61
|
+
return null; // offline, timed out, registry down, malformed response — no nudge, not an error
|
|
62
|
+
} finally {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns a one-line nudge string if a newer version is published on npm, else null.
|
|
69
|
+
* Never throws. `fetchImpl`/`now` are injectable for tests; real callers use the
|
|
70
|
+
* defaults (global fetch, current time).
|
|
71
|
+
*
|
|
72
|
+
* @param {{ currentVersion: string, fetchImpl?: (url: string, init?: { signal?: AbortSignal }) => Promise<{ ok: boolean, json: () => Promise<any> }>, now?: number }} opts
|
|
73
|
+
* @returns {Promise<string | null>}
|
|
74
|
+
*/
|
|
75
|
+
export async function checkForUpdate({ currentVersion, fetchImpl = globalThis.fetch, now = Date.now() }) {
|
|
76
|
+
if (process.env.LETTERSTORY_NO_UPDATE_CHECK) return null;
|
|
77
|
+
|
|
78
|
+
const cache = readCache();
|
|
79
|
+
let latest = cache?.latestVersion;
|
|
80
|
+
const stale = !cache || typeof cache.checkedAt !== "number" || now - cache.checkedAt > CHECK_INTERVAL_MS;
|
|
81
|
+
|
|
82
|
+
if (stale) {
|
|
83
|
+
const fetched = await fetchLatestVersion(fetchImpl);
|
|
84
|
+
if (fetched) {
|
|
85
|
+
latest = fetched;
|
|
86
|
+
writeCache({ checkedAt: now, latestVersion: fetched });
|
|
87
|
+
} else if (!cache) {
|
|
88
|
+
return null; // first-ever check failed (e.g. offline) — nothing to compare against yet
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!latest || !isNewer(latest, currentVersion)) return null;
|
|
93
|
+
return `Update available: ${currentVersion} → ${latest}. Run \`npm install -g @letterstory/cli@latest\` to upgrade. (Set LETTERSTORY_NO_UPDATE_CHECK=1 to silence this.)`;
|
|
94
|
+
}
|