@letterstory/cli 0.19.0 → 0.23.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.
@@ -53,6 +53,138 @@ export function compact(obj) {
53
53
  return out;
54
54
  }
55
55
 
56
+ // Flags every command accepts: transport, output shape, and the ones the runner
57
+ // consumes before a command ever sees them.
58
+ export const GLOBAL_FLAGS = new Set([
59
+ "url",
60
+ "key",
61
+ "staff-key",
62
+ "json",
63
+ "quiet",
64
+ "verbose",
65
+ "help",
66
+ "version",
67
+ "yes",
68
+ "no-color",
69
+ "profile",
70
+ ]);
71
+
72
+ /**
73
+ * Refuse a flag this command does not understand.
74
+ *
75
+ * On a WRITE command an unrecognised flag is not cosmetic. `phantom-onboard
76
+ * collection --status` used to fall straight through to `onboard_collection`
77
+ * and START A SECOND ONBOARDING — a read verb that spent money and left a
78
+ * duplicate collection behind — because nothing checked that `--status` meant
79
+ * anything here. Its sibling `phantom-onboard domain --status` reads, so the
80
+ * same flag meant "read" on one subcommand and "spend" on the other.
81
+ *
82
+ * `own` is the flags the command handles itself; the ARGS map supplies the
83
+ * rest, so the map that BUILDS the call is also the list of what may be passed
84
+ * to it — one source, the same principle the maps exist for.
85
+ */
86
+ export function refuseUnknownFlags(ctx, argsMap, own = []) {
87
+ const allowed = new Set([...GLOBAL_FLAGS, ...own]);
88
+ for (const source of Object.values(argsMap ?? {})) {
89
+ if (typeof source === "string") allowed.add(source);
90
+ else if (Array.isArray(source)) source.forEach((f) => allowed.add(f));
91
+ else if (source && typeof source === "object" && source.flag) allowed.add(source.flag);
92
+ }
93
+ const unknown = Object.keys(ctx.flags).filter((f) => !allowed.has(f));
94
+ if (unknown.length) {
95
+ throw new CliError(
96
+ `Unknown flag${unknown.length > 1 ? "s" : ""} for this command: ${unknown.map((f) => `--${f}`).join(", ")}. ` +
97
+ "This command writes, so it refuses rather than ignoring a flag it does not understand."
98
+ );
99
+ }
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------------
103
+ // ARGS maps: the one place a curated command says how each tool input key is sourced.
104
+ //
105
+ // A command module exports `ARGS = { <tool name>: { <schema key>: <source> } }` and builds
106
+ // its `callTool` payload with `argsFromFlags(ARGS[tool], flags, positionals)`. The map IS
107
+ // the implementation — not a second copy of it — so `scripts/parity-check.ts` can import it
108
+ // and compare its keys against the tool's published input schema. A tool that gains a
109
+ // parameter the command never learned then FAILS parity instead of drifting silently
110
+ // (#1731 shipped enable_planner to MCP and REST and the CLI never got the flags; nothing
111
+ // noticed, because the old check compared tool NAMES only).
112
+ //
113
+ // Source forms, one per schema key:
114
+ // "flag" a string flag, --flag <value>
115
+ // ["about", "direction"] the first of these string flags that is set
116
+ // { flag, type?, required?, enum?, invert?, negate?, positional?, label? }
117
+ // type: "str" (default) | "num" | "bool" | "list" (a repeatable flag)
118
+ // required: throw "Missing required --flag" when absent
119
+ // enum: throw "--flag must be one of: …" on any other value
120
+ // invert: (bool) a set --flag sends FALSE (e.g. --no-phantom → enabled:false)
121
+ // negate: (bool) a second flag that sends FALSE (e.g. --no-auto-deploy)
122
+ // positional: an index into positionals instead of a flag (label names it in errors)
123
+ // { object: { <sub key>: <source> } } a nested object, omitted when every sub key is absent
124
+ // { value: (flags, positionals, extra) => v }
125
+ // computed from several flags or an earlier result;
126
+ // still DECLARED, so parity counts the key as exposed
127
+ // { internal: "why" } supplied by the command from a prior tool result
128
+ // (a job id it just received), never from the user
129
+ export function argsFromFlags(spec, flags, positionals = [], extra = {}) {
130
+ const out = {};
131
+ for (const [key, source] of Object.entries(spec)) {
132
+ const v = readArgSource(key, source, flags, positionals, extra);
133
+ if (v !== undefined) out[key] = v;
134
+ }
135
+ return out;
136
+ }
137
+
138
+ function readArgSource(key, source, flags, positionals, extra) {
139
+ if (typeof source === "string") return flagStr(flags[source]);
140
+ if (Array.isArray(source)) {
141
+ for (const name of source) {
142
+ const v = flagStr(flags[name]);
143
+ if (v !== undefined) return v;
144
+ }
145
+ return undefined;
146
+ }
147
+ if (!source || typeof source !== "object") {
148
+ throw new CliError(`ARGS.${key}: unsupported source ${JSON.stringify(source)}`);
149
+ }
150
+ if (source.internal !== undefined) return undefined;
151
+ if (typeof source.value === "function") return source.value(flags, positionals, extra);
152
+ if (source.object) {
153
+ const nested = argsFromFlags(source.object, flags, positionals, extra);
154
+ return Object.keys(nested).length ? nested : undefined;
155
+ }
156
+ if (source.positional !== undefined) {
157
+ const v = positionals[source.positional];
158
+ if (v === undefined && source.required) {
159
+ throw new CliError(`Missing required <${source.label ?? key}>`);
160
+ }
161
+ return v;
162
+ }
163
+ const raw = flags[source.flag];
164
+ let v;
165
+ switch (source.type ?? "str") {
166
+ case "num":
167
+ v = flagNum(raw);
168
+ break;
169
+ case "bool":
170
+ if (source.negate && flags[source.negate] === true) return false;
171
+ v = raw === true ? (source.invert ? false : true) : undefined;
172
+ break;
173
+ case "list": {
174
+ const list = flagList(raw);
175
+ v = list.length ? list : undefined;
176
+ break;
177
+ }
178
+ default:
179
+ v = flagStr(raw);
180
+ }
181
+ if (v === undefined && source.required) throw new CliError(`Missing required --${source.flag}`);
182
+ if (v !== undefined && source.enum && !source.enum.includes(v)) {
183
+ throw new CliError(`--${source.flag} must be one of: ${source.enum.join(", ")}`);
184
+ }
185
+ return v;
186
+ }
187
+
56
188
  export function printResult(io, flags, value, formatter) {
57
189
  if (flags.json || !formatter) {
58
190
  io.log(JSON.stringify(value, null, 2));
@@ -89,6 +221,58 @@ export async function pollUntil(io, poll, opts = {}) {
89
221
  }
90
222
  }
91
223
 
224
+ // Pull one field out of a tool result.
225
+ //
226
+ // `client.callTool` ALREADY unwraps the MCP envelope and parses the JSON, so a
227
+ // tool result here is the payload itself. Two commands used to reach for
228
+ // `result.content[0].text` — the envelope shape — which is always undefined
229
+ // after unwrapping, so the job id was never found, the follow loop never ran,
230
+ // and `--no-wait` never printed its hint. The envelope fallback below is kept
231
+ // only so a raw result passed in from somewhere else still reads correctly.
232
+ export function toolField(result, field) {
233
+ if (result && typeof result === "object" && field in result) return result[field];
234
+ const raw = result?.content?.[0]?.text;
235
+ if (typeof raw === "string") {
236
+ try {
237
+ return JSON.parse(raw)?.[field];
238
+ } catch {
239
+ return undefined;
240
+ }
241
+ }
242
+ return undefined;
243
+ }
244
+
245
+ // Follow a queued job to a terminal state, SLEEPING FIRST.
246
+ //
247
+ // Unlike `pollUntil`, which checks immediately: a shred or a kernel run takes
248
+ // minutes and starts on the next tick, so an instant first poll only ever says
249
+ // "queued". Sleeping first is also what makes the interval the operator's —
250
+ // both loops used a file-local `setTimeout`, so `io.sleep` (and with it
251
+ // LETTERSTORY_POLL_INTERVAL_MS) was ignored and nobody could pace them.
252
+ //
253
+ // Returns `{ settled, value }`. `settled:false` means the ceiling passed — the
254
+ // caller decides what to say, because the job is still running on the server
255
+ // and that is not an error.
256
+ export async function followJob(io, poll, opts = {}) {
257
+ // The default is the command's own (a shred is slow; polling fast only burns
258
+ // requests). An operator who EXPLICITLY sets LETTERSTORY_POLL_INTERVAL_MS
259
+ // means it, and that is what makes the interval theirs — a CI run or a test
260
+ // harness sets it to 0 and the same loop runs without the wait.
261
+ const explicit = process.env.LETTERSTORY_POLL_INTERVAL_MS;
262
+ const intervalMs =
263
+ explicit !== undefined && explicit !== "" && Number.isFinite(Number(explicit))
264
+ ? Number(explicit)
265
+ : (opts.intervalMs ?? 15000);
266
+ const ceilingMs = opts.ceilingMs ?? 30 * 60 * 1000;
267
+ const deadline = Date.now() + ceilingMs;
268
+ for (;;) {
269
+ await io.sleep(intervalMs);
270
+ const { done, value } = await poll();
271
+ if (done) return { settled: true, value };
272
+ if (Date.now() > deadline) return { settled: false, value };
273
+ }
274
+ }
275
+
92
276
  // Read a text value from an inline flag, or from a file flag (a path, or "-" for
93
277
  // stdin). Returns undefined if neither flag was given, so callers can decide whether
94
278
  // that's an error. `readBodyInput` is the --body/--file pairing; the propose lane reads
@@ -13,6 +13,8 @@ import {
13
13
  printResult,
14
14
  compact,
15
15
  ok,
16
+ toolField,
17
+ followJob,
16
18
  } from "./shared.mjs";
17
19
 
18
20
  export async function cmdShredder(ctx) {
@@ -83,8 +85,6 @@ async function shredderDelete(ctx) {
83
85
  const POLL_INTERVAL_MS = 15_000;
84
86
  const POLL_CEILING_MS = 30 * 60 * 1000;
85
87
 
86
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
87
-
88
88
  async function shredderRun(ctx) {
89
89
  const { client, positionals, flags, io } = ctx;
90
90
  const id = requirePositional(positionals, 0, "endpoint-id");
@@ -105,6 +105,9 @@ async function shredderRun(ctx) {
105
105
  text,
106
106
  coverage: flagNum(flags.coverage),
107
107
  attempts_per_unit: flagNum(flags.attempts),
108
+ // What the text IS. Without this an HTML file could only go through the
109
+ // `auto` heuristic, which misreads a markdown post carrying an embed.
110
+ ...(flags.format ? { format: String(flags.format) } : {}),
108
111
  // How hard to rewrite. Validated service-side; sent only when the caller asked.
109
112
  ...(flags.aggression ? { aggression: String(flags.aggression) } : {}),
110
113
  // Hold every rewrite to the live writing rules. A trade, not a free win: refused rewrites
@@ -132,36 +135,35 @@ async function shredderRun(ctx) {
132
135
  }
133
136
 
134
137
  ok(ctx, `Shred queued as ${jobId}. Waiting — this keeps running on the server if you stop.`);
135
- const deadline = Date.now() + POLL_CEILING_MS;
136
- for (;;) {
137
- await sleep(POLL_INTERVAL_MS);
138
- const polled = await client.callTool("get_shred_job", { job_id: jobId });
139
- const status = readStatus(polled);
140
- if (status && status !== "queued" && status !== "running") {
141
- ok(ctx, `Shred ${status}.`);
142
- printResult(io, flags, polled);
143
- return status === "done" ? 0 : 1;
144
- }
145
- if (Date.now() > deadline) {
146
- ok(ctx, `Still ${status ?? "running"} after 30 minutes. Poll it with: letterstory shredder job ${jobId}`);
147
- printResult(io, flags, polled);
148
- return 0;
149
- }
150
- }
151
- }
138
+ let lastStatus;
139
+ const { settled, value: polled } = await followJob(
140
+ io,
141
+ async () => {
142
+ const result = await client.callTool("get_shred_job", { job_id: jobId });
143
+ lastStatus = readStatus(result);
144
+ const terminal = !!lastStatus && lastStatus !== "queued" && lastStatus !== "running";
145
+ return { done: terminal, value: result };
146
+ },
147
+ { intervalMs: POLL_INTERVAL_MS, ceilingMs: POLL_CEILING_MS }
148
+ );
152
149
 
153
- /** The tool result is a text payload holding JSON; pull one field out of it, leniently. */
154
- function readToolField(result, field) {
155
- const raw = result?.content?.[0]?.text;
156
- if (typeof raw !== "string") return undefined;
157
- try {
158
- return JSON.parse(raw)?.[field];
159
- } catch {
160
- return undefined;
150
+ if (!settled) {
151
+ ok(ctx, `Still ${lastStatus ?? "running"} after 30 minutes. Poll it with: letterstory shredder job ${jobId}`);
152
+ printResult(io, flags, polled);
153
+ return 0;
161
154
  }
155
+ ok(ctx, `Shred ${lastStatus}.`);
156
+ // The operator followed this job to get their DOCUMENT, so print the document
157
+ // and not the envelope around it. `--json` still gets the whole payload, which
158
+ // is what a script piping this wants.
159
+ printResult(io, flags, polled, (v) => (typeof v?.text === "string" ? v.text : JSON.stringify(v, null, 2)));
160
+ // A job that landed as error or degraded is not a success, and a script that
161
+ // pipes this needs to know without parsing the payload.
162
+ return lastStatus === "done" ? 0 : 1;
162
163
  }
163
- const readJobId = (result) => readToolField(result, "job_id");
164
- const readStatus = (result) => readToolField(result, "status");
164
+
165
+ const readJobId = (result) => toolField(result, "job_id");
166
+ const readStatus = (result) => toolField(result, "status");
165
167
 
166
168
  async function shredderJob(ctx) {
167
169
  const { client, positionals, flags, io } = ctx;
@@ -4,16 +4,72 @@
4
4
  // mirrored here as two exported handlers for the same reason.
5
5
 
6
6
  import { CliError } from "../client.mjs";
7
- import {
8
- flagStr,
9
- requireFlag,
10
- requirePositional,
11
- printResult,
12
- compact,
13
- flagList,
14
- ok,
15
- readBodyInput,
16
- } from "./shared.mjs";
7
+ import { flagStr, printResult, flagList, ok, readBodyInput, argsFromFlags } from "./shared.mjs";
8
+
9
+ // A list flag the API clears with an explicit empty array (which compact() keeps):
10
+ // `--clear-topics` sends [], a repeated --topic sends the list, neither sends nothing.
11
+ const clearableList = (flag, clearFlag) => ({
12
+ value: (flags) => {
13
+ if (flags[clearFlag]) return [];
14
+ const list = flagList(flags[flag]);
15
+ return list.length ? list : undefined;
16
+ },
17
+ });
18
+
19
+ // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
20
+ // `scripts/parity-check.ts` compares these keys against each tool's published input schema.
21
+ export const ARGS = {
22
+ get_company_info: {},
23
+ set_company_info: {
24
+ name: "name",
25
+ domain: "domain",
26
+ // The API clears the manifesto with an empty string, which a plain flag read would
27
+ // drop — so it is set explicitly whenever the user supplied a source (text or file).
28
+ manifesto: {
29
+ value: (flags) => {
30
+ const inline = flagStr(flags.manifesto);
31
+ const file = flagStr(flags["manifesto-file"]);
32
+ if (inline === undefined && file === undefined) return undefined;
33
+ return inline ?? readBodyInput({ file }) ?? "";
34
+ },
35
+ },
36
+ market: "market",
37
+ },
38
+ get_positioning: {},
39
+ set_positioning: {
40
+ priority_topics: clearableList("topic", "clear-topics"),
41
+ stances: clearableList("stance", "clear-stances"),
42
+ avoid_topics: clearableList("avoid", "clear-avoid"),
43
+ },
44
+ list_competitors: {},
45
+ add_competitor: {
46
+ name: { positional: 0, label: "name", required: true },
47
+ domain: { positional: 1, label: "domain", required: true },
48
+ },
49
+ import_sitemap: {
50
+ collection_id: { flag: "collection", required: true },
51
+ sitemap_url: { flag: "url", required: true },
52
+ sitemap_urls: { flag: "sub", type: "list" },
53
+ url_pattern: "pattern",
54
+ posts_only: { flag: "posts-only", type: "bool" },
55
+ },
56
+ list_topics: {
57
+ collection_id: { flag: "collection", required: true },
58
+ },
59
+ set_topic: {
60
+ collection_id: { flag: "collection", required: true },
61
+ topic_id: "topic",
62
+ suggestion_id: "suggestion",
63
+ },
64
+ get_onboarding_status: {},
65
+ // Step ids are the server's to define (the registry is the authority); forwarded as given.
66
+ set_onboarding_step: {
67
+ current_step: "current",
68
+ complete_step: "complete",
69
+ skip_step: "skip",
70
+ status: "status",
71
+ },
72
+ };
17
73
 
18
74
  export async function cmdStrategy(ctx) {
19
75
  const sub = ctx.positionals[0];
@@ -60,16 +116,11 @@ async function companyGet(ctx) {
60
116
 
61
117
  async function companySet(ctx) {
62
118
  const { client, flags, io } = ctx;
63
- const args = compact({ name: flagStr(flags.name), domain: flagStr(flags.domain) });
64
- // The API clears the manifesto with an empty string, which compact() would drop —
65
- // so set it explicitly whenever the user supplied a source (text or file).
66
- const manifesto = flagStr(flags.manifesto);
67
- const manifestoFile = flagStr(flags["manifesto-file"]);
68
- if (manifesto !== undefined || manifestoFile !== undefined) {
69
- args.manifesto = manifesto ?? readBodyInput({ file: manifestoFile }) ?? "";
70
- }
119
+ const args = argsFromFlags(ARGS.set_company_info, flags);
71
120
  if (Object.keys(args).length === 0) {
72
- throw new CliError('Nothing to set — pass --name/--domain/--manifesto (empty --manifesto "" clears it).');
121
+ throw new CliError(
122
+ 'Nothing to set — pass --name/--domain/--manifesto/--market (empty --manifesto "" clears it).'
123
+ );
73
124
  }
74
125
  const result = await client.callTool("set_company_info", args);
75
126
  ok(ctx, "Company profile updated.");
@@ -101,15 +152,7 @@ async function positioningGet(ctx) {
101
152
 
102
153
  async function positioningSet(ctx) {
103
154
  const { client, flags, io } = ctx;
104
- const topics = flagList(flags.topic);
105
- const stances = flagList(flags.stance);
106
- const avoid = flagList(flags.avoid);
107
- // The API clears a list with an explicit empty array (which compact() keeps).
108
- const args = compact({
109
- priority_topics: flags["clear-topics"] ? [] : topics.length ? topics : undefined,
110
- stances: flags["clear-stances"] ? [] : stances.length ? stances : undefined,
111
- avoid_topics: flags["clear-avoid"] ? [] : avoid.length ? avoid : undefined,
112
- });
155
+ const args = argsFromFlags(ARGS.set_positioning, flags);
113
156
  if (Object.keys(args).length === 0) {
114
157
  throw new CliError("Pass at least one --topic/--stance/--avoid (or a --clear-* flag).");
115
158
  }
@@ -144,10 +187,9 @@ async function competitorsList(ctx) {
144
187
 
145
188
  async function competitorsAdd(ctx) {
146
189
  const { client, positionals, flags, io } = ctx;
147
- const name = requirePositional(positionals, 0, "name");
148
- const domain = requirePositional(positionals, 1, "domain");
149
- const result = await client.callTool("add_competitor", { name, domain });
150
- ok(ctx, `Added competitor ${name}.`);
190
+ const args = argsFromFlags(ARGS.add_competitor, flags, positionals);
191
+ const result = await client.callTool("add_competitor", args);
192
+ ok(ctx, `Added competitor ${args.name}.`);
151
193
  printResult(io, flags, result);
152
194
  return 0;
153
195
  }
@@ -156,15 +198,7 @@ async function competitorsAdd(ctx) {
156
198
 
157
199
  async function strategySitemap(ctx) {
158
200
  const { client, flags, io } = ctx;
159
- const collection = requireFlag(flags, "collection");
160
- const url = requireFlag(flags, "url");
161
- const sub = flagList(flags.sub);
162
- const args = compact({
163
- collection_id: collection,
164
- sitemap_url: url,
165
- sitemap_urls: sub.length ? sub : undefined,
166
- url_pattern: flagStr(flags.pattern),
167
- });
201
+ const args = argsFromFlags(ARGS.import_sitemap, flags);
168
202
  const result = await client.callTool("import_sitemap", args);
169
203
  ok(ctx, "Sitemap import started.");
170
204
  printResult(io, flags, result);
@@ -189,21 +223,17 @@ async function strategyTopics(ctx) {
189
223
 
190
224
  async function topicsList(ctx) {
191
225
  const { client, flags, io } = ctx;
192
- const collection = requireFlag(flags, "collection");
193
- const result = await client.callTool("list_topics", { collection_id: collection });
226
+ const result = await client.callTool("list_topics", argsFromFlags(ARGS.list_topics, flags));
194
227
  printResult(io, flags, result);
195
228
  return 0;
196
229
  }
197
230
 
198
231
  async function topicsSet(ctx) {
199
232
  const { client, flags, io } = ctx;
200
- const collection = requireFlag(flags, "collection");
201
- const topicId = flagStr(flags.topic);
202
- const suggestionId = flagStr(flags.suggestion);
203
- if ((topicId === undefined) === (suggestionId === undefined)) {
233
+ const args = argsFromFlags(ARGS.set_topic, flags);
234
+ if ((args.topic_id === undefined) === (args.suggestion_id === undefined)) {
204
235
  throw new CliError("Pass exactly one of --topic <topic-id> or --suggestion <suggestion-id>.");
205
236
  }
206
- const args = compact({ collection_id: collection, topic_id: topicId, suggestion_id: suggestionId });
207
237
  const result = await client.callTool("set_topic", args);
208
238
  ok(ctx, "Topic queued.");
209
239
  printResult(io, flags, result);
@@ -234,12 +264,7 @@ async function onboardingStatus(ctx) {
234
264
 
235
265
  async function onboardingStep(ctx) {
236
266
  const { client, flags, io } = ctx;
237
- const args = compact({
238
- current_step: flagStr(flags.current),
239
- complete_step: flagStr(flags.complete),
240
- skip_step: flagStr(flags.skip),
241
- status: flagStr(flags.status),
242
- });
267
+ const args = argsFromFlags(ARGS.set_onboarding_step, flags);
243
268
  if (Object.keys(args).length === 0) {
244
269
  throw new CliError("Pass --current/--complete/--skip/--status.");
245
270
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.19.0",
3
+ "version": "0.23.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",