@flowapt/flowiq-cli 0.10.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.
@@ -0,0 +1,58 @@
1
+ // `flowiq products status|search|reconcile <org>` — the synced catalogue as
2
+ // the agent sees it (server: api/cli/products.js).
3
+
4
+ import { http } from "../http.js";
5
+ import { fmtSast } from "./audit.js";
6
+
7
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+ const pad = (s, n) => String(s ?? "").padEnd(n);
9
+ function fail(prefix, e) { console.error(`${prefix}: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
10
+ const requireOrg = (v) => { if (!UUID_RE.test(v || "")) { console.error(`Error: "${v}" is not a valid organization UUID.`); process.exit(1); } };
11
+
12
+ export async function status(orgId, opts = {}) {
13
+ requireOrg(orgId);
14
+ let resp;
15
+ try { resp = await http.get("products", { organization_id: orgId, status: "1" }); } catch (e) { fail("Status failed", e); }
16
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
17
+ const c = resp.counts;
18
+ console.log(`${resp.organization_name} — catalogue (${resp.platform ?? "no store"})${resp.sync_inactive_products ? " · sync_inactive_products on" : ""}`);
19
+ console.log(` rows: ${c.total} total · ${c.active} active · ${c.archived} archived · ${c.draft} draft${c.other ? ` · ${c.other} other` : ""}`);
20
+ console.log(` embeddings: ${c.embedded} with · ${c.missing_embedding} MISSING${c.missing_embedding ? " ⚠ the agent cannot find these by meaning (products reconcile re-embeds)" : ""}`);
21
+ console.log(` images: ${c.with_image} with an image · prices: ${c.with_price} with a price · ${c.zero_stock} at zero stock`);
22
+ console.log(` newest row: ${resp.newest_row_updated_at ? `${fmtSast(resp.newest_row_updated_at)} SAST` : "—"}`);
23
+ if (resp.last_syncs.length) {
24
+ console.log(` last syncs:`);
25
+ for (const s of resp.last_syncs) console.log(` ${fmtSast(s.created_at)} SAST ${(s.success ?? s.successful) === false ? "FAILED" : "ok "} +${s.added ?? 0} ~${s.updated ?? 0} -${s.deleted ?? 0}${s.source ? ` ${s.source}` : ""}${s.summary ? ` ${String(s.summary).slice(0, 70)}` : ""}`);
26
+ } else console.log(` last syncs: none recorded`);
27
+ if (resp.missing_embedding_sample.length) {
28
+ console.log(` missing embedding (sample):`);
29
+ for (const p of resp.missing_embedding_sample) console.log(` ${pad(p.variant_id, 16)} ${pad(p.status ?? "", 9)} ${String(p.product_title || "").slice(0, 60)}`);
30
+ }
31
+ }
32
+
33
+ export async function search(orgId, textParts, opts = {}) {
34
+ requireOrg(orgId);
35
+ const text = (Array.isArray(textParts) ? textParts.join(" ") : String(textParts ?? "")).trim();
36
+ if (text.length < 2) { console.error("Error: give at least 2 characters, e.g. flowiq products search <org> whey protein"); process.exit(1); }
37
+ let resp;
38
+ try { resp = await http.get("products", { organization_id: orgId, search: text, limit: opts.limit, min_similarity: opts.minSimilarity }); } catch (e) { fail("Search failed", e); }
39
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
40
+ console.log(`${resp.organization_name}: ${resp.count} match(es) for "${text}" via ${resp.rpc} (min similarity ${resp.min_similarity}) — what the agent's product_lookup tool sees`);
41
+ for (const p of resp.results) {
42
+ const sim = p.similarity ?? p.score ?? p.sim;
43
+ const stock = p.stockcount && typeof p.stockcount === "object" ? Object.values(p.stockcount).reduce((a, b) => a + (Number(b) || 0), 0) : p.stockcount;
44
+ console.log(` ${pad(sim != null ? Number(sim).toFixed(2) : "", 5)} ${pad(p.variant_id ?? "", 16)} ${pad(p.price != null ? String(p.price) : "", 9)} ${pad(stock != null ? `stock ${stock}` : "", 11)} ${pad(p.status ?? "", 8)} ${String(p.product_title ?? p.title ?? "").slice(0, 70)}`);
45
+ }
46
+ if (!resp.count) console.log(` (nothing within similarity ${resp.min_similarity}; try --min-similarity 0.05, or check embeddings with: flowiq products status ${orgId})`);
47
+ }
48
+
49
+ export async function reconcile(orgId, opts = {}) {
50
+ requireOrg(orgId);
51
+ let resp;
52
+ try { resp = await http.post("products", { action: "reconcile", organization_id: orgId, platform: opts.platform, confirm: !!opts.confirm }); } catch (e) { fail("Reconcile failed", e); }
53
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
54
+ if (resp.dry_run) { console.log(`Would trigger a ${resp.platform} reconcile for ${resp.organization_name} (${resp.note}). Add --confirm.`); return; }
55
+ console.log(`${resp.ok ? "✓" : "✗"} products-reconcile-scheduler answered ${resp.http_status} for ${resp.organization_name} (${resp.platform})`);
56
+ console.log(` ${JSON.stringify(resp.response).slice(0, 600)}`);
57
+ console.log(`\n Watch it land: flowiq products status ${orgId}`);
58
+ }
@@ -96,7 +96,34 @@ export function mintSectionIds(sections) {
96
96
  return touched;
97
97
  }
98
98
 
99
- export async function push(identifier) {
99
+ // --dry-run: what a push WOULD change, section by section, against the live
100
+ // agent, without writing. Compares by section id (title for sections that
101
+ // have no id yet) and reports added / removed / changed sections with the
102
+ // character delta, plus title / hidden / channels / window changes.
103
+ export function diffSections(live, local) {
104
+ const keyOf = (s) => s.id || `title:${s.title}`;
105
+ const liveBy = new Map((live || []).map((s) => [keyOf(s), s]));
106
+ const localBy = new Map((local || []).map((s) => [keyOf(s), s]));
107
+ const added = [], removed = [], changed = [];
108
+ for (const [k, s] of localBy) if (!liveBy.has(k)) added.push({ title: s.title, chars: (s.content || "").length });
109
+ for (const [k, s] of liveBy) if (!localBy.has(k)) removed.push({ title: s.title, chars: (s.content || "").length });
110
+ for (const [k, s] of localBy) {
111
+ const l = liveBy.get(k);
112
+ if (!l) continue;
113
+ const what = [];
114
+ if ((l.content || "") !== (s.content || "")) what.push(`content ${(l.content || "").length} → ${(s.content || "").length} chars`);
115
+ if ((l.title || "") !== (s.title || "")) what.push(`title "${l.title}" → "${s.title}"`);
116
+ if (!!l.hidden !== !!s.hidden) what.push(`hidden ${!!l.hidden} → ${!!s.hidden}`);
117
+ if (JSON.stringify(l.channels ?? null) !== JSON.stringify(s.channels ?? null)) what.push(`channels ${JSON.stringify(l.channels ?? "all")} → ${JSON.stringify(s.channels ?? "all")}`);
118
+ for (const f of ["active_from", "active_until", "active_tz"]) if ((l[f] ?? null) !== (s[f] ?? null)) what.push(`${f} ${l[f] ?? "unset"} → ${s[f] ?? "unset"}`);
119
+ if (what.length) changed.push({ title: s.title, what });
120
+ }
121
+ const order = (list) => list.map(keyOf).join("|");
122
+ const reordered = !added.length && !removed.length && order(live || []) !== order(local || []);
123
+ return { added, removed, changed, reordered, unchanged: !added.length && !removed.length && !changed.length && !reordered };
124
+ }
125
+
126
+ export async function push(identifier, opts = {}) {
100
127
  let filePath;
101
128
  try {
102
129
  filePath = await resolvePushPath(identifier);
@@ -121,6 +148,24 @@ export async function push(identifier) {
121
148
  process.exit(1);
122
149
  }
123
150
 
151
+ if (opts.dryRun) {
152
+ let live;
153
+ try { live = await http.get("prompts", { organization_id: data.organization_id, agent_id: data.agent_id }); }
154
+ catch (e) { console.error(`Dry run failed (could not read the live prompt): ${e.message}`); process.exit(1); }
155
+ const d = diffSections(live.prompt_sections, data.prompt_sections);
156
+ const liveChars = (live.prompt_sections || []).reduce((a, s) => a + (s.content || "").length, 0);
157
+ const localChars = data.prompt_sections.reduce((a, s) => a + (s.content || "").length, 0);
158
+ console.log(`DRY RUN — ${filePath} against ${live.agent_name} (${live.agent_id}) on ${live.organization_name}`);
159
+ console.log(` live: ${(live.prompt_sections || []).length} sections, ${liveChars.toLocaleString()} chars · file: ${data.prompt_sections.length} sections, ${localChars.toLocaleString()} chars`);
160
+ if (d.unchanged) { console.log(" no differences — a push would change nothing."); return; }
161
+ for (const a of d.added) console.log(` + add "${a.title}" (${a.chars} chars)`);
162
+ for (const r of d.removed) console.log(` - REMOVE "${r.title}" (${r.chars} chars)`);
163
+ for (const c of d.changed) console.log(` ~ change "${c.title}": ${c.what.join("; ")}`);
164
+ if (d.reordered) console.log(" ~ sections reordered");
165
+ console.log(`\nNothing written. Push for real: flowiq prompts push ${identifier}`);
166
+ return;
167
+ }
168
+
124
169
  // A section added by hand has no id yet. Mint one and write it back to the
125
170
  // file BEFORE pushing, so a later pull or push keeps the same id.
126
171
  const minted = mintSectionIds(data.prompt_sections);
@@ -288,7 +288,20 @@ export async function plan(orgId, opts = {}) {
288
288
  console.log(`⚠ could not check existing batch tags (${e.message}) — verify with \`seg list --prefix ${prefix}\` before apply.`);
289
289
  }
290
290
 
291
- const slug = slugify(opts.segment || prefix, prefix);
291
+ // A continuation (--start-index N) is a NEW plan, not a rewrite of the first
292
+ // one: the earlier file is the record of who got the earlier batches, so it
293
+ // is never overwritten. The continuation lands in <slug>-from-NN.json
294
+ // unless --segment names it.
295
+ const baseSlug = slugify(opts.segment || prefix, prefix);
296
+ const slug = !opts.segment && startIndex > 1 ? `${baseSlug}-from-${String(startIndex).padStart(2, "0")}` : baseSlug;
297
+ if (await fs.access(planPath(slug)).then(() => true).catch(() => false)) {
298
+ const prev = JSON.parse(await fs.readFile(planPath(slug), "utf8").catch(() => "{}"));
299
+ if (prev.applied) {
300
+ console.error(`Error: ${planPath(slug)} already exists AND was applied on ${prev.applied.at} — it is the record of who got those batches. Pass --segment <new-name> to write a different file.`);
301
+ process.exit(1);
302
+ }
303
+ console.log(`⚠ overwriting ${planPath(slug)} (never applied)`);
304
+ }
292
305
  const planDoc = {
293
306
  schema_version: 1,
294
307
  segment: slug,
@@ -373,24 +386,44 @@ export async function apply(orgId, identifier, opts = {}) {
373
386
  if (a !== planDoc.segment) { console.log("Mismatch — aborted, nothing tagged."); process.exit(0); }
374
387
  }
375
388
 
376
- let resp;
377
- try {
378
- resp = await http.post("segments", {
379
- mode: "apply", organization_id: orgId,
380
- batches: planDoc.batches.map((b) => ({ tag: b.tag, contact_ids: b.contact_ids })),
381
- });
382
- } catch (e) {
383
- console.error(`Apply failed: ${e.message}`);
384
- if (e.body?.error) console.error(` ${e.body.error}`);
385
- process.exit(1);
389
+ // One HTTP request per chunk of ≤ APPLY_CHUNK ids. The server already splits
390
+ // its DB calls at 8,000 rows, but a big cohort in ONE request still ran past
391
+ // the function timeout (Zero BS 18,614 on 30 Sep 2026: two timeouts, one
392
+ // batch half-tagged). Smaller requests finish, and a failure names the chunk
393
+ // so a re-run (idempotent) picks up where it stopped.
394
+ const APPLY_CHUNK = 4000;
395
+ const chunks = [];
396
+ for (const b of planDoc.batches) {
397
+ for (let i = 0; i < b.contact_ids.length; i += APPLY_CHUNK) chunks.push({ tag: b.tag, contact_ids: b.contact_ids.slice(i, i + APPLY_CHUNK) });
398
+ }
399
+ const byTag = new Map();
400
+ for (const [ci, chunk] of chunks.entries()) {
401
+ let resp;
402
+ try {
403
+ resp = await http.post("segments", { mode: "apply", organization_id: orgId, batches: [chunk] });
404
+ } catch (e) {
405
+ const done = [...byTag.values()].reduce((a, r) => a + r.tagged, 0);
406
+ console.error(`Apply failed on request ${ci + 1}/${chunks.length} (${chunk.tag}, ${chunk.contact_ids.length} ids): ${e.message}`);
407
+ if (e.body?.error) console.error(` ${e.body.error}`);
408
+ console.error(` ${done} contact(s) tagged by the earlier requests. Re-run the same apply: it is idempotent and only the missing ones get tagged.`);
409
+ process.exit(1);
410
+ }
411
+ for (const r of resp.results || []) {
412
+ const prev = byTag.get(r.tag) ?? { tag: r.tag, tagged: 0, already_had: 0, not_found: 0 };
413
+ prev.tagged += r.tagged ?? 0; prev.already_had += r.already_had ?? 0; prev.not_found += r.not_found ?? 0;
414
+ byTag.set(r.tag, prev);
415
+ }
416
+ if (chunks.length > 1) console.log(` request ${ci + 1}/${chunks.length}: ${chunk.tag} ${chunk.contact_ids.length} ids → tagged ${(resp.results || []).reduce((a, r) => a + (r.tagged ?? 0), 0)}`);
386
417
  }
418
+ const results = [...byTag.values()];
419
+ const totals = results.reduce((a, r) => ({ tagged: a.tagged + r.tagged, already_had: a.already_had + r.already_had, not_found: a.not_found + r.not_found }), { tagged: 0, already_had: 0, not_found: 0 });
387
420
 
388
- for (const r of resp.results || []) {
421
+ for (const r of results) {
389
422
  console.log(` ${r.tag.padEnd(40)} tagged ${r.tagged}${r.already_had ? ` · already_had ${r.already_had}` : ""}${r.not_found ? ` · not_found ${r.not_found}` : ""}`);
390
423
  }
391
- console.log(`Totals: tagged ${resp.totals.tagged} · already_had ${resp.totals.already_had} · not_found ${resp.totals.not_found}`);
424
+ console.log(`Totals: tagged ${totals.tagged} · already_had ${totals.already_had} · not_found ${totals.not_found}`);
392
425
 
393
- planDoc.applied = { at: new Date().toISOString(), tag_counts: Object.fromEntries((resp.results || []).map((r) => [r.tag, r.tagged + r.already_had])) };
426
+ planDoc.applied = { at: new Date().toISOString(), tag_counts: Object.fromEntries(results.map((r) => [r.tag, r.tagged + r.already_had])) };
394
427
  await fs.writeFile(filePath, JSON.stringify(planDoc, null, 2) + "\n", "utf8");
395
428
  console.log("");
396
429
  console.log(`Send per tag, one batch at a time: flowiq broadcast send ${orgId} --tag ${planDoc.batches[0].tag} --template <name> …`);
@@ -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);
@@ -8,8 +8,12 @@
8
8
  // flowiq tag segment <org> --min-orders 2 --min-spent 1000 --region ZA-GP --tag gp-repeat
9
9
  // flowiq tag attributes <org> --filter allow_broadcast_true --tag broadcastable
10
10
  // flowiq tag messages <org> --min-count 3 --sender user-whatsapp --tag engaged
11
+ // flowiq tag ids <org> --ids a,b,c | --ids-file ids.txt --tag <name> [--commit]
12
+ // flowiq tag holders <org> <tag> [--limit 200] [--all] [--csv]
13
+ // flowiq tag field <org> --field whatsapp_id --any 27794975464 --exact --tag x --commit
11
14
  // flowiq tag list <org>
12
- // flowiq tag remove <org> <tag>[,<tag>...] --confirm
15
+ // flowiq tag remove <org> <tag>[,<tag>...] --confirm (every contact)
16
+ // flowiq tag remove <org> <tag> --contacts a,b | --contacts-file f --confirm (those only)
13
17
  //
14
18
  // Every match mode prints the dry-run count + a sample; nothing writes until
15
19
  // --commit AND (for large writes) a typed confirmation. Appends only — it
@@ -91,12 +95,87 @@ export async function field(orgId, opts = {}) {
91
95
  requireOrg(orgId);
92
96
  const keywords = splitList(opts.any || opts.all);
93
97
  if (!keywords) { console.error("Error: pass --any \"a,b\" (OR) or --all \"a,b\" (AND)."); process.exit(1); }
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
+ }
94
110
  const payload = {
95
- mode: "field", organization_id: orgId, field_name: opts.field || "order_history",
96
- keywords, logic: opts.all ? "AND" : "OR", case_sensitive: !!opts.caseSensitive,
111
+ mode: "field", organization_id: orgId, field_name: field,
112
+ keywords, logic: opts.all ? "AND" : "OR", case_sensitive: !!opts.caseSensitive, exact,
97
113
  };
98
114
  await confirmLargeWrite(orgId, payload, opts, "Field search");
99
- await runMatch("Field search", payload, opts);
115
+ await runMatch(exact ? "Exact field match" : "Field search", payload, opts);
116
+ }
117
+
118
+ async function readIds(csv, file) {
119
+ const fs = await import("node:fs/promises");
120
+ let ids = splitList(csv) || [];
121
+ if (file) {
122
+ const text = await fs.readFile(file, "utf8").catch((e) => { console.error(`Error: cannot read ${file}: ${e.message}`); process.exit(1); });
123
+ ids.push(...text.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean));
124
+ }
125
+ ids = [...new Set(ids)];
126
+ const bad = ids.filter((id) => !UUID_RE.test(id));
127
+ if (bad.length) console.error(`⚠ ${bad.length} value(s) are not contact UUIDs and will be ignored (e.g. ${bad[0]})`);
128
+ return ids.filter((id) => UUID_RE.test(id));
129
+ }
130
+
131
+ // `flowiq tag ids <org> --ids a,b | --ids-file f --tag <name> [--commit]`
132
+ export async function ids(orgId, opts = {}) {
133
+ requireOrg(orgId);
134
+ const list = await readIds(opts.ids, opts.idsFile);
135
+ if (!list.length) { console.error("Error: pass --ids a,b,c or --ids-file <path> (contact UUIDs, one per line or comma-separated)."); process.exit(1); }
136
+ const payload = { mode: "ids", organization_id: orgId, contact_ids: list };
137
+ await confirmLargeWrite(orgId, payload, opts, "Id list");
138
+ const commit = !!opts.commit;
139
+ if (commit && !opts.tag) { console.error("Error: --tag <name> is required with --commit."); process.exit(1); }
140
+ let resp;
141
+ try { resp = await http.post("tag", { ...payload, dry_run: !commit, tag: commit ? opts.tag.trim() : undefined }); }
142
+ catch (e) { console.error(`Tag ids failed: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
143
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
144
+ if (!commit) {
145
+ console.log(`Id list: ${resp.matched} of ${list.length} id(s) belong to ${resp.organization_name}${resp.not_found ? ` · ${resp.not_found} not in this org (ignored)` : ""}.`);
146
+ renderSample(resp.sample);
147
+ console.log(`\nAdd --tag <name> --commit to apply. (dry run — nothing written)`);
148
+ return;
149
+ }
150
+ console.log(`Id list: matched ${resp.matched} · newly tagged ${resp.tagged} · already had "${resp.tag}" ${resp.already_had}${resp.not_found ? ` · not in org ${resp.not_found}` : ""}.`);
151
+ console.log(`\nVerify: flowiq tag holders ${orgId} ${resp.tag} · Undo: flowiq tag remove ${orgId} ${resp.tag} --contacts-file <the same file> --confirm`);
152
+ }
153
+
154
+ // `flowiq tag holders <org> <tag> [--limit] [--all] [--csv]` — who carries a tag.
155
+ export async function holders(orgId, tagName, opts = {}) {
156
+ requireOrg(orgId);
157
+ if (!tagName) { console.error("Error: pass the tag name, e.g. flowiq tag holders <org> vip"); process.exit(1); }
158
+ const pageSize = opts.all ? 1000 : Math.min(Number(opts.limit) || 200, 1000);
159
+ const contacts = [];
160
+ let offset = 0, resp;
161
+ for (;;) {
162
+ try { resp = await http.post("tag", { mode: "holders", organization_id: orgId, tag: tagName, limit: pageSize, offset }); }
163
+ catch (e) { console.error(`Holders failed: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
164
+ contacts.push(...(resp.contacts || []));
165
+ if (!opts.all || !resp.has_more || !(resp.contacts || []).length) break;
166
+ offset += resp.contacts.length;
167
+ }
168
+ if (opts.json) { console.log(JSON.stringify({ ...resp, contacts }, null, 2)); return; }
169
+ if (opts.csv) {
170
+ console.log("id,full_name,whatsapp_id,email,allow_broadcast,archived,blocked");
171
+ for (const c of contacts) console.log([c.id, c.full_name, c.whatsapp_id, c.email, c.allow_broadcast, c.archived, c.blocked].map((v) => { const s = v == null ? "" : String(v); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }).join(","));
172
+ return;
173
+ }
174
+ console.log(`"${tagName}" on ${resp.organization_name}: ${resp.total} contact(s)${contacts.length < resp.total ? ` (showing ${contacts.length}; --all for every page, --csv to export)` : ""}`);
175
+ for (const c of contacts) {
176
+ const flags = [c.allow_broadcast === false && "no-broadcast", c.archived && "archived", c.blocked && "blocked", c.bot_status === false && "AI off"].filter(Boolean).join(", ");
177
+ console.log(` ${(c.full_name || "—").slice(0, 28).padEnd(28)} ${String(c.whatsapp_id || c.email || "").padEnd(16)} ${c.id}${flags ? ` (${flags})` : ""}`);
178
+ }
100
179
  }
101
180
 
102
181
  export async function cohort(orgId, opts = {}) {
@@ -170,12 +249,14 @@ export async function remove(orgId, tagArg, opts = {}) {
170
249
  requireOrg(orgId);
171
250
  const tags = splitList(tagArg);
172
251
  if (!tags || !tags.length) { console.error("Error: pass one or more tag names (comma-separated)."); process.exit(1); }
252
+ const contactIds = opts.contacts || opts.contactsFile ? await readIds(opts.contacts, opts.contactsFile) : null;
253
+ if ((opts.contacts || opts.contactsFile) && !contactIds.length) { console.error("Error: --contacts / --contacts-file gave no contact UUIDs."); process.exit(1); }
173
254
  if (!opts.confirm) {
174
- console.log(`DRY RUN — would remove ${tags.length} tag(s) from all contacts: ${tags.join(", ")}. Add --confirm to remove.`);
255
+ console.log(`DRY RUN — would remove ${tags.length} tag(s) from ${contactIds ? `${contactIds.length} listed contact(s)` : "ALL contacts"}: ${tags.join(", ")}. Add --confirm to remove.`);
175
256
  return;
176
257
  }
177
258
  let resp;
178
- try { resp = await http.post("tag", { mode: "remove", organization_id: orgId, tags }); }
179
- catch (e) { console.error(`Remove failed: ${e.message}`); process.exit(1); }
180
- console.log(`Removed ${tags.join(", ")} from ${resp.contacts_touched} contact(s) on ${resp.organization_name}.`);
259
+ try { resp = await http.post("tag", { mode: "remove", organization_id: orgId, tags, ...(contactIds ? { contact_ids: contactIds } : {}) }); }
260
+ catch (e) { console.error(`Remove failed: ${e.message}`); if (e.body?.error && !e.message.includes(e.body.error)) console.error(` ${e.body.error}`); process.exit(1); }
261
+ console.log(`Removed ${tags.join(", ")} from ${resp.contacts_touched} contact(s) on ${resp.organization_name}${contactIds ? ` (of ${contactIds.length} listed${resp.not_found ? `, ${resp.not_found} not in this org` : ""})` : ""}.`);
181
262
  }
@@ -343,8 +343,37 @@ export async function show(orgId, name, opts = {}) {
343
343
 
344
344
  const rows = resp.templates || [];
345
345
  if (!rows.length) {
346
- console.error(`No template on this org matching "${name}". List them with: flowiq templates status ${orgId}`);
347
- process.exit(1);
346
+ // Not in FlowIQ's table is not "does not exist": a template made in Meta's
347
+ // own tools or on a swapped WABA lives only at Meta (Health Matrix, 22 Sep
348
+ // 2026). Read it from Meta before giving up.
349
+ let live;
350
+ try { live = await http.get("templates", { organization_id: orgId, name }); } catch { live = null; }
351
+ const liveRows = (live?.templates || []).filter((t) => t.name === name) .concat((live?.templates || []).filter((t) => t.name !== name));
352
+ if (!liveRows.length) {
353
+ console.error(`No template on this org matching "${name}", in FlowIQ or at Meta. List them with: flowiq templates status ${orgId} --live`);
354
+ process.exit(1);
355
+ }
356
+ const t = liveRows[0];
357
+ if (liveRows.length > 1 && t.name !== name) {
358
+ console.error(`"${name}" matches ${liveRows.length} templates at Meta — name one exactly:`);
359
+ for (const r of liveRows) console.error(` ${r.name} [${r.status}] ${r.category}`);
360
+ process.exit(1);
361
+ }
362
+ if (opts.json) { console.log(JSON.stringify({ source: "meta", template: t }, null, 2)); return; }
363
+ console.log(`${t.name} [${t.status}] ${t.category}${t.previous_category && t.previous_category !== t.category ? ` (was ${t.previous_category})` : ""} · ${t.language}${t.parameter_format ? ` · ${t.parameter_format}` : ""}`);
364
+ console.log(` org: ${orgId}`);
365
+ console.log(` source: META ONLY — not recorded in FlowIQ's templates table (made in Meta's tools or on another WABA), so it cannot be picked in the send dialog until it is pulled in`);
366
+ if (t.id) console.log(` meta id: ${t.id}`);
367
+ if (t.rejected_reason && t.rejected_reason !== "NONE") console.log(` rejected: ${t.rejected_reason}`);
368
+ for (const c of t.components || []) {
369
+ const label = String(c.type || "").toLowerCase().padEnd(9);
370
+ if (c.type === "BODY" || c.type === "FOOTER") console.log(` ${label} ${String(c.text || "").replace(/\n/g, "\n ")}`);
371
+ else if (c.type === "HEADER") console.log(` ${label} ${c.format}${c.text ? `: ${c.text}` : ""}`);
372
+ else if (c.type === "BUTTONS") for (const b of c.buttons || []) console.log(` button ${b.type}: ${b.text}${b.url ? ` → ${b.url}` : ""}${b.phone_number ? ` → ${b.phone_number}` : ""}`);
373
+ else if (c.type === "CAROUSEL") console.log(` carousel ${(c.cards || []).length} card(s)`);
374
+ }
375
+ console.log(`\n The full Meta record: flowiq templates pull ${orgId}`);
376
+ return;
348
377
  }
349
378
  // Prefer an exact name match; otherwise if the substring is ambiguous, say so.
350
379
  let row = rows.find((r) => r.template_name === name);
@@ -444,22 +473,54 @@ export async function attempts(orgId, opts = {}) {
444
473
  if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
445
474
 
446
475
  const rows = resp.attempts || [];
447
- if (!rows.length) {
476
+ const logs = resp.status_logs || [];
477
+ if (!rows.length && !logs.length) {
448
478
  console.log(opts.failed ? "(no failed submissions on record for this org)" : "(no template submissions on record for this org — the ledger started 21 Sep 2026)");
449
479
  return;
450
480
  }
451
- console.log(`${rows.length} submission${rows.length === 1 ? "" : "s"}${opts.failed ? " (failed only)" : ""}, newest first (SAST):`);
481
+ // Meta's history per template, newest first: what happened AFTER submit.
482
+ // A category flip shows as the same status with a new category.
483
+ const historyOf = (name) => logs.filter((l) => l.template_name === name);
484
+ const describeHistory = (name, submittedCategory) => {
485
+ const h = historyOf(name).slice().reverse(); // oldest first
486
+ if (!h.length) return [];
487
+ const lines = [];
488
+ let lastCat = submittedCategory || null;
489
+ for (const l of h) {
490
+ const flipped = l.category && lastCat && l.category !== lastCat;
491
+ lines.push(`${sast(l.created_at)} ${l.status}${l.category ? ` / ${l.category}` : ""}${flipped ? ` ⚠ CATEGORY MOVED ${lastCat} → ${l.category}` : ""}${l.reason ? ` (${l.reason})` : ""}`);
492
+ if (l.category) lastCat = l.category;
493
+ }
494
+ return lines;
495
+ };
496
+ if (rows.length) console.log(`${rows.length} submission${rows.length === 1 ? "" : "s"}${opts.failed ? " (failed only)" : ""}, newest first (SAST):`);
497
+ const named = new Set();
452
498
  for (const a of rows) {
453
499
  const mark = a.outcome === "submitted" ? "✓" : "✗";
454
500
  const who = a.source ? ` via ${a.source}` : "";
455
501
  console.log(`\n ${mark} ${sast(a.created_at)} ${a.template_name || "(unnamed)"}${a.category ? ` [${a.category}]` : ""}${who}`);
456
502
  if (a.outcome === "submitted") {
457
503
  console.log(` submitted to Meta — id ${a.meta_template_id || "?"}, status at submit ${a.meta_status || "?"}`);
504
+ const hist = describeHistory(a.template_name, a.category);
505
+ named.add(a.template_name);
506
+ if (hist.length) for (const line of hist) console.log(` then: ${line}`);
507
+ else console.log(` no status webhook from Meta recorded yet (still ${a.meta_status || "PENDING"} as far as FlowIQ knows: flowiq templates status ${orgId} --live --name ${a.template_name})`);
458
508
  } else {
459
509
  console.log(` FAILED (HTTP ${a.http_status ?? "?"}) ${a.error || ""}`);
460
510
  if (a.message && a.message !== a.error) console.log(` ${a.message}`);
461
511
  if (a.meta_error?.error_user_msg && a.meta_error.error_user_msg !== a.message) console.log(` Meta: ${a.meta_error.error_user_msg}`);
462
512
  }
463
513
  }
464
- console.log(`\nA failed row is the reason the template is not in \`templates status\` — fix what the message names and submit again.`);
514
+ // Templates with Meta history but no submission row (submitted before the
515
+ // ledger existed, or from Meta's own tools): still worth showing when asked
516
+ // for by name, or when nothing else matched.
517
+ const orphan = [...new Set(logs.map((l) => l.template_name))].filter((n) => !named.has(n));
518
+ if (orphan.length && (opts.name || !rows.length)) {
519
+ console.log(`\nMeta status history with no submission row (older than the ledger, or submitted outside FlowIQ):`);
520
+ for (const n of orphan.slice(0, 20)) {
521
+ console.log(`\n ${n}`);
522
+ for (const line of describeHistory(n, null)) console.log(` ${line}`);
523
+ }
524
+ }
525
+ if (rows.length) console.log(`\nA failed row is the reason the template is not in \`templates status\` — fix what the message names and submit again.`);
465
526
  }