@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 +209 -0
- package/TEAM-GUIDE.md +25 -0
- package/package.json +1 -1
- package/src/batch-cde.test.mjs +73 -0
- package/src/batch-f.test.mjs +63 -0
- package/src/commands/agent-test.js +15 -3
- package/src/commands/agents.js +31 -0
- package/src/commands/broadcast.js +9 -0
- package/src/commands/contacts.js +173 -0
- package/src/commands/flows.js +82 -0
- package/src/commands/links.js +63 -20
- package/src/commands/members.js +95 -0
- package/src/commands/messages.js +106 -0
- package/src/commands/notes.js +63 -0
- package/src/commands/org.js +190 -5
- package/src/commands/products.js +58 -0
- package/src/commands/prompts.js +46 -1
- package/src/commands/rules.js +67 -0
- package/src/commands/segments.js +47 -14
- package/src/commands/tag.js +77 -7
- package/src/commands/templates.js +66 -5
- package/src/commands/tickets.js +69 -0
- package/src/commands/widgets.js +97 -0
- package/src/index.js +286 -6
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.
|
|
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
|
}
|
package/src/commands/agents.js
CHANGED
|
@@ -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
|
}
|