@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 +1 -0
- package/lib/cli.mjs +32 -2
- package/lib/commands/admin.mjs +64 -1
- package/lib/commands/connectors.mjs +0 -1
- package/lib/commands/flows.mjs +3 -3
- package/lib/commands/phantom.mjs +4 -1
- package/lib/commands/repo.mjs +26 -1
- 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.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>
|
|
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.
|
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
|
}
|
|
@@ -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.
|
package/lib/commands/flows.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// `flows` — manage the
|
|
2
|
-
// (Enhance flow authoring + running) are retired
|
|
3
|
-
// see docs/architecture/enhance-flows-retirement.md
|
|
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";
|
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
|
@@ -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. */
|