@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 +146 -13
- package/bin/phantom.mjs +16 -0
- package/lib/cli.mjs +168 -18
- package/lib/client.mjs +78 -16
- package/lib/commands/auth.mjs +125 -0
- package/lib/commands/collections.mjs +93 -0
- package/lib/commands/connectors.mjs +56 -0
- package/lib/commands/deploy.mjs +387 -0
- package/lib/commands/discovery.mjs +238 -0
- package/lib/commands/flows.mjs +83 -0
- package/lib/commands/insights.mjs +51 -0
- package/lib/commands/mcp.mjs +52 -0
- package/lib/commands/posts.mjs +122 -0
- package/lib/commands/shared.mjs +95 -0
- package/lib/commands/strategy.mjs +209 -0
- package/lib/commands.mjs +16 -323
- package/lib/oauth.mjs +204 -0
- package/package.json +3 -2
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
|
|
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
|
|
180
|
+
Every Letterstory tool is reachable, not just the ones with a dedicated command group:
|
|
62
181
|
|
|
63
182
|
```bash
|
|
64
|
-
letterstory
|
|
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
|
|
72
|
-
|
|
|
73
|
-
| `--json`
|
|
74
|
-
| `--
|
|
75
|
-
| `--
|
|
76
|
-
| `--
|
|
77
|
-
| `--
|
|
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/bin/phantom.mjs
ADDED
|
@@ -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 {
|
|
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.
|
|
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([
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
+
${bin} <command> [args] [--flags]
|
|
54
111
|
|
|
55
112
|
Auth:
|
|
56
|
-
login
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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 <
|
|
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(
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
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
|
|
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
|
|
50
|
-
//
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? [];
|