@flowapt/flowiq-cli 0.4.3 → 0.4.4

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
@@ -206,8 +206,34 @@ flowiq bc send <org_id> --template … --csv … --campaign july-referrals --com
206
206
 
207
207
  # 4) Interrupted? Continue only unsent rows:
208
208
  flowiq bc resume <org_id> --campaign july-referrals --commit [--retry-failed]
209
+
210
+ # SCHEDULE a tag send for later instead of sending now (--at is SAST):
211
+ flowiq bc send <org_id> --tag <batch-tag> --template <name> --body param1="…" \
212
+ --at "2026-08-05 09:00" --commit
213
+ flowiq bc send … --at "2026-08-05 09:00" --needs-approval --commit # park it for approval
214
+
215
+ flowiq bc scheduled list <org_id> # open rows (pending + awaiting-approval)
216
+ flowiq bc scheduled list <org_id> --all # incl. fired / cancelled
217
+ flowiq bc scheduled approve <org_id> <queue_id> # 'request' → 'pending' (--at re-times it)
218
+ flowiq bc scheduled cancel <org_id> <queue_id> --confirm
209
219
  ```
210
220
 
221
+ - **Scheduling (v0.4.4)** — `--at "YYYY-MM-DD HH:MM"` on a `--tag` send queues it
222
+ instead of sending. It writes the same `api_request_queue` row the dashboard
223
+ writes, replayed by the `process-api-queue` cron (every 3 min, so it fires
224
+ 0–3 min after the stated minute). Runs **every** pre-send check first
225
+ (APPROVED, positional, param arithmetic, header-media type) — a scheduled
226
+ send can't skip a guard an immediate one enforces.
227
+ - `--at` is always read as **SAST**, never the machine's local timezone, so
228
+ the same command schedules the same instant from anywhere.
229
+ - The **audience resolves when it FIRES**, not when you schedule — someone
230
+ tagged tomorrow morning still receives a send scheduled today.
231
+ - Defaults to fire-by-itself. `--needs-approval` parks it until
232
+ `bc scheduled approve`. ⚠ **A parked row NEVER fires until approved** and
233
+ never errors — `scheduled list` flags those `AWAITING APPROVAL — will NOT
234
+ fire` (two dashboard-scheduled broadcasts silently missed their sends this
235
+ way). Cancel is dry-run unless you pass `--confirm`.
236
+
211
237
  - **Per-row values**: each template slot maps to a CSV **column**, a
212
238
  **literal**, or a **concat transform** (e.g. `Country Code` + `Phone`,
213
239
  leading-zero handled). The mapper auto-suggests from the template's
@@ -729,6 +755,84 @@ server-side (`auto_synthesized: true`; the response says
729
755
  broadcast mapping suggestions — pass your own only when you want real slot
730
756
  labels (e.g. `"body_params": {"1": "first_name", "2": "order_number"}`).
731
757
 
758
+ ### Store API (READ-ONLY) — `flowiq shopify get|gql` / `flowiq woo get`
759
+
760
+ Write your own request against a client's **own** store API. You supply the
761
+ endpoint (or the GraphQL query); the server resolves that org's credentials and
762
+ makes the call. Credentials never reach your machine and are never printed.
763
+
764
+ **Writes are refused, structurally:**
765
+
766
+ * **REST** — the HTTP method is hardcoded `GET` and is never read from your
767
+ request. Every write in both APIs is POST/PUT/PATCH/DELETE, so "GET only" *is*
768
+ "read only". There is no endpoint allowlist to fall out of date, so any
769
+ endpoint — including ones Shopify or Woo ship tomorrow — works immediately.
770
+ * **GraphQL** — the document is lexed (strings and `#` comments stripped) and
771
+ any `mutation` / `subscription` operation is rejected before the call is made.
772
+ Shopify exposes every Admin write as a mutation, so this rejects the whole
773
+ write surface. The word "mutation" inside a *string* or a *comment* is fine.
774
+
775
+ ```bash
776
+ # Shopify REST — any Admin endpoint. The ".json" suffix is optional.
777
+ flowiq shopify get <org_id> orders.json --q status=any --q limit=250
778
+ flowiq shopify get <org_id> products --q limit=5 --q fields=id,title,status
779
+ flowiq shopify get <org_id> inventory_levels.json --q location_ids=123 --all
780
+ flowiq shopify get <org_id> orders/450789469.json # a single record
781
+
782
+ # Shopify GraphQL — you write the query. Inline, from a file, or on stdin.
783
+ flowiq shopify gql <org_id> -q 'query { shop { name currencyCode } }'
784
+ flowiq shopify gql <org_id> --query-file ./q.graphql --var n=50
785
+ cat q.graphql | flowiq shopify gql <org_id>
786
+
787
+ # WooCommerce REST — default namespace wc/v3.
788
+ flowiq woo get <org_id> orders --q status=completed --q per_page=100 --all
789
+ flowiq woo get <org_id> products/1234
790
+ flowiq woo get <org_id> reports/sales --namespace wc/v3 --q period=month
791
+ flowiq woo get <org_id> settings/general --namespace wc/v3
792
+ ```
793
+
794
+ **Output contract — stdout is the payload and nothing else**, so it pipes
795
+ straight into `jq`. The metadata banner (record count, pages, rate-limit
796
+ headroom, timing) goes to **stderr**, so you still see it on your terminal:
797
+
798
+ ```bash
799
+ flowiq shopify get <org_id> orders.json --q status=any --all | jq -r '.orders[].name'
800
+ flowiq woo get <org_id> orders --all --out ./orders.json # or write to a file
801
+ flowiq shopify get <org_id> shop --json # full envelope incl. metadata
802
+ ```
803
+
804
+ Flags:
805
+
806
+ | Flag | Meaning |
807
+ |---|---|
808
+ | `--q <k=v>` | Query parameter, repeatable. A repeated key becomes a repeated parameter (`--q ids=1 --q ids=2`). Params go here, never in the path. |
809
+ | `--all` | Follow pagination (Shopify's `Link: rel=next`; Woo's `page`) and return every record. |
810
+ | `--max-pages <n>` | Page limit for `--all` (default 10, hard cap 50). |
811
+ | `--namespace <ns>` | Woo REST namespace — default `wc/v3`; also `wc-analytics`, `wp/v2`. |
812
+ | `--api-version <v>` | Shopify Admin API version (default `2024-01`). |
813
+ | `-q, --query` / `--query-file` | The GraphQL document, inline or from a file (stdin also works). |
814
+ | `--var <k=v>` | GraphQL variable, repeatable. JSON values are parsed (`--var ids='["1"]'`). |
815
+ | `--json` | Print the full envelope (metadata + data) instead of just the payload. |
816
+ | `--compact` | Single-line JSON. |
817
+ | `--out <file>` | Write the JSON to a file instead of stdout. |
818
+
819
+ Notes:
820
+
821
+ * **`--all` is bounded and says so.** It stops at `--max-pages`, at a ~3.5MB
822
+ response size, or at a time budget, and then reports `⚠ TRUNCATED — <reason>`
823
+ on stderr. A partial result is never presented as a complete one.
824
+ * **Rate limits are surfaced, not hidden.** REST prints Shopify's
825
+ `api calls 3/40`; GraphQL prints the query cost and remaining points.
826
+ * **GraphQL errors are surfaced.** Shopify returns field errors as HTTP 200 with
827
+ an `errors` array; the CLI prints them rather than letting you read
828
+ `data: null` as "no results".
829
+ * **Orgs behind an IP whitelist / WAF** (`feature_flags.ip_whitelist`, e.g.
830
+ Hydroponic on Wordfence) reject calls from our servers. You get an explicit
831
+ message saying so, not an opaque 403.
832
+ * Every call is written to the audit log (`flowiq audit <org>`) — who, which
833
+ org, which endpoint or query, how many records. **The response body is never
834
+ stored**; the log records what was asked, not the customer data that came back.
835
+
732
836
  ### Org — `flowiq org create` / `flowiq org info <organization_id>`
733
837
 
734
838
  Create a brand-new organization, or look one up (read-only; raw store/Meta
@@ -802,10 +906,22 @@ flowiq agent config <organization_id> --test-contact-number 27000000001 --test-c
802
906
  Settable: `settings.use_settings_prompt`, `settings.model`, agent `--rename`,
803
907
  the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field`,
804
908
  `view_cart_tool`, `restock_tool`, `block_tool_status`, `postal_code_tool_status`,
805
- `shopify_products_web_chat`, `ticket_tool_status`), `discount.enabled`, and the `flowiq test` contact
909
+ `shopify_products_web_chat`, `ticket_tool_status`, `collapse_product_variants`),
910
+ `discount.enabled`, and the `flowiq test` contact
806
911
  (`settings.test_contact_number` / `settings.test_contact_name`). Anything else is
807
912
  rejected; every change is reported before → after.
808
913
 
914
+ **`--tool collapse_product_variants=true` (added 4 Aug 2026).** Not a tool toggle —
915
+ it changes what `get_product_info` RETURNS. OFF (the default) the result cap counts
916
+ **variant rows**, so on a catalogue with several packaging/size variants per product
917
+ the agent sees only a handful of distinct products and will tell a customer an
918
+ in-stock item "isn't showing". ON, the tool fetches `max*6` rows (cap 300) and
919
+ collapses them to ≤`max` **distinct products**, listing each product's variants
920
+ inline. Measured on African Oils: a 40-row response went from **15–24 distinct
921
+ products to 40**, out of a 334-product catalogue. Costs ~50% more tokens per call.
922
+ Requires ≤12 variants per product for the inline rendering branch — check with
923
+ `SELECT max(c) FROM (SELECT count(*) c FROM products_all WHERE organization_id=… GROUP BY product_id) t`.
924
+
809
925
  **Test contact (`--test-contact-number` / `--test-contact-name`, v0.2.9).**
810
926
  This is the contact `flowiq test` drives the live agent through (read by
811
927
  `api/cli/agent-test.js` as `settings.test_contact_number`). ⚠ **Use a clearly
package/TEAM-GUIDE.md CHANGED
@@ -80,8 +80,14 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
80
80
  | Edit keyword auto-replies (incl. competition entry keywords, add/remove-tag, set-agent and delay actions) | `flowiq kw pull <org_id>` → edit → `flowiq kw push <slug> --dry-run` → `flowiq kw push <slug>` |
81
81
  | Send a **different auto-reply depending on the contact** (e.g. "we already have your email" vs "send us your email") | add `"when": {"field":"email","op":"is_not_empty"}` to one action and `is_empty` to the other — see *Keywords* in `flowiq guide --reference`. CLI-only, no dashboard field yet |
82
82
  | Make a follow-up button **close the customer's ticket** ("Query solved") or **re-alert the team** ("I still need help") | keyword action `{"type":"update_ticket_status","status":"resolved"}` / `{"type":"renotify_ticket"}` — see *Keywords* in `flowiq guide --reference`. CLI-only, no dashboard field yet |
83
+ | Ask a client's Shopify store anything (read-only) | `flowiq shopify get <org_id> orders.json --q status=any --q limit=250` — any Admin REST endpoint; writes are refused |
84
+ | Ask a client's Shopify store something REST can't express | `flowiq shopify gql <org_id> -q 'query { shop { name } }'` — you write the GraphQL; any `mutation` is refused |
85
+ | Ask a client's WooCommerce store anything (read-only) | `flowiq woo get <org_id> orders --q status=completed --all` |
86
+ | Pull every matching record, not just the first page | add `--all` (bounded — it prints `⚠ TRUNCATED` if it stops early, so a partial answer never looks complete) |
87
+ | Feed store data into jq / a file | stdout is the payload only, banner goes to stderr: `flowiq shopify get <org> orders.json \| jq -r '.orders[].name'` · or `--out ./orders.json` |
83
88
  | See an org's agents / create one | `flowiq agent list <org_id>` / `flowiq agent create <org_id> --name "…"` |
84
89
  | Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
90
+ | Agent says an in-stock product "isn't showing" | `flowiq agent config <org_id> --tool collapse_product_variants=true` — the search cap counts VARIANT rows until this is on |
85
91
  | Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
86
92
  | Set which contact `flowiq test` uses (use a FAKE number!) | `flowiq agent config <org_id> --test-contact-number 27000000001 --test-contact-name "QA Bot"` |
87
93
  | Send a template broadcast to a CSV of people | `flowiq bc map <org_id> --template … --csv …` → `flowiq bc send … ` (dry-run) → `… --commit` |
@@ -93,6 +99,8 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
93
99
  | Combine existing tags → a batched send list (include some tags, drop others, split into batches of N) | `flowiq seg plan <org_id> --tag-prefix clearance-bc --from-tag "loyalty-list" --exclude "recent-campaign" --batch-size 1000` → `flowiq seg apply <org_id> clearance-bc --commit` (makes `clearance-bc-batch-01/02/…`) |
94
100
  | Split a big id-list cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
95
101
  | Split a whole tagged audience into batches of N (e.g. 90k → 7000s) | `flowiq seg plan <org_id> --tag-prefix 3-aug-bc --from-tag "3-aug-bc" --batch-size 7000` → `flowiq seg apply <org_id> 3-aug-bc --commit --yes` (makes `…-batch-01…13`; works at any size — big applies are chunked internally) |
102
+ | Schedule a broadcast for later instead of sending now | `flowiq bc send <org_id> --tag <batch-tag> --template <name> --at "2026-08-05 09:00" --commit` (time is SAST; fires on its own; audience resolved at send time) |
103
+ | See / approve / cancel what's scheduled | `flowiq bc scheduled list <org_id>` → `flowiq bc scheduled approve <org_id> <queue_id>` or `flowiq bc scheduled cancel <org_id> <queue_id> --confirm` |
96
104
  | Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` (per-contact tokens: the 6 contact fields + `{{attributes.<key>}}`) |
97
105
  | Send a broadcast whose template has an IMAGE/VIDEO/DOCUMENT header | Same as above — the media is automatic (the template's own stored header). Override with `--header-media <public-url>` if needed. The CLI verifies the resolved media's actual type against the header format — `header video (video ✓ video/mp4)` means verified; a mismatch (e.g. a video template whose stored default is secretly a png — templates made before 3 Aug 2026 can carry this) ABORTS and tells you to pass `--header-media` with the real file. |
98
106
  | Send via the SAME engine as the dashboard's "Python" toggle | add `--python` to a `bc send --tag …` (fire-and-forget; python resolves the tag + sends + tracks; no CLI resume for this engine). **Any tag send over 10 recipients uses python automatically.** |
@@ -192,6 +200,75 @@ flowiq tag attributes <org_id> --filter allow_broadcast_true \
192
200
  `--exclude` names the same tag you're adding, so every run skips the people who
193
201
  already have it. Safe to re-run as many times as you like.
194
202
 
203
+ ### Scheduling a broadcast
204
+
205
+ Add `--at` to a tag send and it queues instead of sending. The time is **South
206
+ African time**, always — it does not matter where your laptop thinks it is.
207
+
208
+ ```bash
209
+ flowiq bc send <org_id> --tag 5-aug-batch-01 --template my_template \
210
+ --body param1="Hi {{first_name}}" --at "2026-08-05 09:00" --commit
211
+ ```
212
+
213
+ It fires on its own, so there is nothing more to do. Two things worth knowing:
214
+
215
+ - **The audience is worked out when it fires, not now.** Anyone who picks up
216
+ that tag between now and then is included.
217
+ - It goes out **within about 3 minutes** of the time you set, not to the second.
218
+
219
+ Check on it, change your mind, or hand it to someone else to sign off:
220
+
221
+ ```bash
222
+ flowiq bc scheduled list <org_id> # what's queued
223
+ flowiq bc scheduled cancel <org_id> <queue_id> --confirm
224
+ ```
225
+
226
+ If you'd rather someone approved it before it goes out, add `--needs-approval`
227
+ when scheduling. **It then will not send until somebody runs
228
+ `flowiq bc scheduled approve <org_id> <queue_id>`** — `scheduled list` marks it
229
+ `AWAITING APPROVAL — will NOT fire` so it can't be forgotten. (Two broadcasts
230
+ scheduled in the dashboard were missed exactly this way.)
231
+
232
+ ### Asking a client's store a question directly
233
+
234
+ When you need something the dashboard doesn't show — "how many orders are still
235
+ unfulfilled?", "what does this product's inventory actually look like per
236
+ location?", "which orders used that discount code?" — you can ask the client's
237
+ own store, in their own API, without a Shopify or Woo login.
238
+
239
+ ```bash
240
+ # Shopify — any Admin REST endpoint. ".json" is optional.
241
+ flowiq shopify get <org_id> orders.json --q status=any --q financial_status=paid --q limit=250
242
+
243
+ # WooCommerce — same idea.
244
+ flowiq woo get <org_id> orders --q status=processing --all
245
+ ```
246
+
247
+ **You cannot break anything with this.** Reads are the only thing it can do:
248
+ REST calls are locked to `GET`, and a GraphQL document containing a `mutation`
249
+ is refused before it is sent. There is no "are you sure?" because there is
250
+ nothing to be sure about.
251
+
252
+ The answer comes back as JSON on stdout and a summary line on stderr, so it
253
+ pipes into other tools cleanly:
254
+
255
+ ```bash
256
+ flowiq shopify get <org_id> orders.json --q status=any --all | jq -r '.orders[].name'
257
+ flowiq woo get <org_id> products --all --out ./products.json
258
+ ```
259
+
260
+ Three things worth knowing:
261
+
262
+ - **`--all` pages through everything**, but it stops at a sane limit and tells
263
+ you when it did (`⚠ TRUNCATED — stopped at --max-pages 10`). If you see that,
264
+ narrow the query rather than assuming you have the full set.
265
+ - **If REST can't express your question, write GraphQL** —
266
+ `flowiq shopify gql <org_id> -q 'query { … }'` lets you pick exactly the
267
+ fields and nesting you want. Put a long query in a file and use
268
+ `--query-file`.
269
+ - **Some stores sit behind a firewall** (Hydroponic's, for one) and will refuse
270
+ our call. You'll get a message saying exactly that.
271
+
195
272
  ## Keys, rotation, logging out
196
273
 
197
274
  - **`flowiq auth refresh`** — rotates this device's key in place (a fresh key
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
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": {
@@ -864,6 +864,12 @@ async function runTagPipeline(orgId, opts, { commitStage }) {
864
864
  if (template.url_button?.present && !buttonLiteral) aborts.push("template has a dynamic URL button — pass --button param1=<code>");
865
865
  if (aborts.length) { for (const a of aborts) console.error(`ABORT — ${a}`); process.exit(1); }
866
866
 
867
+ // SCHEDULE (--at): validation above has already run, so a scheduled send is
868
+ // gated by exactly the same checks as an immediate one (APPROVED, positional,
869
+ // param arithmetic, header-media type). Instead of sending we queue the send
870
+ // for later; python resolves the tag at FIRE time, so the audience is fresh.
871
+ if (opts.at) return scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage });
872
+
867
873
  // ENGINE ROUTING (policy): any send of MORE THAN 10 recipients ALWAYS uses the
868
874
  // python /meta-broadcast engine (the proven bulk sender). --python forces it at
869
875
  // any size; only a ≤10 send stays on the resumable per-row Node engine.
@@ -1002,6 +1008,145 @@ export async function map(orgId, opts = {}) {
1002
1008
  console.log(`Saved mapping: ${cfgPath(campaign)}`);
1003
1009
  }
1004
1010
 
1011
+ /** Parse a `--at` value as SOUTH AFRICAN time (SAST, UTC+2) and return an ISO string.
1012
+ * Accepts "YYYY-MM-DD HH:MM" / "YYYY-MM-DDTHH:MM" (SAST), or a value carrying its
1013
+ * own offset/Z (honoured as given). A bare local string is deliberately NOT handed
1014
+ * to `new Date()`: that interprets it in the RUNNER's timezone, so the same command
1015
+ * would schedule a different moment on a laptop in another country. */
1016
+ export function parseSastAt(input) {
1017
+ const raw = String(input || "").trim();
1018
+ if (!raw) return { error: "empty --at value" };
1019
+ if (/(Z|[+-]\d{2}:?\d{2})$/.test(raw)) {
1020
+ const d = new Date(raw);
1021
+ return Number.isNaN(d.getTime()) ? { error: `not a valid timestamp: ${raw}` } : { iso: d.toISOString(), explicitOffset: true };
1022
+ }
1023
+ const m = raw.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?$/);
1024
+ if (!m) return { error: `use "YYYY-MM-DD HH:MM" (SAST), e.g. --at "2026-08-05 09:00" — got "${raw}"` };
1025
+ const [, y, mo, d, h, mi, s] = m;
1026
+ // Build the instant explicitly at +02:00 so the runner's TZ is irrelevant.
1027
+ const iso = new Date(`${y}-${mo}-${d}T${h}:${mi}:${s || "00"}+02:00`);
1028
+ if (Number.isNaN(iso.getTime())) return { error: `not a valid date/time: ${raw}` };
1029
+ return { iso: iso.toISOString(), explicitOffset: false };
1030
+ }
1031
+
1032
+ const fmtSast = (iso) =>
1033
+ new Date(iso).toLocaleString("en-ZA", { timeZone: "Africa/Johannesburg", dateStyle: "medium", timeStyle: "short" });
1034
+
1035
+ /** Queue a tag broadcast to fire later (server writes the api_request_queue row). */
1036
+ async function scheduleTagSend(orgId, { opts, tag, templateName, template, headerMedia, bodyLiterals, buttonLiteral, commitStage }) {
1037
+ const when = parseSastAt(opts.at);
1038
+ if (when.error) { console.error(`Error: --at ${when.error}`); process.exit(1); }
1039
+ if (new Date(when.iso).getTime() <= Date.now()) {
1040
+ console.error(`ABORT — --at is in the past (${fmtSast(when.iso)} SAST). Nothing would ever fire.`);
1041
+ process.exit(1);
1042
+ }
1043
+ const needsApproval = !!opts.needsApproval;
1044
+
1045
+ console.log("");
1046
+ renderPreview(template, { rownum: 1, number: "(resolved at send time)", values: bodyLiterals, buttonValue: buttonLiteral }, headerMedia);
1047
+ if (Object.values(bodyLiterals).some((v) => /\{\{(first_name|full_name|email|phone_number|whatsapp_id)\}\}/.test(String(v)))) {
1048
+ console.log(" ({{first_name}}-style tokens are resolved PER CONTACT at send time)");
1049
+ }
1050
+ console.log("");
1051
+ console.log(`Schedule: ${fmtSast(when.iso)} SAST${when.explicitOffset ? "" : " (--at read as SAST)"}`);
1052
+ console.log(`Audience: tag "${tag}" — resolved when it FIRES, not now (so late joiners are included)`);
1053
+ console.log(`Engine: python /meta-broadcast`);
1054
+ console.log(needsApproval
1055
+ ? `Approval: REQUIRED — parks as 'request'; run "flowiq bc scheduled approve" before it can fire`
1056
+ : `Approval: none — fires automatically at the scheduled time`);
1057
+
1058
+ if (!commitStage) {
1059
+ console.log("");
1060
+ console.log("DRY RUN — nothing was scheduled. Add --commit to queue it.");
1061
+ return;
1062
+ }
1063
+
1064
+ let resp;
1065
+ try {
1066
+ resp = await http.post("broadcast", {
1067
+ action: "schedule",
1068
+ organization_id: orgId,
1069
+ tag,
1070
+ template_name: templateName,
1071
+ scheduled_for: when.iso,
1072
+ needs_approval: needsApproval,
1073
+ body_parameters: bodyLiterals,
1074
+ ...(buttonLiteral ? { button_parameters: { param1: buttonLiteral } } : {}),
1075
+ ...(headerMedia ? { header_media: headerMedia } : {}),
1076
+ });
1077
+ } catch (e) {
1078
+ console.error(`Schedule failed: ${e.message}`);
1079
+ if (e.body?.error) console.error(` ${e.body.error}`);
1080
+ process.exit(1);
1081
+ }
1082
+
1083
+ console.log("");
1084
+ console.log(`✓ Scheduled for ${fmtSast(resp.scheduled_for)} SAST — queue id ${resp.queue_id}`);
1085
+ console.log(` ~${resp.audience_at_schedule ?? "?"} contacts carry "${tag}" right now (re-counted at send time).`);
1086
+ if (resp.fires_automatically) {
1087
+ console.log(` It will fire on its own. Cancel with: flowiq bc scheduled cancel ${orgId} ${resp.queue_id}`);
1088
+ } else {
1089
+ console.log(` ⚠ PARKED awaiting approval — it will NOT fire until you run:`);
1090
+ console.log(` flowiq bc scheduled approve ${orgId} ${resp.queue_id}`);
1091
+ }
1092
+ }
1093
+
1094
+ /** List scheduled broadcasts (queue rows). Read-only. */
1095
+ export async function scheduledList(orgId, opts = {}) {
1096
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
1097
+ let resp;
1098
+ try {
1099
+ resp = await http.post("broadcast", {
1100
+ action: "scheduled-list",
1101
+ organization_id: orgId,
1102
+ status_filter: opts.all ? "all" : (opts.status || "open"),
1103
+ limit: opts.limit,
1104
+ });
1105
+ } catch (e) { console.error(`Scheduled list failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
1106
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
1107
+ if (!resp.scheduled?.length) { console.log(`No ${opts.all ? "" : "open "}scheduled broadcasts on ${resp.organization_name}.`); return; }
1108
+ console.log(`Scheduled broadcasts on ${resp.organization_name}:`);
1109
+ for (const s of resp.scheduled) {
1110
+ const flag = s.stalled ? " ⚠ AWAITING APPROVAL — will NOT fire" : "";
1111
+ console.log("");
1112
+ console.log(` ${s.id}`);
1113
+ console.log(` ${fmtSast(s.scheduled_for)} SAST · ${s.status}${flag}`);
1114
+ console.log(` template ${s.template_name ?? "?"} → tag ${s.tag ?? "?"} · ${s.engine} · via ${s.source}${s.scheduled_by ? ` (${s.scheduled_by})` : ""}`);
1115
+ if (s.audience_at_schedule != null) console.log(` ~${s.audience_at_schedule} recipients at schedule time`);
1116
+ if (s.processed_at) console.log(` fired ${fmtSast(s.processed_at)} SAST${s.broadcast_id ? ` · broadcastId ${s.broadcast_id}` : ""}`);
1117
+ if (s.error_message) console.log(` error: ${s.error_message}`);
1118
+ }
1119
+ console.log("");
1120
+ console.log(` ${resp.count} row(s). Approve: flowiq bc scheduled approve <org> <id> · Cancel: … cancel <org> <id>`);
1121
+ }
1122
+
1123
+ /** Approve (request → pending) or cancel a scheduled broadcast. */
1124
+ export async function scheduledUpdate(orgId, queueId, op, opts = {}) {
1125
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
1126
+ if (!UUID_RE.test(queueId || "")) { console.error(`Error: "${queueId}" is not a valid queue id (get it from: flowiq bc scheduled list <org>).`); process.exit(1); }
1127
+ let at = null;
1128
+ if (opts.at) {
1129
+ const parsed = parseSastAt(opts.at);
1130
+ if (parsed.error) { console.error(`Error: --at ${parsed.error}`); process.exit(1); }
1131
+ at = parsed.iso;
1132
+ }
1133
+ if (op === "cancel" && !opts.confirm) {
1134
+ console.log(`DRY RUN — would cancel scheduled broadcast ${queueId}. Add --confirm to cancel.`);
1135
+ return;
1136
+ }
1137
+ let resp;
1138
+ try {
1139
+ resp = await http.post("broadcast", {
1140
+ action: "scheduled-update", organization_id: orgId, queue_id: queueId, op, ...(at ? { scheduled_for: at } : {}),
1141
+ });
1142
+ } catch (e) { console.error(`${op} failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
1143
+ if (op === "approve") {
1144
+ console.log(`✓ Approved — ${queueId} is now '${resp.status}' and WILL fire at ${fmtSast(resp.scheduled_for)} SAST.`);
1145
+ } else {
1146
+ console.log(`✓ Cancelled — ${queueId} is now '${resp.status}' and will not fire.`);
1147
+ }
1148
+ }
1149
+
1005
1150
  export async function preview(orgId, opts = {}) {
1006
1151
  await runPipeline(orgId, opts, { commitStage: false, isResume: false });
1007
1152
  }
@@ -0,0 +1,176 @@
1
+ // `flowiq shopify` / `flowiq woo` — READ-ONLY passthrough to a client org's own
2
+ // store API. You write the request; the server resolves the credentials and
3
+ // refuses anything that isn't a read.
4
+ //
5
+ // flowiq shopify get <org> orders.json --q status=any --q limit=250 --all
6
+ // flowiq shopify gql <org> -q 'query { shop { name currencyCode } }'
7
+ // flowiq woo get <org> orders --q status=completed --all
8
+ //
9
+ // OUTPUT CONTRACT (why this is jq-friendly by default):
10
+ // stdout = the API payload, and nothing else.
11
+ // stderr = the one-line metadata banner (pages, count, rate limit, timing).
12
+ // So `flowiq shopify get <org> orders.json | jq '.orders[].name'` just works,
13
+ // and the banner still shows up on your terminal. `--json` puts the FULL
14
+ // envelope (metadata + data) on stdout instead, for when you want to keep it.
15
+
16
+ import { writeFileSync } from "node:fs";
17
+ import { readFileSync } from "node:fs";
18
+ import { http } from "../http.js";
19
+
20
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21
+
22
+ /**
23
+ * Repeatable `--q key=value`. A repeated key becomes an array, because several
24
+ * store filters are legitimately multi-valued (`--q ids=1 --q ids=2`).
25
+ */
26
+ export function collectQuery(pair, acc) {
27
+ const idx = pair.indexOf("=");
28
+ if (idx < 1) throw new Error(`Expected key=value (got "${pair}")`);
29
+ const key = pair.slice(0, idx);
30
+ const value = pair.slice(idx + 1);
31
+ if (key in acc) {
32
+ acc[key] = Array.isArray(acc[key]) ? acc[key].concat(value) : [acc[key], value];
33
+ } else {
34
+ acc[key] = value;
35
+ }
36
+ return acc;
37
+ }
38
+
39
+ /** Repeatable `--var key=value` for GraphQL variables (JSON value if it parses). */
40
+ export function collectVar(pair, acc) {
41
+ const idx = pair.indexOf("=");
42
+ if (idx < 1) throw new Error(`Expected key=value (got "${pair}")`);
43
+ const key = pair.slice(0, idx);
44
+ const raw = pair.slice(idx + 1);
45
+ try { acc[key] = JSON.parse(raw); } catch { acc[key] = raw; }
46
+ return acc;
47
+ }
48
+
49
+ function requireOrg(orgId) {
50
+ if (!UUID_RE.test(orgId || "")) {
51
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
52
+ console.error("Find it with: flowiq org info <organization_id>");
53
+ process.exit(1);
54
+ }
55
+ }
56
+
57
+ /** Read a query from --query-file, an inline flag, or piped stdin. */
58
+ function resolveQueryText(opts) {
59
+ if (opts.queryFile) {
60
+ try {
61
+ return readFileSync(opts.queryFile, "utf8");
62
+ } catch (e) {
63
+ console.error(`Could not read --query-file ${opts.queryFile}: ${e.message}`);
64
+ process.exit(1);
65
+ }
66
+ }
67
+ if (opts.query) return opts.query;
68
+ if (!process.stdin.isTTY) {
69
+ try {
70
+ const piped = readFileSync(0, "utf8");
71
+ if (piped.trim()) return piped;
72
+ } catch { /* nothing piped */ }
73
+ }
74
+ console.error("Error: pass a query with -q/--query, --query-file <path>, or on stdin.");
75
+ console.error("Example: flowiq shopify gql <org> -q 'query { shop { name } }'");
76
+ process.exit(1);
77
+ }
78
+
79
+ function banner(resp) {
80
+ const bits = [];
81
+ bits.push(`${resp.organization?.name ?? "?"} · ${resp.platform}${resp.mode === "graphql" ? " graphql" : ""}`);
82
+ if (resp.count != null) bits.push(`${resp.count} record${resp.count === 1 ? "" : "s"}`);
83
+ if (resp.pages > 1) bits.push(`${resp.pages} pages`);
84
+ if (resp.total_pages) bits.push(`of ${resp.total_pages} available`);
85
+ if (resp.rate_limit) bits.push(`api calls ${resp.rate_limit}`);
86
+ if (resp.cost?.throttleStatus) {
87
+ const t = resp.cost.throttleStatus;
88
+ bits.push(`cost ${resp.cost.actualQueryCost ?? "?"} · ${Math.round(t.currentlyAvailable)}/${t.maximumAvailable} left`);
89
+ }
90
+ if (resp.auth_mode === "query") bits.push("auth via query string (host stripped the header)");
91
+ bits.push(`${resp.duration_ms}ms`);
92
+ console.error(`→ ${bits.join(" · ")}`);
93
+
94
+ if (resp.truncated) {
95
+ console.error(`⚠ TRUNCATED — ${resp.truncated_reason}. Not all records were returned.`);
96
+ console.error(" Narrow the query, or raise --max-pages and page through with the store's own cursor.");
97
+ }
98
+ if (resp.graphql_errors?.length) {
99
+ console.error(`⚠ GraphQL returned ${resp.graphql_errors.length} error(s):`);
100
+ for (const err of resp.graphql_errors.slice(0, 5)) {
101
+ console.error(` ${err.message}${err.path ? ` (at ${Array.isArray(err.path) ? err.path.join(".") : err.path})` : ""}`);
102
+ }
103
+ }
104
+ }
105
+
106
+ function emit(resp, opts) {
107
+ banner(resp);
108
+ const payload = opts.json ? resp : resp.data;
109
+ const text = JSON.stringify(payload, null, opts.compact ? 0 : 2);
110
+ if (opts.out) {
111
+ writeFileSync(opts.out, text);
112
+ console.error(`✓ wrote ${opts.out} (${text.length.toLocaleString()} chars)`);
113
+ return;
114
+ }
115
+ console.log(text);
116
+ }
117
+
118
+ async function send(payload, opts) {
119
+ let resp;
120
+ try {
121
+ resp = await http.post("store-api", payload);
122
+ } catch (e) {
123
+ console.error(`Request failed: ${e.message}`);
124
+ if (e.body?.upstream_status) console.error(` upstream HTTP ${e.body.upstream_status}`);
125
+ process.exit(1);
126
+ }
127
+ emit(resp, opts);
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // shopify
132
+ // ---------------------------------------------------------------------------
133
+
134
+ export async function shopifyGet(orgId, path, opts = {}) {
135
+ requireOrg(orgId);
136
+ await send({
137
+ platform: "shopify",
138
+ organization_id: orgId,
139
+ mode: "rest",
140
+ path,
141
+ query: opts.q || {},
142
+ all: !!opts.all,
143
+ max_pages: opts.maxPages ? Number(opts.maxPages) : undefined,
144
+ api_version: opts.apiVersion,
145
+ }, opts);
146
+ }
147
+
148
+ export async function shopifyGql(orgId, opts = {}) {
149
+ requireOrg(orgId);
150
+ const query = resolveQueryText(opts);
151
+ await send({
152
+ platform: "shopify",
153
+ organization_id: orgId,
154
+ mode: "graphql",
155
+ graphql: { query, variables: opts.var && Object.keys(opts.var).length ? opts.var : undefined },
156
+ api_version: opts.apiVersion,
157
+ }, opts);
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // woo
162
+ // ---------------------------------------------------------------------------
163
+
164
+ export async function wooGet(orgId, path, opts = {}) {
165
+ requireOrg(orgId);
166
+ await send({
167
+ platform: "woo",
168
+ organization_id: orgId,
169
+ mode: "rest",
170
+ path,
171
+ namespace: opts.namespace,
172
+ query: opts.q || {},
173
+ all: !!opts.all,
174
+ max_pages: opts.maxPages ? Number(opts.maxPages) : undefined,
175
+ }, opts);
176
+ }
package/src/index.js CHANGED
@@ -30,6 +30,7 @@ import * as tagCmd from "./commands/tag.js";
30
30
  import * as keywordsCmd from "./commands/keywords.js";
31
31
  import * as guideCmd from "./commands/guide.js";
32
32
  import * as auditCmd from "./commands/audit.js";
33
+ import * as storeApiCmd from "./commands/store-api.js";
33
34
  import { maybeNotifyUpdate } from "./update-check.js";
34
35
 
35
36
  // Read version from package.json so it stays in sync with the published npm
@@ -241,7 +242,7 @@ export function run(argv) {
241
242
  .option("--use-settings-prompt [bool]", "settings.use_settings_prompt (true if bare)")
242
243
  .option("--model <model>", "settings.model (e.g. gpt-5.4-mini)")
243
244
  .option("--rename <name>", "rename the agent")
244
- .option("--tool <flag=bool>", "toggle a tool flag (repeatable): woo_order_build/woo_tip_field/woo_order_note_field/view_cart_tool/restock_tool/block_tool_status/postal_code_tool_status/shopify_products_web_chat", agentConfigCmd.collectTool, [])
245
+ .option("--tool <flag=bool>", "toggle a tool flag (repeatable): woo_order_build/woo_tip_field/woo_order_note_field/view_cart_tool/restock_tool/block_tool_status/postal_code_tool_status/shopify_products_web_chat/ticket_tool_status/collapse_product_variants", agentConfigCmd.collectTool, [])
245
246
  .option("--discount [bool]", "agent.discount.enabled (true if bare)")
246
247
  .option("--test-contact-number <number>", "settings.test_contact_number — the contact `flowiq test` uses (use a FAKE number; \"\" clears it → synthetic fallback)")
247
248
  .option("--test-contact-name <name>", "settings.test_contact_name — display name for the test contact (\"\" clears it)")
@@ -426,6 +427,8 @@ export function run(argv) {
426
427
  .option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
427
428
  .option("--header-media <url>", "header image/video/doc URL for a media-header template (default: the template's own stored image)")
428
429
  .option("--python", "with --tag: force the python /meta-broadcast engine (same as the dashboard's Python toggle). NOTE: any tag send >10 recipients ALWAYS uses python automatically")
430
+ .option("--at <when>", "with --tag: SCHEDULE instead of sending now — \"YYYY-MM-DD HH:MM\" in SAST (e.g. --at \"2026-08-05 09:00\"). Fires automatically; audience is resolved at send time")
431
+ .option("--needs-approval", "with --at: park it awaiting approval (flowiq bc scheduled approve) instead of firing automatically")
429
432
  .option("--commit", "actually send (omit to dry-run)")
430
433
  .option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
431
434
  .option("--force-remap", "ignore the saved mapping and rebuild interactively")
@@ -445,6 +448,24 @@ export function run(argv) {
445
448
  .option("--rate <n>", "max messages per second (hard cap 10)", "8")
446
449
  .option("--retry-failed", "also re-attempt rows previously marked failed (confirmed failures only)")
447
450
  .action((orgId, opts) => broadcastCmd.resume(orgId, opts));
451
+ const scheduled = broadcast.command("scheduled")
452
+ .description("Scheduled broadcasts (queue rows): list / approve / cancel");
453
+ scheduled.command("list <organization_id>")
454
+ .description("List scheduled broadcasts. Default: only OPEN rows (pending + awaiting-approval)")
455
+ .option("--all", "include fired / cancelled / failed rows too")
456
+ .option("--status <s>", "filter to one status (pending|request|completed|cancelled|failed)")
457
+ .option("--limit <n>", "max rows (default 25, max 200)")
458
+ .option("--json", "raw JSON")
459
+ .action((orgId, opts) => broadcastCmd.scheduledList(orgId, opts));
460
+ scheduled.command("approve <organization_id> <queue_id>")
461
+ .description("Approve a parked ('request') scheduled broadcast so it will fire")
462
+ .option("--at <when>", "also re-time it — \"YYYY-MM-DD HH:MM\" SAST")
463
+ .action((orgId, queueId, opts) => broadcastCmd.scheduledUpdate(orgId, queueId, "approve", opts));
464
+ scheduled.command("cancel <organization_id> <queue_id>")
465
+ .description("Cancel a scheduled broadcast so it never fires (dry-run unless --confirm)")
466
+ .option("--confirm", "actually cancel")
467
+ .action((orgId, queueId, opts) => broadcastCmd.scheduledUpdate(orgId, queueId, "cancel", opts));
468
+
448
469
  broadcast.command("list-remote <organization_id>")
449
470
  .description("List the org's broadcasts newest-first (full broadcastId + template + status + recipients) — the discovery step for `bc status` / `bc retry`")
450
471
  .option("--limit <n>", "how many to show (default 25, max 200)")
@@ -594,6 +615,45 @@ export function run(argv) {
594
615
  .description("List local keyword snapshots")
595
616
  .action(() => keywordsCmd.list());
596
617
 
618
+ // shopify / woo — READ-ONLY passthrough to the client's own store API.
619
+ // You write the request; creds are resolved server-side and never displayed.
620
+ // stdout is the payload (pipe it to jq); the metadata banner goes to stderr.
621
+ const shopify = program.command("shopify")
622
+ .description("Read-only passthrough to an org's Shopify Admin API (REST + GraphQL). Writes are refused.");
623
+ shopify.command("get <organization_id> <path>")
624
+ .description('GET any Shopify Admin REST endpoint, e.g. `get <org> orders.json --q status=any` (".json" optional)')
625
+ .option("--q <k=v>", "query parameter (repeatable), e.g. --q status=any --q limit=250", storeApiCmd.collectQuery, {})
626
+ .option("--all", "follow Link rel=next and return every page (bounded by --max-pages and a size/time cap)")
627
+ .option("--max-pages <n>", "page limit when using --all (default 10, cap 50)")
628
+ .option("--api-version <ver>", "Shopify Admin API version (default 2024-01)")
629
+ .option("--json", "print the full envelope (metadata + data) instead of just the payload")
630
+ .option("--compact", "single-line JSON")
631
+ .option("--out <file>", "write the JSON to a file instead of stdout")
632
+ .action((orgId, path, opts) => storeApiCmd.shopifyGet(orgId, path, opts));
633
+ shopify.command("gql <organization_id>")
634
+ .description("Run a Shopify Admin GraphQL QUERY (you write it). Any document containing a mutation is refused.")
635
+ .option("-q, --query <graphql>", "the query document, inline")
636
+ .option("--query-file <path>", "read the query document from a file")
637
+ .option("--var <k=v>", "GraphQL variable (repeatable; JSON values parsed)", storeApiCmd.collectVar, {})
638
+ .option("--api-version <ver>", "Shopify Admin API version (default 2024-01)")
639
+ .option("--json", "print the full envelope (metadata + data) instead of just the payload")
640
+ .option("--compact", "single-line JSON")
641
+ .option("--out <file>", "write the JSON to a file instead of stdout")
642
+ .action((orgId, opts) => storeApiCmd.shopifyGql(orgId, opts));
643
+
644
+ const woo = program.command("woo")
645
+ .description("Read-only passthrough to an org's WooCommerce REST API. Writes are refused.");
646
+ woo.command("get <organization_id> <path>")
647
+ .description("GET any WooCommerce REST endpoint, e.g. `get <org> orders --q status=completed`")
648
+ .option("--q <k=v>", "query parameter (repeatable), e.g. --q status=completed --q per_page=100", storeApiCmd.collectQuery, {})
649
+ .option("--namespace <ns>", "REST namespace (default wc/v3; also wc-analytics, wp/v2)")
650
+ .option("--all", "page through every result (bounded by --max-pages and a size/time cap)")
651
+ .option("--max-pages <n>", "page limit when using --all (default 10, cap 50)")
652
+ .option("--json", "print the full envelope (metadata + data) instead of just the payload")
653
+ .option("--compact", "single-line JSON")
654
+ .option("--out <file>", "write the JSON to a file instead of stdout")
655
+ .action((orgId, path, opts) => storeApiCmd.wooGet(orgId, path, opts));
656
+
597
657
  // guide (bundled docs — always match the installed version)
598
658
  program.command("guide")
599
659
  .description("Read the team guide (how we use this CLI); --reference for the full command reference")