@letterstory/cli 0.87.0 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cli.mjs CHANGED
@@ -57,7 +57,7 @@ import {
57
57
  } from "./commands.mjs";
58
58
 
59
59
  // Keep in sync with cli/package.json.
60
- export const VERSION = "0.87.0";
60
+ export const VERSION = "0.89.0";
61
61
 
62
62
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
63
63
  // can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
@@ -68,6 +68,8 @@ export const BOOLEAN_FLAGS = new Set([
68
68
  "confirm-missing-target",
69
69
  "no-wait",
70
70
  "no-checklist",
71
+ "no-control",
72
+ "own-beat",
71
73
  "refresh",
72
74
  "accept",
73
75
  "help",
@@ -347,7 +349,7 @@ Collections:
347
349
  preferred sites — dry, nothing written. Proposals are judged by fit.
348
350
  collections preferred-preview <id>
349
351
  Step 2: the proposals ranked, what the ceiling chooses, and the 1-in-N.
350
- collections cite-preferred <id>
352
+ collections cite-preferred <id> [--exclude <article-id> …]
351
353
  Step 3: apply exactly the chosen sentences, then republish.
352
354
 
353
355
  Authors (the per-collection author bank — recurring bylines):
@@ -637,6 +639,12 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
637
639
  read-only SQL across every org; --sql prints
638
640
  the queries behind the answer. Needs the
639
641
  fleet:query capability on the staff key.
642
+ admin brand-detection <org-id> [--term <t>]... [--exact <t>]... [--exclude <phrase>]... [--note "…"] | --clear [--note "…"]
643
+ How a client's brand is detected in AI answers, with
644
+ every earlier setting; with flags, set the exact set
645
+ (fleet:instrument) — e.g. --term Context.dev for a name
646
+ that is also a word. --clear goes back to inferred,
647
+ recorded as your decision.
640
648
  admin replays [--org <uuid>] [--target <uuid>] [--status captured|not_reproduced|queued|running|unsupported|failed] [--limit <n>]
641
649
  Live answer replays: breakthroughs re-asked in ChatGPT /
642
650
  Gemini / Google / Perplexity / Claude, with screenshots.
@@ -1035,6 +1043,7 @@ Insights:
1035
1043
  insights competitors [--period 14d|30d|90d] GEO → Competitors: share of mentions per brand, is it moving, by engine/question
1036
1044
  insights question <key> [--period 14d|30d|90d] [--filter all|named_you|rival_not_you|no_one] [--limit n]
1037
1045
  One By-question row: the wordings asked and the AI answers
1046
+ insights detection How your brand is detected in AI answers (terms, who set them)
1038
1047
  insights site [--period 14d|30d|90d] [--collection <uuid>]
1039
1048
  insights post <article-id> [--period 14d|30d|90d]
1040
1049
  insights top [--period 14d|30d|90d] [--collection <uuid>] [--limit <n>] [--sort clicks|impressions]
@@ -12,9 +12,11 @@
12
12
  // (feedback-add --fix "…" files the reader's rewrite; the diff is classified and a disposition suggested.
13
13
  // The old names — evals, eval-add, eval-set, proposals, propose-eval, capture-eval, reject-eval — still work.)
14
14
  // admin evals scoreboard|cases|runs|run|triage|guard|retire … (fleet:read / tuning:experiment)
15
- // admin fleet-experiments list|articles|mark|unmark|new|assign|assign-eligible|assign-orgs|apply|start|stop|decide … (fleet:read / fleet:experiment)
15
+ // admin fleet-experiments list|articles|clients|mark|unmark|new|assign|assign-eligible|assign-orgs|spin-up|apply|start|stop|decide … (fleet:read / fleet:experiment)
16
16
  // admin crm import|capture|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (crm:read / crm:write)
17
17
  // admin spend summary|reconcile|pnl|entries|add|import|usage|remove|reference|stripe-sync|services|service … (spend:read / spend:write)
18
+ // admin brand-detection <org-id> (fleet:read)
19
+ // admin brand-detection <org-id> [--term t]... [--exact t]... [--exclude p]... [--note "…"] | --clear [--note "…"] (fleet:instrument)
18
20
  // admin replays [--org <id>] [--target <id>] [--status …] [--limit n] (fleet:read)
19
21
  // admin replay --org <id> [<response-id> | --target <id>] [--surface …] (fleet:replay)
20
22
  // admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
@@ -41,7 +43,7 @@
41
43
 
42
44
  import { readFileSync } from "node:fs";
43
45
  import { CliError, LetterstoryClient } from "../client.mjs";
44
- import { flagStr, flagNum, flagBool, flagList, printResult, compact, ok } from "./shared.mjs";
46
+ import { flagStr, flagNum, flagBool, flagList, printResult, compact, ok, requirePositional } from "./shared.mjs";
45
47
  import { watchDomainJob } from "./onboard.mjs";
46
48
 
47
49
  const SEER_MODES = ["suggest", "skip"];
@@ -453,12 +455,12 @@ async function cmdAdminTuningContext(ctx) {
453
455
  * admin tuning loop where the loop stands: what waits at each station, and where to go next
454
456
  * admin tuning ledger <comparison-id> the defect ledger: per arm, what the diagnosis read named, the floor and the ceiling, the candidate's next arm; then the reader edits per arm by lever, and the schema gaps
455
457
  * admin tuning checklist the checklist: every defect category with its status, the picks' agreement (n, agreement, suggestion) and its labels (+/−) (alias: evals)
456
- * admin tuning checklist-add --id … --label … --how … --layer … --lever … --locality local|structural --axis … [--example "span"] add a category (starts unvetted) (alias: eval-add)
457
- * admin tuning checklist-set <id> [--status confirmed|rejected|unvetted] [--example "span" [--fix "…"]] confirm / reject (a person's call), or add an example (alias: eval-set)
458
+ * admin tuning checklist-add --id … --label … --looks-like "…" --how "…" --layer … --lever … [--paths voice,…] --locality local|structural --axis … [--example "span"] add a category (starts unvetted; an id already on the list is refused); --looks-like is the defect as the judge reads it, --how the fix, --paths what a bake-off merge copies (alias: eval-add)
459
+ * admin tuning checklist-set <id> [--status confirmed|rejected|unvetted] [--example "span" [--fix "…"]] confirm / reject (a person's call, recorded as the key's owner), or add an example (alias: eval-set)
458
460
  * admin tuning checklist-labels <id> the labelled set of one category: positives (examples + feedback filed into it), negatives (rejected feedback suggested for it), grid overrules
459
461
  * admin tuning feedback [--status proposed|captured|rejected] the feedback: spans readers marked in a pair's text with their words, each with its disposition (alias: proposals)
460
462
  * admin tuning feedback-add --span "…" --comment "…" [--fix "…"] [--comparison <id> --cell <key> --side control|candidate] [--kind narrow|wide] add feedback (no --side from a blind read); --fix is the reader's rewrite, classified and a disposition suggested (alias: propose-eval)
461
- * admin tuning feedback-file <id> --as checklist --id … --label … --how … --layer … --lever … --locality local|structural --axis … file it on the checklist, the span (and its fix) as the category's example (appends when the id exists) (alias: capture-eval; --as category)
463
+ * admin tuning feedback-file <id> --as checklist --id … --label … --looks-like "…" --how "…" --layer … --lever … [--paths …] --locality local|structural --axis … file it on the checklist, the span (and its fix) as the category's example (appends when the id exists) (alias: capture-eval; --as category)
462
464
  * admin tuning feedback-file <id> --as rule file it as a rule; the rule itself is written in the kernels rule lab
463
465
  * admin tuning feedback-file <id> --as config [--note "…"] file it as a lever this candidate should pull
464
466
  * admin tuning feedback-file <id> --as implementation [--note "…"] file it as a schema gap no setting can express, for the kernels repo (--as lever still works)
@@ -516,7 +518,9 @@ async function cmdAdminTuningContext(ctx) {
516
518
  *
517
519
  * The bake-off: a screen's versions graded item by item against the checklist's confirmed and
518
520
  * unvetted categories and the craft axes commitment / economy / movement, the winner
519
- * per item tallied across the inputs, and a merge that composes the best of each into one candidate.
521
+ * per item tallied across the inputs, and a merge that composes the best of each into one candidate:
522
+ * each kernel-file path takes the value the most trusted wins stand behind (a person-confirmed
523
+ * category or an axis); versions on another writer take no part.
520
524
  * Deterministic; a person overrides a grade and confirms the merge, which is a hypothesis until the
521
525
  * eye test confirms it.
522
526
  * admin tuning bakeoff <screen-id> [--regrade] the winners table: item, trusted, leader, wins per version, graded; then the conflicts a merge would meet
@@ -614,7 +618,7 @@ function printProgramme(io, r) {
614
618
  for (const row of sm.table ?? [])
615
619
  if (row.leader !== null && row.leader !== sm.base)
616
620
  io.log(
617
- ` ${row.item.id.padEnd(32)} ${row.trusted ? "trusted " : "unvetted "} v${row.leader} ${Object.entries(
621
+ ` ${row.item.id.padEnd(32)} ${row.trusted ? "trusted " : row.item.status === "confirmed" ? "undecided" : "unvetted "} v${row.leader} ${Object.entries(
618
622
  row.wins
619
623
  )
620
624
  .map(([v, n]) => `v${v}:${n}`)
@@ -806,6 +810,7 @@ async function cmdAdminTuning(ctx) {
806
810
  if (json) return printResult(io, flags, r);
807
811
  const rows = r.categories ?? [];
808
812
  if (!rows.length) return io.log("(no categories)");
813
+ io.log('("confirmed?" = confirmed by nobody on record, a seeded status: graded, never merged)');
809
814
  const cell = (v, w) => String(v ?? "—").padEnd(w);
810
815
  io.log(
811
816
  `${cell("id", 24)} ${cell("status", 10)} ${cell("layer", 10)} ${cell("lever", 30)} ${"n".padStart(4)} ${"agree".padStart(6)} ${cell("suggest", 10)} ${"+".padStart(3)} ${"−".padStart(3)}`
@@ -813,8 +818,10 @@ async function cmdAdminTuning(ctx) {
813
818
  for (const c of rows) {
814
819
  const v = c.vetting ?? {};
815
820
  const l = c.labels ?? { positives: (c.examples ?? []).length, negatives: 0 };
821
+ // A confirmed status nobody is on record as deciding is a seeded one: the bake-off does not merge on it.
822
+ const status = c.status === "confirmed" && !c.decided_by ? "confirmed?" : c.status;
816
823
  io.log(
817
- `${cell(c.id, 24)} ${cell(c.status, 10)} ${cell(c.layer, 10)} ${cell(c.lever || "—", 30)} ${String(v.n ?? 0).padStart(4)} ${String(v.agreement ?? "—").padStart(6)} ${cell(v.suggestion, 10)} ${String(l.positives).padStart(3)} ${String(l.negatives).padStart(3)}`
824
+ `${cell(c.id, 24)} ${cell(status, 10)} ${cell(c.layer, 10)} ${cell(c.lever || "—", 30)} ${String(v.n ?? 0).padStart(4)} ${String(v.agreement ?? "—").padStart(6)} ${cell(v.suggestion, 10)} ${String(l.positives).padStart(3)} ${String(l.negatives).padStart(3)}${(c.paths ?? []).length ? "" : " (no path)"}`
818
825
  );
819
826
  }
820
827
  return;
@@ -832,30 +839,17 @@ async function cmdAdminTuning(ctx) {
832
839
  }
833
840
  case "checklist-add":
834
841
  case "eval-add": {
835
- const body = {
836
- id: flagStr(flags.id),
837
- label: flagStr(flags.label),
838
- how: flagStr(flags.how),
839
- layer: flagStr(flags.layer),
840
- lever: flagStr(flags.lever) ?? "",
841
- locality: flagStr(flags.locality),
842
- axis: flagStr(flags.axis),
843
- };
842
+ const body = checklistWords(flags);
844
843
  const example = flagStr(flags.example);
845
- if (
846
- !body.id ||
847
- !body.label ||
848
- !body.how ||
849
- !body.layer ||
850
- !body.axis ||
851
- !["local", "structural"].includes(body.locality ?? "")
852
- )
853
- throw new CliError(
854
- 'Usage: admin tuning checklist-add --id … --label … --how … --layer … --lever … --locality local|structural --axis … [--example "span"]'
855
- );
844
+ if (!body)
845
+ throw new CliError(`Usage: admin tuning checklist-add ${CHECKLIST_WORDS_USAGE} [--example "span"]`);
856
846
  if (example) body.examples = [{ span: example, ...(flags.fix ? { fix: flagStr(flags.fix) } : {}) }];
857
847
  const r = await staffRequest(ctx, "POST", `${T}/evals`, body);
858
- return json ? printResult(io, flags, r) : io.log(`added ${r.category.id} (${r.category.status})`);
848
+ if (json) return printResult(io, flags, r);
849
+ io.log(`added ${r.category.id} (${r.category.status})`);
850
+ if (!(r.category.paths ?? []).length)
851
+ io.log(" no --paths: a bake-off grades this item but can never merge on it");
852
+ return;
859
853
  }
860
854
  case "checklist-set":
861
855
  case "eval-set": {
@@ -877,7 +871,9 @@ async function cmdAdminTuning(ctx) {
877
871
  const r = await staffRequest(ctx, "PATCH", `${T}/evals/${encodeURIComponent(id)}`, patch);
878
872
  return json
879
873
  ? printResult(io, flags, r)
880
- : io.log(`${r.category.id}: ${r.category.status}, ${(r.category.examples ?? []).length} examples`);
874
+ : io.log(
875
+ `${r.category.id}: ${r.category.status}${r.category.decided_by ? ` (by ${r.category.decided_by})` : ""}, ${(r.category.examples ?? []).length} examples`
876
+ );
881
877
  }
882
878
  case "feedback":
883
879
  case "proposals": {
@@ -946,32 +942,15 @@ async function cmdAdminTuning(ctx) {
946
942
  const AS_ALIAS = { category: "checklist", lever: "implementation" };
947
943
  const asGiven = flagStr(flags.as);
948
944
  const as = AS_ALIAS[asGiven] ?? asGiven;
949
- const usage =
950
- 'Usage: admin tuning feedback-file <id> --as checklist --id … --label … --how … --layer … --lever … --locality local|structural --axis … | --as rule | --as config|implementation|material [--note "…"] | --as note';
945
+ const usage = `Usage: admin tuning feedback-file <id> --as checklist ${CHECKLIST_WORDS_USAGE} | --as rule | --as config|implementation|material [--note "…"] | --as note`;
951
946
  if (!id || !["checklist", "rule", "config", "implementation", "material", "note"].includes(as ?? ""))
952
947
  throw new CliError(usage);
953
948
  let body = { as };
954
949
  if (as === "config" || as === "implementation" || as === "material")
955
950
  body = compact({ as, note: flagStr(flags.note) });
956
951
  if (as === "checklist") {
957
- const category = {
958
- id: flagStr(flags.id),
959
- label: flagStr(flags.label),
960
- how: flagStr(flags.how),
961
- layer: flagStr(flags.layer),
962
- lever: flagStr(flags.lever) ?? "",
963
- locality: flagStr(flags.locality),
964
- axis: flagStr(flags.axis),
965
- };
966
- if (
967
- !category.id ||
968
- !category.label ||
969
- !category.how ||
970
- !category.layer ||
971
- !category.axis ||
972
- !["local", "structural"].includes(category.locality ?? "")
973
- )
974
- throw new CliError(usage);
952
+ const category = checklistWords(flags);
953
+ if (!category) throw new CliError(usage);
975
954
  body = { as, category };
976
955
  }
977
956
  const r = await staffRequest(ctx, "POST", `${T}/proposals/${id}/capture`, body);
@@ -1771,7 +1750,11 @@ async function cmdAdminTuning(ctx) {
1771
1750
  `merged from v${r.base} → candidate v${r.version}${r.comparison_id ? ` · comparison ${r.comparison_id}` : ""}`
1772
1751
  );
1773
1752
  for (const p of r.provenance)
1774
- io.log(` ${p.applied ? "applied" : `skipped (${p.skipped})`} ${p.item} ${p.path} from v${p.from}`);
1753
+ io.log(
1754
+ ` ${p.applied ? (p.from === r.base ? "kept (the base's, back on top of a wider copy)" : "applied") : `skipped (${p.skipped})`} ${p.item} ${p.path} from v${p.from}`
1755
+ );
1756
+ for (const e of r.excluded ?? [])
1757
+ io.log(` left out v${e.version}: runs on ${e.writer}, not the base's writer`);
1775
1758
  if (r.conflicts.length)
1776
1759
  io.log(
1777
1760
  `conflicts left (the base's value kept): ${r.conflicts.map((c) => `${c.path} ← ${c.candidates.map((x) => `${x.item} v${x.version}`).join(" | ")}`).join("; ")}`
@@ -3347,6 +3330,43 @@ async function cmdAdminSpend(ctx) {
3347
3330
  }
3348
3331
  }
3349
3332
 
3333
+ /**
3334
+ * A checklist item's words from the flags (checklist-add, feedback-file --as checklist): the
3335
+ * service's add schema — `--looks-like` is the defect as the judge reads it, `--how` the fix,
3336
+ * `--paths` the lever as kernel-file paths (comma-separated) so a bake-off can merge on it.
3337
+ * Null when a required word is missing.
3338
+ */
3339
+ function checklistWords(flags) {
3340
+ const paths = (flagStr(flags.paths) ?? "")
3341
+ .split(",")
3342
+ .map((p) => p.trim())
3343
+ .filter(Boolean);
3344
+ const words = {
3345
+ id: flagStr(flags.id),
3346
+ label: flagStr(flags.label),
3347
+ looks_like: flagStr(flags["looks-like"]),
3348
+ how: flagStr(flags.how),
3349
+ layer: flagStr(flags.layer),
3350
+ lever: flagStr(flags.lever),
3351
+ locality: flagStr(flags.locality),
3352
+ axis: flagStr(flags.axis),
3353
+ ...(paths.length ? { paths } : {}),
3354
+ };
3355
+ const complete =
3356
+ words.id &&
3357
+ words.label &&
3358
+ words.looks_like &&
3359
+ words.how &&
3360
+ words.layer &&
3361
+ words.lever &&
3362
+ words.axis &&
3363
+ ["local", "structural"].includes(words.locality ?? "");
3364
+ return complete ? words : null;
3365
+ }
3366
+
3367
+ const CHECKLIST_WORDS_USAGE =
3368
+ '--id … --label … --looks-like "what the defect looks like" --how "the fix" --layer writer|frame|voice|economy|tells|length|material --lever … [--paths \'voice,rules["<id>"]\'] --locality local|structural --axis specificity|commitment|economy|movement|none';
3369
+
3350
3370
  const FLEET_EXPERIMENTS = "/api/superadmin/experiments";
3351
3371
 
3352
3372
  function pct(rate) {
@@ -3466,7 +3486,7 @@ async function cmdAdminFleetExperiments(ctx) {
3466
3486
  const specFile = flagStr(flags["spec-file"]);
3467
3487
  if (!template && !specFile)
3468
3488
  throw new CliError(
3469
- "Usage: admin fleet-experiments new --template sponsor_disclosure_v1|sponsor_disclosure_clients_v1|article_disclosure_v1|mention_degree_v1|citation_shape_v1 | --spec-file spec.json"
3489
+ "Usage: admin fleet-experiments new --template sponsor_disclosure_v1|sponsor_disclosure_clients_v1|sponsor_disclosure_fresh_v1|article_disclosure_v1|mention_degree_v1|citation_shape_v1 | --spec-file spec.json"
3470
3490
  );
3471
3491
  const body = template
3472
3492
  ? { action: "create_template", template }
@@ -3500,6 +3520,37 @@ async function cmdAdminFleetExperiments(ctx) {
3500
3520
  return lines.join("\n") || "ok";
3501
3521
  });
3502
3522
  }
3523
+ case "clients":
3524
+ return out(await staffRequest(ctx, "GET", `${FLEET_EXPERIMENTS}/clients`), (r) =>
3525
+ r.clients.length
3526
+ ? r.clients
3527
+ .map((c) => `${c.name} ${c.domain} ${c.sites} site${c.sites === 1 ? "" : "s"}`)
3528
+ .join("\n")
3529
+ : "(no clients)"
3530
+ );
3531
+ case "spin-up": {
3532
+ const org = rest.find(Boolean);
3533
+ if (!id || !org)
3534
+ throw new CliError(
3535
+ "Usage: admin fleet-experiments spin-up <experiment-id> <org-domain-or-id> [--no-control] [--own-beat] [--posts n]"
3536
+ );
3537
+ const body = {
3538
+ action: "spin_up",
3539
+ experiment_id: id,
3540
+ org,
3541
+ control: !flagBool(flags["no-control"]),
3542
+ match: flagBool(flags["own-beat"]) ? "own_beat" : "twin",
3543
+ ...(flagStr(flags.posts) ? { posts_to_seed: Number(flagStr(flags.posts)) } : {}),
3544
+ };
3545
+ return out(await action(body), (r) =>
3546
+ [
3547
+ r.twins
3548
+ ? `launching twins for ${r.org.name} on "${r.sites[0]?.beat}":`
3549
+ : `launching for ${r.org.name}:`,
3550
+ ...r.sites.map((s) => ` ${s.arm}: ${s.name ?? s.beat} (onboarding ${s.jobId})`),
3551
+ ].join("\n")
3552
+ );
3553
+ }
3503
3554
  case "start":
3504
3555
  case "stop":
3505
3556
  if (!id) throw new CliError(`Usage: admin fleet-experiments ${sub} <experiment-id>`);
@@ -3512,7 +3563,7 @@ async function cmdAdminFleetExperiments(ctx) {
3512
3563
  }
3513
3564
  default:
3514
3565
  throw new CliError(
3515
- 'Usage: admin fleet-experiments list | articles [--experiment <id>] [--org <id>] [--filter all|assigned|unassigned] [--arm <arm>] [--q …] [--limit n] [--offset n] | mark <experiment-id> <article-id…> --arm <arm> | unmark <experiment-id> <article-id…> | new --template <key> | start|stop <experiment-id> | decide <experiment-id> --decision "…"'
3566
+ 'Usage: admin fleet-experiments list | articles [--experiment <id>] [--org <id>] [--filter all|assigned|unassigned] [--arm <arm>] [--q …] [--limit n] [--offset n] | mark <experiment-id> <article-id…> --arm <arm> | unmark <experiment-id> <article-id…> | new --template <key> | clients | spin-up <experiment-id> <org> [--no-control] [--own-beat] | start|stop <experiment-id> | decide <experiment-id> --decision "…"'
3516
3567
  );
3517
3568
  }
3518
3569
  }
@@ -3531,7 +3582,65 @@ export async function cmdAdmin(ctx) {
3531
3582
  if (sub === "tuning-import") return cmdAdminTuningImport({ ...ctx, positionals: ctx.positionals.slice(1) });
3532
3583
  if (sub === "tuning") return cmdAdminTuning({ ...ctx, positionals: ctx.positionals.slice(1) });
3533
3584
  if (sub === "evals") return cmdAdminEvals({ ...ctx, positionals: ctx.positionals.slice(1) });
3585
+ if (sub === "brand-detection") return cmdAdminBrandDetection({ ...ctx, positionals: ctx.positionals.slice(1) });
3534
3586
  throw new CliError(
3535
- 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin fleet-experiments … | admin crm … | admin spend … | admin evals … | admin tuning … | admin tuning-context | admin replays … (see --help)'
3587
+ 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin fleet-experiments … | admin crm … | admin spend … | admin evals … | admin brand-detection … | admin tuning … | admin tuning-context | admin replays … (see --help)'
3536
3588
  );
3537
3589
  }
3590
+
3591
+ // `admin brand-detection <org-id>` — how the client's brand is detected in AI answers;
3592
+ // with --term / --exact / --exclude it SETS the exact set (replacing the inferred one),
3593
+ // --clear goes back to inferred (recorded as a decision, with --note as the why). Every
3594
+ // earlier setting is kept in `history`. The client's answers are recounted against the new set.
3595
+ async function cmdAdminBrandDetection(ctx) {
3596
+ const { flags, positionals, io } = ctx;
3597
+ const orgId = requirePositional(positionals, 0, "org-id");
3598
+ const path = `/api/superadmin/fleet/mentions/${encodeURIComponent(orgId)}/detection`;
3599
+ const terms = flagList(flags.term);
3600
+ const exact = flagList(flags.exact);
3601
+ const exclude = flagList(flags.exclude);
3602
+ let result;
3603
+ if (flags.clear === true)
3604
+ result = await staffRequest(ctx, "PUT", path, {
3605
+ clear: true,
3606
+ ...(flagStr(flags.note) !== undefined ? { note: flagStr(flags.note) } : {}),
3607
+ });
3608
+ else if (terms.length || exact.length || exclude.length || flagStr(flags.note) !== undefined) {
3609
+ if (!terms.length && !exact.length) throw new CliError("Give at least one --term or --exact (or --clear).");
3610
+ result = await staffRequest(ctx, "PUT", path, {
3611
+ terms,
3612
+ exact_case_terms: exact,
3613
+ exclude_phrases: exclude,
3614
+ ...(flagStr(flags.note) !== undefined ? { note: flagStr(flags.note) } : {}),
3615
+ });
3616
+ } else result = await staffRequest(ctx, "GET", path);
3617
+ printResult(io, flags, result, (r) => {
3618
+ const e = r.effective;
3619
+ const state = r.override
3620
+ ? `exact set, by ${r.override.setBy ?? "?"} at ${r.override.setAt ?? "?"}`
3621
+ : r.cleared
3622
+ ? `inferred (exact set cleared by ${r.cleared.by ?? "?"} at ${r.cleared.at ?? "?"})`
3623
+ : "inferred";
3624
+ const lines = [
3625
+ `${r.orgName}: ${state}`,
3626
+ ` terms (any case): ${e.terms.join(", ") || "—"}`,
3627
+ ` exact case: ${e.exactCaseTerms.join(", ") || "—"}`,
3628
+ ` excluded phrases: ${e.excludePhrases.join(", ") || "—"}`,
3629
+ ];
3630
+ const why = r.override?.note ?? r.cleared?.note;
3631
+ if (why) lines.push(` why: ${why}`);
3632
+ if (r.override) lines.push(` inferred would be: ${r.inferred.terms.join(", ")}`);
3633
+ for (const h of r.history ?? []) {
3634
+ const set = [
3635
+ h.terms.length ? `terms ${h.terms.join(", ")}` : "",
3636
+ h.exactCaseTerms.length ? `exact ${h.exactCaseTerms.join(", ")}` : "",
3637
+ h.excludePhrases.length ? `excludes ${h.excludePhrases.join(", ")}` : "",
3638
+ ].filter(Boolean);
3639
+ lines.push(
3640
+ ` earlier: ${h.action} by ${h.by ?? "?"} at ${h.at ?? "?"}${set.length ? ` — ${set.join("; ")}` : ""}${h.note ? ` (${h.note})` : ""}`
3641
+ );
3642
+ }
3643
+ return lines.join("\n");
3644
+ });
3645
+ return 0;
3646
+ }
@@ -55,6 +55,8 @@ export const ARGS = {
55
55
  },
56
56
  cite_preferred_sources: {
57
57
  collection_id: { positional: 0, label: "collection-id", required: true },
58
+ // Chosen posts a reviewer decided against.
59
+ exclude_article_ids: { flag: "exclude", type: "list" },
58
60
  },
59
61
  update_auto_pen_config: {
60
62
  collection_id: { positional: 0, label: "collection-id", required: true },
@@ -27,6 +27,7 @@ export const ARGS = {
27
27
  get_share_of_voice_detail: {
28
28
  period: { flag: "period", enum: PERIODS },
29
29
  },
30
+ get_brand_detection: {},
30
31
  get_share_of_voice_question: {
31
32
  question_key: { positional: 0, label: "question-key", required: true },
32
33
  period: { flag: "period", enum: PERIODS },
@@ -55,9 +56,11 @@ export async function cmdInsights(ctx) {
55
56
  return call(rest, "get_share_of_voice_detail");
56
57
  case "question":
57
58
  return call(rest, "get_share_of_voice_question");
59
+ case "detection":
60
+ return call(rest, "get_brand_detection");
58
61
  default:
59
62
  throw new CliError(
60
- `Unknown insights subcommand: ${sub ?? "(none)"}. Try: summary, competitors, question, site, post, top, bank, access`
63
+ `Unknown insights subcommand: ${sub ?? "(none)"}. Try: summary, competitors, question, detection, site, post, top, bank, access`
61
64
  );
62
65
  }
63
66
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.87.0",
3
+ "version": "0.89.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",