@letterstory/cli 0.100.4 → 0.101.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -120,6 +120,7 @@ letterstory phantom config <phantom-id> --configure --dry-run # what re-config
120
120
  letterstory phantom rebuild <phantom-id> # after publishing outside a collection run
121
121
  letterstory phantom diagnostics <phantom-id> # why is my site empty?
122
122
  letterstory phantom balance # sites live / granted, and the cadence cap
123
+ letterstory phantom spread-looks [--apply] # a different look per site; dry run unless --apply
123
124
  letterstory phantom delete <phantom-id> --yes
124
125
  ```
125
126
 
package/lib/cli.mjs CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  cmdAdmin,
53
53
  cmdFeedback,
54
54
  cmdRepo,
55
+ cmdSite,
55
56
  cmdEvals,
56
57
  cmdOfferings,
57
58
  cmdTargets,
@@ -59,13 +60,17 @@ import {
59
60
  } from "./commands.mjs";
60
61
 
61
62
  // Keep in sync with cli/package.json.
62
- export const VERSION = "0.100.4";
63
+ export const VERSION = "0.101.1";
63
64
 
64
65
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
65
66
  // can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
66
67
  // `negate` spelling) in a command's ARGS map must be listed; boolean-flags.test.ts enforces it.
67
68
  export const BOOLEAN_FLAGS = new Set([
68
69
  "json",
70
+ // site tree: include children a fold-by-default family would hide.
71
+ "unfold",
72
+ // site map: do not start an audit for a host that has none.
73
+ "no-audit",
69
74
  // lede library update / lede proposals accept: clear or drop the line's condition.
70
75
  "no-condition",
71
76
  // prospects update: clear the HeyReach LinkedIn-reply marker.
@@ -77,6 +82,7 @@ export const BOOLEAN_FLAGS = new Set([
77
82
  "no-control",
78
83
  "own-beat",
79
84
  "refresh",
85
+ "preview",
80
86
  "accept",
81
87
  "help",
82
88
  "version",
@@ -89,6 +95,8 @@ export const BOOLEAN_FLAGS = new Set([
89
95
  "force",
90
96
  "buy",
91
97
  "rebuild",
98
+ // phantom spread-looks --apply
99
+ "apply",
92
100
  "print-key",
93
101
  "stdin",
94
102
  // `authors generate <id> --with-bios`.
@@ -326,6 +334,7 @@ Phantomstory (a collection Letterstory hosts as an independent publication):
326
334
  [--configure [--dry-run]]
327
335
  The site's file joined to its collection's
328
336
  phantom rebuild <phantom-id> · phantom diagnostics <phantom-id> · phantom balance
337
+ phantom spread-looks [--apply] Give each live site a different look (dry run by default)
329
338
  phantom domain check <domain> · phantom domain buy <phantom-id> <domain> --yes
330
339
  phantom domain connect <phantom-id> <domain>
331
340
  phantom delete <phantom-id> --yes
@@ -1116,14 +1125,31 @@ Insights:
1116
1125
  insights access [--period 14d|30d|90d] [--collection <uuid>]
1117
1126
  Page access: who's reading your phantom sites
1118
1127
 
1128
+ The Site (one model of your whole marketing site, keyed by host and path):
1129
+ site list Mapped hosts, and hosts you already have elsewhere
1130
+ site map <host> [--sitemap <url>] [--max-pages <n>] [--no-audit]
1131
+ Map a host from its sitemap; starts an audit if it has none
1132
+ site tree <host> [path] [--depth <n>] [--unfold] The tree one folder at a time, one item per node
1133
+ site page <host> <path> One page: facts, bindings, raw URLs, open items
1134
+ site observations <host> [path] [--state open|dismissed|resolved] [--tone risk|warn|info]
1135
+ site observe <host> [path] [--max-pages <n>] Jev pass: meta accuracy, title, outdated
1136
+ site dismiss <host> <observation-id> --reason <why> [--decision dismiss|reopen]
1137
+ site families <host> [path] [--state suggested|tracked|dismissed]
1138
+ site decide-family <family-id> [--decision tracked|dismissed] [--reason <why>] [--name <n>]
1139
+ [--include <path>]... [--exclude <path>]...
1140
+ site proposals <host> [path] [--state proposed|accepted|shipped|failed|dismissed]
1141
+ site propose <host> [path] --tool <tool> --input '<json>'|--input-file <p> --label <text> [--why <text>]
1142
+ [--observation <id>]... Propose a registry tool call; input is validated now
1143
+ site decide <proposal-id> --decision accept|dismiss [--reason <why>]
1144
+
1119
1145
  Repo changes (Claude Code edits your repo in a sandbox; the Letterstory GitHub App opens a PR):
1120
1146
  repo repos Repos the Letterstory GitHub App can reach
1121
1147
  repo branches --repo owner/name Branches a change can start from (default first)
1122
1148
  repo start --repo <owner/name> --instructions <text>|--instructions-file <path|->
1123
- [--branch <base>] [--attach <path>]... [--attach-url <name>=<https url>]... [--wait]
1149
+ [--branch <base>] [--attach <path>]... [--attach-url <name>=<https url>]... [--preview] [--wait]
1124
1150
  Start a change (attached files go to the sandbox);
1125
1151
  --wait follows it to the PR
1126
- repo fix-audit --issue <issue-type> [--audit <id>] [--branch <base>] [--wait]
1152
+ repo fix-audit --issue <issue-type> [--audit <id>] [--branch <base>] [--preview] [--wait]
1127
1153
  One PR fixing a site-audit issue on every affected page,
1128
1154
  in the repo verified to build the site
1129
1155
  repo sites [--detect [--force]] Which website each repo builds, and the evidence
@@ -1132,7 +1158,8 @@ Repo changes (Claude Code edits your repo in a sandbox; the Letterstory GitHub A
1132
1158
  repo reset-site --repo <owner/name> --site <host> Back to automatic detection
1133
1159
  repo status <run-id> Phase, spend, PR link and Claude's summary
1134
1160
  repo list [--status running|succeeded|no_changes|failed|cancelled] [--limit n] [--audit <id>]
1135
- repo cancel <run-id> Stop it and delete the sandbox; no PR
1161
+ repo publish <run-id> Make PR on a preview in review
1162
+ repo cancel <run-id> Stop it and delete the sandbox; no PR (Discard a preview)
1136
1163
 
1137
1164
  Feedback (tell us what's broken, missing, unclear, or wanted):
1138
1165
  feedback submit --category bug|missing_feature|unclear|feature_request|praise|other
@@ -1239,6 +1266,7 @@ const CLIENT_COMMANDS = {
1239
1266
  mailers: cmdMailers,
1240
1267
  feedback: cmdFeedback,
1241
1268
  repo: cmdRepo,
1269
+ site: cmdSite,
1242
1270
  evals: cmdEvals,
1243
1271
  visits: cmdVisits,
1244
1272
  // Platform-scoped (staff key) — the only commands that act BEFORE an org exists.
@@ -2839,6 +2839,8 @@ async function cmdAdminCrmCapture(ctx, paths) {
2839
2839
  * [--ask …] [--linkedin …] [--email …] [--lookup]
2840
2840
  * outreach lookup "Jane Doe from Acme" [shot.png …] prefilled from the web; writes nothing
2841
2841
  * outreach relookup [--missing linkedin] the web lookup again for whoever lacks it
2842
+ * outreach drafts [--kind ask|thanks] [--owner …] drafts or thank-yous for everyone without one
2843
+ * outreach engagement --outcome upvoted [shot.png] who upvoted, matched by exact name
2842
2844
  * outreach customers [--dry-run] every customer's primary contact onto the list
2843
2845
  * outreach catch-up [--dry-run] handle #launch-outreach posts the bot missed
2844
2846
  * outreach set <id> [--reached|--not-reached] [--response "…" --tone positive]
@@ -2968,6 +2970,67 @@ async function cmdAdminCrmOutreach(ctx, args) {
2968
2970
  }
2969
2971
  return io.log(`looked up ${total}`);
2970
2972
  }
2973
+ case "drafts": {
2974
+ // admin crm outreach drafts [--kind ask|thanks] [--owner Neha] — write drafts (or
2975
+ // thank-yous) for everyone without one, in rounds; a person's own text is kept.
2976
+ const kind = flags.kind === undefined ? "ask" : flagStr(flags.kind);
2977
+ if (!["ask", "thanks"].includes(kind))
2978
+ throw new CliError("Usage: admin crm outreach drafts [--kind ask|thanks] [--owner …] [--list …]");
2979
+ const owner = flagStr(flags.owner)?.toLowerCase();
2980
+ const r0 = await staffRequest(
2981
+ ctx,
2982
+ "GET",
2983
+ `${CRM}/outreach${list ? `?list=${encodeURIComponent(list)}` : ""}`
2984
+ );
2985
+ const helped = ["shared", "upvoted", "commented", "introduced"];
2986
+ let pending = r0.people
2987
+ .filter((p) => !owner || (p.ownerName ?? "").toLowerCase().includes(owner))
2988
+ .filter((p) => !p.noContactReason)
2989
+ .filter((p) =>
2990
+ kind === "thanks" ? !p.thanksMessage && p.outcomes.some((o) => helped.includes(o)) : !p.draftMessage
2991
+ )
2992
+ .map((p) => p.id);
2993
+ let written = 0;
2994
+ for (let i = 0; i < 60 && pending.length; i++) {
2995
+ const r = await staffPost(
2996
+ ctx,
2997
+ `${CRM}/outreach/drafts`,
2998
+ compact({ list, kind, ids: pending.slice(0, 500) })
2999
+ );
3000
+ for (const p of r.written) io.log(`wrote ${p.name}`);
3001
+ const done = new Set([...r.written.map((p) => p.id), ...r.skipped.map((x) => x.id)]);
3002
+ written += r.written.length;
3003
+ const before = pending.length;
3004
+ pending = pending.filter((id) => !done.has(id));
3005
+ if (pending.length === before) break;
3006
+ }
3007
+ return io.log(`wrote ${written} ${kind === "thanks" ? "thank-yous" : "drafts"}`);
3008
+ }
3009
+ case "engagement": {
3010
+ // admin crm outreach engagement --outcome upvoted [shot.png …] [--text "Jane Doe, Tom Ray"]
3011
+ // — who upvoted / commented / shared, matched to the list by exact full name.
3012
+ const outcome = flagStr(flags.outcome) ?? "upvoted";
3013
+ const images = rest
3014
+ .filter((a) => CRM_CAPTURE_TYPES[a.split(".").pop()?.toLowerCase() ?? ""])
3015
+ .map((path) => ({
3016
+ media_type: CRM_CAPTURE_TYPES[path.split(".").pop().toLowerCase()],
3017
+ data: readFileSync(path).toString("base64"),
3018
+ }));
3019
+ const text = flagStr(flags.text);
3020
+ if (!images.length && !text)
3021
+ throw new CliError(
3022
+ 'Usage: admin crm outreach engagement --outcome upvoted [shot.png …] [--text "Jane Doe, Tom Ray"]'
3023
+ );
3024
+ const r = await staffPost(
3025
+ ctx,
3026
+ `${CRM}/outreach/engagement`,
3027
+ compact({ list, outcome, text, images: images.length ? images : undefined })
3028
+ );
3029
+ if (json) return printResult(io, flags, r);
3030
+ io.log(`marked ${r.matched.length}: ${r.matched.map((p) => p.name).join(", ") || "nobody"}`);
3031
+ if (r.unmatched.length) io.log(`not on the list: ${r.unmatched.join(", ")}`);
3032
+ return;
3033
+ }
2971
3034
  case "customers": {
2972
3035
  // admin crm outreach customers [--dry-run] — every customer's primary contact onto the list.
2973
3036
  const r = await staffPost(
@@ -3012,7 +3075,7 @@ async function cmdAdminCrmOutreach(ctx, args) {
3012
3075
  }
3013
3076
  default:
3014
3077
  throw new CliError(
3015
- "Usage: admin crm outreach [list|add|lookup|set|relookup|customers|catch-up] … (see --help)"
3078
+ "Usage: admin crm outreach [list|add|lookup|set|relookup|drafts|engagement|customers|catch-up] … (see --help)"
3016
3079
  );
3017
3080
  }
3018
3081
  }
@@ -104,6 +104,7 @@ export const ARGS = {
104
104
  rebuild_phantom: { phantom_id: { positional: 0, label: "phantom-id", required: true } },
105
105
  get_phantom_diagnostics: { phantom_id: { positional: 0, label: "phantom-id", required: true } },
106
106
  get_phantom_balance: {},
107
+ spread_phantom_looks: { apply: { flag: "apply", type: "bool" } },
107
108
  check_domain: {
108
109
  domain: { value: (flags, positionals) => stripScheme(requirePositional(positionals, 0, "domain")) },
109
110
  },
@@ -149,11 +150,13 @@ export async function cmdPhantom(ctx) {
149
150
  return call(rest, "get_phantom_diagnostics");
150
151
  case "balance":
151
152
  return call(rest, "get_phantom_balance");
153
+ case "spread-looks":
154
+ return call(rest, "spread_phantom_looks");
152
155
  case "domain":
153
156
  return phantomDomain(rest);
154
157
  default:
155
158
  throw new CliError(
156
- `Unknown phantom subcommand: ${sub ?? "(none)"}. Try: list, get, new, config, visitors, delete, rebuild, diagnostics, balance, domain`
159
+ `Unknown phantom subcommand: ${sub ?? "(none)"}. Try: list, get, new, config, visitors, delete, rebuild, diagnostics, balance, spread-looks, domain`
157
160
  );
158
161
  }
159
162
  }
@@ -2,6 +2,7 @@
2
2
  // pull request by the Letterstory GitHub App (src/lib/repo-changes). One subcommand per tool:
3
3
  // repo start → start_repo_change repo status → get_repo_change
4
4
  // repo list → list_repo_changes repo cancel → cancel_repo_change
5
+ // repo publish → publish_repo_change (Make PR on a preview)
5
6
  // repo repos → list_github_app_repos repo branches → list_repo_branches
6
7
 
7
8
  import { readFileSync } from "node:fs";
@@ -36,6 +37,7 @@ const ARGS = {
36
37
  instructions: { value: (flags) => readTextInput(flags, "instructions", "instructions-file") },
37
38
  // --attach <path> (uploaded first) and --attach-url <name>=<https url>, both repeatable.
38
39
  files: { value: (_flags, _positionals, extra) => (extra.files?.length ? extra.files : undefined) },
40
+ preview: { flag: "preview", type: "bool" },
39
41
  },
40
42
  create_repo_change_upload: { file_names: { internal: "the basenames of the --attach files" } },
41
43
  list_repo_sites: {},
@@ -55,9 +57,11 @@ const ARGS = {
55
57
  issue_type: { flag: "issue", required: true },
56
58
  audit_id: "audit",
57
59
  base_branch: "branch",
60
+ preview: { flag: "preview", type: "bool" },
58
61
  },
59
62
  get_repo_change: { run_id: { positional: 0, required: true, label: "run-id" } },
60
63
  cancel_repo_change: { run_id: { positional: 0, required: true, label: "run-id" } },
64
+ publish_repo_change: { run_id: { positional: 0, required: true, label: "run-id" } },
61
65
  list_repo_changes: {
62
66
  status: { flag: "status", enum: ["running", "succeeded", "no_changes", "failed", "cancelled"] },
63
67
  limit: { flag: "limit", type: "num" },
@@ -76,6 +80,9 @@ function describe(run) {
76
80
  const lines = [
77
81
  `${run.id} ${run.repo}@${run.baseBranch} ${run.status} (${run.phase}) $${run.costUsd.toFixed(2)}`,
78
82
  ];
83
+ if (run.preview?.url) lines.push(` Preview: ${run.preview.url}`);
84
+ if (run.preview?.state === "failed") lines.push(` Preview failed: ${run.preview.detail ?? ""}`);
85
+ if (run.phase === "review") lines.push(` Make PR: letterstory repo publish ${run.id}`);
79
86
  if (run.prUrl) lines.push(` PR: ${run.prUrl} (${run.prState ?? "state unknown"})`);
80
87
  if (run.errorCode) lines.push(` Error (${run.errorCode}): ${run.errorDetail ?? ""}`);
81
88
  if (run.summary) lines.push(...run.summary.split("\n").map((l) => ` ${l}`));
@@ -102,13 +109,15 @@ export async function cmdRepo(ctx) {
102
109
  return repoList(rest);
103
110
  case "cancel":
104
111
  return repoCancel(rest);
112
+ case "publish":
113
+ return repoPublish(rest);
105
114
  case "repos":
106
115
  return repoRepos(rest);
107
116
  case "branches":
108
117
  return repoBranches(rest);
109
118
  default:
110
119
  throw new CliError(
111
- `Unknown repo subcommand: ${sub ?? "(none)"}. Try: start, fix-audit, sites, set-site, reset-site, status, list, cancel, repos, branches`
120
+ `Unknown repo subcommand: ${sub ?? "(none)"}. Try: start, fix-audit, sites, set-site, reset-site, status, list, publish, cancel, repos, branches`
112
121
  );
113
122
  }
114
123
  }
@@ -162,6 +171,7 @@ async function repoStart(ctx) {
162
171
  "instructions-file",
163
172
  "attach",
164
173
  "attach-url",
174
+ "preview",
165
175
  "wait",
166
176
  ]);
167
177
  const files = [
@@ -183,7 +193,7 @@ async function repoStart(ctx) {
183
193
  async () => {
184
194
  const polled = await client.callTool("get_repo_change", { run_id: started.run.id });
185
195
  ok(ctx, `… ${polled.run.status} (${polled.run.phase})`);
186
- return { done: TERMINAL.has(polled.run.status), value: polled.run };
196
+ return { done: settledFor(polled.run), value: polled.run };
187
197
  },
188
198
  { intervalMs: 30000, ceilingMs: 60 * 60 * 1000 }
189
199
  );
@@ -192,6 +202,11 @@ async function repoStart(ctx) {
192
202
  return value.status === "failed" ? 1 : 0;
193
203
  }
194
204
 
205
+ /** --wait stops at a terminal state, or at review: a preview waits on a person, not on time. */
206
+ function settledFor(run) {
207
+ return TERMINAL.has(run.status) || run.phase === "review";
208
+ }
209
+
195
210
  async function followRun(ctx, run) {
196
211
  const { client, flags, io } = ctx;
197
212
  const { settled, value } = await followJob(
@@ -199,7 +214,7 @@ async function followRun(ctx, run) {
199
214
  async () => {
200
215
  const polled = await client.callTool("get_repo_change", { run_id: run.id });
201
216
  ok(ctx, `… ${polled.run.status} (${polled.run.phase})`);
202
- return { done: TERMINAL.has(polled.run.status), value: polled.run };
217
+ return { done: settledFor(polled.run), value: polled.run };
203
218
  },
204
219
  { intervalMs: 30000, ceilingMs: 60 * 60 * 1000 }
205
220
  );
@@ -211,7 +226,7 @@ async function followRun(ctx, run) {
211
226
  /** `repo fix-audit --issue <type> [--audit <id>] [--wait]`: one audit issue → one PR, in the repo verified to build the site. */
212
227
  async function repoFixAudit(ctx) {
213
228
  const { client, flags, io } = ctx;
214
- refuseUnknownFlags(ctx, {}, ["issue", "audit", "branch", "wait"]);
229
+ refuseUnknownFlags(ctx, {}, ["issue", "audit", "branch", "preview", "wait"]);
215
230
  const result = await client.callTool(
216
231
  "fix_audit_issue",
217
232
  argsFromFlags(ARGS.fix_audit_issue, flags, ctx.positionals)
@@ -297,6 +312,18 @@ async function repoCancel(ctx) {
297
312
  return 0;
298
313
  }
299
314
 
315
+ /** `repo publish <run-id>`: Make PR on a preview in review. */
316
+ async function repoPublish(ctx) {
317
+ const { client, flags, io } = ctx;
318
+ const result = await client.callTool(
319
+ "publish_repo_change",
320
+ argsFromFlags(ARGS.publish_repo_change, flags, ctx.positionals)
321
+ );
322
+ ok(ctx, result.message);
323
+ printResult(io, flags, result.run, describe);
324
+ return 0;
325
+ }
326
+
300
327
  async function repoRepos(ctx) {
301
328
  const { client, flags, io } = ctx;
302
329
  const result = await client.callTool("list_github_app_repos", argsFromFlags(ARGS.list_github_app_repos, flags));
@@ -0,0 +1,341 @@
1
+ // `site` — The Site: one model of the org's whole marketing site keyed by host and path
2
+ // (src/lib/site). One subcommand per tool:
3
+ // site list → list_sites site map <host> → map_site
4
+ // site tree <host> [path] → get_site_tree site page <host> <path> → get_site_page
5
+ // site observations <host> → list_site_observations site observe <host> → observe_site
6
+ // site dismiss <host> <id> → dismiss_site_observation
7
+ // site families <host> → list_site_families site decide-family <id> → decide_site_family
8
+ // site proposals <host> → list_site_proposals site propose <host> → create_site_proposal
9
+ // site decide <proposal> → decide_site_proposal
10
+
11
+ import { CliError } from "../client.mjs";
12
+ import { argsFromFlags, flagList, flagStr, ok, printResult, readTextInput, refuseUnknownFlags } from "./shared.mjs";
13
+
14
+ /** --include <path>… / --exclude <path>… (+ --reason) → decide_site_family members. */
15
+ function familyMembers(flags) {
16
+ const include = flagList(flags.include).map((path) => ({ path, included: true }));
17
+ const reason = flagStr(flags.reason);
18
+ const exclude = flagList(flags.exclude).map((path) => ({ path, included: false, reason }));
19
+ const members = [...include, ...exclude];
20
+ return members.length ? members : undefined;
21
+ }
22
+
23
+ /** --input '<json>' or --input-file <path|-> → the proposal's tool input. */
24
+ function proposalInput(flags) {
25
+ const raw = readTextInput(flags, "input", "input-file");
26
+ if (raw === undefined) return undefined;
27
+ try {
28
+ const parsed = JSON.parse(raw);
29
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
30
+ return parsed;
31
+ } catch (err) {
32
+ throw new CliError(`--input must be a JSON object: ${err.message}`);
33
+ }
34
+ }
35
+
36
+ const ARGS = {
37
+ list_sites: {},
38
+ map_site: {
39
+ host: { positional: 0, label: "host" },
40
+ url: "url",
41
+ sitemap_url: "sitemap",
42
+ max_pages: { flag: "max-pages", type: "num" },
43
+ audit: { flag: "no-audit", type: "bool", invert: true },
44
+ },
45
+ get_site_tree: {
46
+ host: { positional: 0, required: true, label: "host" },
47
+ path: { positional: 1, label: "path" },
48
+ depth: { flag: "depth", type: "num" },
49
+ unfold: { flag: "unfold", type: "bool" },
50
+ },
51
+ get_site_page: {
52
+ host: { positional: 0, required: true, label: "host" },
53
+ path: { positional: 1, required: true, label: "path" },
54
+ },
55
+ list_site_observations: {
56
+ host: { positional: 0, required: true, label: "host" },
57
+ path: { positional: 1, label: "path" },
58
+ status: { flag: "state", enum: ["open", "dismissed", "resolved"] },
59
+ tone: { flag: "tone", enum: ["ok", "warn", "risk", "info"] },
60
+ source: "source",
61
+ limit: { flag: "limit", type: "num" },
62
+ },
63
+ observe_site: {
64
+ host: { positional: 0, required: true, label: "host" },
65
+ path: { positional: 1, label: "path" },
66
+ max_pages: { flag: "max-pages", type: "num" },
67
+ },
68
+ dismiss_site_observation: {
69
+ host: { positional: 0, required: true, label: "host" },
70
+ observation_id: { positional: 1, required: true, label: "observation-id" },
71
+ decision: { flag: "decision", enum: ["dismiss", "reopen"] },
72
+ reason: "reason",
73
+ },
74
+ list_site_families: {
75
+ host: { positional: 0, required: true, label: "host" },
76
+ status: { flag: "state", enum: ["suggested", "tracked", "dismissed"] },
77
+ path: { positional: 1, label: "path" },
78
+ },
79
+ decide_site_family: {
80
+ family_id: { positional: 0, required: true, label: "family-id" },
81
+ decision: { flag: "decision", enum: ["tracked", "dismissed", "suggested"] },
82
+ reason: "reason",
83
+ name: "name",
84
+ members: { value: (flags) => familyMembers(flags) },
85
+ },
86
+ list_site_proposals: {
87
+ host: { positional: 0, required: true, label: "host" },
88
+ path: { positional: 1, label: "path" },
89
+ status: { flag: "state", enum: ["proposed", "accepted", "shipped", "failed", "dismissed"] },
90
+ limit: { flag: "limit", type: "num" },
91
+ },
92
+ create_site_proposal: {
93
+ host: { positional: 0, required: true, label: "host" },
94
+ path: { positional: 1, label: "path" },
95
+ tool: { flag: "tool", required: true },
96
+ input: { value: (flags) => proposalInput(flags) },
97
+ label: { flag: "label", required: true },
98
+ rationale: "why",
99
+ observation_ids: { flag: "observation", type: "list" },
100
+ },
101
+ decide_site_proposal: {
102
+ proposal_id: { positional: 0, required: true, label: "proposal-id" },
103
+ decision: { flag: "decision", required: true, enum: ["accept", "dismiss"] },
104
+ reason: "reason",
105
+ },
106
+ };
107
+
108
+ export async function cmdSite(ctx) {
109
+ const sub = ctx.positionals[0];
110
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
111
+ switch (sub) {
112
+ case "list":
113
+ return siteList(rest);
114
+ case "map":
115
+ return siteMap(rest);
116
+ case "tree":
117
+ return siteTree(rest);
118
+ case "page":
119
+ return sitePage(rest);
120
+ case "observations":
121
+ return siteObservations(rest);
122
+ case "observe":
123
+ return siteObserve(rest);
124
+ case "dismiss":
125
+ return siteDismiss(rest);
126
+ case "families":
127
+ return siteFamilies(rest);
128
+ case "decide-family":
129
+ return siteDecideFamily(rest);
130
+ case "proposals":
131
+ return siteProposals(rest);
132
+ case "propose":
133
+ return sitePropose(rest);
134
+ case "decide":
135
+ return siteDecide(rest);
136
+ default:
137
+ throw new CliError(
138
+ `Unknown site subcommand: ${sub ?? "(none)"}. Try: list, map, tree, page, observations, observe, dismiss, families, decide-family, proposals, propose, decide`
139
+ );
140
+ }
141
+ }
142
+
143
+ const GLYPH = { risk: "!!", warn: "!", info: "·", ok: " " };
144
+
145
+ function describeNode(node, indent = "") {
146
+ const glyph = node.item ? (GLYPH[node.item.tone] ?? "·") : " ";
147
+ const line = node.item
148
+ ? `${node.item.summary}${node.more ? ` and ${node.more} more` : ""}${node.item.action ? ` → ${node.item.action.label}` : ""}`
149
+ : "";
150
+ const counts = node.hasChildren ? ` (${node.rollup.pages} pages)` : "";
151
+ const out = [`${indent}${glyph.padEnd(2)} ${node.name}${counts}${line ? ` ${line}` : ""}`];
152
+ for (const child of node.children ?? []) out.push(describeNode(child, `${indent} `));
153
+ return out.join("\n");
154
+ }
155
+
156
+ async function siteList(ctx) {
157
+ const { client, flags, io } = ctx;
158
+ const result = await client.callTool("list_sites", argsFromFlags(ARGS.list_sites, flags, ctx.positionals));
159
+ printResult(io, flags, result, (r) => {
160
+ const lines = r.sites.map(
161
+ (s) =>
162
+ `${s.host} ${s.mapStatus} ${s.pagesTotal} pages${s.mapTruncated ? " (truncated)" : ""}${s.mappedAt ? ` mapped ${s.mappedAt.slice(0, 10)}` : ""}`
163
+ );
164
+ if (r.suggestions.length) {
165
+ lines.push("Not yet mapped:");
166
+ for (const s of r.suggestions) lines.push(` ${s.host} (${s.from.join(", ")})`);
167
+ }
168
+ return lines.join("\n") || "No sites yet. Map one with: letterstory site map <host>";
169
+ });
170
+ return 0;
171
+ }
172
+
173
+ async function siteMap(ctx) {
174
+ const { client, flags, io } = ctx;
175
+ refuseUnknownFlags(ctx, ARGS.map_site);
176
+ const args = argsFromFlags(ARGS.map_site, flags, ctx.positionals);
177
+ if (!args.host && !args.url && !args.sitemap_url) throw new CliError("Give a <host>, --url or --sitemap");
178
+ const result = await client.callTool("map_site", args);
179
+ ok(ctx, result.message);
180
+ printResult(
181
+ io,
182
+ flags,
183
+ result.summary,
184
+ (s) =>
185
+ `${s.pages} pages, ${s.fromAuditOnly} crawl-only, ${s.aliases} aliases, ${s.gone} gone; families +${s.families.created}`
186
+ );
187
+ return 0;
188
+ }
189
+
190
+ async function siteTree(ctx) {
191
+ const { client, flags, io } = ctx;
192
+ refuseUnknownFlags(ctx, ARGS.get_site_tree);
193
+ const result = await client.callTool("get_site_tree", argsFromFlags(ARGS.get_site_tree, flags, ctx.positionals));
194
+ printResult(io, flags, result, (r) => describeNode(r.root));
195
+ return 0;
196
+ }
197
+
198
+ async function sitePage(ctx) {
199
+ const { client, flags, io } = ctx;
200
+ refuseUnknownFlags(ctx, ARGS.get_site_page);
201
+ const result = await client.callTool("get_site_page", argsFromFlags(ARGS.get_site_page, flags, ctx.positionals));
202
+ printResult(io, flags, result.page, (p) => {
203
+ const lines = [describeNode(p.node)];
204
+ for (const item of p.items)
205
+ lines.push(
206
+ ` ${GLYPH[item.tone]} ${item.summary}${item.action ? ` → ${item.action.label}` : ""} [${item.id}]`
207
+ );
208
+ return lines.join("\n");
209
+ });
210
+ return 0;
211
+ }
212
+
213
+ async function siteObservations(ctx) {
214
+ const { client, flags, io } = ctx;
215
+ refuseUnknownFlags(ctx, ARGS.list_site_observations);
216
+ const result = await client.callTool(
217
+ "list_site_observations",
218
+ argsFromFlags(ARGS.list_site_observations, flags, ctx.positionals)
219
+ );
220
+ printResult(
221
+ io,
222
+ flags,
223
+ result,
224
+ (r) =>
225
+ r.observations
226
+ .map((o) => `${GLYPH[o.tone].padEnd(2)} ${o.path} ${o.summary} [${o.status}] ${o.id}`)
227
+ .join("\n") || "Nothing noticed."
228
+ );
229
+ return 0;
230
+ }
231
+
232
+ async function siteObserve(ctx) {
233
+ const { client, flags, io } = ctx;
234
+ refuseUnknownFlags(ctx, ARGS.observe_site);
235
+ const result = await client.callTool("observe_site", argsFromFlags(ARGS.observe_site, flags, ctx.positionals));
236
+ ok(ctx, result.message);
237
+ printResult(io, flags, result.summary, () => "");
238
+ return 0;
239
+ }
240
+
241
+ async function siteDismiss(ctx) {
242
+ const { client, flags, io } = ctx;
243
+ refuseUnknownFlags(ctx, ARGS.dismiss_site_observation);
244
+ const result = await client.callTool(
245
+ "dismiss_site_observation",
246
+ argsFromFlags(ARGS.dismiss_site_observation, flags, ctx.positionals)
247
+ );
248
+ printResult(
249
+ io,
250
+ flags,
251
+ result.observation,
252
+ (o) => `${o.path} ${o.summary} [${o.status}${o.statusReason ? `: ${o.statusReason}` : ""}]`
253
+ );
254
+ return 0;
255
+ }
256
+
257
+ async function siteFamilies(ctx) {
258
+ const { client, flags, io } = ctx;
259
+ refuseUnknownFlags(ctx, ARGS.list_site_families);
260
+ const result = await client.callTool(
261
+ "list_site_families",
262
+ argsFromFlags(ARGS.list_site_families, flags, ctx.positionals)
263
+ );
264
+ printResult(
265
+ io,
266
+ flags,
267
+ result,
268
+ (r) =>
269
+ r.families
270
+ .map((f) => {
271
+ const members = f.members.filter((m) => m.included);
272
+ return `${f.id} ${f.name} [${f.status}] ${members.length} members (${f.source})`;
273
+ })
274
+ .join("\n") || "No families yet."
275
+ );
276
+ return 0;
277
+ }
278
+
279
+ async function siteDecideFamily(ctx) {
280
+ const { client, flags, io } = ctx;
281
+ refuseUnknownFlags(ctx, ARGS.decide_site_family, ["include", "exclude"]);
282
+ const result = await client.callTool(
283
+ "decide_site_family",
284
+ argsFromFlags(ARGS.decide_site_family, flags, ctx.positionals)
285
+ );
286
+ printResult(
287
+ io,
288
+ flags,
289
+ result.family,
290
+ (f) => `${f.name} [${f.status}] ${f.members.filter((m) => m.included).length} members`
291
+ );
292
+ return 0;
293
+ }
294
+
295
+ async function siteProposals(ctx) {
296
+ const { client, flags, io } = ctx;
297
+ refuseUnknownFlags(ctx, ARGS.list_site_proposals);
298
+ const result = await client.callTool(
299
+ "list_site_proposals",
300
+ argsFromFlags(ARGS.list_site_proposals, flags, ctx.positionals)
301
+ );
302
+ printResult(
303
+ io,
304
+ flags,
305
+ result,
306
+ (r) =>
307
+ r.proposals
308
+ .map((p) => `${p.id} ${p.path} ${p.label} (${p.tool} via ${p.channel}) [${p.status}]`)
309
+ .join("\n") || "No proposals yet."
310
+ );
311
+ return 0;
312
+ }
313
+
314
+ async function sitePropose(ctx) {
315
+ const { client, flags, io } = ctx;
316
+ refuseUnknownFlags(ctx, ARGS.create_site_proposal, ["input", "input-file"]);
317
+ const args = argsFromFlags(ARGS.create_site_proposal, flags, ctx.positionals);
318
+ if (!args.input) throw new CliError("Missing --input '<json>' (or --input-file <path|->)");
319
+ const result = await client.callTool("create_site_proposal", args);
320
+ printResult(
321
+ io,
322
+ flags,
323
+ result.proposal,
324
+ (p) => `${p.id} ${p.path} ${p.label} (${p.tool} via ${p.channel}) [${p.status}]`
325
+ );
326
+ return 0;
327
+ }
328
+
329
+ async function siteDecide(ctx) {
330
+ const { client, flags, io } = ctx;
331
+ refuseUnknownFlags(ctx, ARGS.decide_site_proposal);
332
+ const result = await client.callTool(
333
+ "decide_site_proposal",
334
+ argsFromFlags(ARGS.decide_site_proposal, flags, ctx.positionals)
335
+ );
336
+ ok(ctx, result.message);
337
+ printResult(io, flags, result.proposal, (p) => `${p.id} ${p.label} [${p.status}]`);
338
+ return 0;
339
+ }
340
+
341
+ export { ARGS };
package/lib/commands.mjs CHANGED
@@ -41,5 +41,6 @@ export * from "./commands/targets.mjs";
41
41
  export * from "./commands/admin.mjs";
42
42
  export * from "./commands/feedback.mjs";
43
43
  export * from "./commands/repo.mjs";
44
+ export * from "./commands/site.mjs";
44
45
  export * from "./commands/evals.mjs";
45
46
  export * from "./commands/visits.mjs";
package/lib/documents.mjs CHANGED
@@ -81,6 +81,8 @@ export const JSON_ARG_FLAG_PAIRS = [
81
81
  ["accept-plan-json", "accept-plan"],
82
82
  // A prospects CSV, parsed and header-mapped server-side (outbound.mjs prospects import).
83
83
  ["csv", "csv-file"],
84
+ // A site proposal's tool input, parsed as JSON (site.mjs proposalInput).
85
+ ["input", "input-file"],
84
86
  ];
85
87
 
86
88
  /** Lower-case extension without the dot. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.100.4",
3
+ "version": "0.101.1",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",