@letterstory/cli 0.100.5 → 0.102.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.5";
63
+ export const VERSION = "0.102.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.
@@ -90,6 +95,8 @@ export const BOOLEAN_FLAGS = new Set([
90
95
  "force",
91
96
  "buy",
92
97
  "rebuild",
98
+ // phantom spread-looks --apply
99
+ "apply",
93
100
  "print-key",
94
101
  "stdin",
95
102
  // `authors generate <id> --with-bios`.
@@ -327,6 +334,7 @@ Phantomstory (a collection Letterstory hosts as an independent publication):
327
334
  [--configure [--dry-run]]
328
335
  The site's file joined to its collection's
329
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)
330
338
  phantom domain check <domain> · phantom domain buy <phantom-id> <domain> --yes
331
339
  phantom domain connect <phantom-id> <domain>
332
340
  phantom delete <phantom-id> --yes
@@ -392,7 +400,7 @@ Flows:
392
400
 
393
401
  Connectors:
394
402
  connectors list List external publish destinations
395
- connectors publish <article-id> --to <connector> --target <uuid> [--flow-run <uuid>]
403
+ connectors publish <article-id> --to <connector> --target <uuid>
396
404
  [--confirm-missing-target] re-publish even though
397
405
  the item was deleted/unpublished on the provider
398
406
  connectors status --connector <connector> --publish-id <id>
@@ -1117,6 +1125,23 @@ Insights:
1117
1125
  insights access [--period 14d|30d|90d] [--collection <uuid>]
1118
1126
  Page access: who's reading your phantom sites
1119
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
+
1120
1145
  Repo changes (Claude Code edits your repo in a sandbox; the Letterstory GitHub App opens a PR):
1121
1146
  repo repos Repos the Letterstory GitHub App can reach
1122
1147
  repo branches --repo owner/name Branches a change can start from (default first)
@@ -1127,12 +1152,16 @@ Repo changes (Claude Code edits your repo in a sandbox; the Letterstory GitHub A
1127
1152
  repo fix-audit --issue <issue-type> [--audit <id>] [--branch <base>] [--preview] [--wait]
1128
1153
  One PR fixing a site-audit issue on every affected page,
1129
1154
  in the repo verified to build the site
1155
+ repo install-letterprove --repo <owner/name> [--branch <base>] [--wait]
1156
+ One PR adding Letterprove's script and identify calls
1157
+ (pick the repo where users sign in)
1130
1158
  repo sites [--detect [--force]] Which website each repo builds, and the evidence
1131
1159
  repo set-site --repo <owner/name> --site <host> [--not]
1132
1160
  Say which site a repo builds (--not: correct a wrong label)
1133
1161
  repo reset-site --repo <owner/name> --site <host> Back to automatic detection
1134
1162
  repo status <run-id> Phase, spend, PR link and Claude's summary
1135
1163
  repo list [--status running|succeeded|no_changes|failed|cancelled] [--limit n] [--audit <id>]
1164
+ [--source site_audit_issue|site_quick_check|letterprove_install]
1136
1165
  repo publish <run-id> Make PR on a preview in review
1137
1166
  repo cancel <run-id> Stop it and delete the sandbox; no PR (Discard a preview)
1138
1167
 
@@ -1241,6 +1270,7 @@ const CLIENT_COMMANDS = {
1241
1270
  mailers: cmdMailers,
1242
1271
  feedback: cmdFeedback,
1243
1272
  repo: cmdRepo,
1273
+ site: cmdSite,
1244
1274
  evals: cmdEvals,
1245
1275
  visits: cmdVisits,
1246
1276
  // 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
  }
@@ -16,7 +16,6 @@ export const ARGS = {
16
16
  article_id: { positional: 0, label: "article-id", required: true },
17
17
  connector: { flag: "to", required: true, enum: PUBLISH_CONNECTORS },
18
18
  target_id: { flag: "target", required: true },
19
- flow_run_id: "flow-run",
20
19
  // A re-publish whose external item was deleted or unpublished in the client's CMS
21
20
  // is refused, and the refusal explains itself. --confirm-missing-target is the
22
21
  // answer to that specific question: it re-creates / re-publishes the item there.
@@ -1,6 +1,6 @@
1
- // `flows` — manage the flow_run.completed completion webhook. list/run/status
2
- // (Enhance flow authoring + running) are retired along with the rest of that UI;
3
- // see docs/architecture/enhance-flows-retirement.md for the removal. get_webhook/set_webhook are untouched —
1
+ // `flows webhook` — manage the API key's webhook destination (article.published, article.finished).
2
+ // list/run/status (Enhance flow authoring + running) are retired and the flow tables dropped;
3
+ // see docs/architecture/enhance-flows-retirement.md. get_webhook/set_webhook are untouched —
4
4
  // they configure a webhook on the API key, not an Enhance flow.
5
5
 
6
6
  import { CliError } from "../client.mjs";
@@ -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
  }
@@ -4,6 +4,7 @@
4
4
  // repo list → list_repo_changes repo cancel → cancel_repo_change
5
5
  // repo publish → publish_repo_change (Make PR on a preview)
6
6
  // repo repos → list_github_app_repos repo branches → list_repo_branches
7
+ // repo install-letterprove → letterprove_open_install_pr
7
8
 
8
9
  import { readFileSync } from "node:fs";
9
10
  import { basename } from "node:path";
@@ -59,6 +60,11 @@ const ARGS = {
59
60
  base_branch: "branch",
60
61
  preview: { flag: "preview", type: "bool" },
61
62
  },
63
+ letterprove_open_install_pr: {
64
+ repo_owner: { value: (flags) => splitRepo(flags).owner },
65
+ repo_name: { value: (flags) => splitRepo(flags).name },
66
+ base_branch: "branch",
67
+ },
62
68
  get_repo_change: { run_id: { positional: 0, required: true, label: "run-id" } },
63
69
  cancel_repo_change: { run_id: { positional: 0, required: true, label: "run-id" } },
64
70
  publish_repo_change: { run_id: { positional: 0, required: true, label: "run-id" } },
@@ -66,6 +72,7 @@ const ARGS = {
66
72
  status: { flag: "status", enum: ["running", "succeeded", "no_changes", "failed", "cancelled"] },
67
73
  limit: { flag: "limit", type: "num" },
68
74
  audit_id: "audit",
75
+ source_kind: { flag: "source", enum: ["site_audit_issue", "site_quick_check", "letterprove_install"] },
69
76
  },
70
77
  list_github_app_repos: {},
71
78
  list_repo_branches: {
@@ -99,6 +106,8 @@ export async function cmdRepo(ctx) {
99
106
  return repoStatus(rest);
100
107
  case "fix-audit":
101
108
  return repoFixAudit(rest);
109
+ case "install-letterprove":
110
+ return repoInstallLetterprove(rest);
102
111
  case "sites":
103
112
  return repoSites(rest);
104
113
  case "set-site":
@@ -117,7 +126,7 @@ export async function cmdRepo(ctx) {
117
126
  return repoBranches(rest);
118
127
  default:
119
128
  throw new CliError(
120
- `Unknown repo subcommand: ${sub ?? "(none)"}. Try: start, fix-audit, sites, set-site, reset-site, status, list, publish, cancel, repos, branches`
129
+ `Unknown repo subcommand: ${sub ?? "(none)"}. Try: start, fix-audit, install-letterprove, sites, set-site, reset-site, status, list, publish, cancel, repos, branches`
121
130
  );
122
131
  }
123
132
  }
@@ -239,6 +248,22 @@ async function repoFixAudit(ctx) {
239
248
  return followRun(ctx, result.run);
240
249
  }
241
250
 
251
+ /** `repo install-letterprove --repo owner/name [--branch b] [--wait]`: Letterprove's script + identify calls → one PR. */
252
+ async function repoInstallLetterprove(ctx) {
253
+ const { client, flags, io } = ctx;
254
+ refuseUnknownFlags(ctx, {}, ["repo", "owner", "name", "branch", "wait"]);
255
+ const result = await client.callTool(
256
+ "letterprove_open_install_pr",
257
+ argsFromFlags(ARGS.letterprove_open_install_pr, flags, ctx.positionals)
258
+ );
259
+ ok(ctx, result.message);
260
+ if (!flags.wait) {
261
+ printResult(io, flags, result.run, describe);
262
+ return 0;
263
+ }
264
+ return followRun(ctx, result.run);
265
+ }
266
+
242
267
  /** `repo sites [--detect] [--force]`: which website each repo builds, and why. */
243
268
  async function repoSites(ctx) {
244
269
  const { client, flags, io } = ctx;
@@ -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.5",
3
+ "version": "0.102.1",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",