@letterstory/cli 0.98.2 → 0.98.4

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/lib/cli.mjs CHANGED
@@ -51,6 +51,7 @@ import {
51
51
  cmdMailers,
52
52
  cmdAdmin,
53
53
  cmdFeedback,
54
+ cmdRepo,
54
55
  cmdEvals,
55
56
  cmdOfferings,
56
57
  cmdTargets,
@@ -58,7 +59,7 @@ import {
58
59
  } from "./commands.mjs";
59
60
 
60
61
  // Keep in sync with cli/package.json.
61
- export const VERSION = "0.98.2";
62
+ export const VERSION = "0.98.4";
62
63
 
63
64
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
64
65
  // can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
@@ -144,6 +145,8 @@ export const BOOLEAN_FLAGS = new Set([
144
145
  "no-enrich",
145
146
  // `offerings set-collection <id> --none` (the org itself is the subject again).
146
147
  "none",
148
+ // `repo set-site --repo <owner/name> --site <host> --not` (the repo does not build that site).
149
+ "not",
147
150
  // `prospects import|source --one-per-company`, `prospects source --enrich`, `prospects import --find-emails`.
148
151
  "one-per-company",
149
152
  "enrich",
@@ -1097,6 +1100,23 @@ Insights:
1097
1100
  insights access [--period 14d|30d|90d] [--collection <uuid>]
1098
1101
  Page access: who's reading your phantom sites
1099
1102
 
1103
+ Repo changes (Claude Code edits your repo in a sandbox; the Letterstory GitHub App opens a PR):
1104
+ repo repos Repos the Letterstory GitHub App can reach
1105
+ repo start --repo <owner/name> --instructions <text>|--instructions-file <path|->
1106
+ [--branch <base>] [--attach <path>]... [--attach-url <name>=<https url>]... [--wait]
1107
+ Start a change (attached files go to the sandbox);
1108
+ --wait follows it to the PR
1109
+ repo fix-audit --issue <issue-type> [--audit <id>] [--branch <base>] [--wait]
1110
+ One PR fixing a site-audit issue on every affected page,
1111
+ in the repo verified to build the site
1112
+ repo sites [--detect [--force]] Which website each repo builds, and the evidence
1113
+ repo set-site --repo <owner/name> --site <host> [--not]
1114
+ Say which site a repo builds (--not: correct a wrong label)
1115
+ repo reset-site --repo <owner/name> --site <host> Back to automatic detection
1116
+ repo status <run-id> Phase, spend, PR link and Claude's summary
1117
+ repo list [--status running|succeeded|no_changes|failed|cancelled] [--limit n] [--audit <id>]
1118
+ repo cancel <run-id> Stop it and delete the sandbox; no PR
1119
+
1100
1120
  Feedback (tell us what's broken, missing, unclear, or wanted):
1101
1121
  feedback submit --category bug|missing_feature|unclear|feature_request|praise|other
1102
1122
  [--message <text>|--body <text>|--file <path>|--file -] [--rating 1-5]
@@ -1201,6 +1221,7 @@ const CLIENT_COMMANDS = {
1201
1221
  mail: cmdMail,
1202
1222
  mailers: cmdMailers,
1203
1223
  feedback: cmdFeedback,
1224
+ repo: cmdRepo,
1204
1225
  evals: cmdEvals,
1205
1226
  visits: cmdVisits,
1206
1227
  // Platform-scoped (staff key) — the only commands that act BEFORE an org exists.
@@ -0,0 +1,305 @@
1
+ // `repo` — changes to the org's own repos, made by Claude Code in a sandbox and opened as a
2
+ // pull request by the Letterstory GitHub App (src/lib/repo-changes). One subcommand per tool:
3
+ // repo start → start_repo_change repo status → get_repo_change
4
+ // repo list → list_repo_changes repo cancel → cancel_repo_change
5
+ // repo repos → list_github_app_repos
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { basename } from "node:path";
9
+ import { CliError } from "../client.mjs";
10
+ import {
11
+ argsFromFlags,
12
+ flagList,
13
+ flagStr,
14
+ followJob,
15
+ ok,
16
+ printResult,
17
+ readTextInput,
18
+ refuseUnknownFlags,
19
+ } from "./shared.mjs";
20
+
21
+ /** `--repo owner/name` → the two schema keys; --owner/--name also work. */
22
+ function splitRepo(flags) {
23
+ const full = flagStr(flags.repo);
24
+ if (full === undefined) return { owner: flagStr(flags.owner), name: flagStr(flags.name) };
25
+ const [owner, name, ...extra] = full.split("/");
26
+ if (!owner || !name || extra.length) throw new CliError("--repo must be owner/name");
27
+ return { owner, name };
28
+ }
29
+
30
+ const ARGS = {
31
+ start_repo_change: {
32
+ repo_owner: { value: (flags) => splitRepo(flags).owner },
33
+ repo_name: { value: (flags) => splitRepo(flags).name },
34
+ base_branch: "branch",
35
+ // --instructions, or a longer brief from --instructions-file <path> / - (stdin).
36
+ instructions: { value: (flags) => readTextInput(flags, "instructions", "instructions-file") },
37
+ // --attach <path> (uploaded first) and --attach-url <name>=<https url>, both repeatable.
38
+ files: { value: (_flags, _positionals, extra) => (extra.files?.length ? extra.files : undefined) },
39
+ },
40
+ create_repo_change_upload: { file_names: { internal: "the basenames of the --attach files" } },
41
+ list_repo_sites: {},
42
+ detect_repo_sites: { force: { flag: "force", type: "bool" } },
43
+ set_repo_site: {
44
+ repo_owner: { value: (flags) => splitRepo(flags).owner },
45
+ repo_name: { value: (flags) => splitRepo(flags).name },
46
+ site_host: { flag: "site", required: true },
47
+ builds: { flag: "not", type: "bool", invert: true },
48
+ },
49
+ reset_repo_site: {
50
+ repo_owner: { value: (flags) => splitRepo(flags).owner },
51
+ repo_name: { value: (flags) => splitRepo(flags).name },
52
+ site_host: { flag: "site", required: true },
53
+ },
54
+ fix_audit_issue: {
55
+ issue_type: { flag: "issue", required: true },
56
+ audit_id: "audit",
57
+ base_branch: "branch",
58
+ },
59
+ get_repo_change: { run_id: { positional: 0, required: true, label: "run-id" } },
60
+ cancel_repo_change: { run_id: { positional: 0, required: true, label: "run-id" } },
61
+ list_repo_changes: {
62
+ status: { flag: "status", enum: ["running", "succeeded", "no_changes", "failed", "cancelled"] },
63
+ limit: { flag: "limit", type: "num" },
64
+ audit_id: "audit",
65
+ },
66
+ list_github_app_repos: {},
67
+ };
68
+
69
+ const TERMINAL = new Set(["succeeded", "no_changes", "failed", "cancelled"]);
70
+
71
+ function describe(run) {
72
+ const lines = [
73
+ `${run.id} ${run.repo}@${run.baseBranch} ${run.status} (${run.phase}) $${run.costUsd.toFixed(2)}`,
74
+ ];
75
+ if (run.prUrl) lines.push(` PR: ${run.prUrl}`);
76
+ if (run.errorCode) lines.push(` Error (${run.errorCode}): ${run.errorDetail ?? ""}`);
77
+ if (run.summary) lines.push(...run.summary.split("\n").map((l) => ` ${l}`));
78
+ return lines.join("\n");
79
+ }
80
+
81
+ export async function cmdRepo(ctx) {
82
+ const sub = ctx.positionals[0];
83
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
84
+ switch (sub) {
85
+ case "start":
86
+ return repoStart(rest);
87
+ case "status":
88
+ return repoStatus(rest);
89
+ case "fix-audit":
90
+ return repoFixAudit(rest);
91
+ case "sites":
92
+ return repoSites(rest);
93
+ case "set-site":
94
+ return repoSetSite(rest);
95
+ case "reset-site":
96
+ return repoResetSite(rest);
97
+ case "list":
98
+ return repoList(rest);
99
+ case "cancel":
100
+ return repoCancel(rest);
101
+ case "repos":
102
+ return repoRepos(rest);
103
+ default:
104
+ throw new CliError(
105
+ `Unknown repo subcommand: ${sub ?? "(none)"}. Try: start, fix-audit, sites, set-site, reset-site, status, list, cancel, repos`
106
+ );
107
+ }
108
+ }
109
+
110
+ /** `--attach-url name=https://…` → `{ name, url }`. */
111
+ function attachmentUrls(specs) {
112
+ return specs.map((spec) => {
113
+ const at = spec.indexOf("=");
114
+ if (at < 1) throw new CliError(`--attach-url must be name=https://… (got ${spec})`);
115
+ return { name: spec.slice(0, at), url: spec.slice(at + 1) };
116
+ });
117
+ }
118
+
119
+ /** Reserve an upload per --attach file, PUT the bytes, and return `{ name, upload_path }` for each. */
120
+ async function uploadAttachments(ctx, paths) {
121
+ if (!paths.length) return [];
122
+ const names = paths.map((p) =>
123
+ basename(p)
124
+ .replace(/[^A-Za-z0-9._ -]/g, "-")
125
+ .replace(/^[^A-Za-z0-9]+/, "")
126
+ );
127
+ if (new Set(names).size !== names.length) throw new CliError("Two --attach files share a name; rename one.");
128
+ const bytes = paths.map((p) => {
129
+ try {
130
+ return readFileSync(p);
131
+ } catch (err) {
132
+ throw new CliError(`Could not read --attach ${p}: ${err.message}`);
133
+ }
134
+ });
135
+ const { uploads } = await ctx.client.callTool("create_repo_change_upload", { file_names: names });
136
+ for (const [i, slot] of uploads.entries()) {
137
+ const res = await fetch(slot.upload_url, {
138
+ method: "PUT",
139
+ headers: { "content-type": "application/octet-stream" },
140
+ body: bytes[i],
141
+ });
142
+ if (!res.ok) throw new CliError(`Uploading ${slot.name} failed (${res.status}).`);
143
+ ok(ctx, `Uploaded ${slot.name}`);
144
+ }
145
+ return uploads.map((slot) => ({ name: slot.name, upload_path: slot.path }));
146
+ }
147
+
148
+ async function repoStart(ctx) {
149
+ const { client, flags, io } = ctx;
150
+ refuseUnknownFlags(ctx, {}, [
151
+ "repo",
152
+ "owner",
153
+ "name",
154
+ "branch",
155
+ "instructions",
156
+ "instructions-file",
157
+ "attach",
158
+ "attach-url",
159
+ "wait",
160
+ ]);
161
+ const files = [
162
+ ...(await uploadAttachments(ctx, flagList(flags.attach))),
163
+ ...attachmentUrls(flagList(flags["attach-url"])),
164
+ ];
165
+ const args = argsFromFlags(ARGS.start_repo_change, flags, ctx.positionals, { files });
166
+ if (!args.repo_owner || !args.repo_name) throw new CliError("Missing --repo owner/name");
167
+ if (!args.instructions) throw new CliError("Missing --instructions (or --instructions-file <path|->)");
168
+
169
+ const started = await client.callTool("start_repo_change", args);
170
+ ok(ctx, started.message);
171
+ if (!flags.wait) {
172
+ printResult(io, flags, started.run, describe);
173
+ return 0;
174
+ }
175
+ const { settled, value } = await followJob(
176
+ io,
177
+ async () => {
178
+ const polled = await client.callTool("get_repo_change", { run_id: started.run.id });
179
+ ok(ctx, `… ${polled.run.status} (${polled.run.phase})`);
180
+ return { done: TERMINAL.has(polled.run.status), value: polled.run };
181
+ },
182
+ { intervalMs: 30000, ceilingMs: 60 * 60 * 1000 }
183
+ );
184
+ if (!settled) ok(ctx, `Still running; check later with: letterstory repo status ${started.run.id}`);
185
+ printResult(io, flags, value, describe);
186
+ return value.status === "failed" ? 1 : 0;
187
+ }
188
+
189
+ async function followRun(ctx, run) {
190
+ const { client, flags, io } = ctx;
191
+ const { settled, value } = await followJob(
192
+ io,
193
+ async () => {
194
+ const polled = await client.callTool("get_repo_change", { run_id: run.id });
195
+ ok(ctx, `… ${polled.run.status} (${polled.run.phase})`);
196
+ return { done: TERMINAL.has(polled.run.status), value: polled.run };
197
+ },
198
+ { intervalMs: 30000, ceilingMs: 60 * 60 * 1000 }
199
+ );
200
+ if (!settled) ok(ctx, `Still running; check later with: letterstory repo status ${run.id}`);
201
+ printResult(io, flags, value, describe);
202
+ return value.status === "failed" ? 1 : 0;
203
+ }
204
+
205
+ /** `repo fix-audit --issue <type> [--audit <id>] [--wait]`: one audit issue → one PR, in the repo verified to build the site. */
206
+ async function repoFixAudit(ctx) {
207
+ const { client, flags, io } = ctx;
208
+ refuseUnknownFlags(ctx, {}, ["issue", "audit", "branch", "wait"]);
209
+ const result = await client.callTool(
210
+ "fix_audit_issue",
211
+ argsFromFlags(ARGS.fix_audit_issue, flags, ctx.positionals)
212
+ );
213
+ ok(ctx, result.message);
214
+ if (!flags.wait) {
215
+ printResult(io, flags, result.run, describe);
216
+ return 0;
217
+ }
218
+ return followRun(ctx, result.run);
219
+ }
220
+
221
+ /** `repo sites [--detect] [--force]`: which website each repo builds, and why. */
222
+ async function repoSites(ctx) {
223
+ const { client, flags, io } = ctx;
224
+ const result = flags.detect
225
+ ? await client.callTool("detect_repo_sites", argsFromFlags(ARGS.detect_repo_sites, flags))
226
+ : await client.callTool("list_repo_sites", argsFromFlags(ARGS.list_repo_sites, flags));
227
+ printResult(io, flags, result, (r) => {
228
+ const serving = r.sites.filter((s) => s.verdict === "serves");
229
+ const lines = serving.map((s) =>
230
+ s.decidedBy === "member"
231
+ ? `${s.repo} builds ${s.siteHost} (set by a member)`
232
+ : `${s.repo} builds ${s.siteHost} (${Math.round(s.score * 100)}%: ${s.evidence.signals.join(", ")})`
233
+ );
234
+ if (r.failed?.length) lines.push(...r.failed.map((f) => `! ${f.repo}: ${f.error}`));
235
+ return lines.length ? lines.join("\n") : "No repo is verified to build any of this organization's sites yet.";
236
+ });
237
+ return 0;
238
+ }
239
+
240
+ /** `repo set-site --repo owner/name --site acme.com [--not]`: say which site a repo builds (or doesn't). */
241
+ async function repoSetSite(ctx) {
242
+ const { client, flags, io } = ctx;
243
+ refuseUnknownFlags(ctx, {}, ["repo", "owner", "name", "site", "not"]);
244
+ const result = await client.callTool("set_repo_site", argsFromFlags(ARGS.set_repo_site, flags));
245
+ ok(ctx, result.message);
246
+ printResult(io, flags, result.site);
247
+ return 0;
248
+ }
249
+
250
+ /** `repo reset-site --repo owner/name --site acme.com`: back to automatic detection. */
251
+ async function repoResetSite(ctx) {
252
+ const { client, flags, io } = ctx;
253
+ refuseUnknownFlags(ctx, {}, ["repo", "owner", "name", "site"]);
254
+ const result = await client.callTool("reset_repo_site", argsFromFlags(ARGS.reset_repo_site, flags));
255
+ ok(ctx, result.message);
256
+ printResult(io, flags, result);
257
+ return 0;
258
+ }
259
+
260
+ async function repoStatus(ctx) {
261
+ const { client, flags, io } = ctx;
262
+ const result = await client.callTool(
263
+ "get_repo_change",
264
+ argsFromFlags(ARGS.get_repo_change, flags, ctx.positionals)
265
+ );
266
+ ok(ctx, result.message);
267
+ printResult(io, flags, result.run, describe);
268
+ return 0;
269
+ }
270
+
271
+ async function repoList(ctx) {
272
+ const { client, flags, io } = ctx;
273
+ const result = await client.callTool(
274
+ "list_repo_changes",
275
+ argsFromFlags(ARGS.list_repo_changes, flags, ctx.positionals)
276
+ );
277
+ printResult(io, flags, result.runs, (runs) =>
278
+ runs.length ? runs.map(describe).join("\n") : "No repo changes yet."
279
+ );
280
+ return 0;
281
+ }
282
+
283
+ async function repoCancel(ctx) {
284
+ const { client, flags, io } = ctx;
285
+ const result = await client.callTool(
286
+ "cancel_repo_change",
287
+ argsFromFlags(ARGS.cancel_repo_change, flags, ctx.positionals)
288
+ );
289
+ ok(ctx, result.message);
290
+ printResult(io, flags, result.run, describe);
291
+ return 0;
292
+ }
293
+
294
+ async function repoRepos(ctx) {
295
+ const { client, flags, io } = ctx;
296
+ const result = await client.callTool("list_github_app_repos", argsFromFlags(ARGS.list_github_app_repos, flags));
297
+ printResult(io, flags, result, (r) =>
298
+ r.repos.length
299
+ ? r.repos.map((repo) => `${repo.fullName} (default ${repo.defaultBranch})`).join("\n")
300
+ : r.message
301
+ );
302
+ return 0;
303
+ }
304
+
305
+ export { ARGS };
@@ -8,7 +8,7 @@
8
8
  // routes and staff Fleet do.
9
9
 
10
10
  import { CliError } from "../client.mjs";
11
- import { flagStr, flagNum, printResult, argsFromFlags, refuseUnknownFlags, ok } from "./shared.mjs";
11
+ import { flagStr, flagNum, flagList, printResult, argsFromFlags, refuseUnknownFlags, ok } from "./shared.mjs";
12
12
 
13
13
  const STATUSES = ["active", "inactive", "achieved", "retired"];
14
14
  const PRIORITIES = ["high", "normal", "low"];
@@ -25,8 +25,9 @@ function readOffering(flags) {
25
25
  }
26
26
  const offering = { value: readOffering };
27
27
 
28
- // `--priority high|normal|low|default`: how much the target's questions count in share of
29
- // voice (high = 3 votes, normal = 2, low = 1); default = back to high, a target's default.
28
+ // `--priority high|normal|low|default`: how often the target's ground is written across target
29
+ // slots, and how much its questions count in share of voice (high = 3, normal = 2, low = 1);
30
+ // default = back to high, a target's default.
30
31
  function readPriority(flags) {
31
32
  const v = flagStr(flags.priority);
32
33
  if (v === undefined) {
@@ -55,6 +56,8 @@ export const ARGS = {
55
56
  mention_framing: "framing",
56
57
  offering_id: offering,
57
58
  priority,
59
+ // `--stance <text>`, repeatable: one point of view per flag.
60
+ stances: { flag: "stance", type: "list" },
58
61
  },
59
62
  update_ranking_target: {
60
63
  target_id: { positional: 0, label: "target-id", required: true },
@@ -67,6 +70,14 @@ export const ARGS = {
67
70
  offering_id: offering,
68
71
  position: { flag: "position", type: "num" },
69
72
  priority,
73
+ // `--stance <text>` (repeatable) REPLACES the list; `--clear-stances` empties it.
74
+ stances: {
75
+ value: (flags) => {
76
+ if (flags["clear-stances"] === true) return [];
77
+ const list = flagList(flags.stance);
78
+ return list.length ? list : undefined;
79
+ },
80
+ },
70
81
  },
71
82
  set_ranking_target_cap: {
72
83
  offering_id: {
@@ -117,8 +128,10 @@ export const ARGS = {
117
128
  const USAGE = `Usage: targets <list|add|update|activate|deactivate|cap|suggest|subqueries|subquery>
118
129
  targets list [--offering <offering-id|company>]
119
130
  targets add <phrase> [--framing <f>] [--offering <offering-id|company>] [--priority high|normal|low]
131
+ [--stance <s>]... points of view held on this target's ground only
120
132
  targets update <target-id> [--phrase <p>] [--framing <f> | --clear-framing] [--offering <offering-id|company>]
121
133
  [--position <n>] [--state active|inactive|achieved|retired] [--priority high|normal|low|default]
134
+ [--stance <s>]... | --clear-stances replaces the target's stances
122
135
  targets activate <target-id>
123
136
  targets deactivate <target-id>
124
137
  targets cap <offering-id|company> <n|default>
@@ -165,7 +178,8 @@ function formatScope(s) {
165
178
  function formatTarget(t) {
166
179
  const framing = t.mention_framing ? ` — as ${t.mention_framing}` : "";
167
180
  const prio = t.priority ? ` (${t.priority} priority)` : "";
168
- return ` ${t.id} [${t.status}] ${t.phrase}${framing}${prio}`;
181
+ const stances = (t.stances ?? []).map((s) => `\n · ${s}`).join("");
182
+ return ` ${t.id} [${t.status}] ${t.phrase}${framing}${prio}${stances}`;
169
183
  }
170
184
 
171
185
  // Targets grouped under their scope, each headed by its cap and usage. A target on an
@@ -223,7 +237,7 @@ async function update(ctx, flags) {
223
237
  const args = argsFromFlags(ARGS.update_ranking_target, flags, positionals);
224
238
  if (Object.keys(args).length === 1) {
225
239
  throw new CliError(
226
- "Nothing to update — pass --phrase, --framing, --clear-framing, --offering, --position, --state or --priority."
240
+ "Nothing to update — pass --phrase, --framing, --clear-framing, --stance, --clear-stances, --offering, --position, --state or --priority."
227
241
  );
228
242
  }
229
243
  const result = await client.callTool("update_ranking_target", args);
@@ -233,7 +247,14 @@ async function update(ctx, flags) {
233
247
  }
234
248
 
235
249
  async function targetsUpdate(ctx) {
236
- refuseUnknownFlags(ctx, ARGS.update_ranking_target, ["framing", "clear-framing", "offering", "priority"]);
250
+ refuseUnknownFlags(ctx, ARGS.update_ranking_target, [
251
+ "framing",
252
+ "clear-framing",
253
+ "stance",
254
+ "clear-stances",
255
+ "offering",
256
+ "priority",
257
+ ]);
237
258
  return update(ctx, ctx.flags);
238
259
  }
239
260
 
package/lib/commands.mjs CHANGED
@@ -40,5 +40,6 @@ export * from "./commands/offerings.mjs";
40
40
  export * from "./commands/targets.mjs";
41
41
  export * from "./commands/admin.mjs";
42
42
  export * from "./commands/feedback.mjs";
43
+ export * from "./commands/repo.mjs";
43
44
  export * from "./commands/evals.mjs";
44
45
  export * from "./commands/visits.mjs";
package/lib/documents.mjs CHANGED
@@ -61,6 +61,8 @@ export const DOCUMENT_FLAG_PAIRS = [
61
61
  ["text", "text-file"],
62
62
  // An offering's (or the company's) manifesto is prose — a PDF or a .docx is fair game.
63
63
  ["manifesto", "manifesto-file"],
64
+ // A repo change's brief (repo.mjs `repo start`) — prose, so a .docx or PDF spec is fine.
65
+ ["instructions", "instructions-file"],
64
66
  ];
65
67
 
66
68
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.98.2",
3
+ "version": "0.98.4",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",