@flowapt/flowiq-cli 0.11.0 → 0.12.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/README.md CHANGED
@@ -261,6 +261,16 @@ flowiq bc route <org_id> <broadcastId> --team "Spa" --commit # set i
261
261
  flowiq bc route <org_id> <broadcastId> --clear --commit # switch it off
262
262
  ```
263
263
 
264
+ **Coexistence numbers are sent at a steady pace (2 Oct 2026).** On an org whose number
265
+ is shared with the WhatsApp Business app (`feature_flags.coexistence.enabled`), every
266
+ send engine (python, `/api/broadcast`, `/api/send-template`) sends one message every
267
+ 200 ms (5 a second) instead of in bursts, and the `bc send` dry run says so with the
268
+ time it will take: `🐢 Coexistence number: sent one at a time at 5 a second, about 5 min
269
+ for 1500 contact(s)`. On every org, a send Meta refuses with **130429** (its send-speed
270
+ limit) is retried after 2 / 5 / 10 s before it is recorded as failed; Meta never sent it,
271
+ so a retry cannot double-message anyone. `flowiq org whatsapp` notes a coexistence
272
+ number (Meta's own `throughput` still reads STANDARD on them). Server-side, no new flags.
273
+
264
274
  - **Scheduling (v0.4.4 tag / v0.6.1 CSV)** — `--at "YYYY-MM-DD HH:MM"` queues the
265
275
  send instead of firing it. It writes the same `api_request_queue` row the
266
276
  dashboard writes, replayed by the `process-api-queue` cron (every 3 min, so it
@@ -2255,6 +2265,44 @@ flowiq messages delete <contact_id> [--before 30d] [--sender-type tool-call] --c
2255
2265
  On with `vert.active` not false = live for every customer, which needs
2256
2266
  `--yes`; the command prints which of the three it would produce.
2257
2267
 
2268
+ ### Batch G (0.12.0): the 1 October filings
2269
+
2270
+ Eighteen requests filed by the team on 1 Oct 2026, each reproduced against the
2271
+ code before the fix.
2272
+
2273
+ ```bash
2274
+ flowiq contacts show <contact_id> # no --org needed any more: the org is resolved from the id
2275
+ flowiq contacts lint <org> # now also flags a 27 + 11 digit number (SA is 27 + 9) and suggests the real one
2276
+ flowiq contacts ai off <org> --tag <tag> [--commit] # AI off/on for every contact on a tag (or --contact / --ids-file), agent-off rows written
2277
+ flowiq contacts add <org> --whatsapp-id 27637186304 --name Lau --no-broadcast --commit
2278
+ flowiq members invite <org> someone@client.co.za --role viewer # an invitation they accept at sign-up (no account needed)
2279
+ flowiq messages search <org> "being updated at the same time" --tool-calls # inside tool-call request / response
2280
+ flowiq messages failures <org> # rows with only an error_message no longer roll up as "?"
2281
+ flowiq messages purge <org> --subject-regex '^Automatic reply' --sender-type user-email --confirm # org-wide delete by pattern
2282
+ flowiq org automations <org> --set payment_received --template payment_received_v3 --body param1=full_name,param2=order_number --buttons param1=short_code --commit
2283
+ flowiq org whatsapp <org> | --all # LIMIT = the business portfolio's messaging limit (Meta's new field); code verification EXPIRED is info, not a problem
2284
+ flowiq org health <org> # flags an active agent with no human_notify (a handover alerts nobody)
2285
+ flowiq agent config <org> # human_notify / order_notify / tool_instructions print "(unset)" instead of vanishing
2286
+ flowiq tag field <org> --field whatsapp_id --any 2779…,2782… # a digit-only list is matched whole (exact) automatically; 0.7 s instead of a timeout
2287
+ flowiq test seed <org> --from <contact_id> --until "2026-10-01 09:08" --commit # copy a real chat onto a test contact; test unseed removes it
2288
+ flowiq plans create <org> --topic "Heritage Day" --type utility --date 2026-10-09 --time 10:00 --line-1 … --line-2 … --follow-up … --landing https://… --commit
2289
+ flowiq woo get <org> products --all --fields id,name,permalink,status # past the 3.5 MB cap; the truncation message now says how to trim and resume
2290
+ ```
2291
+
2292
+ - **`contacts ai off`** writes `bot_status` and the same `agent-off` row the
2293
+ inbox toggle writes, so the chat shows who switched it. It warns about the
2294
+ reactivation cron: an AI-off contact is turned back on once their last
2295
+ customer message is 12 h old and the agent-off row is older than 24 h
2296
+ (unless `bot_reactivation.enabled` is false on the org).
2297
+ - **`messages purge`** matches an email subject regex (`more_data.subject`),
2298
+ a text substring, sender types and a time window; 5,000 rows a run; the dry
2299
+ run lists subjects and contacts; `--confirm` asks you to type the org id.
2300
+ - **`plans create`** files the same row the MCP's `submit_plan` writes and
2301
+ alerts the team the same way; staff are exempt from the client lead time
2302
+ (a sub-24 h send is noted, not refused).
2303
+ - Not a CLI fix: #186 (Shopify store analytics need the `read_reports` scope
2304
+ on the FlowIQ app).
2305
+
2258
2306
  ### Guide — `flowiq guide`
2259
2307
 
2260
2308
  Read the bundled docs in the terminal — no digging through node_modules.
package/TEAM-GUIDE.md CHANGED
@@ -143,7 +143,8 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
143
143
  | Did the agent say something wrong to anyone today? | `flowiq messages search <org_id> "the words" --since 24h` |
144
144
  | Why did messages not arrive / what failed to send? | `flowiq messages failures <org_id> --since 7d` (Meta error codes by reason, media and contact, plus sends Meta accepted and never confirmed) |
145
145
  | How many messages did an org send last month, free-form vs template? | `flowiq messages stats <org_id> --since 6m` |
146
- | Is a client's WhatsApp number healthy / connected / rate-limited? | `flowiq org whatsapp <org_id>` (straight from Meta); every org: `flowiq org whatsapp --all` |
146
+ | Is a client's WhatsApp number healthy / connected / rate-limited? | `flowiq org whatsapp <org_id>` (straight from Meta); every org: `flowiq org whatsapp --all`. A number shared with the WhatsApp Business app says "coexistence number … paced to 5 a second". |
147
+ | Why did a broadcast lose sends to error 130429? | The number is on **coexistence** (shared with the WhatsApp Business app) and was sent to in bursts. Since 2 Oct 2026 every engine sends those numbers one at a time (5 a second, the `bc send` dry run shows how long it takes) and retries a 130429 after a pause. Resend the old failures with `flowiq bc retry <org_id> <broadcastId>`. |
147
148
  | Which channels are connected (Messenger, Instagram, email…)? | `flowiq org channels <org_id>`; every org: `flowiq org channels --all` |
148
149
  | Is this client's agent ready to switch on? | `flowiq org health <org_id>` (master switch, keys probed incl. the spare key, prompt, catalogue, webhooks, order sync, traffic; ⚠ lines are warnings, ✗ lines block) |
149
150
  | What automated messages / flows / rules does an org have armed? | `flowiq org automations <org_id>` · `flowiq flows list <org_id>` (+ `flows status` for failures) · `flowiq rules list <org_id>` (+ `rules results <rule_id>`) · `flowiq org abandoned <org_id>` |
@@ -161,6 +162,14 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
161
162
  | Tag a list of contact ids, or see who carries a tag | `flowiq tag ids <org_id> --ids-file ids.txt --tag vip --commit` · `flowiq tag holders <org_id> vip --csv` |
162
163
  | Why did the agent answer that? | `flowiq test send <org_id> "<the question>" --tools` shows each tool's arguments and result |
163
164
  | Was that message the agent, a broadcast or an automation? | `flowiq messages pull <contact_id>` ends with a "sent by" line per source |
165
+ | Stop the agent answering everyone who got a broadcast (a whole tag) | `flowiq contacts ai off <org_id> --tag <tag>` (dry run) → `--commit`; back on with `ai on`. Read the reactivation warning it prints |
166
+ | Add a tester or a team member as a contact | `flowiq contacts add <org_id> --whatsapp-id 27637186304 --name Lau --no-broadcast --commit` |
167
+ | Invite a client user who has no FlowIQ account | `flowiq members invite <org_id> <email> --role member` (they accept it when they sign up; FlowIQ sends no email, so tell them) |
168
+ | Remove imported junk emails (auto-replies) across the whole inbox | `flowiq messages purge <org_id> --subject-regex '^Automatic reply' --sender-type user-email` (dry run) → `--confirm` |
169
+ | Point an order message at a new template version, or fix its params | `flowiq org automations <org_id> --set <type> --template <name> --body param1=full_name,param2=order_number --buttons param1=short_code --commit` |
170
+ | Reproduce what the agent or the Coworker said to a real customer | `flowiq test seed <org_id> --from <contact_id> --until "YYYY-MM-DD HH:MM" --commit`, open the seeded contact in the inbox, then `flowiq test unseed <org_id> <id> --confirm` |
171
+ | File a broadcast plan for a client from the terminal | `flowiq plans create <org_id> --topic … --type utility\|marketing --date YYYY-MM-DD …` (dry run) → `--commit` |
172
+ | What did a tool actually return? | `flowiq messages search <org_id> "<text>" --tool-calls` |
164
173
  > **Publishing the CLI (maintainers only):** publish from a clean clone, never
165
174
  > from your working tree — `npm publish` packs whatever is on disk. A
166
175
  > `prepublishOnly` check now stops you if `cli/` differs from `origin/main`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,35 @@
1
+ // node --test src/batch-g.test.mjs — pure helpers behind the 0.12.0 verbs.
2
+ import test from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { withFields } from "./commands/store-api.js";
5
+ import { parseButton } from "./commands/plans.js";
6
+ process.env.VITE_SUPABASE_URL ||= "https://example.supabase.co";
7
+ process.env.SUPABASE_SERVICE_ROLE ||= "test-key";
8
+ const { lintNumber } = await import("../../api/cli/contacts.js");
9
+
10
+ test("lintNumber flags a wrong national length and suggests the last-n twin", () => {
11
+ assert.deepEqual(lintNumber("2729829037707"), { problem: "27 + 11 digits (a +27 number is 27 + 9)", suggested: "27829037707" });
12
+ assert.deepEqual(lintNumber("2782903770"), { problem: "27 + 8 digits (a +27 number is 27 + 9)", suggested: null });
13
+ assert.equal(lintNumber("27829037707"), null);
14
+ assert.equal(lintNumber("351912345678"), null);
15
+ assert.equal(lintNumber("447700900123"), null);
16
+ // variable-length countries stay untouched
17
+ assert.equal(lintNumber("4915112345678"), null);
18
+ assert.equal(lintNumber("12125551234"), null);
19
+ // the older rules still win first
20
+ assert.match(lintNumber("270821234567").problem, /trunk 0/);
21
+ assert.match(lintNumber("2727821234567").problem, /doubled/);
22
+ });
23
+
24
+ test("withFields adds the platform's field-trim parameter and leaves other query keys", () => {
25
+ assert.deepEqual(withFields({ status: "any" }, "id, name ,permalink", "_fields"), { status: "any", _fields: "id,name,permalink" });
26
+ assert.deepEqual(withFields({ status: "any" }, undefined, "fields"), { status: "any" });
27
+ assert.deepEqual(withFields(undefined, "id", "fields"), { fields: "id" });
28
+ });
29
+
30
+ test("parseButton reads the four button shapes", () => {
31
+ assert.deepEqual(parseButton("quick_reply:VIEW MORE"), { type: "quick_reply", text: "VIEW MORE" });
32
+ assert.deepEqual(parseButton("url:CLICK HERE=https://x.co/a=b"), { type: "url", text: "CLICK HERE", url: "https://x.co/a=b" });
33
+ assert.deepEqual(parseButton("phone:Call us=+27 60 975 1832"), { type: "phone", text: "Call us", phone_number: "+27 60 975 1832" });
34
+ assert.deepEqual(parseButton("copy_code:SAVE10"), { type: "copy_code", code: "SAVE10" });
35
+ });
@@ -165,6 +165,35 @@ export async function qa(orgId, opts = {}) {
165
165
  await runPack(orgId, pack, opts);
166
166
  }
167
167
 
168
+ // `flowiq test seed <org> --from <contact_id> [--until "2026-10-01 09:08"] [--name X] [--rows 60] [--with-tools] [--ai-on] [--commit]`
169
+ export async function seed(orgId, opts = {}) {
170
+ requireOrg(orgId);
171
+ if (!UUID_RE.test(opts.from || "")) { console.error("Error: --from <contact_id> is required (the real chat to copy)."); process.exit(1); }
172
+ let until = opts.until;
173
+ if (until && /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/.test(until)) until = until.replace(" ", "T") + (until.length === 10 ? "T23:59:59" : "") + "+02:00";
174
+ let r;
175
+ try { r = await http.post("agent-test", { action: "seed", organization_id: orgId, agent_id: opts.agent, from_contact_id: opts.from, until, name: opts.name, rows: opts.rows, with_tools: !!opts.withTools, ai_on: !!opts.aiOn, copy_attributes: !!opts.copyAttributes, dry_run: !opts.commit }); }
176
+ catch (e) { console.error(`Seed failed: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
177
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
178
+ console.log(`${r.organization_name}: copy ${r.rows} row(s) from ${r.from.name ?? r.from.whatsapp_id} (${r.from.id})${r.until ? ` up to ${r.until}` : ""}${r.first ? `, ${r.first.slice(0, 16)} → ${r.last.slice(0, 16)}` : ""}`);
179
+ console.log(` by sender: ${Object.entries(r.by_sender).map(([k, v]) => `${k} ${v}`).join(" · ") || "none"}`);
180
+ console.log(` new contact: ${r.new_contact.whatsapp_id} "${r.new_contact.full_name}" · AI ${r.new_contact.bot_status ? "on" : "OFF"} · no broadcasts · tags ${r.new_contact.tags.join(", ")}`);
181
+ if (r.dry_run) { console.log("\nDRY RUN — add --commit to create the seeded contact."); return; }
182
+ console.log(`\n✓ seeded ${r.contact_id} (${r.copied} rows). Open it in the inbox or: flowiq test send ${orgId} "<question>" is NOT routed to it (the agent's test contact is separate); the Coworker reads it from the inbox. Remove: flowiq test unseed ${orgId} ${r.contact_id} --confirm`);
183
+ }
184
+
185
+ // `flowiq test unseed <org> <contact_id> --confirm`
186
+ export async function unseed(orgId, contactId, opts = {}) {
187
+ requireOrg(orgId);
188
+ if (!UUID_RE.test(contactId || "")) { console.error("Error: pass the seeded contact id."); process.exit(1); }
189
+ let r;
190
+ try { r = await http.post("agent-test", { action: "unseed", organization_id: orgId, agent_id: opts.agent, contact_id: contactId, confirm: !!opts.confirm }); }
191
+ catch (e) { console.error(`Unseed failed: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
192
+ if (opts.json) { console.log(JSON.stringify(r, null, 2)); return; }
193
+ if (r.dry_run) { console.log(`Would delete ${r.whatsapp_id} "${r.name}" and its ${r.messages} message(s)${r.seeded_from ? ` (seeded from ${r.seeded_from})` : ""}. Add --confirm.`); return; }
194
+ console.log(`✓ deleted the seeded contact and ${r.messages} message(s).`);
195
+ }
196
+
168
197
  export async function clear(orgId, opts = {}) {
169
198
  requireOrg(orgId);
170
199
  let resp;
@@ -1129,6 +1129,7 @@ async function runPipeline(orgId, opts, { commitStage, isResume }) {
1129
1129
  try {
1130
1130
  const dry = await http.post("broadcast", reqBody(true));
1131
1131
  pyTotal = dry.total_contacts_found ?? null;
1132
+ printPacing(dry.pacing, pyTotal);
1132
1133
  } catch (e) {
1133
1134
  console.error(`Python dry-run failed after tagging: ${e.message}${e.body?.error ? ` — ${e.body.error}` : ""}`);
1134
1135
  console.error(`Nothing sent. The ${workSet.length} contact(s) ARE tagged "${tag}" — re-run to retry the fan-off.`);
@@ -1391,6 +1392,14 @@ function reportRoutingResult(orgId, rr, broadcastId) {
1391
1392
  }
1392
1393
  }
1393
1394
 
1395
+ // Coexistence numbers (shared with the WhatsApp Business app) are sent one at a time
1396
+ // at a fixed pace by python (flowiq gaps #196); the server says so on the dry run.
1397
+ export function printPacing(pacing, total) {
1398
+ if (!pacing?.coexistence) return;
1399
+ const time = pacing.estimated_minutes != null ? `, about ${pacing.estimated_minutes} min for ${total} contact(s)` : "";
1400
+ console.log(`🐢 Coexistence number: sent one at a time at ${pacing.per_second} a second${time}. Meta's 130429 throttles are retried, not dropped.`);
1401
+ }
1402
+
1394
1403
  async function runPythonTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, buttonParams = null, commitStage, cardOverrides = null, cardLines = [], replyRouting = null }) {
1395
1404
  const buttons = buttonParams && Object.keys(buttonParams).length ? buttonParams : (buttonLiteral ? { param1: buttonLiteral } : null);
1396
1405
  const reqBody = (dryRun) => ({
@@ -1412,6 +1421,7 @@ async function runPythonTagSend(orgId, { opts, tag, templateName, template, head
1412
1421
  console.log("");
1413
1422
  console.log(`Engine: PYTHON (yapi.store/meta-broadcast) — fire-and-forget, python-tracked.`);
1414
1423
  console.log(`Tag "${tag}": ${total} eligible contact(s) (allow_broadcast + not blocked, resolved server-side).`);
1424
+ printPacing(dry.pacing, total);
1415
1425
  const sampleNum = dry.sample_contacts?.[0]?.whatsapp_id || dry.sample_contacts?.[0]?.phone_number || "<first eligible>";
1416
1426
  renderPreview(template, { rownum: 1, number: sampleNum, values: bodyLiterals, buttonValue: buttonLiteral, buttonParams: buttons }, headerMedia);
1417
1427
  printCarouselPlan(cardOverrides, cardLines);
@@ -47,8 +47,49 @@ export async function find(orgId, opts = {}) {
47
47
 
48
48
  export async function show(contactId, opts = {}) {
49
49
  if (!UUID_RE.test(contactId || "")) { console.error(`Error: "${contactId}" is not a valid contact UUID.`); process.exit(1); }
50
- if (!UUID_RE.test(opts.org || "")) { console.error("Error: --org <organization_id> is required (a contact id alone is not scoped)."); process.exit(1); }
51
- return find(opts.org, { id: contactId, json: opts.json });
50
+ if (opts.org && !UUID_RE.test(opts.org)) { console.error(`Error: "${opts.org}" is not a valid organization UUID.`); process.exit(1); }
51
+ if (opts.org) return find(opts.org, { id: contactId, json: opts.json });
52
+ // No --org: the server resolves the contact's own org (one indexed read).
53
+ let resp;
54
+ try { resp = await http.get("contacts", { find: "1", contact_id: contactId }); } catch (e) { fail("Lookup failed", e); }
55
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
56
+ if (!resp.contacts.length) { console.log(`No contact ${contactId}.`); return; }
57
+ console.log(`${resp.organization_name} (${resp.organization_id}):\n`);
58
+ for (const c of resp.contacts) { printContact(c); console.log(""); }
59
+ }
60
+
61
+ // `flowiq contacts ai off|on <org> --tag <tag> | --contact <id> | --ids-file f [--reason] [--commit]`
62
+ export async function ai(state, orgId, opts = {}) {
63
+ requireOrg(orgId);
64
+ const s = String(state || "").toLowerCase();
65
+ if (!["on", "off"].includes(s)) { console.error('Error: flowiq contacts ai off|on <org> --tag <tag> (or --contact / --ids-file)'); process.exit(1); }
66
+ if (!opts.tag && !opts.contact?.length && !opts.idsFile) { console.error("Error: pass --tag <tag>, --contact <id> (repeatable) or --ids-file <file>."); process.exit(1); }
67
+ if (opts.tag && (opts.contact?.length || opts.idsFile)) { console.error("Error: --tag or an id list, not both."); process.exit(1); }
68
+ const ids = opts.tag ? undefined : await readIds(opts);
69
+ let resp;
70
+ try { resp = await http.post("contacts", { action: "ai", organization_id: orgId, enabled: s === "on", tag: opts.tag, contact_ids: ids, reason: opts.reason, dry_run: !opts.commit }); }
71
+ catch (e) { fail("AI switch failed", e); }
72
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
73
+ console.log(`${resp.organization_name}: ${resp.matched} contact(s)${resp.tag ? ` on tag ${resp.tag}` : ""} · ${resp.to_change} to switch ${s.toUpperCase()} · ${resp.already} already ${s}${resp.not_found ? ` · ${resp.not_found} not in this org` : ""}`);
74
+ for (const c of resp.sample) console.log(` ${pad(c.whatsapp_id ?? "—", 16)} ${c.name ?? ""} ${c.id}`);
75
+ if (resp.to_change > resp.sample.length) console.log(` … ${resp.to_change - resp.sample.length} more`);
76
+ if (resp.caveat) console.log(`\n⚠ ${resp.caveat}`);
77
+ if (resp.dry_run) { console.log(`\nDRY RUN — nothing changed. Add --commit to set bot_status=${s === "on"} and write the agent-${s === "on" ? "on" : "off"} row on each chat.`); return; }
78
+ console.log(`\n✓ AI ${s.toUpperCase()} for ${resp.changed} contact(s).${resp.tag ? ` Undo: flowiq contacts ai ${s === "on" ? "off" : "on"} ${orgId} --tag ${resp.tag} --commit` : ""}`);
79
+ }
80
+
81
+ // `flowiq contacts add <org> --whatsapp-id <n> [--name] [--email] [--no-broadcast] [--tag a,b] [--ai-off] [--commit]`
82
+ export async function add(orgId, opts = {}) {
83
+ requireOrg(orgId);
84
+ if (!opts.whatsappId) { console.error("Error: --whatsapp-id <digits> is required (country code first, e.g. 27637186304)."); process.exit(1); }
85
+ let resp;
86
+ try { resp = await http.post("contacts", { action: "add", organization_id: orgId, whatsapp_id: opts.whatsappId, full_name: opts.name, email: opts.email, allow_broadcast: opts.broadcast !== false, tags: opts.tag ? String(opts.tag).split(",").map((t) => t.trim()).filter(Boolean) : [], bot_status: opts.aiOff ? false : true, dry_run: !opts.commit }); }
87
+ catch (e) { fail("Add failed", e); }
88
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
89
+ const r = resp.would_insert ?? resp.inserted;
90
+ console.log(`${resp.organization_name}: ${r.whatsapp_id} ${r.full_name ?? "(no name)"}${r.email ? ` · ${r.email}` : ""} · broadcasts ${r.allow_broadcast ? "yes" : "NO"} · AI ${r.bot_status ? "on" : "off"}${r.tags?.length ? ` · tags ${r.tags.join(", ")}` : ""}`);
91
+ if (resp.dry_run) { console.log("DRY RUN — add --commit to create the contact."); return; }
92
+ console.log(`✓ created ${resp.contact_id}. Show: flowiq contacts show ${resp.contact_id}`);
52
93
  }
53
94
 
54
95
  export async function census(orgId, opts = {}) {
@@ -37,6 +37,19 @@ export async function add(orgId, emailOrId, opts = {}) {
37
37
  console.log(`✓ ${resp.email} is now a ${resp.role}${resp.reactivated ? " again" : ""}. Undo: flowiq members remove ${orgId} ${resp.email} --confirm`);
38
38
  }
39
39
 
40
+ // `flowiq members invite <org> <email> [--role member]` — the app's door for
41
+ // someone with no account: an invitations row they are shown at sign-up.
42
+ export async function invite(orgId, email, opts = {}) {
43
+ requireOrg(orgId);
44
+ if (!email) { console.error("Error: flowiq members invite <org> <email> [--role member]"); process.exit(1); }
45
+ let resp;
46
+ try { resp = await http.post("members", { action: "invite", organization_id: orgId, email, role: opts.role || "member", dry_run: !!opts.dryRun }); } catch (e) { fail("Invite failed", e); }
47
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
48
+ if (resp.dry_run) { console.log(`Would invite ${resp.would_insert.email} to ${resp.organization_name} as ${resp.would_insert.role}.`); return; }
49
+ if (resp.unchanged) { console.log(resp.note); return; }
50
+ console.log(`✓ invited ${resp.email} as ${resp.role} (invitation ${resp.invitation_id}).\n ${resp.note}.`);
51
+ }
52
+
40
53
  export async function role(orgId, emailOrId, newRole, opts = {}) {
41
54
  requireOrg(orgId);
42
55
  if (!emailOrId || !newRole) { console.error("Error: flowiq members role <org> <email> admin|member|viewer"); process.exit(1); }
@@ -94,6 +94,33 @@ export function sentBy(m) {
94
94
  return "agent";
95
95
  }
96
96
 
97
+ // `flowiq messages purge <org> --subject-regex <re> | --text <s> | --sender-type a,b [--since] [--before] --confirm`
98
+ // Org-wide delete by pattern (the 71 Lego auto-replies across 69 contacts).
99
+ export async function purge(orgId, opts = {}) {
100
+ requireOrg(orgId);
101
+ const senderTypes = opts.senderType ? String(opts.senderType).split(",").map((s) => s.trim()).filter(Boolean) : undefined;
102
+ if (!opts.subjectRegex && !opts.text && !senderTypes) { console.error("Error: give at least one of --subject-regex, --text, --sender-type."); process.exit(1); }
103
+ const body = { action: "delete", organization_id: orgId, subject_regex: opts.subjectRegex, text: opts.text, sender_types: senderTypes, since: opts.since, before: opts.before };
104
+ let preview;
105
+ try { preview = await http.post("messages", { ...body, dry_run: true }); } catch (e) { fail("Purge failed", e); }
106
+ if (opts.json && !opts.confirm) { console.log(JSON.stringify(preview, null, 2)); return; }
107
+ console.log(`${preview.organization_name}: ${preview.count} message(s) across ${preview.contacts} contact(s) match${preview.capped ? " (capped at 5,000 a run; re-run for the rest)" : ""}`);
108
+ for (const [k, v] of Object.entries(preview.by_sender).sort()) console.log(` ${pad(k, 18)} ${v}`);
109
+ if (preview.top_subjects.length) { console.log(" subjects:"); for (const s of preview.top_subjects) console.log(` ${String(s.n).padStart(5)} × ${s.subject}`); }
110
+ if (preview.oldest) console.log(` from ${fmtSast(preview.oldest)} to ${fmtSast(preview.newest)} SAST`);
111
+ for (const c of preview.sample_contacts.slice(0, 8)) console.log(` ${String(c.n).padStart(5)} × ${c.contact?.full_name ?? ""} ${c.contact?.whatsapp_id ?? ""} (${c.contact_id})`);
112
+ if (!preview.count) return;
113
+ if (!opts.confirm) { console.log(`\nDRY RUN — nothing deleted. Add --confirm to delete (you will be asked to type the organization id).`); return; }
114
+ const readline = await import("node:readline");
115
+ const typed = await new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.question(`This cannot be undone. Type the organization id (${orgId}) to delete ${preview.count} message(s): `, (a) => { rl.close(); resolve(a.trim()); }); });
116
+ if (typed !== orgId) { console.log("Mismatch — aborted, nothing deleted."); process.exit(0); }
117
+ let resp;
118
+ try { resp = await http.post("messages", { ...body, dry_run: false, confirm: true }); } catch (e) { fail("Purge failed", e); }
119
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
120
+ console.log(`✓ deleted ${resp.deleted} message(s) across ${resp.contacts} contact(s). The audit row keeps ids, types and times (flowiq audit --endpoint messages).`);
121
+ if (resp.error) console.error(` ⚠ ${resp.error}`);
122
+ }
123
+
97
124
  // `flowiq messages delete <contact_id> [--before 30d] [--sender-type a,b] --confirm`
98
125
  export async function remove(contactId, opts = {}) {
99
126
  if (!UUID_RE.test(contactId)) { console.error(`Error: "${contactId}" is not a valid contact UUID.`); process.exit(1); }
@@ -137,12 +164,12 @@ export async function search(orgId, textParts, opts = {}) {
137
164
  const text = (Array.isArray(textParts) ? textParts.join(" ") : String(textParts ?? "")).trim();
138
165
  if (text.length < 2) { console.error("Error: give at least 2 characters to search for."); process.exit(1); }
139
166
  let resp;
140
- try { resp = await http.get("messages", { organization_id: orgId, search: text, since: opts.since, until: opts.until, sender_type: opts.senderType, include_templates: opts.includeTemplates ? "1" : undefined, limit: opts.limit }); }
167
+ try { resp = await http.get("messages", { organization_id: orgId, search: text, since: opts.since, until: opts.until, sender_type: opts.senderType, include_templates: opts.includeTemplates ? "1" : undefined, tool_calls: opts.toolCalls ? "1" : undefined, limit: opts.limit }); }
141
168
  catch (e) { fail("Search failed", e); }
142
169
  if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
143
- console.log(`${resp.organization_name}: ${resp.count} message(s) containing "${text}" since ${fmtSast(resp.since)} SAST${resp.capped ? ` (capped at ${resp.limit}; narrow with --since / --sender-type)` : ""}${opts.includeTemplates ? "" : " (template sends excluded; --include-templates)"}`);
170
+ console.log(`${resp.organization_name}: ${resp.count} ${opts.toolCalls ? "tool call(s) whose request or response contains" : "message(s) containing"} "${text}" since ${fmtSast(resp.since)} SAST${resp.capped ? ` (capped at ${resp.limit}; narrow with --since / --sender-type)` : ""}${opts.includeTemplates || opts.toolCalls ? "" : " (template sends excluded; --include-templates)"}`);
144
171
  for (const r of resp.rows) {
145
- console.log(` ${pad(fmtSast(r.created_at), 17)} ${pad(r.sender_type, 16)} ${pad((r.contact?.full_name || r.contact?.whatsapp_id || r.contact_id || "").slice(0, 22), 22)} ${snippetAround(r.message, text)}`);
172
+ console.log(` ${pad(fmtSast(r.created_at), 17)} ${pad(opts.toolCalls ? (r.tool_name ?? "tool") : r.sender_type, 16)} ${pad((r.contact?.full_name || r.contact?.whatsapp_id || r.contact_id || "").slice(0, 22), 22)} ${opts.toolCalls ? String(r.message).replace(/\s+/g, " ").slice(0, 160) : snippetAround(r.message, text)}`);
146
173
  }
147
174
  if (resp.rows.length) console.log(`\n contact ids: flowiq messages search … --json | jq '.rows[].contact_id' · full chat: flowiq messages pull <contact_id>`);
148
175
  }
@@ -197,8 +197,9 @@ export async function whatsapp(orgId, opts = {}) {
197
197
  console.log(`${resp.checked} Meta number(s) checked at Meta, ${resp.unhealthy} with a problem (${resp.skipped_not_meta_or_unconnected} org(s) skipped: not Meta or not connected)`);
198
198
  const pad = (s, n) => String(s ?? "").padEnd(n);
199
199
  console.log("");
200
- console.log(` ${pad("ORG", 30)} ${pad("NUMBER", 18)} ${pad("STATUS", 12)} ${pad("QUALITY", 9)} ${pad("TIER", 12)} PROBLEMS`);
201
- for (const o of [...resp.orgs].sort((a, b) => Number(a.healthy) - Number(b.healthy))) console.log(` ${pad(o.name.slice(0, 30), 30)} ${pad(o.phone_number, 18)} ${pad(o.status ?? "?", 12)} ${pad(o.quality ?? "?", 9)} ${pad(o.tier ?? "?", 12)} ${(o.problems || []).join("; ")}`);
200
+ console.log(` ${pad("ORG", 30)} ${pad("NUMBER", 18)} ${pad("STATUS", 12)} ${pad("QUALITY", 9)} ${pad("LIMIT", 14)} PROBLEMS`);
201
+ for (const o of [...resp.orgs].sort((a, b) => Number(a.healthy) - Number(b.healthy))) console.log(` ${pad(o.name.slice(0, 30), 30)} ${pad(o.phone_number, 18)} ${pad(o.status ?? "?", 12)} ${pad(o.quality ?? "?", 9)} ${pad(o.tier ?? "—", 14)} ${(o.problems || []).join("; ")}${o.notes?.length ? `${o.problems?.length ? " · " : ""}info: ${o.notes.join("; ")}` : ""}`);
202
+ console.log(`\n LIMIT = the business portfolio's messaging limit (Meta sets it per portfolio now; the per-number tier field is deprecated). "—" = Meta returned neither field for this number.`);
202
203
  return;
203
204
  }
204
205
  const resp = await view(orgId, "whatsapp");
@@ -209,11 +210,11 @@ export async function whatsapp(orgId, opts = {}) {
209
210
  const p = w.phone || {};
210
211
  console.log(` number: ${p.display_phone_number ?? "?"} · "${p.verified_name ?? "?"}" · phone ${w.phone_id} · WABA ${w.waba_id ?? "?"}`);
211
212
  console.log(` status: ${p.status ?? "?"} · platform ${p.platform_type ?? "?"} · account mode ${p.account_mode ?? "?"}`);
212
- console.log(` quality: ${p.quality_rating ?? "?"} · limit tier ${p.messaging_limit_tier ?? "?"}${p.throughput?.level ? ` · throughput ${p.throughput.level}` : ""}`);
213
+ console.log(` quality: ${p.quality_rating ?? "?"} · messaging limit ${w.messaging_limit ?? "not returned"}${w.messaging_limit_source ? ` (${w.messaging_limit_source})` : ""}${p.throughput?.level ? ` · throughput ${p.throughput.level}` : ""}`);
213
214
  console.log(` name: ${p.name_status ?? "?"}${p.new_name_status && p.new_name_status !== "NONE" ? ` · pending "${p.new_display_name}" (${p.new_name_status})` : ""} · OBA ${yn(p.is_official_business_account)} · code verification ${p.code_verification_status ?? "?"}`);
214
215
  console.log(` webhooks: ${Array.isArray(w.subscribed_apps) ? (w.subscribed_apps.length ? w.subscribed_apps.join(", ") : "NO app subscribed") : w.subscribed_apps ?? "?"}`);
215
216
  if (w.waba && !w.waba.error) console.log(` waba: ${w.waba.name ?? ""} · review ${w.waba.account_review_status ?? "?"} · ${w.waba.ownership_type ?? ""}`);
216
- console.log(` ${w.healthy ? "✓ healthy" : `✗ ${w.problems.join("; ")}`}`);
217
+ console.log(` ${w.healthy ? "✓ healthy" : `✗ ${w.problems.join("; ")}`}${w.notes?.length ? ` (info: ${w.notes.join("; ")})` : ""}`);
217
218
  }
218
219
 
219
220
  export async function health(orgId, opts = {}) {
@@ -236,6 +237,22 @@ export async function health(orgId, opts = {}) {
236
237
 
237
238
  export async function automations(orgId, opts = {}) {
238
239
  requireOrg(orgId);
240
+ // --set <type> --template <name> / --body k=v,… / --buttons k=v,…: repoint
241
+ // one type at a new template version or fix its param mapping.
242
+ if (opts.set) {
243
+ const parseMap = (v, flag) => { if (v === undefined) return undefined; if (v === "none" || v === "null") return null; const out = {}; for (const pair of String(v).split(",").map((s) => s.trim()).filter(Boolean)) { const i = pair.indexOf("="); if (i < 1) { console.error(`Error: ${flag} takes param1=full_name,param2=order_number (got "${pair}")`); process.exit(1); } out[pair.slice(0, i).trim()] = pair.slice(i + 1).trim(); } return out; };
244
+ const body = { action: "automations_set", organization_id: orgId, type: opts.set, template_name: opts.template, body_parameters: parseMap(opts.body, "--body"), button_parameters: parseMap(opts.buttons, "--buttons"), dry_run: !opts.commit };
245
+ if (body.template_name === undefined && body.body_parameters === undefined && body.button_parameters === undefined) { console.error("Error: with --set <type> pass --template <name>, --body k=v,… and/or --buttons k=v,… (\"none\" clears a mapping)."); process.exit(1); }
246
+ let resp;
247
+ try { resp = await http.post("org", body); } catch (e) { fail("Set failed", e); }
248
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
249
+ if (resp.unchanged) { console.log(`${opts.set}: nothing to change (template ${resp.current?.template_name ?? "none"}, mapping ${JSON.stringify(resp.current?.meta_parameter_mapping ?? null)}).`); return; }
250
+ console.log(`${resp.organization_name} · ${resp.type}:`);
251
+ for (const c of resp.changes) console.log(` ${c}`);
252
+ if (resp.warning) console.log(` ⚠ ${resp.warning}`);
253
+ console.log(resp.dry_run ? "\nDRY RUN — add --commit to write." : "\n✓ written (the next order event sends with these values).");
254
+ return;
255
+ }
239
256
  if (opts.enable || opts.disable) {
240
257
  const type = opts.enable || opts.disable;
241
258
  let resp;
@@ -257,6 +274,7 @@ export async function automations(orgId, opts = {}) {
257
274
  if (t.warning) console.log(` ⚠ ${t.warning}`);
258
275
  }
259
276
  console.log(`\nflowiq org automations ${orgId} --enable <type> | --disable <type> [--commit] (the whole feature: --enable/--disable with no type)`);
277
+ console.log(`flowiq org automations ${orgId} --set <type> --template <name> --body param1=full_name,param2=order_number --buttons param1=short_code [--commit]`);
260
278
  }
261
279
 
262
280
  export async function abandoned(orgId, opts = {}) {
@@ -230,6 +230,56 @@ export async function show(planId, opts = {}) {
230
230
  console.log(`\nChange status: flowiq plans status ${p.id} --to <status> [--comment "..."] [--commit]`);
231
231
  }
232
232
 
233
+ // `flowiq plans create <org> --topic … --type utility|marketing --date YYYY-MM-DD [--time HH:MM] …`
234
+ // Files a plan on the client's Planning board (the MCP submit_plan row). Staff
235
+ // are exempt from the client lead time. Dry run unless --commit.
236
+ export function parseButton(spec) {
237
+ // type:text[=value] e.g. quick_reply:VIEW MORE · url:CLICK HERE=https://x · phone:Call us=+27… · copy_code:SAVE10
238
+ const m = /^(quick_reply|url|phone|copy_code):(.*)$/.exec(String(spec).trim());
239
+ if (!m) { console.error(`Error: --button must be type:text[=value] (got "${spec}"); types quick_reply | url | phone | copy_code`); process.exit(1); }
240
+ const [, type, rest] = m;
241
+ if (type === "copy_code") return { type, code: rest.trim() };
242
+ const eq = rest.indexOf("=");
243
+ const text = (eq >= 0 ? rest.slice(0, eq) : rest).trim();
244
+ const value = eq >= 0 ? rest.slice(eq + 1).trim() : "";
245
+ if (type === "url") return { type, text, url: value };
246
+ if (type === "phone") return { type, text, phone_number: value };
247
+ return { type, text };
248
+ }
249
+
250
+ export async function create(orgId, opts = {}) {
251
+ if (!UUID_RE.test(orgId || "")) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
252
+ const buttons = (opts.button || []).map(parseButton);
253
+ for (const f of opts.reply || []) {
254
+ const i = f.indexOf("=");
255
+ if (i < 1) { console.error(`Error: --reply must be <button text>=<follow-up message> (got "${f}")`); process.exit(1); }
256
+ const b = buttons.find((x) => x.type === "quick_reply" && x.text.toLowerCase() === f.slice(0, i).trim().toLowerCase());
257
+ if (!b) { console.error(`Error: --reply names "${f.slice(0, i).trim()}" but no quick_reply button has that text`); process.exit(1); }
258
+ b.follow_up = f.slice(i + 1).trim();
259
+ }
260
+ const body = {
261
+ action: "create", organization_id: orgId, topic: opts.topic, type: opts.type, scheduled_date: opts.date, scheduled_time: opts.time,
262
+ product_focus: opts.product, audience: opts.audience, line_1: opts.line1, line_2: opts.line2, follow_up_message: opts.followUp, landing_page_url: opts.landing,
263
+ message: opts.message, buttons: buttons.length ? buttons : undefined, graphic_brief: opts.graphic, media_url: opts.media, save_as_draft: !!opts.draft, dry_run: !opts.commit,
264
+ };
265
+ let resp;
266
+ try { resp = await http.post("plans", body); }
267
+ catch (e) {
268
+ console.error(`Create failed: ${e.message}`);
269
+ for (const p of e.body?.problems || []) console.error(` - ${p}`);
270
+ process.exit(1);
271
+ }
272
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
273
+ console.log(`${resp.organization_name}: "${resp.topic}" (${resp.type}) · ${resp.send} · audience: ${resp.audience} · files as ${resp.files_as}`);
274
+ console.log(` copy: ${String(resp.copy).replace(/\n/g, "\n ")}`);
275
+ if (resp.buttons?.length) console.log(` buttons: ${resp.buttons.map((b) => `${b.type}:${b.text ?? b.code}${b.url ? `=${b.url}` : ""}`).join(" · ")}`);
276
+ if (resp.follow_ups && Object.keys(resp.follow_ups).length) console.log(` follow-ups: ${JSON.stringify(resp.follow_ups)}`);
277
+ console.log(` graphic: ${resp.graphic}`);
278
+ for (const n of resp.notes || []) console.log(` ⚠ ${n}`);
279
+ if (resp.dry_run) { console.log("\nDRY RUN — nothing filed. Add --commit to file it on the Planning board."); return; }
280
+ console.log(`\n✓ filed plan ${resp.plan_id} (${resp.status}). ${resp.alert?.note ?? ""}\n flowiq plans show ${resp.plan_id}`);
281
+ }
282
+
233
283
  export async function status(planId, opts = {}) {
234
284
  if (!UUID_RE.test(planId)) {
235
285
  console.error(`Error: "${planId}" is not a valid plan UUID (get it from \`flowiq plans list\`).`);
@@ -107,7 +107,7 @@ function banner(resp) {
107
107
 
108
108
  if (resp.truncated) {
109
109
  console.error(`⚠ TRUNCATED — ${resp.truncated_reason}. Not all records were returned.`);
110
- console.error(" Narrow the query, or raise --max-pages and page through with the store's own cursor.");
110
+ console.error(" Narrow the query, trim records with --fields id,name,…, or raise --max-pages and page through with the store's own cursor.");
111
111
  }
112
112
  // Shopify's *Count fields return {count, precision}. `AT_LEAST` means the
113
113
  // number is a CAP (10,000), not an answer — and it is quiet enough to be read
@@ -213,6 +213,14 @@ async function send(payload, opts, sentQuery) {
213
213
  // shopify
214
214
  // ---------------------------------------------------------------------------
215
215
 
216
+ // --fields id,name,… = the platform's own field-trim parameter (Shopify REST
217
+ // `fields`, Woo `_fields`): the way past the 3.5 MB response cap on a fat
218
+ // record type (Revive's 894 products stopped at 200 without it, 30 Sep 2026).
219
+ export function withFields(query, fields, key) {
220
+ if (!fields) return query;
221
+ return { ...(query || {}), [key]: String(fields).split(",").map((s) => s.trim()).filter(Boolean).join(",") };
222
+ }
223
+
216
224
  export async function shopifyGet(orgId, path, opts = {}) {
217
225
  requireOrg(orgId);
218
226
  await send({
@@ -220,7 +228,7 @@ export async function shopifyGet(orgId, path, opts = {}) {
220
228
  organization_id: orgId,
221
229
  mode: "rest",
222
230
  path,
223
- query: opts.q || {},
231
+ query: withFields(opts.q, opts.fields, "fields"),
224
232
  all: !!opts.all,
225
233
  max_pages: opts.maxPages ? Number(opts.maxPages) : undefined,
226
234
  api_version: opts.apiVersion,
@@ -446,7 +454,7 @@ export async function wooGet(orgId, path, opts = {}) {
446
454
  mode: "rest",
447
455
  path,
448
456
  namespace: opts.namespace,
449
- query: opts.q || {},
457
+ query: withFields(opts.q, opts.fields, "_fields"),
450
458
  all: !!opts.all,
451
459
  max_pages: opts.maxPages ? Number(opts.maxPages) : undefined,
452
460
  }, opts);
@@ -96,12 +96,23 @@ export async function field(orgId, opts = {}) {
96
96
  const keywords = splitList(opts.any || opts.all);
97
97
  if (!keywords) { console.error("Error: pass --any \"a,b\" (OR) or --all \"a,b\" (AND)."); process.exit(1); }
98
98
  if (opts.exact && opts.all) { console.error("Error: --exact matches whole values, so it is an OR over --any (drop --all)."); process.exit(1); }
99
+ // A list of phone numbers against whatsapp_id / phone_number is a whole-value
100
+ // match by nature. The substring RPC runs one ILIKE per keyword over every
101
+ // contact and timed out at 77 numbers on a 95k-contact org (Kiah, 30 Sep
102
+ // 2026: 8.2 s measured); the exact path is an indexed IN and is what
103
+ // everyone meant anyway, so it is chosen for a digit-only list.
104
+ let exact = !!opts.exact;
105
+ const field = opts.field || "order_history";
106
+ if (!exact && !opts.all && ["whatsapp_id", "phone_number"].includes(field) && keywords.every((k) => /^\d{8,15}$/.test(k))) {
107
+ exact = true;
108
+ console.error(`(whole-number match: ${keywords.length} number(s) against ${field}; the substring search would scan every contact. Pass --any with a partial number for a substring match.)`);
109
+ }
99
110
  const payload = {
100
- mode: "field", organization_id: orgId, field_name: opts.field || "order_history",
101
- keywords, logic: opts.all ? "AND" : "OR", case_sensitive: !!opts.caseSensitive, exact: !!opts.exact,
111
+ mode: "field", organization_id: orgId, field_name: field,
112
+ keywords, logic: opts.all ? "AND" : "OR", case_sensitive: !!opts.caseSensitive, exact,
102
113
  };
103
114
  await confirmLargeWrite(orgId, payload, opts, "Field search");
104
- await runMatch(opts.exact ? "Exact field match" : "Field search", payload, opts);
115
+ await runMatch(exact ? "Exact field match" : "Field search", payload, opts);
105
116
  }
106
117
 
107
118
  async function readIds(csv, file) {
package/src/index.js CHANGED
@@ -148,9 +148,20 @@ export function run(argv) {
148
148
  .option("--until <when>", "upper bound")
149
149
  .option("--sender-type <list>", "comma list, e.g. bot-whatsapp or user-whatsapp,user-web (default: conversation rows only)")
150
150
  .option("--include-templates", "also match template / broadcast payloads")
151
+ .option("--tool-calls", "search inside tool-call rows' request / response instead of message text (what a tool was asked and answered)")
151
152
  .option("--limit <n>", "max rows (default 200, max 1000)")
152
153
  .option("--json", "raw JSON (rows carry contact_id)")
153
154
  .action((orgId, text, opts) => messagesCmd.search(orgId, text, opts));
155
+ msgs.command("purge <organization_id>")
156
+ .description("DELETE messages org-wide by pattern: email subject regex, text, sender types, time window (5,000 a run). Dry run by default; --confirm asks you to type the org id")
157
+ .option("--subject-regex <re>", "case-insensitive regex on the email subject (more_data.subject), e.g. '^Automatic reply'")
158
+ .option("--text <substr>", "message text contains this")
159
+ .option("--sender-type <list>", "comma list, e.g. user-email")
160
+ .option("--since <when>", "only rows newer than this (30d | YYYY-MM-DD)")
161
+ .option("--before <when>", "only rows older than this")
162
+ .option("--confirm", "actually delete (typed confirmation follows)")
163
+ .option("--json", "raw JSON")
164
+ .action((orgId, opts) => messagesCmd.purge(orgId, opts));
154
165
  msgs.command("failures <organization_id>")
155
166
  .description("What failed to send and why (Meta error codes by reason / media / contact), plus sends Meta accepted and never confirmed")
156
167
  .option("--since <when>", "7d (default) | 24h | 30d | YYYY-MM-DD")
@@ -484,9 +495,13 @@ export function run(argv) {
484
495
  .option("--json", "raw JSON")
485
496
  .action((orgId, opts) => orgCmd.health(orgId, opts));
486
497
  org.command("automations <organization_id>")
487
- .description("Automated order messages (automated_messages_config): each type, its template and whether that template is approved")
498
+ .description("Automated order messages (automated_messages_config): each type, its template and whether that template is approved; --set changes a type's template or mapping")
488
499
  .option("--enable [type]", "switch a type on (or the whole feature with no type); dry run unless --commit")
489
500
  .option("--disable [type]", "switch a type off (or the whole feature)")
501
+ .option("--set <type>", "change this type: with --template / --body / --buttons")
502
+ .option("--template <name>", "with --set: the template name to send (must exist in FlowIQ's templates; approval is checked and warned)")
503
+ .option("--body <map>", "with --set: body param mapping, param1=full_name,param2=order_number (\"none\" clears)")
504
+ .option("--buttons <map>", "with --set: button param mapping, param1=short_code (\"none\" clears)")
490
505
  .option("--commit", "write the change")
491
506
  .option("--json", "raw JSON")
492
507
  .action((orgId, opts) => orgCmd.automations(orgId, opts));
@@ -694,6 +709,27 @@ export function run(argv) {
694
709
  .option("--json", "print the raw response")
695
710
  .option("--out <file>", "also write the full plan JSON to a file")
696
711
  .action((planId, opts) => plansCmd.show(planId, opts));
712
+ plans.command("create <organization_id>")
713
+ .description("File a plan on the client's Planning board (the MCP submit_plan row; staff are exempt from the client lead time). Dry run unless --commit")
714
+ .requiredOption("--topic <text>", "campaign name (under 120 chars)")
715
+ .requiredOption("--type <t>", "utility | marketing")
716
+ .requiredOption("--date <YYYY-MM-DD>", "send date (SAST)")
717
+ .option("--time <HH:MM>", "send time SAST (omit = any time that day)")
718
+ .option("--product <text>", "product or collection it is about")
719
+ .option("--audience <text>", "who it goes to, in words (omit = the whole opted-in list)")
720
+ .option("--line-1 <text>", "utility: first sentence after \"Hi [Name],\"")
721
+ .option("--line-2 <text>", "utility: second paragraph")
722
+ .option("--follow-up <text>", "utility: the message VIEW MORE sends")
723
+ .option("--landing <url>", "utility: the https:// page CLICK HERE opens")
724
+ .option("--message <text>", "marketing: the full first message (≤ 1,024 chars)")
725
+ .option("--button <spec>", "marketing button, repeatable: quick_reply:TEXT | url:TEXT=https://… | phone:TEXT=+27… | copy_code:CODE", (v, acc) => (acc || []).concat([v]), [])
726
+ .option("--reply <text=follow-up>", "marketing: the message a quick_reply button sends (repeatable)", (v, acc) => (acc || []).concat([v]), [])
727
+ .option("--graphic <brief>", "have Flowapt design the image: describe it")
728
+ .option("--media <url>", "use your own public https:// image or video instead")
729
+ .option("--draft", "save as a draft (nobody alerted) instead of filing for review")
730
+ .option("--commit", "file it")
731
+ .option("--json", "raw JSON")
732
+ .action((orgId, opts) => plansCmd.create(orgId, opts));
697
733
  plans.command("status <plan_id>")
698
734
  .description("Change a plan's status (dry run unless --commit); approved/rejected also record you as the reviewer")
699
735
  .requiredOption("--to <status>", "draft | pending_review | approved | rejected | scheduled | sent | cancelled")
@@ -710,9 +746,29 @@ export function run(argv) {
710
746
  .option("--limit <n>", "max matches (default 20, max 100)").option("--json", "raw JSON")
711
747
  .action((orgId, opts) => contactsCmd.find(orgId, opts));
712
748
  contacts.command("show <contact_id>")
713
- .description("One contact's record + inbox state (needs --org)")
714
- .requiredOption("--org <organization_id>", "the contact's org").option("--json", "raw JSON")
749
+ .description("One contact's record + inbox state (the org is resolved from the id; --org to pin it)")
750
+ .option("--org <organization_id>", "the contact's org (optional since 0.12.0)").option("--json", "raw JSON")
715
751
  .action((contactId, opts) => contactsCmd.show(contactId, opts));
752
+ contacts.command("add <organization_id>")
753
+ .description("Create one contact by hand (a tester, a team member for a test send). Dry run unless --commit")
754
+ .requiredOption("--whatsapp-id <digits>", "the number with country code, e.g. 27637186304")
755
+ .option("--name <name>", "full_name")
756
+ .option("--email <email>", "email")
757
+ .option("--no-broadcast", "allow_broadcast false (default: true, like the inbox)")
758
+ .option("--ai-off", "bot_status false (default: on)")
759
+ .option("--tag <csv>", "tags to start with")
760
+ .option("--commit", "create it")
761
+ .option("--json", "raw JSON")
762
+ .action((orgId, opts) => contactsCmd.add(orgId, opts));
763
+ contacts.command("ai <on|off> <organization_id>")
764
+ .description("Switch the AI agent on or off for every contact on a tag (or an id list), writing the agent-on/off row each chat shows. Dry run unless --commit")
765
+ .option("--tag <tag>", "every contact carrying this tag")
766
+ .option("--contact <id>", "contact UUID (repeatable)", (v, acc) => (acc || []).concat([v]), [])
767
+ .option("--ids-file <path>", "file of contact UUIDs")
768
+ .option("--reason <text>", "noted on the agent-on/off row and the audit")
769
+ .option("--commit", "write bot_status + the marker rows")
770
+ .option("--json", "raw JSON")
771
+ .action((state, orgId, opts) => contactsCmd.ai(state, orgId, opts));
716
772
  contacts.command("census <organization_id>")
717
773
  .description("Archived chats that still carry unread messages or where the customer wrote last")
718
774
  .option("--json", "raw JSON")
@@ -908,6 +964,24 @@ export function run(argv) {
908
964
  .option("--timeout <ms>", "per-turn HTTP timeout")
909
965
  .option("--json", "print raw JSON results")
910
966
  .action((orgId, opts) => testCmd.qa(orgId, opts));
967
+ test.command("seed <organization_id>")
968
+ .description("Copy a real chat onto a NEW test contact (test_seed_*, tag test-contact, no broadcasts, AI off) to reproduce an agent or Coworker answer. Dry run unless --commit")
969
+ .requiredOption("--from <contact_id>", "the real contact whose thread is copied")
970
+ .option("--until <time>", "copy rows up to this SAST time, e.g. \"2026-10-01 09:08\" or an ISO time")
971
+ .option("--rows <n>", "newest rows to copy (default 60, max 500)")
972
+ .option("--name <name>", "the seeded contact's display name (default: \"<name> (seeded test)\")")
973
+ .option("--with-tools", "also copy tool-call rows")
974
+ .option("--copy-attributes", "copy the contact's attributes (dog name, weight, …)")
975
+ .option("--ai-on", "leave the agent ON for the seeded contact (default off)")
976
+ .option("--agent <id>", "scope to a specific agent (audit only)")
977
+ .option("--commit", "create it")
978
+ .option("--json", "raw JSON")
979
+ .action((orgId, opts) => testCmd.seed(orgId, opts));
980
+ test.command("unseed <organization_id> <contact_id>")
981
+ .description("DELETE a seeded test contact (test_seed_* only) and its copied rows")
982
+ .option("--confirm", "actually delete")
983
+ .option("--json", "raw JSON")
984
+ .action((orgId, contactId, opts) => testCmd.unseed(orgId, contactId, opts));
911
985
  test.command("clear <organization_id>")
912
986
  .description("Clear the test conversation (hide history via memory_cutoff)")
913
987
  .option("--agent <id>", "target a specific agent's test contact")
@@ -1267,6 +1341,12 @@ export function run(argv) {
1267
1341
  .description("Members with role, name, email, last sign-in; pending invitations")
1268
1342
  .option("--json", "raw JSON")
1269
1343
  .action((orgId, opts) => membersCmd.list(orgId, opts));
1344
+ members.command("invite <organization_id> <email>")
1345
+ .description("Invite someone who has NO account yet (an invitations row they accept at sign-up; FlowIQ sends no email)")
1346
+ .option("--role <role>", "admin | member | viewer", "member")
1347
+ .option("--dry-run", "show what would be written")
1348
+ .option("--json", "raw JSON")
1349
+ .action((orgId, email, opts) => membersCmd.invite(orgId, email, opts));
1270
1350
  members.command("add <organization_id> <email>")
1271
1351
  .description("Add an EXISTING account to the org (someone with no account must be invited from the app)")
1272
1352
  .option("--role <role>", "admin | member | viewer", "member")
@@ -1363,6 +1443,7 @@ export function run(argv) {
1363
1443
  shopify.command("get <organization_id> <path>")
1364
1444
  .description('GET any Shopify Admin REST endpoint, e.g. `get <org> orders.json --q status=any` (".json" optional)')
1365
1445
  .option("--q <k=v>", "query parameter (repeatable), e.g. --q status=any --q limit=250", storeApiCmd.collectQuery, {})
1446
+ .option("--fields <csv>", "return only these fields per record (Shopify REST `fields`); the way past the 3.5 MB cap on big lists")
1366
1447
  .option("--all", "follow Link rel=next and return every page (bounded by --max-pages and a size/time cap)")
1367
1448
  .option("--max-pages <n>", "page limit when using --all (default 10, cap 50)")
1368
1449
  .option("--api-version <ver>", "Shopify Admin API version (default 2024-01)")
@@ -1398,6 +1479,7 @@ export function run(argv) {
1398
1479
  woo.command("get <organization_id> <path>")
1399
1480
  .description("GET any WooCommerce REST endpoint, e.g. `get <org> orders --q status=completed`")
1400
1481
  .option("--q <k=v>", "query parameter (repeatable), e.g. --q status=completed --q per_page=100", storeApiCmd.collectQuery, {})
1482
+ .option("--fields <csv>", "return only these fields per record (Woo `_fields`), e.g. id,name,permalink,status; the way past the 3.5 MB cap on a big catalogue")
1401
1483
  .option("--namespace <ns>", "REST namespace (default wc/v3; also wc-analytics, wp/v2)")
1402
1484
  .option("--all", "page through every result (bounded by --max-pages and a size/time cap)")
1403
1485
  .option("--max-pages <n>", "page limit when using --all (default 10, cap 50)")