@letterstory/cli 0.1.1 → 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
@@ -11,25 +11,42 @@ The CLI is plain ESM with **zero dependencies** and needs no build step (Node
11
11
  ```bash
12
12
  # from a checkout of this repo
13
13
  cd cli
14
- npm link # puts `letterstory` on your PATH
14
+ npm link # puts `letterstory` (and `phantom`) on your PATH
15
15
  # …or run it directly without linking:
16
16
  node cli/bin/letterstory.mjs --help
17
17
  ```
18
18
 
19
+ ## `phantom` — the same CLI, Phantomstory-branded
20
+
21
+ `phantom` is a ghost-branded entry point for demos and presentations — same binary, same
22
+ commands, same auth, same guardrails as `letterstory`, just a different printed name:
23
+
24
+ ```bash
25
+ phantom deploy create --name "My Blog" --theme gazette
26
+ # 👻 phantom — deploy and manage Phantomstory blogs from your terminal
27
+ ```
28
+
29
+ There is no separate implementation to keep in sync: `bin/phantom.mjs` sets one env var
30
+ before importing `lib/cli.mjs`, which swaps the banner and command-hint strings and
31
+ otherwise runs the exact same code path as `letterstory`.
32
+
19
33
  ## Authenticate
20
34
 
21
- You need a Letterstory API key (starts with `lb_`) with the `deployment:read` and
35
+ You need a Letterstory API key (starts with `ls_`; legacy `lb_` keys still work) with the `deployment:read` and
22
36
  `deployment:write` capabilities — mint one in the app under **Settings → API keys**.
23
37
  Add `deployment:domain` too if you plan to buy custom domains.
24
38
 
25
39
  ```bash
26
- letterstory login --key lb_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
40
+ letterstory login --key ls_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
27
41
  # points at https://app.letterstory.com by default; override with --url
28
42
  ```
29
43
 
30
44
  Credentials resolve from `--key`/`--url` flags, then `LETTERSTORY_API_KEY` /
31
45
  `LETTERSTORY_API_URL`, then `~/.letterstory/config.json` (written by `login`, mode 600).
32
46
 
47
+ Run `letterstory whoami` (alias `status`) any time to confirm which key/url resolved and
48
+ see your company profile.
49
+
33
50
  ## Spin up a blog
34
51
 
35
52
  ```bash
@@ -43,11 +60,32 @@ letterstory deploy create --name "My Blog" --theme gazette
43
60
 
44
61
  letterstory deploy list
45
62
  letterstory deploy get <deployment-id>
63
+ letterstory deploy update <deployment-id> --name "New Name"
46
64
  letterstory deploy rebuild <deployment-id> # after publishing new articles
65
+ letterstory deploy diagnostics <deployment-id> # why is my blog empty?
47
66
  letterstory deploy delete <deployment-id> --yes
48
67
  ```
49
68
 
50
- Pass `--no-wait` to return immediately and poll later with `deploy get`.
69
+ Pass `--no-wait` to return immediately and poll later with `deploy get`, or `--dry-run`
70
+ on `create` to print what would be sent without creating anything.
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
+ ```
51
89
 
52
90
  ## Custom domains
53
91
 
@@ -56,22 +94,117 @@ letterstory domain check myblog.com # price it (no charge)
56
94
  letterstory domain buy <deployment-id> myblog.com # buys + attaches the domain
57
95
  ```
58
96
 
97
+ `blogs` and `domains` are aliases for `deploy` and `domain` — the exact same commands
98
+ under Phantomstory's vocabulary (`blogs ls`, `blogs new`, `blogs rm`, `blogs update`,
99
+ `domains buy`, etc. all call the identical handler as their `deploy`/`domain`
100
+ counterpart). Use whichever set of verbs you prefer; both are always available from
101
+ both `letterstory` and `phantom`.
102
+
103
+ ## Content
104
+
105
+ ```bash
106
+ letterstory posts list [--limit <n>] [--collection <uuid>]
107
+ letterstory posts show <article-id>
108
+ letterstory posts new --title "…" --collection <uuid> --body "…" # or --file <path|->
109
+ letterstory posts publish <article-id>
110
+ letterstory posts unpublish <article-id>
111
+
112
+ letterstory published list [--limit <n>] [--collection <uuid>] [--format html|md]
113
+ letterstory published show --slug my-post-slug [--format html|md] # or --id <uuid>
114
+ ```
115
+
116
+ ## Collections
117
+
118
+ ```bash
119
+ letterstory collections list
120
+ letterstory collections new --name "Launches" --description "…"
121
+ letterstory collections update <id> --cadence-target 4 --cadence-period week
122
+ letterstory collections delete <id> --yes
123
+ letterstory collections assign <article-id> <collection-id>
124
+ ```
125
+
126
+ ## Flows
127
+
128
+ Run an editorial pass over an article, check on it, and manage the completion webhook:
129
+
130
+ ```bash
131
+ letterstory flows list
132
+ letterstory flows run <flow-id> <article-id>
133
+ letterstory flows status <run-id>
134
+
135
+ letterstory flows webhook get
136
+ letterstory flows webhook set --url https://hooks.example.com/x --secret s3cr3t
137
+ letterstory flows webhook set --url none # disable it
138
+ ```
139
+
140
+ ## Connectors
141
+
142
+ Publish a finished article out to an external CMS or doc target:
143
+
144
+ ```bash
145
+ letterstory connectors list
146
+ letterstory connectors publish <article-id> --to webflow --target <target-uuid>
147
+ letterstory connectors status --connector webflow --publish-id <id>
148
+ ```
149
+
150
+ ## Strategy & onboarding
151
+
152
+ ```bash
153
+ letterstory strategy company get
154
+ letterstory strategy company set --name "Acme" --domain acme.com --manifesto "…"
155
+
156
+ letterstory strategy positioning get
157
+ letterstory strategy positioning set --topic seo --topic ai --stance "…" --clear-avoid
158
+
159
+ letterstory strategy competitors list
160
+ letterstory strategy competitors add "Rival Co" rival.com
161
+
162
+ letterstory strategy sitemap --collection <uuid> --url https://x.com/sitemap.xml
163
+
164
+ letterstory onboarding status
165
+ letterstory onboarding step --complete connect_domain
166
+ ```
167
+
168
+ ## Insights
169
+
170
+ Search Console performance, network-wide, per post, or top posts:
171
+
172
+ ```bash
173
+ letterstory insights site [--period 14d|30d|90d] [--collection <uuid>]
174
+ letterstory insights post <article-id> [--period 14d|30d|90d]
175
+ letterstory insights top [--period 14d|30d|90d] [--collection <uuid>] [--limit <n>] [--sort clicks|impressions]
176
+ ```
177
+
59
178
  ## Anything else
60
179
 
61
- Every Letterstory tool is reachable, not just the deployment ones:
180
+ Every Letterstory tool is reachable, not just the ones with a dedicated command group:
62
181
 
63
182
  ```bash
64
- letterstory tools # list all tools
183
+ letterstory whoami # or: status — verify your key + who it's for
184
+ letterstory tools list # list all tools (bare `tools` also works)
185
+ letterstory tools show list_articles # one tool's capability + full argument schema
65
186
  letterstory call list_articles --args '{"limit":5}'
66
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
67
196
  ```
68
197
 
69
198
  ## Global flags
70
199
 
71
- | Flag | Meaning |
72
- | ----------- | -------------------------------------- |
73
- | `--json` | Machine-readable output |
74
- | `--url` | Override the API base URL for one call |
75
- | `--key` | Override the API key for one call |
76
- | `--help` | Show usage |
77
- | `--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 |
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ // Ghost-branded entry point for Phantomstory demos/presentations. Sets the brand
3
+ // flag `lib/cli.mjs` reads before importing it, then defers to the exact same
4
+ // run() as `letterstory.mjs` — same auth, same commands, same guardrails, only
5
+ // the printed name and banner differ.
6
+ process.env.LETTERSTORY_CLI_BRAND = "phantom";
7
+
8
+ import { run } from "../lib/cli.mjs";
9
+
10
+ run(process.argv.slice(2))
11
+ .then((code) => process.exit(code))
12
+ .catch((err) => {
13
+ // A non-CliError escaped run() — a real bug. Show the stack.
14
+ console.error(err);
15
+ process.exit(1);
16
+ });
package/lib/cli.mjs CHANGED
@@ -2,24 +2,69 @@
2
2
  // is the single entry the bin and the tests both call; it returns an exit code and
3
3
  // never throws for user-facing problems (those become a printed CliError + code 1).
4
4
 
5
- import { LetterstoryClient, CliError, resolveConfig } from "./client.mjs";
6
- import { cmdLogin, cmdLogout, cmdConfig, cmdTools, cmdCall, cmdDeploy, cmdDomain } from "./commands.mjs";
5
+ import { LetterstoryClient, CliError, resolveConfig, readConfigFile, writeConfigFile } from "./client.mjs";
6
+ import {
7
+ cmdLogin,
8
+ cmdLogout,
9
+ cmdConfig,
10
+ cmdWhoami,
11
+ cmdTools,
12
+ cmdCall,
13
+ cmdTool,
14
+ cmdMcp,
15
+ cmdDeploy,
16
+ cmdDomain,
17
+ cmdBlogs,
18
+ cmdDomains,
19
+ cmdPosts,
20
+ cmdPublished,
21
+ cmdCollections,
22
+ cmdFlows,
23
+ cmdConnectors,
24
+ cmdStrategy,
25
+ cmdOnboarding,
26
+ cmdInsights,
27
+ } from "./commands.mjs";
7
28
 
8
29
  // Keep in sync with cli/package.json.
9
- export const VERSION = "0.1.1";
30
+ export const VERSION = "0.2.1";
10
31
 
11
32
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
12
33
  // can't accidentally swallow the id as --json's value.
13
- const BOOLEAN_FLAGS = new Set(["json", "yes", "no-wait", "help", "version"]);
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
+ ]);
14
49
 
15
50
  // Tiny argv parser: `--flag value`, `--flag=value`, boolean `--flag`, and positionals.
51
+ // A flag repeated more than once (e.g. `--topic a --topic b`, used by `strategy
52
+ // positioning set`) accumulates into an array; a single occurrence stays a plain
53
+ // string, so every existing single-value flag read is unaffected.
16
54
  /**
17
55
  * @param {string[]} argv
18
- * @returns {{ positionals: string[], flags: Record<string, string | boolean> }}
56
+ * @returns {{ positionals: string[], flags: Record<string, string | boolean | string[]> }}
19
57
  */
20
58
  export function parseArgs(argv) {
21
59
  const positionals = [];
22
60
  const flags = {};
61
+ const setFlag = (name, value) => {
62
+ if (Object.prototype.hasOwnProperty.call(flags, name)) {
63
+ flags[name] = [].concat(flags[name], value);
64
+ } else {
65
+ flags[name] = value;
66
+ }
67
+ };
23
68
  for (let i = 0; i < argv.length; i++) {
24
69
  const tok = argv[i];
25
70
  if (!tok.startsWith("--")) {
@@ -28,7 +73,7 @@ export function parseArgs(argv) {
28
73
  }
29
74
  const eq = tok.indexOf("=");
30
75
  if (eq !== -1) {
31
- flags[tok.slice(2, eq)] = tok.slice(eq + 1);
76
+ setFlag(tok.slice(2, eq), tok.slice(eq + 1));
32
77
  continue;
33
78
  }
34
79
  const name = tok.slice(2);
@@ -36,7 +81,7 @@ export function parseArgs(argv) {
36
81
  if (BOOLEAN_FLAGS.has(name) || next === undefined || next.startsWith("--")) {
37
82
  flags[name] = true;
38
83
  } else {
39
- flags[name] = next;
84
+ setFlag(name, next);
40
85
  i++;
41
86
  }
42
87
  }
@@ -47,17 +92,34 @@ function str(v) {
47
92
  return typeof v === "string" ? v : undefined;
48
93
  }
49
94
 
50
- export const HELP = `letterstory spin up and manage Letterstory phantom blogs from your terminal
95
+ // Same binary, two names: `phantom` is a ghost-branded entry point for Phantomstory
96
+ // demos (bin/phantom.mjs sets this before importing us) that shares every code path —
97
+ // auth, config, commands — with `letterstory`. No behavior differs, only this string.
98
+ export function binName() {
99
+ return process.env.LETTERSTORY_CLI_BRAND === "phantom" ? "phantom" : "letterstory";
100
+ }
101
+
102
+ function buildHelp(bin) {
103
+ const heading =
104
+ bin === "phantom"
105
+ ? "👻 phantom — deploy and manage Phantomstory blogs from your terminal"
106
+ : "letterstory — spin up and manage Letterstory phantom blogs from your terminal";
107
+ return `${heading}
51
108
 
52
109
  Usage:
53
- letterstory <command> [args] [--flags]
110
+ ${bin} <command> [args] [--flags]
54
111
 
55
112
  Auth:
56
- login --key <lb_…> [--url <url>] Save your API key (default url: https://app.letterstory.com)
57
- logout Forget saved credentials
58
- config Show the resolved url + key (masked)
113
+ login [--url <url>] Sign in via your browser (default url: https://app.letterstory.com)
114
+ login --key <ls_…> [--url <url>] Save a static API key instead (for CI/automation)
115
+ logout Forget saved credentials (revokes an OAuth session, if any)
116
+ config Show the resolved url + credential source
59
117
 
60
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
61
123
  deploy create --name <name> [--description <text>] [--theme <theme>]
62
124
  [--collection <uuid>] [--no-wait] Create a blog; waits until it's live
63
125
  deploy list [--limit <n>] List your blogs
@@ -70,28 +132,101 @@ Custom domains:
70
132
  domain check <domain> Price a domain (read-only, no charge)
71
133
  domain buy <deployment-id> <domain> Buy + attach a custom domain
72
134
 
135
+ "blogs" and "domains" are aliases for "deploy" and "domain" (same commands, ls/new/
136
+ show/update/rm verbs also accepted) — use whichever vocabulary you prefer.
137
+
138
+ Content:
139
+ posts list [--limit <n>] [--collection <uuid>] List your articles
140
+ posts show <article-id> Show one article
141
+ posts new --title <t> --collection <uuid> (--body <text> | --file <path|->)
142
+ posts publish <article-id> Publish an article
143
+ posts unpublish <article-id> Unpublish an article
144
+ published list [--limit <n>] [--collection <uuid>] [--format html|md]
145
+ published show (--id <uuid> | --slug <slug>) [--format html|md]
146
+
147
+ Collections:
148
+ collections list List your collections
149
+ collections new --name <n> [--description <text>]
150
+ collections update <id> [--name] [--description] [--cadence-target <n>] [--cadence-period week|month]
151
+ collections delete <id> --yes
152
+ collections assign <article-id> <collection-id>
153
+
154
+ Flows:
155
+ flows list List available editorial flows
156
+ flows run <flow-id> <article-id> Start a flow run
157
+ flows status <run-id> Show a flow run's status
158
+ flows webhook get Show the completion webhook
159
+ flows webhook set --url <https://…|none|off> [--secret <secret>]
160
+
161
+ Connectors:
162
+ connectors list List external publish destinations
163
+ connectors publish <article-id> --to <connector> --target <uuid> [--flow-run <uuid>]
164
+ connectors status --connector <connector> --publish-id <id>
165
+
166
+ Strategy & onboarding:
167
+ strategy company get | set [--name] [--domain] [--manifesto <text>|--manifesto-file <path>]
168
+ strategy positioning get | set [--topic <t> …] [--stance <s> …] [--avoid <t> …]
169
+ [--clear-topics] [--clear-stances] [--clear-avoid]
170
+ strategy competitors list | add <name> <domain>
171
+ strategy sitemap --collection <uuid> --url <sitemap_url> [--sub <url> …] [--pattern <glob>]
172
+ onboarding status Show the onboarding checklist
173
+ onboarding step [--current <step>] [--complete <step>] [--skip <step>] [--status <status>]
174
+
175
+ Insights:
176
+ insights site [--period 14d|30d|90d] [--collection <uuid>]
177
+ insights post <article-id> [--period 14d|30d|90d]
178
+ insights top [--period 14d|30d|90d] [--collection <uuid>] [--limit <n>] [--sort clicks|impressions]
179
+
73
180
  Anything else:
74
- 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
75
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
188
+ whoami (alias status) Verify the resolved key and show who it's for
76
189
 
77
190
  Global flags:
78
191
  --json Machine-readable output
192
+ --quiet Suppress success chatter (new command groups only; --json implies it)
193
+ --verbose Log HTTP requests/responses to stderr
194
+ --no-color Accepted for compatibility; this CLI prints plain text already
195
+ --dry-run For deploy/blogs create, or bare deploy: print what would happen, don't call the API
79
196
  --url <url> Override the API base URL for this invocation
80
- --key <lb_…> Override the API key for this invocation
197
+ --key <ls_…> Override the API key for this invocation
81
198
  --help Show this help
82
199
  --version Print the CLI version
83
200
 
84
201
  Credentials resolve from --key/--url, then LETTERSTORY_API_KEY / LETTERSTORY_API_URL,
85
202
  then ~/.letterstory/config.json.`;
203
+ }
86
204
 
87
205
  const CLIENT_COMMANDS = {
88
206
  login: cmdLogin,
89
207
  logout: cmdLogout,
90
208
  config: cmdConfig,
209
+ whoami: cmdWhoami,
210
+ status: cmdWhoami,
91
211
  tools: cmdTools,
92
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,
93
217
  deploy: cmdDeploy,
94
218
  domain: cmdDomain,
219
+ // Mathew's phantomstory-cli names for the exact same deploy/domain commands.
220
+ blogs: cmdBlogs,
221
+ domains: cmdDomains,
222
+ posts: cmdPosts,
223
+ published: cmdPublished,
224
+ collections: cmdCollections,
225
+ flows: cmdFlows,
226
+ connectors: cmdConnectors,
227
+ strategy: cmdStrategy,
228
+ onboarding: cmdOnboarding,
229
+ insights: cmdInsights,
95
230
  };
96
231
 
97
232
  // LETTERSTORY_POLL_INTERVAL_MS / LETTERSTORY_MAX_POLLS let an operator (or an
@@ -120,27 +255,42 @@ export function defaultIo() {
120
255
  export async function run(argv, io = defaultIo()) {
121
256
  const { positionals, flags } = parseArgs(argv);
122
257
  const command = positionals[0];
258
+ const bin = binName();
123
259
 
124
260
  if (flags.version || command === "version") {
125
261
  io.log(VERSION);
126
262
  return 0;
127
263
  }
128
264
  if (!command || command === "help" || flags.help) {
129
- io.log(HELP);
265
+ io.log(buildHelp(bin));
130
266
  return 0;
131
267
  }
132
268
 
133
269
  const handler = CLIENT_COMMANDS[command];
134
270
  if (!handler) {
135
271
  io.error(`Unknown command: ${command}`);
136
- io.error(`Run \`letterstory help\` for usage.`);
272
+ io.error(`Run \`${bin} help\` for usage.`);
137
273
  return 1;
138
274
  }
139
275
 
140
276
  try {
141
277
  const config = resolveConfig({ url: str(flags.url), key: str(flags.key) });
142
- const client = new LetterstoryClient({ url: config.url, key: config.key });
143
- const ctx = { client, config, positionals: positionals.slice(1), flags, io };
278
+ // A silent refresh mid-command should persist the new tokens so the next
279
+ // invocation doesn't have to refresh again but only when the config we
280
+ // resolved actually came from a saved OAuth session (a --key/env override
281
+ // for this one call shouldn't get written back to disk as a login).
282
+ const onTokensRefreshed =
283
+ config.keySource === "oauth" ? (oauth) => writeConfigFile({ ...readConfigFile(), oauth }) : undefined;
284
+ const client = new LetterstoryClient({
285
+ url: config.url,
286
+ key: config.key,
287
+ oauth: config.oauth,
288
+ onTokensRefreshed,
289
+ verbose: Boolean(flags.verbose),
290
+ verboseLog: io.error,
291
+ bin,
292
+ });
293
+ const ctx = { client, config, positionals: positionals.slice(1), flags, io, bin };
144
294
  return await handler(ctx);
145
295
  } catch (err) {
146
296
  if (err instanceof CliError) {
package/lib/client.mjs CHANGED
@@ -30,7 +30,7 @@ export function readConfigFile() {
30
30
  }
31
31
  }
32
32
 
33
- // The file holds an lb_ API key, so it's written owner-only (0600) and the mode is
33
+ // The file holds an ls_ API key, so it's written owner-only (0600) and the mode is
34
34
  // re-asserted in case it pre-existed with looser bits.
35
35
  export function writeConfigFile(config) {
36
36
  const path = configPath();
@@ -46,23 +46,41 @@ export function clearConfigFile() {
46
46
  return path;
47
47
  }
48
48
 
49
- // Precedence: explicit flags > env > config file > built-in default. Returns which
50
- // source the key came from so `config` can show it. The URL defaults to prod.
49
+ // Precedence: explicit flags > env > config file (OAuth tokens from a browser
50
+ // login, then a static key) > built-in default. keySource is surfaced so
51
+ // `config` can show where the credential came from. The URL defaults to prod.
52
+ //
53
+ // A static --key/env key always wins over a saved browser login — that's the
54
+ // CI/automation path and it should never silently fall back to a stale OAuth
55
+ // session saved on the same machine.
51
56
  export function resolveConfig({ url, key } = {}) {
52
57
  const file = readConfigFile();
53
- const keySource = key ? "flag" : process.env.LETTERSTORY_API_KEY ? "env" : file.key ? "file" : "none";
54
- return {
55
- url: url || process.env.LETTERSTORY_API_URL || file.url || DEFAULT_API_URL,
56
- key: key || process.env.LETTERSTORY_API_KEY || file.key || null,
57
- keySource,
58
- };
58
+ const resolvedUrl = url || process.env.LETTERSTORY_API_URL || file.url || DEFAULT_API_URL;
59
+
60
+ const explicitKey = key || process.env.LETTERSTORY_API_KEY || null;
61
+ if (explicitKey) {
62
+ return { url: resolvedUrl, key: explicitKey, oauth: null, keySource: key ? "flag" : "env" };
63
+ }
64
+ if (file.oauth?.access_token) {
65
+ return { url: resolvedUrl, key: null, oauth: file.oauth, keySource: "oauth" };
66
+ }
67
+ return { url: resolvedUrl, key: file.key || null, oauth: null, keySource: file.key ? "file" : "none" };
59
68
  }
60
69
 
61
70
  export class LetterstoryClient {
62
- constructor({ url, key, fetchImpl } = {}) {
71
+ // `oauth` is { access_token, refresh_token, expires_at, scope } from a
72
+ // browser login. `onTokensRefreshed(oauth)` is called after a successful
73
+ // silent refresh so the caller can persist the new tokens to disk —
74
+ // this class never touches the config file itself.
75
+ constructor({ url, key, oauth, fetchImpl, onTokensRefreshed, verbose, verboseLog, bin } = {}) {
63
76
  this.url = (url || DEFAULT_API_URL).replace(/\/+$/, "");
64
77
  this.key = key || null;
78
+ this.oauth = oauth || null;
65
79
  this.fetch = fetchImpl || globalThis.fetch;
80
+ this.onTokensRefreshed = onTokensRefreshed || (() => {});
81
+ this.bin = bin || "letterstory";
82
+ this.verbose = Boolean(verbose);
83
+ this.verboseLog = verboseLog || ((m) => process.stderr.write(`${m}\n`));
66
84
  }
67
85
 
68
86
  get mcpEndpoint() {
@@ -72,22 +90,36 @@ export class LetterstoryClient {
72
90
  // Unauthenticated discovery (GET /api/mcp): tool names + descriptions, no schema.
73
91
  // Lets `letterstory tools` work before you've logged in.
74
92
  async discover() {
93
+ if (this.verbose) this.verboseLog(`» GET ${this.mcpEndpoint}`);
75
94
  let res;
76
95
  try {
77
96
  res = await this.fetch(this.mcpEndpoint, { headers: { accept: "application/json" } });
78
97
  } catch (err) {
79
98
  throw new CliError(`Could not reach ${this.mcpEndpoint}: ${err.message}`);
80
99
  }
100
+ if (this.verbose) this.verboseLog(`« ${res.status}`);
81
101
  if (!res.ok) throw new CliError(`Discovery failed (HTTP ${res.status}) at ${this.mcpEndpoint}`);
82
102
  return res.json();
83
103
  }
84
104
 
105
+ authHeaders() {
106
+ if (this.oauth?.access_token) return { authorization: `Bearer ${this.oauth.access_token}` };
107
+ if (this.key) return { "x-integrations-key": this.key };
108
+ return null;
109
+ }
110
+
85
111
  // One JSON-RPC round trip. The MCP route is stateless per POST, so tools/call
86
- // needs no prior initialize handshake.
87
- async rpc(method, params) {
88
- if (!this.key) {
89
- throw new CliError("Not logged in. Run `letterstory login --key <lb_…>` or set LETTERSTORY_API_KEY.");
112
+ // needs no prior initialize handshake. When authenticated via OAuth, a single
113
+ // 401 triggers one silent refresh-and-retry before giving up — a static key
114
+ // has no such recovery, since a rejected key is just wrong.
115
+ async rpc(method, params, { _retried = false } = {}) {
116
+ const authHeaders = this.authHeaders();
117
+ if (!authHeaders) {
118
+ throw new CliError(
119
+ `Not logged in. Run \`${this.bin} login\` (opens your browser) or \`${this.bin} login --key <ls_…>\`.`
120
+ );
90
121
  }
122
+ if (this.verbose) this.verboseLog(`» POST ${this.mcpEndpoint} (${method})`);
91
123
  let res;
92
124
  try {
93
125
  res = await this.fetch(this.mcpEndpoint, {
@@ -95,16 +127,23 @@ export class LetterstoryClient {
95
127
  headers: {
96
128
  "content-type": "application/json",
97
129
  accept: "application/json",
98
- "x-integrations-key": this.key,
130
+ ...authHeaders,
99
131
  },
100
132
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
101
133
  });
102
134
  } catch (err) {
103
135
  throw new CliError(`Could not reach ${this.mcpEndpoint}: ${err.message}`);
104
136
  }
137
+ if (this.verbose) this.verboseLog(`« ${res.status}`);
105
138
  // Auth failures come back as a plain HTTP error, not a JSON-RPC envelope.
106
139
  if (res.status === 401 || res.status === 403) {
107
- throw new CliError(`Authentication failed (HTTP ${res.status}). Check your API key and its capabilities.`);
140
+ if (!_retried && this.oauth?.refresh_token && (await this.tryRefresh())) {
141
+ return this.rpc(method, params, { _retried: true });
142
+ }
143
+ const hint = this.oauth
144
+ ? `Your session has expired. Run \`${this.bin} login\` again.`
145
+ : "Check your API key and its capabilities.";
146
+ throw new CliError(`Authentication failed (HTTP ${res.status}). ${hint}`);
108
147
  }
109
148
  const body = await res.json().catch(() => null);
110
149
  if (!body) throw new CliError(`Unexpected non-JSON response (HTTP ${res.status}) from ${this.mcpEndpoint}`);
@@ -115,6 +154,29 @@ export class LetterstoryClient {
115
154
  return body.result;
116
155
  }
117
156
 
157
+ // Best-effort silent refresh. Returns false (never throws) so rpc() can
158
+ // fall through to its normal "please log in again" error on any failure.
159
+ async tryRefresh() {
160
+ try {
161
+ const { refreshAccessToken } = await import("./oauth.mjs");
162
+ const tokens = await refreshAccessToken({
163
+ url: this.url,
164
+ refreshToken: this.oauth.refresh_token,
165
+ fetchImpl: this.fetch,
166
+ });
167
+ this.oauth = {
168
+ access_token: tokens.access_token,
169
+ refresh_token: tokens.refresh_token || this.oauth.refresh_token,
170
+ expires_at: Date.now() + (tokens.expires_in ?? 3600) * 1000,
171
+ scope: tokens.scope,
172
+ };
173
+ this.onTokensRefreshed(this.oauth);
174
+ return true;
175
+ } catch {
176
+ return false;
177
+ }
178
+ }
179
+
118
180
  async listTools() {
119
181
  const result = await this.rpc("tools/list", {});
120
182
  return result?.tools ?? [];