@letterstory/cli 0.102.3 → 0.103.2

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
@@ -441,6 +441,7 @@ letterstory admin crm capture thread.png --context "this is for tryordinal.com"
441
441
  # a screenshot → a lead: what it read, which account
442
442
  letterstory admin crm capture thread.png --context "this is for tryordinal.com" # …and for real
443
443
  letterstory admin crm capture --text-file notes.txt # pasted text or notes work the same way
444
+ letterstory admin crm capture thread.png --retry # sent before, some entries didn't save: write only what's missing
444
445
 
445
446
  # One-click onboarding of a won client (key needs crm:write + fleet:onboard + org:create)
446
447
  letterstory admin crm onboard <account-id> --domain acme.com \
package/lib/cli.mjs CHANGED
@@ -60,15 +60,16 @@ import {
60
60
  } from "./commands.mjs";
61
61
 
62
62
  // Keep in sync with cli/package.json.
63
- export const VERSION = "0.102.3";
63
+ export const VERSION = "0.103.2";
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
67
67
  // `negate` spelling) in a command's ARGS map must be listed; boolean-flags.test.ts enforces it.
68
68
  export const BOOLEAN_FLAGS = new Set([
69
69
  "json",
70
- // site tree: include children a fold-by-default family would hide.
70
+ // site tree: include children a fold-by-default family would hide; items over the whole subtree.
71
71
  "unfold",
72
+ "subtree",
72
73
  // site map: do not start an audit for a host that has none.
73
74
  "no-audit",
74
75
  // site refresh / observe: the loop's switches.
@@ -772,7 +773,7 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
772
773
  Needs spend:read (reads) / spend:write (writes).
773
774
  admin crm import <file.csv> [--source <name>] [--tag "…"] [--dry-run]
774
775
  | capture [shot.png …] [--text "…" | --text-file f] [--context "this is for acme.com"]
775
- [--account <id>] [--dry-run] screenshots / a thread / a note → a lead
776
+ [--account <id>] [--dry-run] [--retry] screenshots / a thread / a note → a lead
776
777
  | signals [--status new] [--intent qualified] [--source rb2b] [--account <id>]
777
778
  | record --title "…" [--source bookface] [--kind other] [--intent qualified]
778
779
  [--name …] [--email …] [--company …] [--domain …] [--linkedin …] [--detail …]
@@ -1169,14 +1170,20 @@ The Site (one model of your whole marketing site, keyed by host and path):
1169
1170
  Repo changes (Claude Code edits your repo in a sandbox; the Letterstory GitHub App opens a PR):
1170
1171
  repo repos Repos the Letterstory GitHub App can reach
1171
1172
  repo branches --repo owner/name Branches a change can start from (default first)
1173
+ repo suggestions Fixes found and not acted on yet, and how to start each
1174
+ repo settings [--action build|build_pr|slack] [--branch <name>|none] [--slack-agent <name|U…>|none]
1175
+ The org's defaults for every fix: build (preview first),
1176
+ build_pr, or slack (post it for your own agent, tagging
1177
+ --slack-agent), and the branch changes go to
1172
1178
  repo start --repo <owner/name> --instructions <text>|--instructions-file <path|->
1173
- [--branch <base>] [--attach <path>]... [--attach-url <name>=<https url>]... [--preview] [--wait]
1179
+ [--branch <base>] [--attach <path>]... [--attach-url <name>=<https url>]...
1180
+ [--action build|build_pr|slack] [--wait]
1174
1181
  Start a change (attached files go to the sandbox);
1175
1182
  --wait follows it to the PR
1176
- repo fix-audit --issue <issue-type> [--audit <id>] [--branch <base>] [--preview] [--wait]
1177
- One PR fixing a site-audit issue on every affected page,
1183
+ repo fix-audit --issue <issue-type>... [--audit <id>] [--branch <base>] [--action build|build_pr|slack] [--wait]
1184
+ One PR fixing site-audit issues (repeat --issue to bundle) on every affected page,
1178
1185
  in the repo verified to build the site
1179
- repo install-letterprove --repo <owner/name> [--branch <base>] [--wait]
1186
+ repo install-letterprove --repo <owner/name> [--branch <base>] [--action build|build_pr|slack] [--wait]
1180
1187
  One PR adding Letterprove's script and identify calls
1181
1188
  (pick the repo where users sign in)
1182
1189
  repo sites [--detect [--force]] Which website each repo builds, and the evidence
@@ -2777,7 +2777,7 @@ const CRM_CAPTURE_TYPES = {
2777
2777
  async function cmdAdminCrmCapture(ctx, paths) {
2778
2778
  const { flags, io } = ctx;
2779
2779
  const usage =
2780
- 'Usage: admin crm capture [image.png …] [--text "…" | --text-file notes.txt] [--context "this is for acme.com"] [--account <id>] [--dry-run]';
2780
+ 'Usage: admin crm capture [image.png …] [--text "…" | --text-file notes.txt] [--context "this is for acme.com"] [--account <id>] [--dry-run] [--retry]';
2781
2781
  const images = paths.map((path) => {
2782
2782
  const media_type = CRM_CAPTURE_TYPES[path.split(".").pop()?.toLowerCase() ?? ""];
2783
2783
  if (!media_type) throw new CliError(`${path}: only png, jpg, gif and webp screenshots can be read`);
@@ -2807,6 +2807,8 @@ async function cmdAdminCrmCapture(ctx, paths) {
2807
2807
  account_id: flagStr(flags.account),
2808
2808
  source: flagStr(flags.source) ?? "agent",
2809
2809
  dry_run: flagBool(flags["dry-run"]) || undefined,
2810
+ // Sent before but some entries didn't save: read it again, writing only what isn't in.
2811
+ retry: flagBool(flags.retry) || undefined,
2810
2812
  })
2811
2813
  );
2812
2814
  if (flagBool(flags.json)) return printResult(io, flags, r);
@@ -2835,7 +2837,9 @@ export function captureSummary(r) {
2835
2837
  if (r.batch) {
2836
2838
  const width = Math.max(...r.batch.map((b) => (BATCH_STATUS[b.status] ?? b.status).length), 0);
2837
2839
  return [
2838
- `${r.dryRun ? "would save" : "saved"} ${r.accountName ?? `${r.batch.length} entries`}`,
2840
+ r.duplicate
2841
+ ? `sent before: ${r.accountName ?? `${r.batch.length} already in`}`
2842
+ : `${r.dryRun ? "would save" : "saved"} ${r.accountName ?? `${r.batch.length} entries`}`,
2839
2843
  ...r.batch.map((b) => {
2840
2844
  const status = (BATCH_STATUS[b.status] ?? b.status).padEnd(width);
2841
2845
  const where = b.accountName ?? b.companyName ?? "?";
@@ -2847,12 +2851,13 @@ export function captureSummary(r) {
2847
2851
  }),
2848
2852
  ];
2849
2853
  }
2850
- const verb = r.dryRun
2851
- ? r.accountId
2852
- ? "would add to"
2853
- : "would create"
2854
- : r.duplicate
2855
- ? "already captured →"
2854
+ // A preview says "sent before" too (the app checks before it reads), so that comes first.
2855
+ const verb = r.duplicate
2856
+ ? "already captured →"
2857
+ : r.dryRun
2858
+ ? r.accountId
2859
+ ? "would add to"
2860
+ : "would create"
2856
2861
  : r.accountCreated
2857
2862
  ? "created"
2858
2863
  : "added to";
@@ -2876,7 +2881,7 @@ export function captureSummary(r) {
2876
2881
  * outreach add "Jane Doe" [--company …] [--owner …] [--channel x] [--relationship …]
2877
2882
  * [--ask …] [--linkedin …] [--email …] [--lookup]
2878
2883
  * outreach lookup "Jane Doe from Acme" [shot.png …] prefilled from the web; writes nothing
2879
- * outreach relookup [--missing linkedin] the web lookup again for whoever lacks it
2884
+ * outreach relookup [--missing linkedin|company|location|any] the web lookup again for whoever lacks it
2880
2885
  * outreach drafts [--kind ask|thanks] [--owner …] drafts or thank-yous for everyone without one
2881
2886
  * outreach engagement --outcome upvoted [shot.png] who upvoted, matched by exact name
2882
2887
  * outreach customers [--dry-run] every customer's primary contact onto the list
@@ -2949,6 +2954,7 @@ async function cmdAdminCrmOutreach(ctx, args) {
2949
2954
  for (const p of r.created) io.log(`added ${line(p)}`);
2950
2955
  for (const d of r.duplicates)
2951
2956
  io.log(`already on the list: ${d.existingName} (matched on ${d.matchedOn}) ${d.existingId}`);
2957
+ for (const x of r.skipped ?? []) io.log(`not added: ${x.name} (${x.reason})`);
2952
2958
  return;
2953
2959
  }
2954
2960
  case "lookup": {
@@ -2999,7 +3005,7 @@ async function cmdAdminCrmOutreach(ctx, args) {
2999
3005
  return json ? printResult(io, flags, r) : io.log(line(r));
3000
3006
  }
3001
3007
  case "relookup": {
3002
- // admin crm outreach relookup [--missing linkedin|company|any] — the web lookup again,
3008
+ // admin crm outreach relookup [--missing linkedin|company|location|any] — the web lookup again,
3003
3009
  // filling only blanks, calling until none remain (a dozen people per call).
3004
3010
  const missing = flagStr(flags.missing) ?? "linkedin";
3005
3011
  let total = 0;
@@ -3081,9 +3087,12 @@ async function cmdAdminCrmOutreach(ctx, args) {
3081
3087
  );
3082
3088
  if (json) return printResult(io, flags, r);
3083
3089
  return io.log(
3084
- r.dryRun
3090
+ (r.dryRun
3085
3091
  ? `would add up to ${r.candidates.length}: ${r.candidates.join(", ")}`
3086
- : `added ${r.created.length}; already on the list: ${r.duplicates.length}`
3092
+ : `added ${r.created.length}; already on the list: ${r.duplicates.length}`) +
3093
+ (r.skipped?.length
3094
+ ? `\nnot added (on the no-contact list): ${r.skipped.map((x) => x.name).join(", ")}`
3095
+ : "")
3087
3096
  );
3088
3097
  }
3089
3098
  case "catch-up": {
@@ -5,6 +5,8 @@
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
7
  // repo install-letterprove → letterprove_open_install_pr
8
+ // repo suggestions → list_suggested_fixes
9
+ // repo settings → get_repo_change_settings, or set_repo_change_settings with --action / --branch
8
10
 
9
11
  import { readFileSync } from "node:fs";
10
12
  import { basename } from "node:path";
@@ -29,6 +31,13 @@ function splitRepo(flags) {
29
31
  return { owner, name };
30
32
  }
31
33
 
34
+ function issueList(flags) {
35
+ return flagList(flags.issue)
36
+ .flatMap((v) => v.split(","))
37
+ .map((v) => v.trim())
38
+ .filter(Boolean);
39
+ }
40
+
32
41
  const ARGS = {
33
42
  start_repo_change: {
34
43
  repo_owner: { value: (flags) => splitRepo(flags).owner },
@@ -38,6 +47,7 @@ const ARGS = {
38
47
  instructions: { value: (flags) => readTextInput(flags, "instructions", "instructions-file") },
39
48
  // --attach <path> (uploaded first) and --attach-url <name>=<https url>, both repeatable.
40
49
  files: { value: (_flags, _positionals, extra) => (extra.files?.length ? extra.files : undefined) },
50
+ action: { flag: "action", enum: ["build", "build_pr", "slack"] },
41
51
  preview: { flag: "preview", type: "bool" },
42
52
  },
43
53
  create_repo_change_upload: { file_names: { internal: "the basenames of the --attach files" } },
@@ -55,21 +65,34 @@ const ARGS = {
55
65
  site_host: { flag: "site", required: true },
56
66
  },
57
67
  fix_audit_issue: {
58
- issue_type: { flag: "issue", required: true },
68
+ // --issue <type>, repeatable or comma-separated: several go into one PR (issue_types).
69
+ issue_type: { value: (flags) => (issueList(flags).length === 1 ? issueList(flags)[0] : undefined) },
70
+ issue_types: { value: (flags) => (issueList(flags).length > 1 ? issueList(flags) : undefined) },
59
71
  audit_id: "audit",
60
72
  base_branch: "branch",
73
+ action: { flag: "action", enum: ["build", "build_pr", "slack"] },
61
74
  preview: { flag: "preview", type: "bool" },
62
75
  },
63
76
  letterprove_open_install_pr: {
64
77
  repo_owner: { value: (flags) => splitRepo(flags).owner },
65
78
  repo_name: { value: (flags) => splitRepo(flags).name },
66
79
  base_branch: "branch",
80
+ action: { flag: "action", enum: ["build", "build_pr", "slack"] },
81
+ },
82
+ get_repo_change_settings: {},
83
+ list_suggested_fixes: {},
84
+ set_repo_change_settings: {
85
+ default_action: { flag: "action", enum: ["build", "build_pr", "slack"] },
86
+ // --branch <name>, or --branch none for each repo's own default.
87
+ default_branch: { value: (flags) => (flags.branch === "none" ? null : flagStr(flags.branch)) },
88
+ // --slack-agent Devin (or a U… member id), or --slack-agent none to tag nobody.
89
+ slack_agent: { value: (flags) => (flags["slack-agent"] === "none" ? null : flagStr(flags["slack-agent"])) },
67
90
  },
68
91
  get_repo_change: { run_id: { positional: 0, required: true, label: "run-id" } },
69
92
  cancel_repo_change: { run_id: { positional: 0, required: true, label: "run-id" } },
70
93
  publish_repo_change: { run_id: { positional: 0, required: true, label: "run-id" } },
71
94
  list_repo_changes: {
72
- status: { flag: "status", enum: ["running", "succeeded", "no_changes", "failed", "cancelled"] },
95
+ status: { flag: "status", enum: ["running", "succeeded", "no_changes", "failed", "cancelled", "sent"] },
73
96
  limit: { flag: "limit", type: "num" },
74
97
  audit_id: "audit",
75
98
  source_kind: { flag: "source", enum: ["site_audit_issue", "site_quick_check", "letterprove_install"] },
@@ -124,9 +147,13 @@ export async function cmdRepo(ctx) {
124
147
  return repoRepos(rest);
125
148
  case "branches":
126
149
  return repoBranches(rest);
150
+ case "settings":
151
+ return repoSettings(rest);
152
+ case "suggestions":
153
+ return repoSuggestions(rest);
127
154
  default:
128
155
  throw new CliError(
129
- `Unknown repo subcommand: ${sub ?? "(none)"}. Try: start, fix-audit, install-letterprove, sites, set-site, reset-site, status, list, publish, cancel, repos, branches`
156
+ `Unknown repo subcommand: ${sub ?? "(none)"}. Try: start, fix-audit, install-letterprove, sites, set-site, reset-site, status, list, publish, cancel, repos, branches, settings, suggestions`
130
157
  );
131
158
  }
132
159
  }
@@ -180,6 +207,7 @@ async function repoStart(ctx) {
180
207
  "instructions-file",
181
208
  "attach",
182
209
  "attach-url",
210
+ "action",
183
211
  "preview",
184
212
  "wait",
185
213
  ]);
@@ -235,7 +263,9 @@ async function followRun(ctx, run) {
235
263
  /** `repo fix-audit --issue <type> [--audit <id>] [--wait]`: one audit issue → one PR, in the repo verified to build the site. */
236
264
  async function repoFixAudit(ctx) {
237
265
  const { client, flags, io } = ctx;
238
- refuseUnknownFlags(ctx, {}, ["issue", "audit", "branch", "preview", "wait"]);
266
+ refuseUnknownFlags(ctx, {}, ["issue", "audit", "branch", "action", "preview", "wait"]);
267
+ if (issueList(flags).length === 0)
268
+ throw new CliError("--issue <issue-type> is required (repeat it to fix several in one PR)");
239
269
  const result = await client.callTool(
240
270
  "fix_audit_issue",
241
271
  argsFromFlags(ARGS.fix_audit_issue, flags, ctx.positionals)
@@ -251,7 +281,7 @@ async function repoFixAudit(ctx) {
251
281
  /** `repo install-letterprove --repo owner/name [--branch b] [--wait]`: Letterprove's script + identify calls → one PR. */
252
282
  async function repoInstallLetterprove(ctx) {
253
283
  const { client, flags, io } = ctx;
254
- refuseUnknownFlags(ctx, {}, ["repo", "owner", "name", "branch", "wait"]);
284
+ refuseUnknownFlags(ctx, {}, ["repo", "owner", "name", "branch", "action", "wait"]);
255
285
  const result = await client.callTool(
256
286
  "letterprove_open_install_pr",
257
287
  argsFromFlags(ARGS.letterprove_open_install_pr, flags, ctx.positionals)
@@ -371,4 +401,44 @@ async function repoBranches(ctx) {
371
401
  return 0;
372
402
  }
373
403
 
404
+ /**
405
+ * `repo settings` shows the org's defaults; `--action build|build_pr` and/or `--branch <name>`
406
+ * (`--branch none`: each repo's own default) change them.
407
+ */
408
+ async function repoSettings(ctx) {
409
+ const { client, flags, io } = ctx;
410
+ refuseUnknownFlags(ctx, {}, ["action", "branch", "slack-agent"]);
411
+ const setting = flags.action !== undefined || flags.branch !== undefined || flags["slack-agent"] !== undefined;
412
+ const result = setting
413
+ ? await client.callTool("set_repo_change_settings", argsFromFlags(ARGS.set_repo_change_settings, flags))
414
+ : await client.callTool("get_repo_change_settings", {});
415
+ ok(ctx, result.message);
416
+ printResult(io, flags, result);
417
+ return 0;
418
+ }
419
+
420
+ /** `repo suggestions`: fixes found and not acted on yet, and the command that starts each. */
421
+ async function repoSuggestions(ctx) {
422
+ const { client, flags, io } = ctx;
423
+ refuseUnknownFlags(ctx, {}, []);
424
+ const result = await client.callTool("list_suggested_fixes", {});
425
+ printResult(io, flags, result, (r) =>
426
+ r.suggestions.length === 0
427
+ ? "Nothing to fix right now."
428
+ : r.suggestions
429
+ .map((s) => {
430
+ const where = s.repo ? `${s.repo} → ${s.siteHost}` : `${s.siteHost} (pick a repo)`;
431
+ const start =
432
+ s.start.tool === "fix_audit_issue"
433
+ ? `letterstory repo fix-audit --audit ${s.start.args.audit_id} --issue ${s.start.args.issue_type}`
434
+ : s.start.args
435
+ ? `letterstory repo install-letterprove --repo ${s.start.args.repo_owner}/${s.start.args.repo_name}`
436
+ : "letterstory repo install-letterprove --repo <owner/name>";
437
+ return `[${s.severity}] ${s.title} ${where}\n ${start}`;
438
+ })
439
+ .join("\n")
440
+ );
441
+ return 0;
442
+ }
443
+
374
444
  export { ARGS };
@@ -6,7 +6,7 @@
6
6
  // site dismiss <host> <id> → dismiss_site_observation
7
7
  // site families <host> → list_site_families site decide-family <id> → decide_site_family
8
8
  // site proposals <host> → list_site_proposals site propose <host> → create_site_proposal
9
- // site decide <proposal> → decide_site_proposal
9
+ // site decide <proposal> → decide_site_proposal site fix <host> <id>… → fix_site_observations
10
10
 
11
11
  import { CliError } from "../client.mjs";
12
12
  import { argsFromFlags, flagList, flagStr, ok, printResult, readTextInput, refuseUnknownFlags } from "./shared.mjs";
@@ -55,6 +55,7 @@ const ARGS = {
55
55
  path: { positional: 1, label: "path" },
56
56
  depth: { flag: "depth", type: "num" },
57
57
  unfold: { flag: "unfold", type: "bool" },
58
+ subtree: { flag: "subtree", type: "bool" },
58
59
  },
59
60
  get_site_page: {
60
61
  host: { positional: 0, required: true, label: "host" },
@@ -116,6 +117,18 @@ const ARGS = {
116
117
  decision: { flag: "decision", required: true, enum: ["accept", "dismiss"] },
117
118
  reason: "reason",
118
119
  },
120
+ fix_site_observations: {
121
+ host: { positional: 0, required: true, label: "host" },
122
+ // Every positional after the host, plus --observation <id> (repeatable).
123
+ observation_ids: {
124
+ value: (flags, positionals) => {
125
+ const ids = [...positionals.slice(1), ...flagList(flags.observation)];
126
+ return ids.length ? ids : undefined;
127
+ },
128
+ },
129
+ base_branch: { flag: "base-branch" },
130
+ preview: { flag: "preview", type: "bool" },
131
+ },
119
132
  };
120
133
 
121
134
  export async function cmdSite(ctx) {
@@ -150,9 +163,11 @@ export async function cmdSite(ctx) {
150
163
  return sitePropose(rest);
151
164
  case "decide":
152
165
  return siteDecide(rest);
166
+ case "fix":
167
+ return siteFix(rest);
153
168
  default:
154
169
  throw new CliError(
155
- `Unknown site subcommand: ${sub ?? "(none)"}. Try: list, map, refresh, unmap, tree, page, observations, observe, dismiss, families, decide-family, proposals, propose, decide`
170
+ `Unknown site subcommand: ${sub ?? "(none)"}. Try: list, map, refresh, unmap, tree, page, observations, observe, dismiss, families, decide-family, proposals, propose, decide, fix`
156
171
  );
157
172
  }
158
173
  }
@@ -373,4 +388,21 @@ async function siteDecide(ctx) {
373
388
  return 0;
374
389
  }
375
390
 
391
+ async function siteFix(ctx) {
392
+ const { client, flags, io } = ctx;
393
+ refuseUnknownFlags(ctx, ARGS.fix_site_observations, ["observation"]);
394
+ const args = argsFromFlags(ARGS.fix_site_observations, flags, ctx.positionals);
395
+ if (!args.observation_ids)
396
+ throw new CliError("Give at least one observation id: site fix <host> <id>… (or --observation <id>)");
397
+ const result = await client.callTool("fix_site_observations", args);
398
+ ok(ctx, result.message);
399
+ printResult(io, flags, result, (r) => {
400
+ const lines = [`${r.run.id} ${r.run.repo} [${r.run.status}]${r.reused ? " (already running)" : ""}`];
401
+ for (const id of r.fixing) lines.push(` fixing ${id}`);
402
+ for (const s of r.skipped) lines.push(` skipped ${s.id} ${s.reason}`);
403
+ return lines.join("\n");
404
+ });
405
+ return 0;
406
+ }
407
+
376
408
  export { ARGS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.102.3",
3
+ "version": "0.103.2",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",