@letterstory/cli 0.31.1 → 0.33.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 +19 -0
- package/lib/cli.mjs +12 -4
- package/lib/commands/admin.mjs +46 -1
- package/lib/commands/directives.mjs +147 -0
- package/lib/commands/flows.mjs +6 -42
- package/lib/commands.mjs +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -276,6 +276,25 @@ LETTERSTORY_STAFF_API_KEY=<your staff key with org:create> \
|
|
|
276
276
|
Everything the parent job does is also its own tool for existing orgs:
|
|
277
277
|
`ensure_collection_enhance_flow`, `provision_org_visuals`, `propose_seers` (`--args '{"apply":true}'`).
|
|
278
278
|
|
|
279
|
+
### Staff: ask the fleet a question
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
# Needs a STAFF key with the fleet:query capability. The answer is derived from
|
|
283
|
+
# read-only SQL across every org -- nothing here can write, and credential columns
|
|
284
|
+
# are unreadable, so this is safe to point at production because it cannot be
|
|
285
|
+
# anything else.
|
|
286
|
+
LETTERSTORY_STAFF_API_KEY=<your staff key with fleet:query> \
|
|
287
|
+
letterstory admin ask "which writing kernels are hooked up to the most clients?"
|
|
288
|
+
|
|
289
|
+
# --sql prints every query behind the answer, which is what makes a scripted answer
|
|
290
|
+
# checkable; --json emits the whole result (answer + steps) for a caller that wants
|
|
291
|
+
# the queries as data.
|
|
292
|
+
letterstory admin ask "which clients published nothing in the last 30 days?" --sql
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
This is the headless half of `/superadmin/ask` — same function, same schema — so a
|
|
296
|
+
long-running agent can watch the fleet on a schedule without a browser.
|
|
297
|
+
|
|
279
298
|
### Propose, then onboard a phantom
|
|
280
299
|
|
|
281
300
|
The Fleet wizard's Plan and Name screens as two commands: pick the beat, then the name,
|
package/lib/cli.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
cmdFlows,
|
|
25
25
|
cmdConnectors,
|
|
26
26
|
cmdStrategy,
|
|
27
|
+
cmdDirectives,
|
|
27
28
|
cmdProbes,
|
|
28
29
|
cmdSuggestions,
|
|
29
30
|
cmdOnboarding,
|
|
@@ -44,7 +45,7 @@ import {
|
|
|
44
45
|
} from "./commands.mjs";
|
|
45
46
|
|
|
46
47
|
// Keep in sync with cli/package.json.
|
|
47
|
-
export const VERSION = "0.
|
|
48
|
+
export const VERSION = "0.33.1";
|
|
48
49
|
|
|
49
50
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
50
51
|
// can't accidentally swallow the id as --json's value.
|
|
@@ -248,9 +249,6 @@ Authors (the per-collection author bank — recurring bylines):
|
|
|
248
249
|
Batch-swap bylines (auto-distribute, or --author to pin one)
|
|
249
250
|
|
|
250
251
|
Flows:
|
|
251
|
-
flows list List available editorial flows
|
|
252
|
-
flows run <flow-id> <article-id> Start a flow run
|
|
253
|
-
flows status <run-id> Show a flow run's status
|
|
254
252
|
flows webhook get Show the completion webhook
|
|
255
253
|
flows webhook set --url <https://…|none|off> [--secret <secret>]
|
|
256
254
|
|
|
@@ -286,6 +284,10 @@ Strategy & onboarding:
|
|
|
286
284
|
strategy topics set --collection <uuid> (--topic <topic-id> | --suggestion <suggestion-id>)
|
|
287
285
|
suggestions list <collection_id> List pending strategy suggestions + their auto-apply countdown
|
|
288
286
|
suggestions approve | dismiss | snooze <verdict|proposal> <id> [--days <n>]
|
|
287
|
+
directives list <collection_id> What is steering this collection right now
|
|
288
|
+
directives propose <collection_id> <what you want> Read one instruction back as concrete levers (applies nothing)
|
|
289
|
+
directives apply <directive_id> --effect <id>... Commit the levers you kept (or --reason to decline them all)
|
|
290
|
+
directives off <directive_id> --reason <why> Stop a live instruction; the reason is read back by the next proposal
|
|
289
291
|
probes list [--collection <uuid>] [--active-only] [--kind mention|citation|page]
|
|
290
292
|
probes keep | pause | resume <probe_id> Keep = protect an AI probe from rotation (no pause)
|
|
291
293
|
probes edit <probe_id> <new question text> Fix/sharpen a generated probe
|
|
@@ -463,6 +465,11 @@ Domain onboarding (ONE SHOT: a bare domain in, a fully-configured organization o
|
|
|
463
465
|
${bin === "phantom" ? "onboard" : "phantom-onboard"} domain --status Latest domain onboarding on this org.
|
|
464
466
|
|
|
465
467
|
Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org exists):
|
|
468
|
+
admin ask "<question>" [--sql] [--json] Ask the whole fleet a question in English.
|
|
469
|
+
Answered from the live database with
|
|
470
|
+
read-only SQL across every org; --sql prints
|
|
471
|
+
the queries behind the answer. Needs the
|
|
472
|
+
fleet:query capability on the staff key.
|
|
466
473
|
admin create-org --domain <bare-domain> [--name "Acme"] [--test] [--no-key]
|
|
467
474
|
[--key-name N] [--capabilities a,b] [--onboard] [--watch] [+ the onboard domain flags]
|
|
468
475
|
The key exchange: create the client org from
|
|
@@ -640,6 +647,7 @@ const CLIENT_COMMANDS = {
|
|
|
640
647
|
flows: cmdFlows,
|
|
641
648
|
connectors: cmdConnectors,
|
|
642
649
|
strategy: cmdStrategy,
|
|
650
|
+
directives: cmdDirectives,
|
|
643
651
|
probes: cmdProbes,
|
|
644
652
|
suggestions: cmdSuggestions,
|
|
645
653
|
onboarding: cmdOnboarding,
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// (`x-staff-api-key`, minted at Settings → Staff keys) against the superadmin REST
|
|
4
4
|
// routes, because they exist precisely to act BEFORE an org exists.
|
|
5
5
|
//
|
|
6
|
+
// admin ask "which kernels are hooked up to the most clients?" [--sql] [--json]
|
|
6
7
|
// admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
|
|
7
8
|
// [--capabilities a,b] [--onboard] [--about "…"] [--sitemap <url>]
|
|
8
9
|
// [--cms <provider>] [--posts N] [--seers suggest|skip] [--no-visuals]
|
|
@@ -17,6 +18,11 @@
|
|
|
17
18
|
// `phantom-onboard domain --watch` uses. Without --watch, watch it afterwards with
|
|
18
19
|
// LETTERSTORY_API_KEY=<printed key> letterstory phantom-onboard domain --watch --attach <job-id>
|
|
19
20
|
//
|
|
21
|
+
// `admin ask` is the headless half of /superadmin/ask: the same question, the same
|
|
22
|
+
// function, the same read-only SQL across every org — no browser. That projection is the
|
|
23
|
+
// point rather than a convenience, because a background agent that watches the fleet
|
|
24
|
+
// cannot open a console (AGENTS.md principle 5). Needs a staff key with `fleet:query`.
|
|
25
|
+
//
|
|
20
26
|
// The staff key comes from --staff-key or LETTERSTORY_STAFF_API_KEY; the base URL from the
|
|
21
27
|
// usual --url / LETTERSTORY_API_URL / config resolution.
|
|
22
28
|
|
|
@@ -178,8 +184,47 @@ async function cmdAdminCreateOrg(ctx) {
|
|
|
178
184
|
return watchDomainJob({ ...ctx, client: orgClient }, result.job_id);
|
|
179
185
|
}
|
|
180
186
|
|
|
187
|
+
/**
|
|
188
|
+
* `admin ask "<question>"` — one fleet question, one answer.
|
|
189
|
+
*
|
|
190
|
+
* Prints the prose by default. `--sql` also prints every query behind it, which is
|
|
191
|
+
* what makes a scripted answer checkable; `--json` emits the whole result for a
|
|
192
|
+
* caller that wants the steps as data.
|
|
193
|
+
*/
|
|
194
|
+
async function cmdAdminAsk(ctx) {
|
|
195
|
+
const question = ctx.positionals.join(" ").trim();
|
|
196
|
+
if (question.length < 3) {
|
|
197
|
+
throw new CliError('Usage: admin ask "which clients published nothing in the last 30 days?"');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const result = await staffPost(ctx, "/api/superadmin/ask", { question });
|
|
201
|
+
|
|
202
|
+
if (flagBool(ctx.flags.json)) {
|
|
203
|
+
printResult(ctx.io, ctx.flags, result);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The partial-answer warning goes to stderr, so `admin ask ... > answer.md` still
|
|
208
|
+
// captures only the answer while a human at a terminal still sees the caveat.
|
|
209
|
+
if (result.stopped_by && result.stopped_by !== "end_turn") {
|
|
210
|
+
ctx.io.error(`! Run ended on ${result.stopped_by} — the answer below is partial.`);
|
|
211
|
+
}
|
|
212
|
+
ctx.io.log(result.answer || "(no answer)");
|
|
213
|
+
|
|
214
|
+
if (flagBool(ctx.flags.sql)) {
|
|
215
|
+
for (const step of result.steps ?? []) {
|
|
216
|
+
if (!step.sql) continue;
|
|
217
|
+
ctx.io.log(`\n-- ${step.tool}${step.ok ? "" : " (failed)"}${step.truncated ? " (truncated)" : ""}`);
|
|
218
|
+
ctx.io.log(step.sql);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
181
223
|
export async function cmdAdmin(ctx) {
|
|
182
224
|
const sub = ctx.positionals[0];
|
|
183
225
|
if (sub === "create-org") return cmdAdminCreateOrg({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
184
|
-
|
|
226
|
+
if (sub === "ask") return cmdAdminAsk({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
227
|
+
throw new CliError(
|
|
228
|
+
'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" (see --help)'
|
|
229
|
+
);
|
|
185
230
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// `directives` — tell one collection what to do, and see what you've already told it.
|
|
2
|
+
//
|
|
3
|
+
// Thin wrappers over the four MCP tools (propose / apply / list / revoke), which
|
|
4
|
+
// call the same lib functions the app's routes do — so a script or an agent gets
|
|
5
|
+
// exactly the sentence-to-levers interpretation a person gets, and reads back the
|
|
6
|
+
// same "what is steering this collection" list.
|
|
7
|
+
//
|
|
8
|
+
// `propose` PERSISTS NOTHING you'd notice: it writes one proposal row nothing
|
|
9
|
+
// consumes and returns the levers, so `--json | jq` is a legitimate way to ask
|
|
10
|
+
// "what would you do?" without doing it. Applying is a second, explicit call.
|
|
11
|
+
|
|
12
|
+
import { CliError } from "../client.mjs";
|
|
13
|
+
import { flagStr, flagList, requirePositional, printResult, argsFromFlags, ok } from "./shared.mjs";
|
|
14
|
+
|
|
15
|
+
// How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
|
|
16
|
+
// `scripts/parity-check.ts` compares these keys against each tool's published input schema.
|
|
17
|
+
export const ARGS = {
|
|
18
|
+
propose_collection_directive: {
|
|
19
|
+
collection_id: { positional: 0, label: "collection-id", required: true },
|
|
20
|
+
// The instruction is the REST of the line, joined — quoting a sentence on a
|
|
21
|
+
// shell is exactly the friction that makes people type one word instead.
|
|
22
|
+
text: { value: (_flags, positionals) => positionals.slice(1).join(" ").trim() || undefined },
|
|
23
|
+
},
|
|
24
|
+
apply_collection_directive: {
|
|
25
|
+
directive_id: { positional: 0, label: "directive-id", required: true },
|
|
26
|
+
accepted_effect_ids: { flag: "effect", type: "list" },
|
|
27
|
+
rejection_reason: "reason",
|
|
28
|
+
},
|
|
29
|
+
list_collection_directives: {
|
|
30
|
+
collection_id: { positional: 0, label: "collection-id", required: true },
|
|
31
|
+
},
|
|
32
|
+
revoke_collection_directive: {
|
|
33
|
+
directive_id: { positional: 0, label: "directive-id", required: true },
|
|
34
|
+
reason: { flag: "reason", required: true },
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export async function cmdDirectives(ctx) {
|
|
39
|
+
const sub = ctx.positionals[0];
|
|
40
|
+
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
41
|
+
switch (sub) {
|
|
42
|
+
case "list":
|
|
43
|
+
case "ls":
|
|
44
|
+
return directivesList(rest);
|
|
45
|
+
case "propose":
|
|
46
|
+
return directivesPropose(rest);
|
|
47
|
+
case "apply":
|
|
48
|
+
return directivesApply(rest);
|
|
49
|
+
case "off":
|
|
50
|
+
case "revoke":
|
|
51
|
+
return directivesRevoke(rest);
|
|
52
|
+
default:
|
|
53
|
+
throw new CliError("Usage: directives <list|propose|apply|off>");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatEffect(e) {
|
|
58
|
+
const where = e.clusterName ? ` (${e.clusterName})` : "";
|
|
59
|
+
const life = e.ongoing ? "keeps applying" : "once";
|
|
60
|
+
return ` ${e.id} ${e.label}${where} [${life}]\n why: ${e.rationale}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function directivesList(ctx) {
|
|
64
|
+
const { client, flags, positionals, io } = ctx;
|
|
65
|
+
const result = await client.callTool(
|
|
66
|
+
"list_collection_directives",
|
|
67
|
+
argsFromFlags(ARGS.list_collection_directives, flags, positionals)
|
|
68
|
+
);
|
|
69
|
+
printResult(io, flags, result, (r) => {
|
|
70
|
+
const list = r.directives ?? [];
|
|
71
|
+
if (!list.length) return "Nothing is steering this collection.";
|
|
72
|
+
return list
|
|
73
|
+
.map((d) => {
|
|
74
|
+
const until = d.expiresAt ? ` stops ${d.expiresAt.slice(0, 10)}` : "";
|
|
75
|
+
const who = d.decidedBy ? "set by a person" : "set without a person in the loop";
|
|
76
|
+
const head = `${d.id} ${d.body}${until}\n (${who}, ${d.createdAt.slice(0, 10)})`;
|
|
77
|
+
return [head, ...(d.effects ?? []).map(formatEffect)].join("\n");
|
|
78
|
+
})
|
|
79
|
+
.join("\n\n");
|
|
80
|
+
});
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function directivesPropose(ctx) {
|
|
85
|
+
const { client, flags, positionals, io } = ctx;
|
|
86
|
+
if (!positionals.slice(1).join(" ").trim()) {
|
|
87
|
+
throw new CliError(
|
|
88
|
+
"Usage: directives propose <collection-id> <what you want>\n e.g. directives propose abc-123 focus on the pricing area"
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const result = await client.callTool(
|
|
92
|
+
"propose_collection_directive",
|
|
93
|
+
argsFromFlags(ARGS.propose_collection_directive, flags, positionals)
|
|
94
|
+
);
|
|
95
|
+
printResult(io, flags, result, (r) => {
|
|
96
|
+
const p = r.proposal ?? {};
|
|
97
|
+
const lines = [p.interpretation || p.body, "", `directive: ${p.directiveId}`];
|
|
98
|
+
for (const e of p.effects ?? []) lines.push(formatEffect(e));
|
|
99
|
+
if (!(p.effects ?? []).length) lines.push(" (no levers — nothing here we can currently do)");
|
|
100
|
+
if (p.unsupported) lines.push("", `Can't express: ${p.unsupported}`);
|
|
101
|
+
for (const s of p.supersedes ?? []) lines.push("", `Accepting turns off: "${s.body}"`);
|
|
102
|
+
lines.push(
|
|
103
|
+
"",
|
|
104
|
+
`Then: directives apply ${p.directiveId} --effect <id> [--effect <id>]`,
|
|
105
|
+
` or: directives apply ${p.directiveId} --reason "why none of these"`
|
|
106
|
+
);
|
|
107
|
+
return lines.join("\n");
|
|
108
|
+
});
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function directivesApply(ctx) {
|
|
113
|
+
const { client, flags, positionals } = ctx;
|
|
114
|
+
const args = argsFromFlags(ARGS.apply_collection_directive, flags, positionals);
|
|
115
|
+
const effects = flagList(flags.effect);
|
|
116
|
+
// Declining everything is a legitimate answer, but it has to say why — the next
|
|
117
|
+
// proposal reads the reason back, and one that doesn't is a dismissal.
|
|
118
|
+
if (!effects.length && !flagStr(flags.reason)) {
|
|
119
|
+
throw new CliError('Pass --effect <id> to apply one, or --reason "why" to decline the lot.');
|
|
120
|
+
}
|
|
121
|
+
args.accepted_effect_ids = effects;
|
|
122
|
+
const result = await client.callTool("apply_collection_directive", args);
|
|
123
|
+
const applied = result.applied ?? {};
|
|
124
|
+
const failed = (applied.results ?? []).filter((r) => !r.ok);
|
|
125
|
+
if (!effects.length) {
|
|
126
|
+
ok(ctx, "Noted — we won't suggest that again.");
|
|
127
|
+
} else if (failed.length) {
|
|
128
|
+
ok(
|
|
129
|
+
ctx,
|
|
130
|
+
`Applied ${effects.length - failed.length}/${effects.length}. ${failed.map((f) => f.detail).join(" ")}`
|
|
131
|
+
);
|
|
132
|
+
} else {
|
|
133
|
+
ok(ctx, `Applied ${effects.length} change${effects.length === 1 ? "" : "s"}.`);
|
|
134
|
+
}
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function directivesRevoke(ctx) {
|
|
139
|
+
const { client, flags, positionals } = ctx;
|
|
140
|
+
requirePositional(positionals, 0, "directive-id");
|
|
141
|
+
await client.callTool(
|
|
142
|
+
"revoke_collection_directive",
|
|
143
|
+
argsFromFlags(ARGS.revoke_collection_directive, flags, positionals)
|
|
144
|
+
);
|
|
145
|
+
ok(ctx, "Turned off.");
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
package/lib/commands/flows.mjs
CHANGED
|
@@ -1,58 +1,22 @@
|
|
|
1
|
-
// `flows` —
|
|
2
|
-
//
|
|
1
|
+
// `flows` — manage the flow_run.completed completion webhook. list/run/status
|
|
2
|
+
// (Enhance flow authoring + running) are retired along with the rest of that UI;
|
|
3
|
+
// see docs/architecture/enhance-flows-retirement.md for the removal. get_webhook/set_webhook are untouched —
|
|
4
|
+
// they configure a webhook on the API key, not an Enhance flow.
|
|
3
5
|
|
|
4
6
|
import { CliError } from "../client.mjs";
|
|
5
|
-
import { flagStr,
|
|
6
|
-
|
|
7
|
-
/** See `argsFromFlags` in shared.mjs; `scripts/parity-check.ts` diffs these keys against the tool. */
|
|
8
|
-
export const ARGS = {
|
|
9
|
-
run_flow: {
|
|
10
|
-
flow_id: { positional: 0, label: "flow-id", required: true },
|
|
11
|
-
article_id: { positional: 1, label: "article-id", required: true },
|
|
12
|
-
},
|
|
13
|
-
};
|
|
7
|
+
import { flagStr, printResult, compact, ok } from "./shared.mjs";
|
|
14
8
|
|
|
15
9
|
export async function cmdFlows(ctx) {
|
|
16
10
|
const sub = ctx.positionals[0];
|
|
17
11
|
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
|
18
12
|
switch (sub) {
|
|
19
|
-
case "list":
|
|
20
|
-
case "ls":
|
|
21
|
-
return flowsList(rest);
|
|
22
|
-
case "run":
|
|
23
|
-
return flowsRun(rest);
|
|
24
|
-
case "status":
|
|
25
|
-
return flowsStatus(rest);
|
|
26
13
|
case "webhook":
|
|
27
14
|
return flowsWebhook(rest);
|
|
28
15
|
default:
|
|
29
|
-
throw new CliError(`Unknown flows subcommand: ${sub ?? "(none)"}. Try:
|
|
16
|
+
throw new CliError(`Unknown flows subcommand: ${sub ?? "(none)"}. Try: webhook`);
|
|
30
17
|
}
|
|
31
18
|
}
|
|
32
19
|
|
|
33
|
-
async function flowsList(ctx) {
|
|
34
|
-
const { client, flags, io } = ctx;
|
|
35
|
-
const result = await client.callTool("list_flows", {});
|
|
36
|
-
printResult(io, flags, result);
|
|
37
|
-
return 0;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function flowsRun(ctx) {
|
|
41
|
-
const { client, positionals, flags, io } = ctx;
|
|
42
|
-
const result = await client.callTool("run_flow", argsFromFlags(ARGS.run_flow, flags, positionals));
|
|
43
|
-
ok(ctx, "Flow run started.");
|
|
44
|
-
printResult(io, flags, result);
|
|
45
|
-
return 0;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async function flowsStatus(ctx) {
|
|
49
|
-
const { client, positionals, flags, io } = ctx;
|
|
50
|
-
const runId = requirePositional(positionals, 0, "run-id");
|
|
51
|
-
const result = await client.callTool("get_run_status", { flow_run_id: runId });
|
|
52
|
-
printResult(io, flags, result);
|
|
53
|
-
return 0;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
20
|
async function flowsWebhook(ctx) {
|
|
57
21
|
const sub = ctx.positionals[0];
|
|
58
22
|
const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
|
package/lib/commands.mjs
CHANGED
|
@@ -16,6 +16,7 @@ export * from "./commands/authors.mjs";
|
|
|
16
16
|
export * from "./commands/flows.mjs";
|
|
17
17
|
export * from "./commands/connectors.mjs";
|
|
18
18
|
export * from "./commands/strategy.mjs";
|
|
19
|
+
export * from "./commands/directives.mjs";
|
|
19
20
|
export * from "./commands/probes.mjs";
|
|
20
21
|
export * from "./commands/series.mjs";
|
|
21
22
|
export * from "./commands/insights.mjs";
|