@flowapt/flowiq-cli 0.9.9 → 0.11.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
@@ -2046,6 +2046,215 @@ channel gets a warning on every run. Point the agent at a fake number with
2046
2046
  CLI creates is born with `allow_broadcast=false` and the `test-contact` tag, so it
2047
2047
  never lands on a broadcast list (stress contacts too).
2048
2048
 
2049
+ ### Contacts — `flowiq contacts find|show|census|lint|merge|optout|optin|set|export` (0.10.0)
2050
+
2051
+ The contact record from the terminal. `find` searches every contact of the org,
2052
+ archived and blocked included (the inbox hides archived chats on every tab but
2053
+ Archived), and prints the inbox state with each match.
2054
+
2055
+ ```bash
2056
+ flowiq contacts find <org> --email sofia@ # or --phone 3519 / --name "Sofia" / --id <uuid>
2057
+ flowiq contacts show <contact_id> --org <org>
2058
+ flowiq contacts census <org> # archived chats still carrying unread / customer-last messages
2059
+ flowiq contacts lint <org> # malformed numbers + the repair + whether a twin contact holds it
2060
+ flowiq contacts set <org> <contact_id> --whatsapp-id 27821234567 --commit
2061
+ flowiq contacts merge <org> <keep_id> <remove_id> --commit # the app's merge_contacts_v2
2062
+ flowiq contacts optout <org> --contact <id> --reason "asked on 30 Sep" --commit
2063
+ flowiq contacts optin <org> --ids-file ids.txt --commit --confirm
2064
+ flowiq contacts export <org> [--subscribers] [--out contacts.csv]
2065
+ ```
2066
+
2067
+ - **`find`** prints id, number, email, other channel identities, state
2068
+ (archived / blocked / AI off / opted out / unread), team, assignee, the
2069
+ latest message and any open ticket. Max 100 matches.
2070
+ - **`lint`** flags a doubled country code (`351351…`), a trunk 0 kept after the
2071
+ code (`270…`), non-digits and bad lengths, with the suggested repair. When
2072
+ another contact already holds the repaired number it names the twin and
2073
+ prints the `merge` command instead. Test, stress and web-chat identities
2074
+ are counted and skipped.
2075
+ - **`set`** changes one contact's WhatsApp number (and `phone_number` when it
2076
+ was the same). Refused when another contact holds the number: merge instead.
2077
+ - **`merge`** is dry-run by default and shows both records with message and
2078
+ order counts; `--commit` runs `merge_contacts_v2` (identities released, every
2079
+ child row repointed, the losing contact deactivated, undo in the inbox).
2080
+ - **`optout` / `optin`** write `allow_broadcast` and the same inbox marker row
2081
+ the app writes (`contact-opted-out` / `contact-opted-in`). Already-set
2082
+ contacts are skipped so the count is honest. Opting someone back IN needs
2083
+ `--confirm` on top of `--commit`.
2084
+ - **`export`** writes `.flowiq/contacts/<org>.csv` (Excel opens it). `--subscribers`
2085
+ = opted in, not archived, not blocked.
2086
+
2087
+ ### Widgets — `flowiq widgets list|show|pull|push|embed|activate` (0.10.0)
2088
+
2089
+ ```bash
2090
+ flowiq widgets list <org>
2091
+ flowiq widgets pull <widget_id> # → .flowiq/widgets/<org>-<id8>.json
2092
+ flowiq widgets push .flowiq/widgets/lego-ddb5ab43.json # dry run: every field that would change
2093
+ flowiq widgets push … --commit
2094
+ flowiq widgets embed <org> [widget_id] # the <script> snippet a client pastes
2095
+ flowiq widgets activate <widget_id> # the widget an org-only embed resolves to
2096
+ ```
2097
+
2098
+ A push is an **id-keyed patch, never a full replace**: scalar columns are set,
2099
+ jsonb bags (`home_copy`, `home_design`, `launcher`, `team_avatars`, …) are
2100
+ merged at their top level and a key set to `null` is removed. These render on
2101
+ live storefronts, so the dry run prints each change before `--commit`. Avatar
2102
+ and icon uploads are not here yet (file upload with circle compositing).
2103
+
2104
+ ### Tickets — `flowiq tickets list|show|status` (0.10.0)
2105
+
2106
+ ```bash
2107
+ flowiq tickets list <org> # open + in_progress, newest first
2108
+ flowiq tickets list <org> --older-than 48h --status open # stale escalations
2109
+ flowiq tickets list <org> --contact <contact_id> --status all
2110
+ flowiq tickets show <ticket_id>
2111
+ flowiq tickets status <ticket_id> --set resolved --note "…" --commit
2112
+ ```
2113
+
2114
+ `status` writes what the app writes (`resolved_by/at`, `last_updated_*`, reopen
2115
+ fields, a `status_history` entry) and, when the org runs
2116
+ `reenable_bot_on_ticket_resolve`, switches the contact's AI back on with the
2117
+ inbox's three guards (open → closed transition, latest ticket only, bot not
2118
+ already on). Dry run by default.
2119
+
2120
+ ### Flows — `flowiq flows list|show|status|enable|disable` (0.10.0)
2121
+
2122
+ ```bash
2123
+ flowiq flows list <org> # trigger, templates, stats, param-key lint per flow
2124
+ flowiq flows show <flow_id> # full configuration
2125
+ flowiq flows status <org> --since 14d [--failed] [--flow <id>] # execution funnel + verbatim error rollup
2126
+ flowiq flows enable <flow_id> --confirm # arms real sends
2127
+ flowiq flows disable <flow_id>
2128
+ ```
2129
+
2130
+ The lint flags a `send_message` step whose `body_parameters` keys are not
2131
+ `param1..N` (`b1..b4`, `1,b1`, …): `bc send` refuses those shapes, the flow
2132
+ engine hands them to Meta where jsonb key order decides the slots.
2133
+
2134
+ ### Rules — `flowiq rules list|show|results|enable|disable` (0.10.0)
2135
+
2136
+ The Automations rules engine (Shoppers → Rules, `custom_notification_rules`):
2137
+ the delayed-send engine behind tag-based, order-stage, webhook and
2138
+ Instagram-comment sends.
2139
+
2140
+ ```bash
2141
+ flowiq rules list <org> [--trigger tag_added]
2142
+ flowiq rules show <rule_id>
2143
+ flowiq rules results <rule_id> --since 30d # what it queued: status counts, errors, recent sends
2144
+ flowiq rules enable <rule_id> --confirm
2145
+ flowiq rules disable <rule_id>
2146
+ ```
2147
+
2148
+ ### Org views — `flowiq org channels|whatsapp|health|automations|abandoned` (0.10.0)
2149
+
2150
+ ```bash
2151
+ flowiq org channels <org> # WhatsApp (provider, master switch), Messenger, Instagram, TikTok, email, Meta Ads (FlowIQ app or Zernio route), Zernio
2152
+ flowiq org channels --all # census across every active org
2153
+ flowiq org whatsapp <org> # the number's health straight from Meta: status, quality, tier, name status, webhook subscription
2154
+ flowiq org whatsapp --all # every Meta org, unhealthy first
2155
+ flowiq org health <org> # is the agent ready? master switch, keys (OpenAI probed), agent, catalogue + embeddings, store webhooks + order sync, traffic
2156
+ flowiq org automations <org> # automated order messages, each type checked against the org's templates
2157
+ flowiq org automations <org> --enable order_shipped --commit # or --disable; no type = the whole feature
2158
+ flowiq org abandoned <org> # abandoned-cart recovery per sequence + the last 30 days of sends
2159
+ flowiq org abandoned <org> --disable 2 --commit
2160
+ ```
2161
+
2162
+ `health` probes the org's OpenAI key with a one-token embedding, so an
2163
+ exhausted or invalid key shows as such rather than "present". It probes the
2164
+ spare key (`feature_flags.spare_openai_key`) too: the agent, product search and
2165
+ both product syncs fall back to it, so a dead primary with a working spare is a
2166
+ `⚠` warning, not a problem (1 Oct 2026). The webhook count covers both hosts our
2167
+ Shopify webhooks use (`auth.flowiq.live` and the older `supabase.co`); before
2168
+ 1 Oct 2026 it counted `supabase.co` only and read 0 on every org registered by
2169
+ `flowiq webhooks push`.
2170
+
2171
+ ### Messages, org-wide — `flowiq messages search|failures|stats` (0.10.0)
2172
+
2173
+ ```bash
2174
+ flowiq messages search <org> "flash sale" --since 24h [--sender-type bot-whatsapp] [--include-templates]
2175
+ flowiq messages failures <org> --since 7d
2176
+ flowiq messages stats <org> --since 6m
2177
+ ```
2178
+
2179
+ - **`search`** answers "did the agent say X to anyone today?" without knowing
2180
+ the contact: time · sender · contact · the match in context. Template and
2181
+ broadcast payloads are excluded unless `--include-templates`.
2182
+ - **`failures`** rolls up Meta-failed sends by error code, media type and
2183
+ contact, and lists the sends Meta accepted (wamid) but never confirmed.
2184
+ `messages pull` now ends with the same delivery footer.
2185
+ - **`stats`** is the monthly volume: inbound, outbound, free-form (sent inside
2186
+ a customer's 24 h window, Meta's in-window charge from 1 Oct 2026) vs
2187
+ template, bot vs human. The classifier lives in ONE RPC
2188
+ (`cli_message_volume_summary`): template = broadcast_id, JSON payload, or no
2189
+ customer message in the preceding 24 h.
2190
+
2191
+ ### Members · users · steers · notes · products (0.11.0)
2192
+
2193
+ Batch F of the request list. Three new topics, plus a long tail of verbs on
2194
+ existing ones (next section).
2195
+
2196
+ ```bash
2197
+ flowiq members list <org> # role · name · email · last sign-in · pending invitations
2198
+ flowiq members add <org> <email> --role member # an EXISTING account (no account → invite from the app)
2199
+ flowiq members role <org> <email> admin|member|viewer # the last admin cannot be demoted
2200
+ flowiq members rename <email> "Full Name" # profiles.full_name, shown in every org
2201
+ flowiq members remove <org> <email> --confirm # owner + last admin refused
2202
+ flowiq users lookup <email> # sign-in method, confirmation, last sign-in, every org + role
2203
+ flowiq steer list <contact_id> · steer add <contact_id> "Offer the 10% code" [--once] · steer clear <contact_id> [--id <id>] --confirm
2204
+ flowiq notes list <contact_id> · notes add <contact_id> "Vet to CS: …"
2205
+ flowiq products status <org> · products search <org> "whey protein" · products reconcile <org> --confirm
2206
+ ```
2207
+
2208
+ - **`steer`** is the inbox's supervisor instruction: vert injects every
2209
+ unconsumed steer into the agent's history. An ongoing steer applies to EVERY
2210
+ reply until it is deleted, and the inbox has no delete, which is why
2211
+ `steer clear` exists (dry-run; the texts stay in the audit row).
2212
+ - **`products search`** calls the agent's own `fuzzy_match_products` RPC with
2213
+ the agent's threshold (0.15), so "what does the agent find for X?" is answered
2214
+ without a test send. `products status` counts rows with no embedding (the
2215
+ agent cannot find those by meaning) and shows the last syncs; `reconcile`
2216
+ queues the org on `products-reconcile-scheduler` (re-sync + re-embed).
2217
+ - **`members add` takes an existing account only.** Someone who has never
2218
+ signed up goes through the app's invite flow (an invitation row + email);
2219
+ the CLI says so rather than minting a login.
2220
+
2221
+ ### The 0.11.0 long tail (verbs added to existing topics)
2222
+
2223
+ ```bash
2224
+ flowiq links shorten <org> --url <u> --slug spring-sale --commit # readable code; a taken slug falls back and says so
2225
+ flowiq links list <org> --code <code> | --to /collections/x | --all # one code · by destination · every page (500 a call)
2226
+ flowiq links repoint <org> <code> --to <url> --commit # move a live code (clicks + messages keep it)
2227
+ flowiq org list --number 27609751832 # the org behind a WhatsApp number / phone id / WABA
2228
+ flowiq org agent <org> on|off --commit [--yes] # the master switch; ON for everyone needs --yes
2229
+ flowiq org park <org> [--unpark] --confirm # organizations.inactive
2230
+ flowiq org flags set <org> product-tag-notes @notes.json # {tag: sentence}; instagram_comments.* are objects now
2231
+ flowiq agent flags [--tool product_lookup] [--model luna] # every active agent's model + tool gates
2232
+ flowiq tag ids <org> --ids-file ids.txt --tag vip --commit # one tag on an explicit id list
2233
+ flowiq tag holders <org> vip [--all] [--csv] # who carries a tag
2234
+ flowiq tag field <org> --field whatsapp_id --any 27794975464 --exact # whole value, not a substring
2235
+ flowiq tag remove <org> vip --contacts a,b --confirm # only those contacts lose it
2236
+ flowiq prompts push <slug> --dry-run # added / removed / changed sections, nothing written
2237
+ flowiq bc scheduled list <org> --detail # the queued send's body / button values + header
2238
+ flowiq templates show <org> <name> # falls back to Meta when FlowIQ has no row
2239
+ flowiq templates attempts <org> --name x # + Meta's status / category history ("CATEGORY MOVED")
2240
+ flowiq test send <org> "hi" --tools --sender "Sofia" # tool arguments + results; renames a synthetic test contact
2241
+ flowiq messages pull <contact_id> # footer: sent by agent / broadcast / automation: <template>
2242
+ flowiq messages delete <contact_id> [--before 30d] [--sender-type tool-call] --confirm
2243
+ ```
2244
+
2245
+ - **`seg apply` sends one request per 4,000 ids** (the server already split its
2246
+ DB calls; one request still timed out on 18k), and a failure names the chunk;
2247
+ re-running is idempotent. **`seg plan --start-index N`** writes
2248
+ `<slug>-from-NN.json` instead of overwriting the earlier plan (the record of
2249
+ who got the earlier batches); an applied plan file is never overwritten.
2250
+ - **`bc send` audit rows and the `[CLI]` email now carry the audience** (python
2251
+ reports no count on a real fire; the server counts the tag's broadcast-safe
2252
+ contacts itself).
2253
+ - **`org agent`** is `organizations.wati_webhook_status`. Off = no customer gets
2254
+ the AI. On with `vert.active false` + `vert.allowed_whatsapp_ids` = test mode.
2255
+ On with `vert.active` not false = live for every customer, which needs
2256
+ `--yes`; the command prints which of the three it would produce.
2257
+
2049
2258
  ### Guide — `flowiq guide`
2050
2259
 
2051
2260
  Read the bundled docs in the terminal — no digging through node_modules.
package/TEAM-GUIDE.md CHANGED
@@ -134,8 +134,33 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
134
134
  | Which changelog rows has nobody emailed the team about yet? | `flowiq updates status` / `flowiq updates uncovered` — anything left 20h+ goes out automatically at 08:30 SAST as a plain digest |
135
135
  | Set which contact `flowiq test` uses (use a FAKE number!) | `flowiq agent config <org_id> --test-contact-number 27000000001 --test-contact-name "QA Bot"` |
136
136
  | **The CLI can't do something I need** (or does it wrong, or should do more) | `flowiq gaps report "<command> cannot <thing>" --command "<command>" --error "<exact error>" --workaround "<what you did instead>"`. If it is already on the list you are told its number: `flowiq gaps hit <number> --note "…"` |
137
+ | Find a customer the inbox cannot show (archived, blocked, wrong tab) | `flowiq contacts find <org_id> --email <part>` (or `--phone`, `--name`): every match with its state, team, latest message and open ticket |
138
+ | A customer's number is wrong (country code doubled, leading 0 kept) | `flowiq contacts lint <org_id>` finds them with the repair; `flowiq contacts set <org_id> <contact_id> --whatsapp-id <number> --commit` fixes one; if a twin contact holds the number, `flowiq contacts merge <org_id> <keep> <remove> --commit` |
139
+ | Opt a customer out of broadcasts (or back in) | `flowiq contacts optout <org_id> --contact <id> --reason "…" --commit` · back in: `flowiq contacts optin … --commit --confirm` |
140
+ | Export the contact list for a client | `flowiq contacts export <org_id> --subscribers` → a CSV Excel opens |
141
+ | Change a web chat widget's copy / colours, or get the embed code | `flowiq widgets pull <widget_id>` → edit → `flowiq widgets push <file>` (dry run) → `--commit` · `flowiq widgets embed <org_id>` |
142
+ | Which escalations are still open, and close one | `flowiq tickets list <org_id> --older-than 48h` · `flowiq tickets status <ticket_id> --set resolved --note "…" --commit` |
143
+ | Did the agent say something wrong to anyone today? | `flowiq messages search <org_id> "the words" --since 24h` |
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
+ | 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` |
147
+ | Which channels are connected (Messenger, Instagram, email…)? | `flowiq org channels <org_id>`; every org: `flowiq org channels --all` |
148
+ | 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
+ | 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>` |
137
150
  | See what the team has asked the CLI to do | `flowiq gaps list` (open, most-hit first) · `flowiq gaps list --mine` · `flowiq gaps show <number>` · in the app: Changelog → **CLI requests** |
138
151
  | Close a request (Matt / Gidon) | `flowiq gaps status <number> --to done --fixed-in 0.9.8 --note "what shipped"` — emails everyone who reported or hit it |
152
+ | Who is on a client's account, and give someone access | `flowiq members list <org_id>` · `flowiq members add <org_id> <email> --role member` (they must already have an account; otherwise invite from the app) · `flowiq members role <org_id> <email> admin` |
153
+ | "I can't log in" / which orgs is this person on? | `flowiq users lookup <email>`: sign-in method, email confirmed or not, last sign-in, every org and role |
154
+ | Tell the agent how to handle ONE customer, or stop an ongoing steer | `flowiq steer add <contact_id> "Offer the 10% code once"` (`--once` for the next reply only) · `flowiq steer list <contact_id>` · `flowiq steer clear <contact_id> --confirm` |
155
+ | Leave a note for the team on a contact | `flowiq notes add <contact_id> "Vet to CS: …"` |
156
+ | The agent cannot find a product the store has | `flowiq products search <org_id> "<what the customer typed>"` (the agent's own match) · `flowiq products status <org_id>` (rows with no embedding) · `flowiq products reconcile <org_id> --confirm` |
157
+ | Which org owns this WhatsApp number? | `flowiq org list --number 27609751832` |
158
+ | Switch an org's agent off (or on) for every customer | `flowiq org agent <org_id> off --commit` · back on: `flowiq org agent <org_id> on --commit --yes` (test mode first: `org flags set <org> vert.allowed_whatsapp_ids <numbers>` + `vert.active false`) |
159
+ | A short link points at the wrong page | `flowiq links list <org_id> --code <code>` to see it · `flowiq links repoint <org_id> <code> --to <url> --commit` |
160
+ | Check a prompt edit before it goes live | `flowiq prompts push <slug> --dry-run` — added / removed / changed sections against the live agent |
161
+ | 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
+ | Why did the agent answer that? | `flowiq test send <org_id> "<the question>" --tools` shows each tool's arguments and result |
163
+ | Was that message the agent, a broadcast or an automation? | `flowiq messages pull <contact_id>` ends with a "sent by" line per source |
139
164
  > **Publishing the CLI (maintainers only):** publish from a clean clone, never
140
165
  > from your working tree — `npm publish` packs whatever is on disk. A
141
166
  > `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.9.9",
3
+ "version": "0.11.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,73 @@
1
+ // node --test src/batch-cde.test.mjs — pure helpers behind the 0.10.0 topics.
2
+ import test from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { toCsv } from "./commands/contacts.js";
5
+ import { embedSnippet } from "./commands/widgets.js";
6
+ import { snippetAround } from "./commands/messages.js";
7
+ // The server modules build a Supabase client at import time; dummies keep the
8
+ // pure helpers testable without a key on the machine.
9
+ process.env.VITE_SUPABASE_URL ||= "https://example.supabase.co";
10
+ process.env.SUPABASE_SERVICE_ROLE ||= "test-key";
11
+ const { lintNumber } = await import("../../api/cli/contacts.js");
12
+ const { buildUpdate } = await import("../../api/cli/widgets.js");
13
+ const { lintParams, summariseFlow } = await import("../../api/cli/flows.js");
14
+ const { describeSendAt } = await import("../../api/cli/rules.js");
15
+
16
+ test("lintNumber catches the doubled country code and the kept trunk 0", () => {
17
+ assert.deepEqual(lintNumber("351351912345678"), { problem: "country code 351 doubled", suggested: "351912345678" });
18
+ assert.deepEqual(lintNumber("270821234567"), { problem: "trunk 0 kept after +27", suggested: "27821234567" });
19
+ assert.equal(lintNumber("27821234567"), null);
20
+ assert.equal(lintNumber("351912345678"), null);
21
+ assert.equal(lintNumber("447700900123"), null);
22
+ assert.equal(lintNumber("12125551234"), null);
23
+ assert.match(lintNumber("+27 82 123 4567").problem, /non-digit/);
24
+ });
25
+
26
+ test("toCsv quotes commas, quotes and newlines; arrays join with |", () => {
27
+ const csv = toCsv([{ a: 'He said "hi", ok', b: ["x", "y"], c: null }], ["a", "b", "c"]);
28
+ assert.equal(csv, 'a,b,c\n"He said ""hi"", ok",x|y,\n');
29
+ });
30
+
31
+ test("embedSnippet matches the app's snippet shape", () => {
32
+ const s = embedSnippet("org-1", "w-1");
33
+ assert.match(s, /widget\/loader\.js/);
34
+ assert.match(s, /data-organization-id="org-1"/);
35
+ assert.match(s, /data-widget-id="w-1"/);
36
+ assert.doesNotMatch(embedSnippet("org-1", null), /data-widget-id/);
37
+ });
38
+
39
+ test("snippetAround centres the match", () => {
40
+ const s = snippetAround("a".repeat(200) + " flash sale " + "b".repeat(200), "flash sale", 60);
41
+ assert.match(s, /^…a+ flash sale b+…$/);
42
+ });
43
+
44
+ test("buildUpdate patches scalars, merges jsonb bags and removes null keys", () => {
45
+ const cur = { name: "A", home_copy: { greeting: "Hi", tagline: "x" }, launcher: null };
46
+ const { update, changes, errors } = buildUpdate(cur, { name: "B", home_copy: { tagline: null, intro: "yo" }, launcher: { size: "lg" }, organization_id: "nope" });
47
+ assert.deepEqual(errors.map((e) => e.split(" ")[0]), ["organization_id"]);
48
+ assert.equal(update.name, "B");
49
+ assert.deepEqual(update.home_copy, { greeting: "Hi", intro: "yo" });
50
+ assert.deepEqual(update.launcher, { size: "lg" });
51
+ assert.deepEqual(changes.map((c) => c.field), ["name", "home_copy.tagline", "home_copy.intro", "launcher.size"]);
52
+ assert.deepEqual(buildUpdate(cur, { name: "A" }).changes, []);
53
+ });
54
+
55
+ test("lintParams accepts param1..N and flags anything else", () => {
56
+ assert.equal(lintParams({ param1: "a", param2: "b" }), null);
57
+ assert.match(lintParams({ b1: "a", b2: "b" }), /jsonb order/);
58
+ assert.match(lintParams({ param1: "a", param3: "b" }), /skip a slot/);
59
+ assert.equal(lintParams({}), null);
60
+ });
61
+
62
+ test("summariseFlow reads the trigger and the template steps", () => {
63
+ const f = summariseFlow({ id: "f", name: "n", enabled: true, configuration: { trigger: { type: "event", event: "subscription.renewed", source: "stitch" }, steps: [{ type: "action", action_type: "send_message", config: { template_name: "t_v1", body_parameters: { param1: "x" } } }] } });
64
+ assert.equal(f.trigger.event, "subscription.renewed");
65
+ assert.equal(f.steps[0].template_name, "t_v1");
66
+ assert.deepEqual(f.warnings, []);
67
+ });
68
+
69
+ test("describeSendAt reads delay, time, quiet hours", () => {
70
+ assert.equal(describeSendAt({ delay_minutes: 720, quiet_hours: { tz: "Europe/Madrid", start_hour: 10, end_hour: 20 } }), "+720min · quiet 10-20 Europe/Madrid");
71
+ assert.equal(describeSendAt({ delay_days: 3, send_at_time: "10:00", skip_weekends: true }), "+3d · at 10:00 · skip weekends");
72
+ assert.equal(describeSendAt(null), "immediately");
73
+ });
@@ -0,0 +1,63 @@
1
+ // node --test src/batch-f.test.mjs — pure helpers behind the 0.11.0 topics.
2
+ import test from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { diffSections } from "./commands/prompts.js";
5
+ import { sentBy } from "./commands/messages.js";
6
+ process.env.VITE_SUPABASE_URL ||= "https://example.supabase.co";
7
+ process.env.SUPABASE_SERVICE_ROLE ||= "test-key";
8
+ const reg = await import("../../api/_shared/feature-flag-registry.js");
9
+
10
+ test("diffSections reports added / removed / changed by id and ignores untouched sections", () => {
11
+ const live = [
12
+ { id: "a", title: "Main", content: "hello" },
13
+ { id: "b", title: "Hours", content: "9-5", hidden: false },
14
+ { id: "c", title: "Old", content: "gone" },
15
+ ];
16
+ const local = [
17
+ { id: "a", title: "Main", content: "hello" },
18
+ { id: "b", title: "Hours", content: "9-6", hidden: true, channels: ["web"] },
19
+ { id: "d", title: "New", content: "x".repeat(12) },
20
+ ];
21
+ const d = diffSections(live, local);
22
+ assert.deepEqual(d.added, [{ title: "New", chars: 12 }]);
23
+ assert.deepEqual(d.removed, [{ title: "Old", chars: 4 }]);
24
+ assert.equal(d.changed.length, 1);
25
+ assert.equal(d.changed[0].title, "Hours");
26
+ assert.match(d.changed[0].what.join("; "), /content 3 → 3 chars; hidden false → true; channels "all" → \["web"\]/);
27
+ assert.equal(d.unchanged, false);
28
+ assert.equal(diffSections(live, live).unchanged, true);
29
+ });
30
+
31
+ test("diffSections notices a pure reorder", () => {
32
+ const live = [{ id: "a", title: "A", content: "1" }, { id: "b", title: "B", content: "2" }];
33
+ const d = diffSections(live, [live[1], live[0]]);
34
+ assert.equal(d.reordered, true);
35
+ assert.equal(d.unchanged, false);
36
+ });
37
+
38
+ test("sentBy labels broadcast, automation, rule and agent rows", () => {
39
+ assert.equal(sentBy({ broadcast_id: "x" }), "broadcast");
40
+ assert.equal(sentBy({ notification_template: "order_confirm_v1", notification_event: "orders/paid" }), "automation: order_confirm_v1 (orders/paid)");
41
+ assert.equal(sentBy({ notification_rule_id: "38f20704-aaaa-bbbb-cccc-dddddddddddd" }), "rule 38f20704");
42
+ assert.equal(sentBy({ sender_type: "bot-whatsapp" }), "agent");
43
+ });
44
+
45
+ test("product-tag-notes validator takes a {tag: sentence} map and refuses empties", () => {
46
+ const spec = reg.FLAG_REGISTRY["product-tag-notes"];
47
+ assert.deepEqual(reg.validateFlagValue(spec, '{"Free2024":" Ships free. "}'), { value: { Free2024: "Ships free." } });
48
+ assert.match(reg.validateFlagValue(spec, { Free2024: "" }).error, /non-empty sentence/);
49
+ assert.match(reg.validateFlagValue(spec, {}).error, /empty/);
50
+ assert.match(reg.validateFlagValue(spec, "true").error, /must be an object/);
51
+ });
52
+
53
+ test("instagram_comments legs are objects with a bounded message", () => {
54
+ const leg = reg.FLAG_REGISTRY["instagram_comments.private_reply"];
55
+ assert.equal(leg.type, "object");
56
+ assert.match(reg.validateFlagValue(leg, "true").error, /must be an object/);
57
+ assert.deepEqual(reg.validateFlagValue(leg, { enabled: true, message: " hi " }), { value: { enabled: true, message: "hi" } });
58
+ assert.match(reg.validateFlagValue(leg, { enabled: true }).error, /message is empty/);
59
+ assert.match(reg.validateFlagValue(leg, { enabled: true, message: "x".repeat(1001) }).error, /over 1000/);
60
+ const whole = reg.FLAG_REGISTRY["instagram_comments"];
61
+ assert.deepEqual(reg.validateFlagValue(whole, { enabled: true, ads_only: false, public_reply: { enabled: false } }), { value: { enabled: true, ads_only: false, public_reply: { enabled: false } } });
62
+ assert.match(reg.validateFlagValue(whole, { bogus: 1 }).error, /not a known key/);
63
+ });
@@ -72,10 +72,22 @@ function checkExpectations(turn, replyText) {
72
72
  return results;
73
73
  }
74
74
 
75
- function printTurn(sc, t, userText, r, checks) {
75
+ function printTurn(sc, t, userText, r, checks, opts = {}) {
76
76
  if (t === 0) console.log(`\n━━ ${sc.tq ? sc.tq + " · " : ""}${sc.title || sc.id} ━━`);
77
77
  console.log(`\n 🧑 ${userText}`);
78
78
  if (r.tool_calls?.length) console.log(` ⚙ tools: ${r.tool_calls.join(", ")}`);
79
+ // --tools: what each tool was asked and what it answered (the tool-call rows
80
+ // vert writes). Without it a wrong answer could not be traced to the tool
81
+ // result that produced it.
82
+ if (opts.tools && r.tool_details?.length) {
83
+ for (const d of r.tool_details) {
84
+ console.log(` ⚙ ${d.tool_name}${d.duration_ms != null ? ` (${d.duration_ms} ms)` : ""}${d.error ? ` ERROR ${d.error}` : ""}`);
85
+ if (d.request != null) console.log(` in: ${String(d.request).split("\n").join("\n ")}`);
86
+ if (d.response != null) console.log(` out: ${String(d.response).split("\n").join("\n ")}`);
87
+ }
88
+ } else if (opts.tools && r.tool_calls?.length) {
89
+ console.log(` (tool arguments / results are not stored for this org: switch on feature flag tool_logging, or the API predates 1 Oct 2026)`);
90
+ }
79
91
  if (r.reply) console.log(` 🤖 ${r.reply.split("\n").join("\n ")}`);
80
92
  else console.log(` ⚠ no reply — status=${r.status}${r.raw?.error ? " (" + r.raw.error + ")" : ""}`);
81
93
  for (const c of checks || []) {
@@ -103,7 +115,7 @@ export async function send(orgId, messageParts, opts = {}) {
103
115
  printWarnings(r);
104
116
  if (opts.clear && r.previous_memory_cutoff !== undefined) console.log(`# cleared first; previous memory_cutoff: ${r.previous_memory_cutoff ?? "(none)"}`);
105
117
  console.log(`# org=${r.org?.name} agent=${r.agent?.name}${r.agent?.is_active ? " (active)" : " (override)"} contact=${r.contact?.id}`);
106
- printTurn({ title: "send", tq: "" }, 0, message, r, []);
118
+ printTurn({ title: "send", tq: "" }, 0, message, r, [], { tools: !!opts.tools });
107
119
  }
108
120
 
109
121
  async function runPack(orgId, pack, opts) {
@@ -128,7 +140,7 @@ async function runPack(orgId, pack, opts) {
128
140
  }
129
141
  const checks = checkExpectations(turn, r.reply);
130
142
  scResult.turns.push({ ...r, checks });
131
- if (!opts.json) printTurn(sc, t, turn.text, r, checks);
143
+ if (!opts.json) printTurn(sc, t, turn.text, r, checks, { tools: !!opts.tools });
132
144
  }
133
145
  results.push(scResult);
134
146
  }
@@ -32,6 +32,37 @@ export async function list(orgId, opts = {}) {
32
32
  console.log("\n★ = active agent. Pass any id as --agent <id> to prompts/questionnaires/fine-tuning/agent config.");
33
33
  }
34
34
 
35
+ // `flowiq agent flags [--tool product_lookup] [--model x] [--all] [--json]` —
36
+ // every active org's active agent: model, reasoning, prompt switch and the
37
+ // tool gates, one row per org. Filters narrow to the orgs that matter.
38
+ export async function flags(opts = {}) {
39
+ let resp;
40
+ try { resp = await http.get("agents", { census: "flags", all: opts.all ? "1" : undefined }); }
41
+ catch (e) { console.error(`Census failed: ${e.message}`); process.exit(1); }
42
+ let rows = resp.agents || [];
43
+ if (opts.tool) {
44
+ const [k, v] = String(opts.tool).split("=");
45
+ const want = v === undefined ? true : !["false", "0", "off", "no"].includes(v.toLowerCase());
46
+ rows = rows.filter((r) => !r.missing && (r.tools?.[k] === want || (want === false && r.tools?.[k] === undefined)));
47
+ }
48
+ if (opts.model) rows = rows.filter((r) => String(r.model || "").includes(opts.model));
49
+ if (opts.json) { console.log(JSON.stringify({ ...resp, count: rows.length, agents: rows }, null, 2)); return; }
50
+ const pad = (s, n) => String(s ?? "").padEnd(n);
51
+ const short = { product_lookup: "lookup", ticket_tool_status: "tickets", postal_code_tool_status: "postal", restock_tool: "restock", view_cart_tool: "cart", block_tool_status: "block", email_request_tool: "email-req", shopify_products_web_chat: "web-products", woo_order_build: "woo-order", woo_tip_field: "woo-tip", woo_order_note_field: "woo-note", collapse_product_variants: "collapse", silent_option: "silent" };
52
+ console.log(`${rows.length} active agent(s)${opts.tool ? ` with ${opts.tool}` : ""}${opts.model ? ` on model ~${opts.model}` : ""}`);
53
+ console.log("");
54
+ console.log(` ${pad("ORG", 26)} ${pad("AGENT", 18)} ${pad("MODEL", 20)} ${pad("REASON", 7)} ${pad("PROMPT", 9)} ${pad("SW", 3)} TOOLS ON`);
55
+ for (const r of rows) {
56
+ if (r.missing) { console.log(` ${pad(r.organization_name.slice(0, 26), 26)} ⚠ active_whatsapp_agent ${r.agent_id} does not exist`); continue; }
57
+ const on = Object.entries(r.tools || {}).filter(([, v]) => v).map(([k]) => short[k] || k);
58
+ if (r.discount) on.push("discount");
59
+ if (r.custom_tools) on.push(`${r.custom_tools} custom`);
60
+ if (r.disabled_base_tools?.length) on.push(`-${r.disabled_base_tools.join(",-")}`);
61
+ console.log(` ${pad(r.organization_name.slice(0, 26), 26)} ${pad(String(r.agent_name || "").slice(0, 18), 18)} ${pad(r.model || "house", 20)} ${pad(r.reasoning_effort || "—", 7)} ${pad(`${r.prompt_chars.toLocaleString()}${r.use_settings_prompt ? "" : " OFF"}`, 9)} ${pad(r.master_switch ? "on" : "OFF", 3)} ${on.join(" ") || "—"}`);
62
+ }
63
+ console.log(`\n SW = agent master switch · PROMPT = chars (OFF = use_settings_prompt false) · columns: ${(resp.tool_columns || []).map((k) => `${short[k] || k}=${k}`).join(", ")}`);
64
+ }
65
+
35
66
  export async function create(orgId, opts = {}) {
36
67
  if (!UUID_RE.test(orgId)) {
37
68
  console.error(`Error: "${orgId}" is not a valid organization UUID.`);
@@ -1815,6 +1815,15 @@ export async function scheduledList(orgId, opts = {}) {
1815
1815
  console.log(` template ${s.template_name ?? "?"} → tag ${s.tag ?? "?"} · ${s.engine} · via ${s.source}${s.scheduled_by ? ` (${s.scheduled_by})` : ""}`);
1816
1816
  if (s.audience_at_schedule != null) console.log(` ~${s.audience_at_schedule} recipients at schedule time`);
1817
1817
  if (s.reply_routing) console.log(` replies → ${describeRouting(s.reply_routing)}`);
1818
+ if (opts.detail) {
1819
+ const bp = s.body_parameters && Object.keys(s.body_parameters).length ? s.body_parameters : null;
1820
+ const bt = s.button_parameters && Object.keys(s.button_parameters).length ? s.button_parameters : null;
1821
+ console.log(` body: ${bp ? Object.entries(bp).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ") : "(no body values)"}`);
1822
+ if (bt) console.log(` buttons: ${Object.entries(bt).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(" ")}`);
1823
+ if (s.header_media) console.log(` header: ${typeof s.header_media === "string" ? s.header_media : JSON.stringify(s.header_media)}`);
1824
+ if (s.card_overrides != null) console.log(` cards: ${s.card_overrides} card override(s)`);
1825
+ if (!bp && !bt && !s.header_media && s.engine !== "python") console.log(` (a node-engine row carries its values inside the request; --json shows them)`);
1826
+ }
1818
1827
  if (s.processed_at) console.log(` fired ${fmtSast(s.processed_at)} SAST${s.broadcast_id ? ` · broadcastId ${s.broadcast_id}` : ""}`);
1819
1828
  if (s.error_message) console.log(` error: ${s.error_message}`);
1820
1829
  }