@letterstory/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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,24 +60,109 @@ 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.
51
71
 
52
72
  ## Custom domains
53
73
 
54
74
  ```bash
55
75
  letterstory domain check myblog.com # price it (no charge)
56
- letterstory domain buy <deployment-id> myblog.com --yes # buys + attaches (spends money)
76
+ letterstory domain buy <deployment-id> myblog.com # buys + attaches the domain
77
+ ```
78
+
79
+ `blogs` and `domains` are aliases for `deploy` and `domain` — the exact same commands
80
+ under Phantomstory's vocabulary (`blogs ls`, `blogs new`, `blogs rm`, `blogs update`,
81
+ `domains buy`, etc. all call the identical handler as their `deploy`/`domain`
82
+ counterpart). Use whichever set of verbs you prefer; both are always available from
83
+ both `letterstory` and `phantom`.
84
+
85
+ ## Content
86
+
87
+ ```bash
88
+ letterstory posts list [--limit <n>] [--collection <uuid>]
89
+ letterstory posts show <article-id>
90
+ letterstory posts new --title "…" --collection <uuid> --body "…" # or --file <path|->
91
+ letterstory posts publish <article-id>
92
+ letterstory posts unpublish <article-id>
93
+
94
+ letterstory published list [--limit <n>] [--collection <uuid>] [--format html|md]
95
+ letterstory published show --slug my-post-slug [--format html|md] # or --id <uuid>
96
+ ```
97
+
98
+ ## Collections
99
+
100
+ ```bash
101
+ letterstory collections list
102
+ letterstory collections new --name "Launches" --description "…"
103
+ letterstory collections update <id> --cadence-target 4 --cadence-period week
104
+ letterstory collections delete <id> --yes
105
+ letterstory collections assign <article-id> <collection-id>
106
+ ```
107
+
108
+ ## Flows
109
+
110
+ Run an editorial pass over an article, check on it, and manage the completion webhook:
111
+
112
+ ```bash
113
+ letterstory flows list
114
+ letterstory flows run <flow-id> <article-id>
115
+ letterstory flows status <run-id>
116
+
117
+ letterstory flows webhook get
118
+ letterstory flows webhook set --url https://hooks.example.com/x --secret s3cr3t
119
+ letterstory flows webhook set --url none # disable it
120
+ ```
121
+
122
+ ## Connectors
123
+
124
+ Publish a finished article out to an external CMS or doc target:
125
+
126
+ ```bash
127
+ letterstory connectors list
128
+ letterstory connectors publish <article-id> --to webflow --target <target-uuid>
129
+ letterstory connectors status --connector webflow --publish-id <id>
130
+ ```
131
+
132
+ ## Strategy & onboarding
133
+
134
+ ```bash
135
+ letterstory strategy company get
136
+ letterstory strategy company set --name "Acme" --domain acme.com --manifesto "…"
137
+
138
+ letterstory strategy positioning get
139
+ letterstory strategy positioning set --topic seo --topic ai --stance "…" --clear-avoid
140
+
141
+ letterstory strategy competitors list
142
+ letterstory strategy competitors add "Rival Co" rival.com
143
+
144
+ letterstory strategy sitemap --collection <uuid> --url https://x.com/sitemap.xml
145
+
146
+ letterstory onboarding status
147
+ letterstory onboarding step --complete connect_domain
148
+ ```
149
+
150
+ ## Insights
151
+
152
+ Search Console performance, network-wide, per post, or top posts:
153
+
154
+ ```bash
155
+ letterstory insights site [--period 14d|30d|90d] [--collection <uuid>]
156
+ letterstory insights post <article-id> [--period 14d|30d|90d]
157
+ letterstory insights top [--period 14d|30d|90d] [--collection <uuid>] [--limit <n>] [--sort clicks|impressions]
57
158
  ```
58
159
 
59
160
  ## Anything else
60
161
 
61
- Every Letterstory tool is reachable, not just the deployment ones:
162
+ Every Letterstory tool is reachable, not just the ones with a dedicated command group:
62
163
 
63
164
  ```bash
165
+ letterstory whoami # or: status — verify your key + who it's for
64
166
  letterstory tools # list all tools
65
167
  letterstory call list_articles --args '{"limit":5}'
66
168
  letterstory call ingest_article --args '{"title":"…","content":"…"}'
@@ -68,10 +170,14 @@ letterstory call ingest_article --args '{"title":"…","content":"…"}'
68
170
 
69
171
  ## Global flags
70
172
 
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 |
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 |
@@ -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,53 @@
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
+ cmdDeploy,
14
+ cmdDomain,
15
+ cmdBlogs,
16
+ cmdDomains,
17
+ cmdPosts,
18
+ cmdPublished,
19
+ cmdCollections,
20
+ cmdFlows,
21
+ cmdConnectors,
22
+ cmdStrategy,
23
+ cmdOnboarding,
24
+ cmdInsights,
25
+ } from "./commands.mjs";
7
26
 
8
27
  // Keep in sync with cli/package.json.
9
- export const VERSION = "0.1.0";
28
+ export const VERSION = "0.2.0";
10
29
 
11
30
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
12
31
  // can't accidentally swallow the id as --json's value.
13
- const BOOLEAN_FLAGS = new Set(["json", "yes", "no-wait", "help", "version"]);
32
+ const BOOLEAN_FLAGS = new Set(["json", "yes", "no-wait", "help", "version", "quiet", "verbose", "no-color", "dry-run"]);
14
33
 
15
34
  // Tiny argv parser: `--flag value`, `--flag=value`, boolean `--flag`, and positionals.
35
+ // A flag repeated more than once (e.g. `--topic a --topic b`, used by `strategy
36
+ // positioning set`) accumulates into an array; a single occurrence stays a plain
37
+ // string, so every existing single-value flag read is unaffected.
16
38
  /**
17
39
  * @param {string[]} argv
18
- * @returns {{ positionals: string[], flags: Record<string, string | boolean> }}
40
+ * @returns {{ positionals: string[], flags: Record<string, string | boolean | string[]> }}
19
41
  */
20
42
  export function parseArgs(argv) {
21
43
  const positionals = [];
22
44
  const flags = {};
45
+ const setFlag = (name, value) => {
46
+ if (Object.prototype.hasOwnProperty.call(flags, name)) {
47
+ flags[name] = [].concat(flags[name], value);
48
+ } else {
49
+ flags[name] = value;
50
+ }
51
+ };
23
52
  for (let i = 0; i < argv.length; i++) {
24
53
  const tok = argv[i];
25
54
  if (!tok.startsWith("--")) {
@@ -28,7 +57,7 @@ export function parseArgs(argv) {
28
57
  }
29
58
  const eq = tok.indexOf("=");
30
59
  if (eq !== -1) {
31
- flags[tok.slice(2, eq)] = tok.slice(eq + 1);
60
+ setFlag(tok.slice(2, eq), tok.slice(eq + 1));
32
61
  continue;
33
62
  }
34
63
  const name = tok.slice(2);
@@ -36,7 +65,7 @@ export function parseArgs(argv) {
36
65
  if (BOOLEAN_FLAGS.has(name) || next === undefined || next.startsWith("--")) {
37
66
  flags[name] = true;
38
67
  } else {
39
- flags[name] = next;
68
+ setFlag(name, next);
40
69
  i++;
41
70
  }
42
71
  }
@@ -47,15 +76,28 @@ function str(v) {
47
76
  return typeof v === "string" ? v : undefined;
48
77
  }
49
78
 
50
- export const HELP = `letterstory spin up and manage Letterstory phantom blogs from your terminal
79
+ // Same binary, two names: `phantom` is a ghost-branded entry point for Phantomstory
80
+ // demos (bin/phantom.mjs sets this before importing us) that shares every code path —
81
+ // auth, config, commands — with `letterstory`. No behavior differs, only this string.
82
+ export function binName() {
83
+ return process.env.LETTERSTORY_CLI_BRAND === "phantom" ? "phantom" : "letterstory";
84
+ }
85
+
86
+ function buildHelp(bin) {
87
+ const heading =
88
+ bin === "phantom"
89
+ ? "👻 phantom — deploy and manage Phantomstory blogs from your terminal"
90
+ : "letterstory — spin up and manage Letterstory phantom blogs from your terminal";
91
+ return `${heading}
51
92
 
52
93
  Usage:
53
- letterstory <command> [args] [--flags]
94
+ ${bin} <command> [args] [--flags]
54
95
 
55
96
  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)
97
+ login [--url <url>] Sign in via your browser (default url: https://app.letterstory.com)
98
+ login --key <ls_…> [--url <url>] Save a static API key instead (for CI/automation)
99
+ logout Forget saved credentials (revokes an OAuth session, if any)
100
+ config Show the resolved url + credential source
59
101
 
60
102
  Phantom blogs:
61
103
  deploy create --name <name> [--description <text>] [--theme <theme>]
@@ -68,30 +110,94 @@ Phantom blogs:
68
110
 
69
111
  Custom domains:
70
112
  domain check <domain> Price a domain (read-only, no charge)
71
- domain buy <deployment-id> <domain> --yes Buy + attach a domain (spends money)
113
+ domain buy <deployment-id> <domain> Buy + attach a custom domain
114
+
115
+ "blogs" and "domains" are aliases for "deploy" and "domain" (same commands, ls/new/
116
+ show/update/rm verbs also accepted) — use whichever vocabulary you prefer.
117
+
118
+ Content:
119
+ posts list [--limit <n>] [--collection <uuid>] List your articles
120
+ posts show <article-id> Show one article
121
+ posts new --title <t> --collection <uuid> (--body <text> | --file <path|->)
122
+ posts publish <article-id> Publish an article
123
+ posts unpublish <article-id> Unpublish an article
124
+ published list [--limit <n>] [--collection <uuid>] [--format html|md]
125
+ published show (--id <uuid> | --slug <slug>) [--format html|md]
126
+
127
+ Collections:
128
+ collections list List your collections
129
+ collections new --name <n> [--description <text>]
130
+ collections update <id> [--name] [--description] [--cadence-target <n>] [--cadence-period week|month]
131
+ collections delete <id> --yes
132
+ collections assign <article-id> <collection-id>
133
+
134
+ Flows:
135
+ flows list List available editorial flows
136
+ flows run <flow-id> <article-id> Start a flow run
137
+ flows status <run-id> Show a flow run's status
138
+ flows webhook get Show the completion webhook
139
+ flows webhook set --url <https://…|none|off> [--secret <secret>]
140
+
141
+ Connectors:
142
+ connectors list List external publish destinations
143
+ connectors publish <article-id> --to <connector> --target <uuid> [--flow-run <uuid>]
144
+ connectors status --connector <connector> --publish-id <id>
145
+
146
+ Strategy & onboarding:
147
+ strategy company get | set [--name] [--domain] [--manifesto <text>|--manifesto-file <path>]
148
+ strategy positioning get | set [--topic <t> …] [--stance <s> …] [--avoid <t> …]
149
+ [--clear-topics] [--clear-stances] [--clear-avoid]
150
+ strategy competitors list | add <name> <domain>
151
+ strategy sitemap --collection <uuid> --url <sitemap_url> [--sub <url> …] [--pattern <glob>]
152
+ onboarding status Show the onboarding checklist
153
+ onboarding step [--current <step>] [--complete <step>] [--skip <step>] [--status <status>]
154
+
155
+ Insights:
156
+ insights site [--period 14d|30d|90d] [--collection <uuid>]
157
+ insights post <article-id> [--period 14d|30d|90d]
158
+ insights top [--period 14d|30d|90d] [--collection <uuid>] [--limit <n>] [--sort clicks|impressions]
72
159
 
73
160
  Anything else:
74
161
  tools List every tool this server exposes
75
162
  call <tool> [--args '<json>'] [--flag value …] Call any tool directly
163
+ whoami (alias status) Verify the resolved key and show who it's for
76
164
 
77
165
  Global flags:
78
166
  --json Machine-readable output
167
+ --quiet Suppress success chatter (new command groups only; --json implies it)
168
+ --verbose Log HTTP requests/responses to stderr
169
+ --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
79
171
  --url <url> Override the API base URL for this invocation
80
- --key <lb_…> Override the API key for this invocation
172
+ --key <ls_…> Override the API key for this invocation
81
173
  --help Show this help
82
174
  --version Print the CLI version
83
175
 
84
176
  Credentials resolve from --key/--url, then LETTERSTORY_API_KEY / LETTERSTORY_API_URL,
85
177
  then ~/.letterstory/config.json.`;
178
+ }
86
179
 
87
180
  const CLIENT_COMMANDS = {
88
181
  login: cmdLogin,
89
182
  logout: cmdLogout,
90
183
  config: cmdConfig,
184
+ whoami: cmdWhoami,
185
+ status: cmdWhoami,
91
186
  tools: cmdTools,
92
187
  call: cmdCall,
93
188
  deploy: cmdDeploy,
94
189
  domain: cmdDomain,
190
+ // Mathew's phantomstory-cli names for the exact same deploy/domain commands.
191
+ blogs: cmdBlogs,
192
+ domains: cmdDomains,
193
+ posts: cmdPosts,
194
+ published: cmdPublished,
195
+ collections: cmdCollections,
196
+ flows: cmdFlows,
197
+ connectors: cmdConnectors,
198
+ strategy: cmdStrategy,
199
+ onboarding: cmdOnboarding,
200
+ insights: cmdInsights,
95
201
  };
96
202
 
97
203
  // LETTERSTORY_POLL_INTERVAL_MS / LETTERSTORY_MAX_POLLS let an operator (or an
@@ -120,27 +226,42 @@ export function defaultIo() {
120
226
  export async function run(argv, io = defaultIo()) {
121
227
  const { positionals, flags } = parseArgs(argv);
122
228
  const command = positionals[0];
229
+ const bin = binName();
123
230
 
124
231
  if (flags.version || command === "version") {
125
232
  io.log(VERSION);
126
233
  return 0;
127
234
  }
128
235
  if (!command || command === "help" || flags.help) {
129
- io.log(HELP);
236
+ io.log(buildHelp(bin));
130
237
  return 0;
131
238
  }
132
239
 
133
240
  const handler = CLIENT_COMMANDS[command];
134
241
  if (!handler) {
135
242
  io.error(`Unknown command: ${command}`);
136
- io.error(`Run \`letterstory help\` for usage.`);
243
+ io.error(`Run \`${bin} help\` for usage.`);
137
244
  return 1;
138
245
  }
139
246
 
140
247
  try {
141
248
  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 };
249
+ // A silent refresh mid-command should persist the new tokens so the next
250
+ // invocation doesn't have to refresh again but only when the config we
251
+ // resolved actually came from a saved OAuth session (a --key/env override
252
+ // for this one call shouldn't get written back to disk as a login).
253
+ const onTokensRefreshed =
254
+ config.keySource === "oauth" ? (oauth) => writeConfigFile({ ...readConfigFile(), oauth }) : undefined;
255
+ const client = new LetterstoryClient({
256
+ url: config.url,
257
+ key: config.key,
258
+ oauth: config.oauth,
259
+ onTokensRefreshed,
260
+ verbose: Boolean(flags.verbose),
261
+ verboseLog: io.error,
262
+ bin,
263
+ });
264
+ const ctx = { client, config, positionals: positionals.slice(1), flags, io, bin };
144
265
  return await handler(ctx);
145
266
  } catch (err) {
146
267
  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 ?? [];