@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 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.31.1";
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,
@@ -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
- throw new CliError("Usage: admin create-org --domain <bare-domain> [--onboard] … (see --help)");
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
+ }
@@ -1,58 +1,22 @@
1
- // `flows` — run editorial passes over articles, check run status, and manage the
2
- // flow_run.completed completion webhook. Ported from phantomstory-cli's flows.ts.
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, requirePositional, printResult, compact, ok, argsFromFlags } from "./shared.mjs";
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: list, run, status, webhook`);
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.31.1",
3
+ "version": "0.33.1",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",