@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 +1 -0
- package/lib/cli.mjs +32 -4
- package/lib/commands/admin.mjs +64 -1
- package/lib/commands/phantom.mjs +4 -1
- package/lib/commands/repo.mjs +31 -4
- package/lib/commands/site.mjs +341 -0
- package/lib/commands.mjs +1 -0
- package/lib/documents.mjs +2 -0
- package/package.json +1 -1
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.
|
|
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
|
|
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.
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -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
|
}
|
package/lib/commands/phantom.mjs
CHANGED
|
@@ -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
|
}
|
package/lib/commands/repo.mjs
CHANGED
|
@@ -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:
|
|
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:
|
|
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. */
|