@letterstory/cli 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,6 +69,24 @@ letterstory deploy delete <deployment-id> --yes
69
69
  Pass `--no-wait` to return immediately and poll later with `deploy get`, or `--dry-run`
70
70
  on `create` to print what would be sent without creating anything.
71
71
 
72
+ ### One-shot: bare `deploy`
73
+
74
+ For the common case — reserve a blog, price (and optionally buy) a domain, and rebuild,
75
+ all in one call — skip the subcommand word entirely:
76
+
77
+ ```bash
78
+ letterstory deploy --domain yourverticalreview.com --buy
79
+ # Reserved phantom blog "Yourverticalreview" (7c19c7…)
80
+ # Priced yourverticalreview.com
81
+ # Registered yourverticalreview.com
82
+ # SSL + DNS provisioned · sitemap + schema generated
83
+ #
84
+ # Your phantom blog is live at https://yourverticalreview.com.
85
+
86
+ letterstory deploy --blog 7c19c7… --rebuild # rebuild an existing blog instead of creating one
87
+ letterstory deploy --domain demo.com --dry-run # preview without calling the API
88
+ ```
89
+
72
90
  ## Custom domains
73
91
 
74
92
  ```bash
@@ -163,21 +181,30 @@ Every Letterstory tool is reachable, not just the ones with a dedicated command
163
181
 
164
182
  ```bash
165
183
  letterstory whoami # or: status — verify your key + who it's for
166
- letterstory tools # list all tools
184
+ letterstory tools list # list all tools (bare `tools` also works)
185
+ letterstory tools show list_articles # one tool's capability + full argument schema
167
186
  letterstory call list_articles --args '{"limit":5}'
168
187
  letterstory call ingest_article --args '{"title":"…","content":"…"}'
188
+
189
+ # `tool` is the same idea with schema-coerced arguments instead of raw JSON:
190
+ letterstory tool list_articles --arg limit=5 --arg collection_id=c1
191
+ letterstory tool ingest_article --json-args '{"title":"…"}' --arg content="…"
192
+ cat article.json | letterstory tool ingest_article --stdin
193
+
194
+ letterstory mcp # print MCP server config for an agent
195
+ letterstory mcp --print-key # inline the real key instead of a placeholder
169
196
  ```
170
197
 
171
198
  ## Global flags
172
199
 
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 be created, don't create it |
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 |
200
+ | Flag | Meaning |
201
+ | ------------ | ----------------------------------------------------------------------------------------- |
202
+ | `--json` | Machine-readable output |
203
+ | `--quiet` | Suppress success chatter on the new command groups (`--json` implies it) |
204
+ | `--verbose` | Log HTTP requests/responses to stderr |
205
+ | `--no-color` | Accepted for compatibility; this CLI already prints plain text |
206
+ | `--dry-run` | On `deploy`/`blogs create`, or bare `deploy`: print what would happen, don't call the API |
207
+ | `--url` | Override the API base URL for one call |
208
+ | `--key` | Override the API key for one call |
209
+ | `--help` | Show usage |
210
+ | `--version` | Print the CLI version |
package/lib/cli.mjs CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  cmdWhoami,
11
11
  cmdTools,
12
12
  cmdCall,
13
+ cmdTool,
14
+ cmdMcp,
13
15
  cmdDeploy,
14
16
  cmdDomain,
15
17
  cmdBlogs,
@@ -25,11 +27,25 @@ import {
25
27
  } from "./commands.mjs";
26
28
 
27
29
  // Keep in sync with cli/package.json.
28
- export const VERSION = "0.2.0";
30
+ export const VERSION = "0.2.1";
29
31
 
30
32
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
31
33
  // can't accidentally swallow the id as --json's value.
32
- const BOOLEAN_FLAGS = new Set(["json", "yes", "no-wait", "help", "version", "quiet", "verbose", "no-color", "dry-run"]);
34
+ const BOOLEAN_FLAGS = new Set([
35
+ "json",
36
+ "yes",
37
+ "no-wait",
38
+ "help",
39
+ "version",
40
+ "quiet",
41
+ "verbose",
42
+ "no-color",
43
+ "dry-run",
44
+ "buy",
45
+ "rebuild",
46
+ "print-key",
47
+ "stdin",
48
+ ]);
33
49
 
34
50
  // Tiny argv parser: `--flag value`, `--flag=value`, boolean `--flag`, and positionals.
35
51
  // A flag repeated more than once (e.g. `--topic a --topic b`, used by `strategy
@@ -100,6 +116,10 @@ Auth:
100
116
  config Show the resolved url + credential source
101
117
 
102
118
  Phantom blogs:
119
+ deploy [--domain <d>] [--name <n>] [--theme <t>] [--collection <uuid>]
120
+ [--blog <id>] [--buy] [--rebuild] [--dry-run]
121
+ One-shot: reserve/locate a blog, price
122
+ the domain, optionally buy it, rebuild
103
123
  deploy create --name <name> [--description <text>] [--theme <theme>]
104
124
  [--collection <uuid>] [--no-wait] Create a blog; waits until it's live
105
125
  deploy list [--limit <n>] List your blogs
@@ -158,8 +178,13 @@ Insights:
158
178
  insights top [--period 14d|30d|90d] [--collection <uuid>] [--limit <n>] [--sort clicks|impressions]
159
179
 
160
180
  Anything else:
161
- tools List every tool this server exposes
181
+ tools list List every tool this server exposes, by name
182
+ tools show <name> Show one tool's capability + full argument schema
162
183
  call <tool> [--args '<json>'] [--flag value …] Call any tool directly
184
+ tool <name> [--arg k=v …] [--json-args '<json>'] [--stdin]
185
+ Call any tool with schema-coerced arguments
186
+ mcp [--name <name>] [--print-key] [--json]
187
+ Print MCP server config for Claude Code/Desktop/Cursor
163
188
  whoami (alias status) Verify the resolved key and show who it's for
164
189
 
165
190
  Global flags:
@@ -167,7 +192,7 @@ Global flags:
167
192
  --quiet Suppress success chatter (new command groups only; --json implies it)
168
193
  --verbose Log HTTP requests/responses to stderr
169
194
  --no-color Accepted for compatibility; this CLI prints plain text already
170
- --dry-run For deploy/blogs create: print what would be created, don't create it
195
+ --dry-run For deploy/blogs create, or bare deploy: print what would happen, don't call the API
171
196
  --url <url> Override the API base URL for this invocation
172
197
  --key <ls_…> Override the API key for this invocation
173
198
  --help Show this help
@@ -185,6 +210,10 @@ const CLIENT_COMMANDS = {
185
210
  status: cmdWhoami,
186
211
  tools: cmdTools,
187
212
  call: cmdCall,
213
+ // `tool` is Mathew's phantomstory-cli name for `call`, with schema-aware
214
+ // argument coercion (--arg/--json-args/--stdin) added on top.
215
+ tool: cmdTool,
216
+ mcp: cmdMcp,
188
217
  deploy: cmdDeploy,
189
218
  domain: cmdDomain,
190
219
  // Mathew's phantomstory-cli names for the exact same deploy/domain commands.
@@ -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 ?? "(none)"}. Try: create, list, get, update, rebuild, diagnostics, delete`
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` (list the manifest) and `call` (invoke any
2
- // tool by name). Unchanged from the original commands.mjs this is the escape hatch
3
- // that keeps every tool reachable even ones with no dedicated command group.
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",