@letterstory/cli 0.121.3 → 0.121.5

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
@@ -146,6 +146,17 @@ letterstory posts list [--limit <n>] [--collection <uuid>]
146
146
  letterstory posts show <article-id>
147
147
  letterstory posts new --title "…" --collection <uuid> --body "…" [--article-id <uuid>] # or --file <path|->; --article-id updates that article instead of creating one
148
148
  letterstory posts new --title "…" --collection <uuid> --body "…" --context "Internal data from 120 accounts: disputes fell 38%." # your own data: stated in the post with your attribution
149
+ letterstory posts update <article-id> --title "…" --summary "…" --tag seo --tag ai # only what you pass changes; --body/--file for the HTML body
150
+ letterstory posts edit <article-id> --instruction "Tighten the intro" # AI rewrite, saved as your edit
151
+ letterstory posts mentions <article-id> # re-apply the brand-mention & competitor policy
152
+ letterstory posts history <article-id>
153
+ letterstory posts restore <article-id> <version> # comes back as the newest version
154
+ letterstory posts export <article-id> --format pdf --out post.pdf # or --format md (prints the Markdown)
155
+ letterstory posts comments <article-id>
156
+ letterstory posts comments add <article-id> --body "Source?" --quote "grew 40%"
157
+ letterstory posts comments update <article-id> <comment-id> --resolve # or --reopen, --body "…"
158
+ letterstory posts comments resolve-ai <article-id> <comment-id> # AI makes the change the comment asks for
159
+ letterstory reviewers invite <article-id> --email editor@client.com --name "Sam" # a guest reviewer: link + PIN by email
149
160
  letterstory posts publish <article-id>
150
161
  letterstory posts unpublish <article-id>
151
162
  letterstory posts delete --id <uuid> [--id <uuid> ...] # permanent; content, history, and any published copy are all removed
File without changes
package/bin/phantom.mjs CHANGED
File without changes
package/lib/cli.mjs CHANGED
@@ -60,7 +60,7 @@ import {
60
60
  } from "./commands.mjs";
61
61
 
62
62
  // Keep in sync with cli/package.json.
63
- export const VERSION = "0.121.3";
63
+ export const VERSION = "0.121.5";
64
64
 
65
65
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
66
66
  // can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
@@ -136,6 +136,9 @@ export const BOOLEAN_FLAGS = new Set([
136
136
  "stock-only",
137
137
  "gate",
138
138
  "no-verify",
139
+ // `posts comments update <article-id> <comment-id> --resolve | --reopen`.
140
+ "resolve",
141
+ "reopen",
139
142
  "skip",
140
143
  // `phantom-onboard domain` / `admin create-org --onboard` switches.
141
144
  "watch",
@@ -185,6 +188,8 @@ export const BOOLEAN_FLAGS = new Set([
185
188
  "retired",
186
189
  "retire",
187
190
  "restore",
191
+ // `posts list --mine`: only your posts.
192
+ "mine",
188
193
  // `collection add "<headline>" --collection <id> --queue`.
189
194
  "queue",
190
195
  // Bool flags from command ARGS maps that were missing here.
@@ -376,9 +381,29 @@ Phantomstory (a collection Letterstory hosts as an independent publication):
376
381
  phantom delete <phantom-id> --yes
377
382
 
378
383
  Content:
379
- posts list [--limit <n>] [--collection <uuid>] List your articles
384
+ posts list [--limit <n>] [--offset <n>] [--collection <uuid>] [--search <words>] [--mine]
385
+ List your articles
380
386
  posts show <article-id> Show one article
381
387
  posts new --title <t> --collection <uuid> (--body <text> | --file <path|->) [--article-id <uuid>] [--context <text>]
388
+ posts update <article-id> [--title <t>] [--body <html> | --file <path|->] [--status draft|published|archived]
389
+ [--summary <dek>] [--tag <t> ...] [--owner <user-id> ...] [--due <YYYY-MM-DD>] [--author <name>]
390
+ [--author-id <id>] [--slug <s>] [--meta-title <t>] [--meta-description <d>] [--published-url <url>]
391
+ [--template <key|none|inherit>] [--mention off|subtle|woven|inherit] [--direction-gate on|off|inherit]
392
+ [--paper-trail '<json>'] [--metadata-json '<json>']
393
+ Edit a post; only what you pass changes
394
+ posts edit <article-id> --instruction "<what to change>"
395
+ Rewrite the post with AI from an instruction
396
+ posts mentions <article-id> Re-apply the brand-mention & competitor policy
397
+ posts history <article-id> The post's versions, newest first
398
+ posts restore <article-id> <version> Bring a version back as the latest
399
+ posts export <article-id> --format md|pdf [--out <path>]
400
+ The draft as Markdown or PDF
401
+ posts comments <article-id> The post's comments and replies
402
+ posts comments add <article-id> --body <text> [--quote <text> [--prefix <t>] [--suffix <t>]]
403
+ [--kind note|ai_esque] [--scope sentence|document] [--reply-to <comment-id>] [--mention <user-id> ...]
404
+ posts comments update <article-id> <comment-id> [--body <text>] [--resolve | --reopen] [--mention <user-id> ...]
405
+ posts comments delete <article-id> <comment-id>
406
+ posts comments resolve-ai <article-id> <comment-id> Make the change the comment asks for, with AI
382
407
  posts publish <article-id> Publish an article
383
408
  posts unpublish <article-id> Unpublish an article
384
409
  posts delete --id <uuid> [--id <uuid> ...] Permanently delete article(s)
@@ -391,6 +416,10 @@ Reviewers:
391
416
  guest reviewer on every future
392
417
  finished draft (no email sent now)
393
418
  reviewers standing remove <reviewer-id> Stop future auto-invites
419
+ reviewers assigned Posts waiting on your review, across letterbases
420
+ reviewers invite <article-id> --email <e> [--name <n>]
421
+ Invite a guest to review one post
422
+ (emails them a link and a PIN)
394
423
 
395
424
  Collections:
396
425
  collections list List your collections
@@ -20,6 +20,9 @@ export const ARGS = {
20
20
  from_scratch: { flag: "from-scratch", type: "bool" },
21
21
  // --skip finish,metadata: leave those steps out of this run only.
22
22
  skip_steps: { flag: "skip", type: "list" },
23
+ // --source <url> … / --must-include <t> …: what this run's research reads first and covers.
24
+ source_urls: { flag: "source", type: "list" },
25
+ must_include: { flag: "must-include", type: "list" },
23
26
  },
24
27
  };
25
28
 
@@ -2,6 +2,7 @@
2
2
  // Ported from phantomstory-cli's posts.ts — same tool calls, same shape, adapted to
3
3
  // this CLI's flag/positional/io conventions (no commander, no color).
4
4
 
5
+ import { writeFileSync } from "node:fs";
5
6
  import { CliError } from "../client.mjs";
6
7
  import {
7
8
  flagStr,
@@ -13,16 +14,130 @@ import {
13
14
  ok,
14
15
  readBodyInput,
15
16
  argsFromFlags,
17
+ refuseUnknownFlags,
16
18
  } from "./shared.mjs";
17
19
 
20
+ const ARTICLE = { positional: 0, label: "article-id", required: true };
21
+ const COMMENT = { positional: 1, label: "comment-id", required: true };
22
+
23
+ /** `--<name> '<json>'` → a JSON object, or undefined when the flag is not set. */
24
+ function jsonObjectFlag(flags, name) {
25
+ const raw = flagStr(flags[name]);
26
+ if (raw === undefined) return undefined;
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(raw);
30
+ } catch (err) {
31
+ throw new CliError(`--${name} is not valid JSON: ${err.message}`);
32
+ }
33
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
34
+ throw new CliError(`--${name} must be a JSON object`);
35
+ return parsed;
36
+ }
37
+
38
+ /** A per-post override: a value, or `inherit` (null: back to the collection's). */
39
+ function overrideFlag(flags, name, allowed) {
40
+ const raw = flagStr(flags[name]);
41
+ if (raw === undefined) return undefined;
42
+ if (raw === "inherit") return null;
43
+ if (allowed && !allowed.includes(raw))
44
+ throw new CliError(`--${name} must be one of: ${[...allowed, "inherit"].join(", ")}`);
45
+ return raw;
46
+ }
47
+
48
+ /** `--direction-gate on|off|inherit`. */
49
+ function directionGateFlag(flags) {
50
+ const raw = overrideFlag(flags, "direction-gate", ["on", "off"]);
51
+ return raw === undefined || raw === null ? raw : raw === "on";
52
+ }
53
+
18
54
  /** See `argsFromFlags` in shared.mjs; `scripts/parity-check.ts` diffs these keys against the tool. */
19
55
  export const ARGS = {
20
56
  publish_article: {
21
- article_id: { positional: 0, label: "article-id", required: true },
57
+ article_id: ARTICLE,
22
58
  },
23
59
  delete_article: {
24
60
  article_ids: { flag: "id", type: "list", required: true, label: "id" },
25
61
  },
62
+ update_article: {
63
+ article_id: ARTICLE,
64
+ title: "title",
65
+ content: { value: (flags) => readBodyInput(flags) },
66
+ status: { flag: "status", enum: ["draft", "published", "archived"] },
67
+ metadata: { value: (flags) => jsonObjectFlag(flags, "metadata-json") },
68
+ metadataPatch: {
69
+ object: {
70
+ summary: "summary",
71
+ tags: { flag: "tag", type: "list" },
72
+ owners: { flag: "owner", type: "list" },
73
+ due_date: "due",
74
+ author: "author",
75
+ author_id: "author-id",
76
+ slug: "slug",
77
+ meta_title: "meta-title",
78
+ meta_description: "meta-description",
79
+ published_url: "published-url",
80
+ template_key: { value: (flags) => overrideFlag(flags, "template") },
81
+ mention_degree: { value: (flags) => overrideFlag(flags, "mention", ["off", "subtle", "woven"]) },
82
+ direction_gate: { value: (flags) => directionGateFlag(flags) },
83
+ paper_trail: { value: (flags) => jsonObjectFlag(flags, "paper-trail") },
84
+ },
85
+ },
86
+ },
87
+ edit_article_with_prompt: {
88
+ article_id: ARTICLE,
89
+ instruction: { flag: "instruction", required: true },
90
+ },
91
+ run_mention_pass: {
92
+ article_id: ARTICLE,
93
+ },
94
+ get_article_history: {
95
+ article_id: ARTICLE,
96
+ },
97
+ restore_article_version: {
98
+ article_id: ARTICLE,
99
+ // Positional, not `--version`: that is the global "print the CLI version" switch.
100
+ version: {
101
+ value: (_flags, positionals) => {
102
+ const n = flagNum(positionals[1]);
103
+ if (n === undefined) throw new CliError("Missing required <version>");
104
+ return n;
105
+ },
106
+ },
107
+ },
108
+ export_article: {
109
+ article_id: ARTICLE,
110
+ format: { flag: "format", required: true, enum: ["md", "pdf"] },
111
+ },
112
+ list_article_comments: {
113
+ article_id: ARTICLE,
114
+ },
115
+ add_article_comment: {
116
+ article_id: ARTICLE,
117
+ body: { flag: "body", required: true },
118
+ kind: { flag: "kind", enum: ["note", "ai_esque"] },
119
+ scope: { flag: "scope", enum: ["sentence", "document"] },
120
+ parent_id: "reply-to",
121
+ quote: "quote",
122
+ quote_prefix: "prefix",
123
+ quote_suffix: "suffix",
124
+ mentioned_user_ids: { flag: "mention", type: "list" },
125
+ },
126
+ update_article_comment: {
127
+ article_id: ARTICLE,
128
+ comment_id: COMMENT,
129
+ body: "body",
130
+ resolved: { flag: "resolve", type: "bool", negate: "reopen" },
131
+ mentioned_user_ids: { flag: "mention", type: "list" },
132
+ },
133
+ delete_article_comment: {
134
+ article_id: ARTICLE,
135
+ comment_id: COMMENT,
136
+ },
137
+ resolve_comment_with_ai: {
138
+ article_id: ARTICLE,
139
+ comment_id: COMMENT,
140
+ },
26
141
  };
27
142
 
28
143
  export async function cmdPosts(ctx) {
@@ -36,6 +151,20 @@ export async function cmdPosts(ctx) {
36
151
  return postsShow(rest);
37
152
  case "new":
38
153
  return postsNew(rest);
154
+ case "update":
155
+ return postsWrite(rest, "update_article", "Updated.");
156
+ case "edit":
157
+ return postsWrite(rest, "edit_article_with_prompt", "Edited.");
158
+ case "mentions":
159
+ return postsWrite(rest, "run_mention_pass", "Mention pass done.");
160
+ case "history":
161
+ return postsRead(rest, "get_article_history");
162
+ case "restore":
163
+ return postsWrite(rest, "restore_article_version", "Restored as the latest version.");
164
+ case "export":
165
+ return postsExport(rest);
166
+ case "comments":
167
+ return postsComments(rest);
39
168
  case "publish":
40
169
  return postsPublish(rest);
41
170
  case "unpublish":
@@ -45,14 +174,85 @@ export async function cmdPosts(ctx) {
45
174
  return postsDelete(rest);
46
175
  default:
47
176
  throw new CliError(
48
- `Unknown posts subcommand: ${sub ?? "(none)"}. Try: list, show, new, publish, unpublish, delete`
177
+ `Unknown posts subcommand: ${sub ?? "(none)"}. Try: list, show, new, update, edit, mentions, history, restore, export, comments, publish, unpublish, delete`
49
178
  );
50
179
  }
51
180
  }
52
181
 
182
+ // Each tool named once, literally, so scripts/parity-check.ts sees which command calls it.
183
+ const CALL = {
184
+ update_article: (client, args) => client.callTool("update_article", args),
185
+ edit_article_with_prompt: (client, args) => client.callTool("edit_article_with_prompt", args),
186
+ run_mention_pass: (client, args) => client.callTool("run_mention_pass", args),
187
+ get_article_history: (client, args) => client.callTool("get_article_history", args),
188
+ restore_article_version: (client, args) => client.callTool("restore_article_version", args),
189
+ list_article_comments: (client, args) => client.callTool("list_article_comments", args),
190
+ add_article_comment: (client, args) => client.callTool("add_article_comment", args),
191
+ update_article_comment: (client, args) => client.callTool("update_article_comment", args),
192
+ delete_article_comment: (client, args) => client.callTool("delete_article_comment", args),
193
+ resolve_comment_with_ai: (client, args) => client.callTool("resolve_comment_with_ai", args),
194
+ };
195
+
196
+ /** A write: refuse a flag its ARGS map does not declare, then call the tool. */
197
+ async function postsWrite(ctx, tool, done) {
198
+ const { client, flags, io, positionals } = ctx;
199
+ refuseUnknownFlags(ctx, ARGS[tool]);
200
+ const result = await CALL[tool](client, argsFromFlags(ARGS[tool], flags, positionals));
201
+ ok(ctx, done);
202
+ printResult(io, flags, result);
203
+ return 0;
204
+ }
205
+
206
+ async function postsRead(ctx, tool) {
207
+ const { client, flags, io, positionals } = ctx;
208
+ const result = await CALL[tool](client, argsFromFlags(ARGS[tool], flags, positionals));
209
+ printResult(io, flags, result);
210
+ return 0;
211
+ }
212
+
213
+ // `--out <path>` writes the file (a PDF's bytes decoded); without it the result prints.
214
+ async function postsExport(ctx) {
215
+ const { client, flags, io, positionals } = ctx;
216
+ const result = await client.callTool("export_article", argsFromFlags(ARGS.export_article, flags, positionals));
217
+ const out = flagStr(flags.out);
218
+ if (out === undefined) {
219
+ if (!flags.json && result?.encoding === "utf8") io.log(result.content);
220
+ else printResult(io, flags, result);
221
+ return 0;
222
+ }
223
+ const bytes = result.encoding === "base64" ? Buffer.from(result.content, "base64") : result.content;
224
+ try {
225
+ writeFileSync(out, bytes);
226
+ } catch (err) {
227
+ throw new CliError(`Could not write --out ${out}: ${err.message}`);
228
+ }
229
+ ok(ctx, `Wrote ${out}.`);
230
+ return 0;
231
+ }
232
+
233
+ // `posts comments <article-id>` lists; `posts comments add|update|delete|resolve-ai <article-id> …` write.
234
+ async function postsComments(ctx) {
235
+ const verbs = {
236
+ add: ["add_article_comment", "Commented."],
237
+ update: ["update_article_comment", "Updated."],
238
+ delete: ["delete_article_comment", "Deleted."],
239
+ rm: ["delete_article_comment", "Deleted."],
240
+ "resolve-ai": ["resolve_comment_with_ai", "Resolved with AI."],
241
+ };
242
+ const verb = verbs[ctx.positionals[0]];
243
+ if (!verb) return postsRead(ctx, "list_article_comments");
244
+ return postsWrite({ ...ctx, positionals: ctx.positionals.slice(1) }, verb[0], verb[1]);
245
+ }
246
+
53
247
  async function postsList(ctx) {
54
248
  const { client, flags, io } = ctx;
55
- const args = compact({ limit: flagNum(flags.limit), collection_id: flagStr(flags.collection) });
249
+ const args = compact({
250
+ limit: flagNum(flags.limit),
251
+ offset: flagNum(flags.offset),
252
+ collection_id: flagStr(flags.collection),
253
+ search: flagStr(flags.search),
254
+ mine: flags.mine === true ? true : undefined,
255
+ });
56
256
  const result = await client.callTool("list_articles", args);
57
257
  printResult(io, flags, result);
58
258
  return 0;
@@ -5,7 +5,26 @@
5
5
  // Same flag/positional/io conventions as posts.mjs — no commander, no color.
6
6
 
7
7
  import { CliError } from "../client.mjs";
8
- import { flagStr, requireFlag, requirePositional, printResult, compact, ok } from "./shared.mjs";
8
+ import {
9
+ flagStr,
10
+ requireFlag,
11
+ requirePositional,
12
+ printResult,
13
+ compact,
14
+ ok,
15
+ argsFromFlags,
16
+ refuseUnknownFlags,
17
+ } from "./shared.mjs";
18
+
19
+ /** See `argsFromFlags` in shared.mjs; `scripts/parity-check.ts` diffs these keys against the tool. */
20
+ export const ARGS = {
21
+ list_assigned_reviews: {},
22
+ invite_guest_reviewer: {
23
+ article_id: { positional: 0, label: "article-id", required: true },
24
+ email: { flag: "email", required: true },
25
+ name: "name",
26
+ },
27
+ };
9
28
 
10
29
  export async function cmdReviewers(ctx) {
11
30
  const sub = ctx.positionals[0];
@@ -13,11 +32,33 @@ export async function cmdReviewers(ctx) {
13
32
  switch (sub) {
14
33
  case "standing":
15
34
  return cmdStandingReviewers(rest);
35
+ case "invite":
36
+ return inviteGuest(rest);
37
+ case "assigned":
38
+ return assigned(rest);
16
39
  default:
17
- throw new CliError(`Unknown reviewers subcommand: ${sub ?? "(none)"}. Try: standing`);
40
+ throw new CliError(`Unknown reviewers subcommand: ${sub ?? "(none)"}. Try: assigned, standing, invite`);
18
41
  }
19
42
  }
20
43
 
44
+ // `reviewers invite <article-id> --email <e> [--name <n>]` — a guest reviewer on one post.
45
+ async function inviteGuest(ctx) {
46
+ const { client, flags, io, positionals } = ctx;
47
+ refuseUnknownFlags(ctx, ARGS.invite_guest_reviewer);
48
+ const result = await client.callTool(
49
+ "invite_guest_reviewer",
50
+ argsFromFlags(ARGS.invite_guest_reviewer, flags, positionals)
51
+ );
52
+ ok(
53
+ ctx,
54
+ result?.email_sent
55
+ ? `Invited ${flags.email}: the link and PIN are on their way.`
56
+ : `Invited ${flags.email}, but the email could not be sent: share the link and PIN yourself.`
57
+ );
58
+ printResult(io, flags, result);
59
+ return 0;
60
+ }
61
+
21
62
  async function cmdStandingReviewers(ctx) {
22
63
  const sub = ctx.positionals[0];
23
64
  const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
@@ -60,3 +101,11 @@ async function standingRemove(ctx) {
60
101
  printResult(io, flags, result);
61
102
  return 0;
62
103
  }
104
+
105
+ // `reviewers assigned` — the posts waiting on your review, across every letterbase.
106
+ async function assigned(ctx) {
107
+ const { client, flags, io } = ctx;
108
+ const result = await client.callTool("list_assigned_reviews", argsFromFlags(ARGS.list_assigned_reviews, flags));
109
+ printResult(io, flags, result);
110
+ return 0;
111
+ }
package/lib/oauth.mjs CHANGED
@@ -53,7 +53,13 @@ const CALLBACK_HTML_ERROR = `<!doctype html><html><head><title>Letterstory CLI</
53
53
  // redirect_uri sent to /authorize.
54
54
  function startLoopbackServer() {
55
55
  return new Promise((resolve, reject) => {
56
- const server = createServer();
56
+ // A browser sends its whole Cookie jar for "localhost" (cookies aren't
57
+ // port-scoped) to this loopback callback -- years of accumulated dev-server
58
+ // cookies routinely blow past Node's 16KB default `maxHeaderSize`, which
59
+ // Node itself rejects with a 431 before this server's own request handler
60
+ // ever runs. This server only ever reads a handful of short query params,
61
+ // so there's no reason to enforce that default here.
62
+ const server = createServer({ maxHeaderSize: 65536 });
57
63
  let settled = false;
58
64
 
59
65
  const resultPromise = new Promise((resolveResult) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.121.3",
3
+ "version": "0.121.5",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",