@flowapt/flowiq-cli 0.9.6 → 0.9.8

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.
@@ -0,0 +1,48 @@
1
+ // node --test src/gaps.test.mjs
2
+ import test from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { filedVia, normaliseRef, ago } from "./commands/gaps.js";
5
+ import { gapHint } from "./index.js";
6
+
7
+ test("filedVia: a Claude Code session is marked claude, even on a TTY", () => {
8
+ const tty = { isTTY: true };
9
+ assert.equal(filedVia({ CLAUDECODE: "1" }, tty, tty), "claude");
10
+ assert.equal(filedVia({ CLAUDE_CODE_ENTRYPOINT: "cli" }, tty, tty), "claude");
11
+ });
12
+
13
+ test("filedVia: a person at a terminal vs a pipe or script", () => {
14
+ assert.equal(filedVia({}, { isTTY: true }, { isTTY: true }), "human");
15
+ assert.equal(filedVia({}, { isTTY: undefined }, { isTTY: true }), "script");
16
+ assert.equal(filedVia({}, { isTTY: true }, { isTTY: false }), "script");
17
+ });
18
+
19
+ test("normaliseRef: number, #number and uuid pass; anything else is refused", () => {
20
+ assert.equal(normaliseRef("42"), "42");
21
+ assert.equal(normaliseRef("#42"), "42");
22
+ assert.equal(normaliseRef(" 7 "), "7");
23
+ assert.equal(normaliseRef("0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b"), "0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b");
24
+ assert.equal(normaliseRef("abc"), null);
25
+ assert.equal(normaliseRef("42a"), null);
26
+ assert.equal(normaliseRef(""), null);
27
+ assert.equal(normaliseRef(undefined), null);
28
+ });
29
+
30
+ test("ago: minutes, hours, days", () => {
31
+ const now = Date.parse("2026-09-30T12:00:00Z");
32
+ assert.equal(ago("2026-09-30T11:55:00Z", now), "5m ago");
33
+ assert.equal(ago("2026-09-30T09:00:00Z", now), "3h ago");
34
+ assert.equal(ago("2026-09-26T12:00:00Z", now), "4d ago");
35
+ assert.equal(ago(null, now), "-");
36
+ });
37
+
38
+ test("gapHint: only for a missing command / flag, with the topic filled in", () => {
39
+ const argv = ["node", "flowiq", "agent", "config", "0689d0be-ba76-43d9-a0d5-f4d59c1db537", "--tool-instructions", "x"];
40
+ const hint = gapHint("error: unknown option '--tool-instructions'\n", argv);
41
+ assert.match(hint, /flowiq gaps report/);
42
+ assert.match(hint, /--command "agent config"/);
43
+ assert.match(hint, /--error "unknown option '--tool-instructions'"/);
44
+ assert.equal(gapHint("error: missing required argument 'organization_id'\n", argv), "");
45
+ assert.match(gapHint("error: unknown command 'widgets'\n", ["node", "flowiq", "widgets", "list"]), /--command "widgets list"/);
46
+ // an id in second place is not part of the topic
47
+ assert.match(gapHint("error: too many arguments\n", ["node", "flowiq", "messages", "0689d0be-ba76-43d9-a0d5-f4d59c1db537"]), /--command "messages"/);
48
+ });
package/src/http.js CHANGED
@@ -70,7 +70,7 @@ export function isBelowServerMinimum() {
70
70
  * serialised — and always name the HTTP status, since a 504 tells you "retry or
71
71
  * narrow it" while a 400 never will.
72
72
  */
73
- export function describeError(parsed, method, endpoint, status) {
73
+ export function describeError(parsed, method, endpoint, status, { json = true } = {}) {
74
74
  const pick = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
75
75
  const nested = (v) => {
76
76
  if (!v || typeof v !== "object") return null;
@@ -95,12 +95,27 @@ export function describeError(parsed, method, endpoint, status) {
95
95
  if (!detail) return where;
96
96
 
97
97
  // A timeout is transient and the remedy is specific, so say so rather than
98
- // leaving the reader to work out whether they hit a hard limit.
99
- const timedOut = status === 504 || /TIMEOUT|timed out/i.test(detail);
100
- return timedOut
101
- ? `${detail} [${where}] — this is a TIMEOUT, not a limit: retry, or narrow the request ` +
102
- `(fewer pages via --max-pages, a smaller --q limit, or a tighter filter).`
103
- : `${detail} [${where}]`;
98
+ // leaving the reader to work out whether they hit a hard limit. Only a JSON
99
+ // body is scanned for the word: an HTML page (a Vercel challenge) carries
100
+ // "timeout" in its inline script and is not one.
101
+ const timedOut = status === 504 || (json && /TIMEOUT|timed out/i.test(detail));
102
+ if (!timedOut) return `${detail} [${where}]`;
103
+ // A write can finish on the server after the proxy gave up, so "retry" is the
104
+ // wrong first move there (a template created twice, a send fired twice).
105
+ if (method !== "GET" && endpoint !== "store-api") {
106
+ return `${detail} [${where}] — this is a TIMEOUT: the server may still have finished the write. ` +
107
+ `Check the result (the command's list / status / pull verb, or flowiq audit) before retrying.`;
108
+ }
109
+ return `${detail} [${where}] — this is a TIMEOUT, not a limit: retry, or narrow the request ` +
110
+ `(fewer pages via --max-pages, a smaller --q limit, or a tighter filter).`;
111
+ }
112
+
113
+ /** Vercel's bot protection answers with an HTML challenge page (403/429),
114
+ * before the request ever reaches our API. It is about this network, not the key. */
115
+ export function isVercelChallenge(status, headers, text) {
116
+ const mitigated = headers?.get?.("x-vercel-mitigated") || "";
117
+ if (/challenge/i.test(mitigated)) return true;
118
+ return (status === 403 || status === 429) && /Vercel Security Checkpoint|vercel-challenge|_vercel\/challenge/i.test(text || "");
104
119
  }
105
120
 
106
121
  async function call(method, endpoint, { query, body } = {}) {
@@ -138,8 +153,19 @@ async function call(method, endpoint, { query, body } = {}) {
138
153
  const text = await resp.text();
139
154
  let parsed, isJson = true;
140
155
  try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { _raw: text }; isJson = false; }
156
+ if (!resp.ok && isVercelChallenge(resp.status, resp.headers, text)) {
157
+ const err = new Error(
158
+ `Vercel is challenging this network (bot protection) [${method} ${endpoint} → HTTP ${resp.status}]. ` +
159
+ `This is not your key and the request never reached the API: wait a few minutes, or try another ` +
160
+ `network (a phone hotspot), then retry.`
161
+ );
162
+ err.status = resp.status;
163
+ err.code = "EVERCELCHALLENGE";
164
+ err.body = parsed;
165
+ throw err;
166
+ }
141
167
  if (!resp.ok) {
142
- const err = new Error(describeError(parsed, method, endpoint, resp.status));
168
+ const err = new Error(describeError(parsed, method, endpoint, resp.status, { json: isJson }));
143
169
  err.status = resp.status;
144
170
  err.body = parsed;
145
171
  throw err;
package/src/index.js CHANGED
@@ -27,6 +27,7 @@ import * as agentConfigCmd from "./commands/agent-config.js";
27
27
  import * as agentsCmd from "./commands/agents.js";
28
28
  import * as agentUpdatesCmd from "./commands/agent-updates.js";
29
29
  import * as plansCmd from "./commands/plans.js";
30
+ import * as gapsCmd from "./commands/gaps.js";
30
31
  import * as exportCmd from "./commands/export.js";
31
32
  import * as testCmd from "./commands/agent-test.js";
32
33
  import * as knowledgeCmd from "./commands/knowledge.js";
@@ -48,6 +49,19 @@ import * as doctorCmd from "./commands/doctor.js";
48
49
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
49
50
  const pkg = JSON.parse(readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
50
51
 
52
+ /** The one-line "file it" hint appended to a missing-command / missing-flag error. */
53
+ export function gapHint(errorText, argv = []) {
54
+ if (!/unknown option|unknown command|too many arguments/i.test(String(errorText))) return "";
55
+ const args = argv.slice(2).filter((a) => !a.startsWith("-"));
56
+ // "agent config", "bc send", "org flags": a verb-shaped second word belongs to the topic.
57
+ const topic = (args[1] && /^[a-z][a-z-]*$/.test(args[1]) ? args.slice(0, 2) : args.slice(0, 1)).join(" ") || "<topic>";
58
+ const firstLine = String(errorText).split("\n")[0].replace(/^error:\s*/i, "").replace(/"/g, "'").trim();
59
+ return (
60
+ ` → If the CLI should be able to do this, file it so it gets built:\n` +
61
+ ` flowiq gaps report "<what you needed>" --command "${topic}" --error "${firstLine}"\n`
62
+ );
63
+ }
64
+
51
65
  export function run(argv) {
52
66
  // Non-blocking "you're behind" hint (stderr; cached; detached refresh).
53
67
  maybeNotifyUpdate(pkg.version);
@@ -56,7 +70,12 @@ export function run(argv) {
56
70
  program
57
71
  .name("flowiq")
58
72
  .description("FlowIQ staff CLI — round-trip agent prompts, webhooks, and more, without service-role credentials.")
59
- .version(pkg.version);
73
+ .version(pkg.version)
74
+ // Set before any subcommand exists so every subcommand inherits it. When a
75
+ // command or flag does not exist, that is usually a gap: say how to file it,
76
+ // so a teammate (or their Claude session) records it instead of quietly
77
+ // falling back to SQL.
78
+ .configureOutput({ outputError: (str, write) => write(str + gapHint(str, argv)) });
60
79
 
61
80
  // auth
62
81
  const auth = program.command("auth").description("Manage CLI authentication");
@@ -77,6 +96,7 @@ export function run(argv) {
77
96
  prompts.command("pull <organization_id>")
78
97
  .description("Fetch an agent's prompt_sections into a local JSON file (default: active agent)")
79
98
  .option("--agent <id>", "target a specific agent instead of the org's active one")
99
+ .option("--out <file>", "write the JSON here instead of .flowiq/prompts/<slug>.json")
80
100
  .action((orgId, opts) => promptsCmd.pull(orgId, opts));
81
101
  prompts.command("history <organization_id>")
82
102
  .description("Every recorded prompt push (who / when / size) — the version list for `restore`")
@@ -362,8 +382,10 @@ export function run(argv) {
362
382
  .requiredOption("--request-file <path>", "JSON file with template_request (+ optional template_data/media_header/cards_media)")
363
383
  .action((orgId, opts) => templatesCmd.create(orgId, opts));
364
384
  templates.command("status <organization_id>")
365
- .description("List the org's template rows (name + status) to poll Meta approval")
385
+ .description("Template status: FlowIQ's record by default, or straight from Meta with --live (incl. rejection reasons)")
366
386
  .option("--name <substr>", "filter by template name substring")
387
+ .option("--live", "read Meta directly (one cheap call with --name; no snapshot file written)")
388
+ .option("--watch", "with --live: re-check every 15 s until every match is decided (max 15 min)")
367
389
  .action((orgId, opts) => templatesCmd.status(orgId, opts));
368
390
  templates.command("show <organization_id> <name>")
369
391
  .description("Render ONE template row in full — including a DRAFT, which `pull` cannot see (drafts never reach Meta)")
@@ -495,12 +517,27 @@ export function run(argv) {
495
517
  .option("--model <model>", "settings.model (e.g. gpt-6-luna, the house default)")
496
518
  .option("--reasoning-effort <level>", "settings.reasoning_effort — low|medium|high|xhigh|max (\"\" clears it; max = GPT-6 and Claude only); pairs with reasoning models (e.g. gpt-6-luna + high, gpt-6-luna + high, claude-sonnet-5 + medium)")
497
519
  .option("--rename <name>", "rename the agent")
498
- .option("--tool <flag=bool>", "toggle a tool flag (repeatable): woo_order_build/woo_tip_field/woo_order_note_field/view_cart_tool/restock_tool/block_tool_status/postal_code_tool_status/shopify_products_web_chat/ticket_tool_status/product_lookup/email_request_tool/collapse_product_variants", agentConfigCmd.collectTool, [])
520
+ .option("--tool <flag=bool>", "toggle a tool flag (repeatable): woo_order_build/woo_tip_field/woo_order_note_field/view_cart_tool/restock_tool/block_tool_status/postal_code_tool_status/shopify_products_web_chat/ticket_tool_status/product_lookup/email_request_tool/collapse_product_variants/silent_option", agentConfigCmd.collectTool, [])
499
521
  .option("--discount [bool]", "agent.discount.enabled (true if bare)")
500
522
  .option("--test-contact-number <number>", "settings.test_contact_number — the contact `flowiq test` uses (use a FAKE number; \"\" clears it → synthetic fallback)")
501
523
  .option("--test-contact-name <name>", "settings.test_contact_name — display name for the test contact (\"\" clears it)")
502
524
  .option("--disable-base-tool <name>", "hide a disableable built-in tool from this agent (repeatable)", agentConfigCmd.collectToolName, [])
503
525
  .option("--enable-base-tool <name>", "restore a previously disabled built-in tool (repeatable)", agentConfigCmd.collectToolName, [])
526
+ .option("--tool-instruction <tool=text>", "settings.tool_instructions: text APPENDED to that tool's description (repeatable; text or @file; \"tool=\" removes it)", agentConfigCmd.collectTool, [])
527
+ .option("--shorten-links <channel=on|off|default>", "per-channel link shortening (repeatable): whatsapp/web/messenger/instagram/tiktok/email/call; default = on for whatsapp only", agentConfigCmd.collectTool, [])
528
+ .option("--email-from-name <name>", "settings.email_from_name: the From display name on agent emails (\"\" clears)")
529
+ .option("--email-signature-name <name>", "settings.email_signature_name: name printed in the email signature (\"\" clears → agent name)")
530
+ .option("--email-signature-title <title>", "settings.email_signature_title: title line under the signature name (\"\" clears)")
531
+ .option("--email-request-to <addresses>", "settings.email_request.to: comma-separated recipients for email_request_to_team (\"\" clears)")
532
+ .option("--email-request-subject-prefix <text>", "settings.email_request.subject_prefix (\"\" clears)")
533
+ .option("--send-success-rule <when=text>", "send_message_success_instructions rule, one per condition: media/image/video/document/audio/text/always (repeatable; text or @file; \"when=\" removes it)", agentConfigCmd.collectTool, [])
534
+ .option("--send-success [bool]", "send_message_success_instructions.enabled (true if bare)")
535
+ .option("--after-escalation <text>", "human_notify.success_instructions: the NEXT STEP appended to the handover tool's reply (text or @file; \"\" clears)")
536
+ .option("--knowledge <pairs>", "knowledge_config: enabled=true,match_count=8,min_similarity=0.3 (\"default\" clears a key)")
537
+ .option("--human-notify <json>", "merge keys into human_notify (JSON or @file.json; null deletes a key; unknown keys refused)")
538
+ .option("--order-notify <json>", "merge keys into order_notify (JSON or @file.json; null deletes a key; unknown keys refused)")
539
+ .option("--full", "show long values in full (show mode and the before/after diff)")
540
+ .option("--json", "show mode: print the raw response as JSON")
504
541
  .action((orgId, opts) => agentConfigCmd.config(orgId, opts));
505
542
 
506
543
  // agent-updates (pending client change-requests + chat context; pull + resolve)
@@ -544,11 +581,11 @@ export function run(argv) {
544
581
  .description("List local agent-updates snapshots")
545
582
  .action(() => agentUpdatesCmd.list());
546
583
  au.command("resolve <update_id>")
547
- .description("Flip a client-raised ticket to resolved/declined + write the client-facing note (the client reads \"FlowIQ: <note>\")")
584
+ .description("Flip a client-raised ticket to resolved/declined (or back to in_progress) + write the client-facing note (the client reads \"FlowIQ: <note>\")")
548
585
  .option("--note <text>", "client-facing note (required when resolving)")
549
586
  .option("--internal <text>", "staff-only note (never shown to the client)")
550
587
  .option("--image <url>", "client-facing response image URL")
551
- .option("--status <status>", "resolved (default) | declined", "resolved")
588
+ .option("--status <status>", "resolved (default) | declined | in_progress (open, fix not yet proven; note optional)", "resolved")
552
589
  .option("--yes", "skip the interactive confirm gate (the --note requirement still applies)")
553
590
  .action((updateId, opts) => agentUpdatesCmd.resolve(updateId, opts));
554
591
 
@@ -579,6 +616,60 @@ export function run(argv) {
579
616
  .option("--commit", "apply the change (default is a dry run)")
580
617
  .action((planId, opts) => plansCmd.status(planId, opts));
581
618
 
619
+ // gaps (CLI gap reports + feature requests, filed by anyone incl. a Claude session)
620
+ const gaps = program.command("gaps")
621
+ .alias("requests")
622
+ .description("Found something the CLI can't do (or does wrong)? `report` it; `list` what is asked for; `hit` one that bit you too");
623
+ gaps.command("report <title...>")
624
+ .description("File a gap / bug / idea (one-line title). Refused with the matching number if it is already on the list")
625
+ .option("--command <topic>", "the flowiq command it concerns, e.g. \"agent config\" or \"bc send\" (none yet? name the one you expected)")
626
+ .option("--kind <kind>", "gap (default: the CLI cannot do it) | bug (it does it wrong) | idea (it should)")
627
+ .option("--detail <text>", "what you were trying to do and what should happen")
628
+ .option("--detail-file <path>", "read --detail from a file")
629
+ .option("--error <text>", "the exact error the CLI printed, verbatim")
630
+ .option("--workaround <text>", "what you did instead (SQL, the dashboard, a script…)")
631
+ .option("--org <uuid>", "the org you were working on, for context")
632
+ .option("--priority <p>", "low | normal (default) | high — high = it blocked the job")
633
+ .option("--new", "file it even though a similar open request exists")
634
+ .option("--dry-run", "only show similar requests; file nothing")
635
+ .option("--json", "print the raw response")
636
+ .action((titleWords, opts) => gapsCmd.report(titleWords, opts));
637
+ gaps.command("list")
638
+ .description("Requests, most-hit first (default: open = open, planned, in_progress)")
639
+ .option("--status <list>", "open (default) | all | open | planned | in_progress | done | declined | duplicate (comma-separated allowed)")
640
+ .option("--command <topic>", "only requests about this command (contains)")
641
+ .option("--kind <kind>", "gap | bug | idea")
642
+ .option("--search <text>", "title / detail / error contains this text")
643
+ .option("--mine", "only requests you filed")
644
+ .option("--sort <how>", "hits (default) | new | old | touched")
645
+ .option("--limit <n>", "max rows (default 100, max 500)")
646
+ .option("--json", "print the raw response")
647
+ .action((opts) => gapsCmd.list(opts));
648
+ gaps.command("show <number>")
649
+ .description("One request in full: detail, verbatim error, every hit, note and status change")
650
+ .option("--json", "print the raw response")
651
+ .action((ref, opts) => gapsCmd.show(ref, opts));
652
+ gaps.command("hit <number>")
653
+ .description("It bit you too: +1 with what you were doing (the most-hit requests get built first)")
654
+ .option("--note <text>", "what you were doing when you hit it")
655
+ .option("--error <text>", "the exact error, if it differs")
656
+ .option("--org <uuid>", "the org you were working on")
657
+ .option("--json", "print the raw response")
658
+ .action((ref, opts) => gapsCmd.hit(ref, opts));
659
+ gaps.command("note <number> <text...>")
660
+ .description("Add context to a request without counting a hit")
661
+ .option("--json", "print the raw response")
662
+ .action((ref, textWords, opts) => gapsCmd.note(ref, textWords, opts));
663
+ gaps.command("status <number>")
664
+ .description("Maintainers: planned | in_progress | done | declined | duplicate. Closing emails everyone who reported or hit it")
665
+ .requiredOption("--to <status>", "open | planned | in_progress | done | declined | duplicate")
666
+ .option("--note <text>", "what shipped / why not (required for done + declined; the people who asked read it)")
667
+ .option("--version <x.y.z>", "the CLI version the fix shipped in (done)")
668
+ .option("--of <number>", "with --to duplicate: the request this one duplicates (hits move across)")
669
+ .option("--no-notify", "close without emailing the people who asked")
670
+ .option("--json", "print the raw response")
671
+ .action((ref, opts) => gapsCmd.status(ref, opts));
672
+
582
673
  // audit (who did what, when — with the full before/after content)
583
674
  const audit = program.command("audit")
584
675
  .description("Staff-CLI audit trail: who changed what, when — with full before/after content")
@@ -589,9 +680,10 @@ export function run(argv) {
589
680
  .option("--agent <id>", "scope to one agent")
590
681
  .option("--target <id>", "scope to one target (task id, tool name, tag, broadcast id)")
591
682
  .option("--user <substr>", "filter by staff email (substring)")
592
- .option("--since <date>", "YYYY-MM-DD or ISO")
593
- .option("--until <date>", "YYYY-MM-DD or ISO")
594
- .option("--limit <n>", "max entries (default 50, max 200)")
683
+ .option("--since <when>", "YYYY-MM-DD (SAST day start), ISO, or a window: 14d / 12h / 2w")
684
+ .option("--until <when>", "YYYY-MM-DD, ISO, or a window: 14d / 12h / 2w")
685
+ .option("--before <created_at>", "page back: only entries older than this (the listing prints the value to use)")
686
+ .option("--limit <n>", "max entries (default 50, max 200; above 200 is clamped and the total is shown)")
595
687
  .option("--json", "raw JSON")
596
688
  .action((orgId, opts) => auditCmd.list(orgId, opts));
597
689
  audit.command("show <audit_id>")
@@ -622,11 +714,13 @@ export function run(argv) {
622
714
  .option("--image <url>", "attach a publicly-fetchable image URL (text is the caption)")
623
715
  .option("--timeout <ms>", "per-turn HTTP timeout (default 120000)")
624
716
  .option("--json", "print raw JSON instead of the transcript")
717
+ .option("--force", "go ahead even though the test contact had real (non-web) messages in the last 6 h")
625
718
  .action((orgId, message, opts) => testCmd.send(orgId, message, opts));
626
719
  test.command("scenario <organization_id> <file>")
627
720
  .description("Run a scenario pack (JSON: {scenarios:[{id,title,turns:[{text,expect?,expectNot?}]}]})")
628
721
  .option("--agent <id>", "test a specific agent")
629
722
  .option("--no-clear-between", "do NOT clear between scenarios (one long thread)")
723
+ .option("--force", "go ahead even though the test contact had real (non-web) messages in the last 6 h")
630
724
  .option("--sender <name>", "senderName")
631
725
  .option("--timeout <ms>", "per-turn HTTP timeout (default 120000)")
632
726
  .option("--json", "print raw JSON results")
@@ -635,6 +729,7 @@ export function run(argv) {
635
729
  .description("Run the bundled generic smoke pack")
636
730
  .option("--agent <id>", "test a specific agent")
637
731
  .option("--no-clear-between", "do NOT clear between scenarios")
732
+ .option("--force", "go ahead even though the test contact had real (non-web) messages in the last 6 h")
638
733
  .option("--sender <name>", "senderName")
639
734
  .option("--timeout <ms>", "per-turn HTTP timeout")
640
735
  .option("--json", "print raw JSON results")
@@ -642,6 +737,7 @@ export function run(argv) {
642
737
  test.command("clear <organization_id>")
643
738
  .description("Clear the test conversation (hide history via memory_cutoff)")
644
739
  .option("--agent <id>", "target a specific agent's test contact")
740
+ .option("--force", "go ahead even though the test contact had real (non-web) messages in the last 6 h")
645
741
  .action((orgId, opts) => testCmd.clear(orgId, opts));
646
742
  test.command("stress <organization_id> [pack]")
647
743
  .description("Run an adversarial pack in parallel, one isolated contact per scenario (phase 2)")
@@ -797,10 +893,11 @@ export function run(argv) {
797
893
  .option("--limit <n>", "how many to show (default 25, max 200)")
798
894
  .option("--template <substr>", "filter: template_name contains this (case-insensitive)")
799
895
  .option("--since <date>", "filter: created on/after this date, e.g. 2026-07-01")
896
+ .option("--min-recipients <n>", "filter: only sends to at least N recipients (2 hides welcomes and cart reminders)")
800
897
  .option("--json", "raw JSON")
801
898
  .action((orgId, opts) => broadcastCmd.listRemote(orgId, opts));
802
899
  broadcast.command("status <organization_id> <broadcast_id>")
803
- .description("Live delivery counts (read/delivered/sent/failed) for a broadcast by its broadcastId — e.g. from a --python fire-and-forget send")
900
+ .description("Live delivery counts (read/delivered/sent/failed) for a broadcast by its broadcastId, plus what it sent (slot values, button code and the link's destination)")
804
901
  .option("--failures", "also list each FAILED recipient + the Meta error reason (makes a python send auditable)")
805
902
  .option("--limit <n>", "with --failures: how many failed rows to print", "40")
806
903
  .option("--json", "raw JSON")
@@ -878,6 +975,7 @@ export function run(argv) {
878
975
  .option("--content <value>", "utm_content, the per-link tag (e.g. ViewMore); defaults to the campaign")
879
976
  .option("--date <value>", "send date for the campaign tag when it is not today: 9Sep | 2026-09-24 | 24/9/2026")
880
977
  .option("--raw-campaign", "use --campaign exactly as typed, bypassing the Date_Campaign normalisation")
978
+ .option("--new-campaign", "confirm the tag is a NEW campaign even though a differently spelled one with the same name already runs")
881
979
  .option("--domain <host>", "short domain: chatcart.io (default) | linklnk.io | yapi.store")
882
980
  .option("--commit", "actually mint the links (omit = dry-run preview)")
883
981
  .option("--json", "raw JSON output")