@flowapt/flowiq-cli 0.4.4 → 0.4.5

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
@@ -578,6 +578,18 @@ flowiq audit show <audit_id> --out entry.json # dump the whole entry
578
578
  Filters: `--endpoint --action --status --agent --target --user --since --until
579
579
  --limit` (default 50, max 200) `--json`.
580
580
 
581
+ **What gets logged.** Every command that CHANGES something writes a row —
582
+ including two that were missed until 4 Aug 2026 and are now covered:
583
+ `flowiq test` (it creates the test contact, can re-enable a contact's bot, and
584
+ clears the conversation) and `flowiq auth refresh` (it mints a new key and
585
+ revokes the old one). Reads and dry-runs deliberately write nothing.
586
+
587
+ **What is never logged:** the content itself. `messages` / `export chats` /
588
+ `shopify` / `woo` record *who read what*, never a copy of the conversation or
589
+ the store data. `flowiq test` records the prompt you sent, the tools the agent
590
+ called and how long its reply was — not the reply. `auth refresh` records key
591
+ ids and prefixes — never a key.
592
+
581
593
  **What's captured, and how much:**
582
594
 
583
595
  | Surface | Action | Content stored |
@@ -814,8 +826,33 @@ Flags:
814
826
  | `--var <k=v>` | GraphQL variable, repeatable. JSON values are parsed (`--var ids='["1"]'`). |
815
827
  | `--json` | Print the full envelope (metadata + data) instead of just the payload. |
816
828
  | `--compact` | Single-line JSON. |
829
+ | `--table` | Render the returned list as a table instead of JSON. A rendering only — same data, nothing reshaped or filtered. |
817
830
  | `--out <file>` | Write the JSON to a file instead of stdout. |
818
831
 
832
+ #### Two recipes — composed of the SAME calls, and they print them
833
+
834
+ Some questions take 3-4 chained calls and a manual id-join (stock needs
835
+ product → `inventory_item_id` → `inventory_levels` → `locations`). These two do
836
+ that chaining for you — and **echo every raw call they make as a runnable
837
+ command**, so the API stays the interface and you can take those lines further
838
+ than the recipe goes. Nothing here is expressible only through the recipe.
839
+
840
+ ```bash
841
+ flowiq shopify stock <org> "olive oil" # stock per LOCATION NAME, per variant
842
+ flowiq shopify order <org> '#14728' # order + fulfilments + tracking url
843
+ flowiq shopify stock <org> "olive oil" --raw # the underlying JSON instead
844
+ ```
845
+
846
+ ```
847
+ Looking up stock for "1 litre Oil Pourer" — the calls being made:
848
+ $ flowiq shopify get <org> products.json --q title="1 litre Oil Pourer" --q limit=50
849
+ $ flowiq shopify get <org> inventory_levels.json --q inventory_item_ids=45186940764314
850
+ $ flowiq shopify get <org> locations.json --q fields=id,name
851
+ ```
852
+
853
+ `stock` prints **"not tracked"** where Shopify returns `available: null` — that
854
+ means no inventory record exists at that location, which is not the same as zero.
855
+
819
856
  Notes:
820
857
 
821
858
  * **`--all` is bounded and says so.** It stops at `--max-pages`, at a ~3.5MB
@@ -844,6 +881,10 @@ flowiq org create --name "New Client" [--slug new-client] [--owner client@email.
844
881
  # person to already have an account (otherwise invite them later in the app).
845
882
  # Provider defaults to meta. Prints the new org id + the next-step commands.
846
883
 
884
+ flowiq org list # every org + its UUID (the lookup everything else needs)
885
+ flowiq org list african # filter by name or slug
886
+ flowiq org list --all # include inactive orgs
887
+
847
888
  flowiq org info <organization_id> # platform, storefront url, active agent
848
889
  ```
849
890
 
package/TEAM-GUIDE.md CHANGED
@@ -80,6 +80,9 @@ 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
+ | **Find an org's ID** (needed by nearly every command) | `flowiq org list` — or `flowiq org list african` to filter |
84
+ | What's the stock on a product, per branch? | `flowiq shopify stock <org_id> "olive oil"` — shows each location by NAME, and says "not tracked" rather than a confusing 0 |
85
+ | Has this order shipped? What's the tracking? | `flowiq shopify order <org_id> '#14728'` — status, courier, tracking number + link |
83
86
  | 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
87
  | 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
88
  | Ask a client's WooCommerce store anything (read-only) | `flowiq woo get <org_id> orders --q status=completed --all` |
@@ -111,6 +114,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
111
114
  | **Get an OLD version of a prompt back** | `flowiq prompts history <org_id>` (pick the version) → `flowiq prompts restore <org_id> <audit_id>` (dry-run) → `… --commit` |
112
115
  | **See who changed what, and when** | `flowiq audit <org_id>` — add `--endpoint prompts`, `--user <name>`, `--since 2026-07-01` to narrow |
113
116
  | See exactly what a change looked like (before → after) | `flowiq audit show <audit_id> --content` (or `--out entry.json`) |
117
+ | Know what the log does and doesn't keep | Anything that CHANGES something is logged — including `flowiq test` (it creates the test contact and clears conversations) and `flowiq auth refresh`. Reads and dry-runs are not. The log never stores the content itself: chats, store data and agent replies are recorded as "who read what", never copied. |
114
118
  | Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
115
119
  | Export an org's full chat history | `flowiq export chats <org_id>` |
116
120
  | Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.4.4",
3
+ "version": "0.4.5",
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": {
@@ -6,6 +6,37 @@ import { http } from "../http.js";
6
6
 
7
7
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
8
 
9
+ // `flowiq org list [search]` — the discovery step every other command needed.
10
+ // Until this existed the only way to turn "African Oils" into a UUID was the
11
+ // dashboard or raw SQL, which made every org-scoped command awkward to start.
12
+ export async function list(search, opts = {}) {
13
+ let resp;
14
+ try {
15
+ resp = await http.get("org", { list: "1", search: search || undefined });
16
+ } catch (e) {
17
+ console.error(`Lookup failed: ${e.message}`);
18
+ process.exit(1);
19
+ }
20
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
21
+
22
+ const orgs = (resp.orgs || []).filter((o) => (opts.all ? true : !o.inactive));
23
+ if (!orgs.length) {
24
+ console.log(search ? `No organizations match "${search}".` : "No organizations found.");
25
+ return;
26
+ }
27
+ const pad = (s, n) => String(s ?? "").padEnd(n);
28
+ const nameW = Math.min(34, Math.max(4, ...orgs.map((o) => (o.name || "").length)));
29
+ console.log(`${pad("NAME", nameW)} ${pad("ID", 36)} ${pad("PLATFORM", 9)}STORE`);
30
+ for (const o of orgs) {
31
+ console.log(
32
+ `${pad((o.name || "").slice(0, nameW), nameW)} ${pad(o.id, 36)} ` +
33
+ `${pad(o.platform || "—", 9)}${o.store || ""}${o.inactive ? " (inactive)" : ""}`
34
+ );
35
+ }
36
+ const hidden = (resp.orgs || []).length - orgs.length;
37
+ console.log(`\n${orgs.length} organization(s)${hidden ? ` · ${hidden} inactive hidden (--all to show)` : ""}`);
38
+ }
39
+
9
40
  export async function info(orgId, opts = {}) {
10
41
  if (!UUID_RE.test(orgId)) {
11
42
  console.error(`Error: "${orgId}" is not a valid organization UUID.`);
@@ -103,9 +103,59 @@ function banner(resp) {
103
103
  }
104
104
  }
105
105
 
106
+ /**
107
+ * Print a flat array of objects as a table. Only ever a RENDERING of whatever
108
+ * the API returned — it never changes, filters or reshapes the data, so
109
+ * `--table` and the default JSON are the same answer in two skins.
110
+ */
111
+ function renderTable(rows) {
112
+ if (!Array.isArray(rows) || rows.length === 0) return null;
113
+ const flat = rows.map((r) => {
114
+ if (r === null || typeof r !== "object" || Array.isArray(r)) return { value: r };
115
+ const o = {};
116
+ for (const [k, v] of Object.entries(r)) {
117
+ o[k] = v === null || v === undefined ? ""
118
+ : typeof v === "object" ? (Array.isArray(v) ? `[${v.length}]` : "{…}")
119
+ : String(v);
120
+ }
121
+ return o;
122
+ });
123
+ const cols = [...new Set(flat.flatMap((r) => Object.keys(r)))];
124
+ if (!cols.length) return null;
125
+ const w = {};
126
+ for (const c of cols) w[c] = Math.min(48, Math.max(c.length, ...flat.map((r) => (r[c] ?? "").length)));
127
+ const cut = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s.padEnd(n));
128
+ const lines = [cols.map((c) => cut(c.toUpperCase(), w[c])).join(" ")];
129
+ lines.push(cols.map((c) => "─".repeat(w[c])).join(" "));
130
+ for (const r of flat) lines.push(cols.map((c) => cut(r[c] ?? "", w[c])).join(" "));
131
+ return lines.join("\n");
132
+ }
133
+
134
+ /** The single array in a Shopify-style `{orders: [...]}` wrapper, or the array itself. */
135
+ function primaryArray(data) {
136
+ if (Array.isArray(data)) return data;
137
+ if (data && typeof data === "object") {
138
+ const keys = Object.keys(data).filter((k) => Array.isArray(data[k]));
139
+ if (keys.length === 1) return data[keys[0]];
140
+ }
141
+ return null;
142
+ }
143
+
106
144
  function emit(resp, opts) {
107
145
  banner(resp);
108
146
  const payload = opts.json ? resp : resp.data;
147
+
148
+ if (opts.table && !opts.json) {
149
+ const rows = primaryArray(resp.data);
150
+ const table = rows ? renderTable(rows) : renderTable([resp.data]);
151
+ if (table) {
152
+ if (opts.out) { writeFileSync(opts.out, table + "\n"); console.error(`✓ wrote ${opts.out}`); return; }
153
+ console.log(table);
154
+ return;
155
+ }
156
+ console.error("(--table: response is not a flat list; printing JSON)");
157
+ }
158
+
109
159
  const text = JSON.stringify(payload, null, opts.compact ? 0 : 2);
110
160
  if (opts.out) {
111
161
  writeFileSync(opts.out, text);
@@ -157,6 +207,154 @@ export async function shopifyGql(orgId, opts = {}) {
157
207
  }, opts);
158
208
  }
159
209
 
210
+ // ---------------------------------------------------------------------------
211
+ // Recipes — NOT wrappers.
212
+ //
213
+ // These answer the two questions people actually ask ("what's the stock on X",
214
+ // "did this order ship") which otherwise take 3-4 chained calls and a manual
215
+ // id-join. They are composed of THE SAME `shopify get` passthrough calls you
216
+ // could type yourself, and they PRINT EVERY CALL THEY MAKE as a runnable
217
+ // command — so the raw API stays the interface, and anyone (or any agent)
218
+ // reading the output can copy those lines, change them, and go further than
219
+ // the recipe does. Nothing here is expressible only through the recipe.
220
+ // `--raw` dumps the underlying JSON instead of the summary.
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /** Run a passthrough GET, echoing the equivalent command so it can be reused. */
224
+ async function rawGet(orgId, path, query, { label } = {}) {
225
+ const qs = Object.entries(query || {})
226
+ .flatMap(([k, v]) => (Array.isArray(v) ? v.map((x) => [k, x]) : [[k, v]]))
227
+ .map(([k, v]) => `--q ${k}=${/[ "']/.test(String(v)) ? JSON.stringify(String(v)) : v}`)
228
+ .join(" ");
229
+ console.error(` $ flowiq shopify get <org> ${path}${qs ? " " + qs : ""}${label ? ` # ${label}` : ""}`);
230
+ const resp = await http.post("store-api", {
231
+ platform: "shopify", organization_id: orgId, mode: "rest", path, query: query || {},
232
+ });
233
+ return resp.data;
234
+ }
235
+
236
+ export async function shopifyStock(orgId, search, opts = {}) {
237
+ requireOrg(orgId);
238
+ if (!search || !String(search).trim()) {
239
+ console.error('Error: give me something to look for, e.g. flowiq shopify stock <org> "olive oil"');
240
+ process.exit(1);
241
+ }
242
+ console.error(`Looking up stock for "${search}" — the calls being made:`);
243
+ let products, levels, locations;
244
+ try {
245
+ // 1. name → products + variants (variants carry inventory_item_id)
246
+ const p = await rawGet(orgId, "products.json", { title: search, limit: 50 }, { label: "find the product" });
247
+ products = (p?.products || []).filter((x) =>
248
+ String(x.title).toLowerCase().includes(String(search).toLowerCase()));
249
+ if (!products.length) {
250
+ const all = await rawGet(orgId, "products.json", { limit: 250, fields: "id,title,variants,status" },
251
+ { label: "no title match — scanning the catalogue" });
252
+ products = (all?.products || []).filter((x) =>
253
+ String(x.title).toLowerCase().includes(String(search).toLowerCase()));
254
+ }
255
+ if (!products.length) {
256
+ console.error(`\nNothing matched "${search}". This searches product TITLES only — ` +
257
+ `the passthrough can query anything else Shopify exposes.`);
258
+ process.exit(1);
259
+ }
260
+ const itemIds = products.flatMap((x) => (x.variants || []).map((v) => v.inventory_item_id)).filter(Boolean);
261
+ // 2. inventory_item_id → per-location levels 3. location_id → names
262
+ levels = itemIds.length
263
+ ? (await rawGet(orgId, "inventory_levels.json",
264
+ { inventory_item_ids: itemIds.slice(0, 50).join(","), limit: 250 },
265
+ { label: "per-location levels" }))?.inventory_levels || []
266
+ : [];
267
+ locations = (await rawGet(orgId, "locations.json", { fields: "id,name" },
268
+ { label: "turn location ids into names" }))?.locations || [];
269
+ } catch (e) {
270
+ console.error(`\nRequest failed: ${e.message}`);
271
+ process.exit(1);
272
+ }
273
+
274
+ if (opts.raw) { console.log(JSON.stringify({ products, inventory_levels: levels, locations }, null, 2)); return; }
275
+
276
+ const locName = new Map(locations.map((l) => [l.id, l.name]));
277
+ const byItem = new Map();
278
+ for (const l of levels) {
279
+ if (!byItem.has(l.inventory_item_id)) byItem.set(l.inventory_item_id, []);
280
+ byItem.get(l.inventory_item_id).push(l);
281
+ }
282
+ const rows = [];
283
+ for (const p of products) {
284
+ for (const v of p.variants || []) {
285
+ const ls = byItem.get(v.inventory_item_id) || [];
286
+ const per = ls.map((l) => {
287
+ // `available: null` is Shopify saying "this item is not TRACKED at this
288
+ // location" — not zero, and not an error. Say so, rather than printing
289
+ // null and letting the reader guess.
290
+ const qty = l.available === null || l.available === undefined ? "not tracked" : String(l.available);
291
+ return `${locName.get(l.location_id) || l.location_id}: ${qty}`;
292
+ });
293
+ rows.push({
294
+ product: p.title,
295
+ variant: v.title === "Default Title" ? "—" : v.title,
296
+ sku: v.sku || "",
297
+ price: v.price ?? "",
298
+ policy: v.inventory_policy === "continue" ? "oversell ok" : "stop at 0",
299
+ total: v.inventory_quantity ?? "",
300
+ per_location: per.length ? per.join(" · ") : "(no level rows — untracked)",
301
+ });
302
+ }
303
+ }
304
+ console.error("");
305
+ console.log(renderTable(rows) ?? "(no variants)");
306
+ console.error(`\n${products.length} product(s) · ${rows.length} variant(s). ` +
307
+ `"not tracked" means Shopify holds no inventory record at that location — it is not a zero.`);
308
+ }
309
+
310
+ export async function shopifyOrder(orgId, ref, opts = {}) {
311
+ requireOrg(orgId);
312
+ const name = String(ref).trim();
313
+ console.error(`Looking up order ${name} — the calls being made:`);
314
+ let order;
315
+ try {
316
+ if (/^\d{10,}$/.test(name)) {
317
+ order = (await rawGet(orgId, `orders/${name}.json`, {}, { label: "by Shopify order id" }))?.order;
318
+ } else {
319
+ const r = await rawGet(orgId, "orders.json",
320
+ { name, status: "any", limit: 5 }, { label: "by order name" });
321
+ order = (r?.orders || [])[0];
322
+ }
323
+ } catch (e) {
324
+ console.error(`\nRequest failed: ${e.message}`);
325
+ process.exit(1);
326
+ }
327
+ if (!order) {
328
+ console.error(`\nNo order matching "${name}". Note Shopify matches the ORDER NAME (#14728), not the ` +
329
+ `confirmation number customers are shown on the thank-you page.`);
330
+ process.exit(1);
331
+ }
332
+ if (opts.raw) { console.log(JSON.stringify(order, null, 2)); return; }
333
+
334
+ const f = order.fulfillments || [];
335
+ console.error("");
336
+ console.log(`${order.name} ${order.financial_status || "?"} / ${order.fulfillment_status || "unfulfilled"}`);
337
+ console.log(` placed ${order.created_at}`);
338
+ console.log(` total ${order.currency} ${order.total_price}`);
339
+ console.log(` customer ${[order.customer?.first_name, order.customer?.last_name].filter(Boolean).join(" ") || "—"}` +
340
+ `${order.email ? ` · ${order.email}` : ""}`);
341
+ console.log(` items ${(order.line_items || []).map((li) => `${li.quantity}× ${li.title}`).join(", ") || "—"}`);
342
+ if (!f.length) {
343
+ console.log(` shipping nothing fulfilled yet`);
344
+ } else {
345
+ for (const ff of f) {
346
+ console.log(` shipment ${ff.status}` +
347
+ ` · ${ff.tracking_company || "no carrier recorded"}` +
348
+ ` · ${ff.tracking_number || "no tracking number"}` +
349
+ ` · courier status: ${ff.shipment_status || "none reported"}` +
350
+ ` · ${(ff.line_items || []).length} item(s)`);
351
+ if (ff.tracking_url) console.log(` ${ff.tracking_url}`);
352
+ }
353
+ }
354
+ console.error(`\nFull payload: add --raw (or use the passthrough directly — ` +
355
+ `flowiq shopify get <org> orders/${order.id}.json)`);
356
+ }
357
+
160
358
  // ---------------------------------------------------------------------------
161
359
  // woo
162
360
  // ---------------------------------------------------------------------------
package/src/index.js CHANGED
@@ -211,6 +211,11 @@ export function run(argv) {
211
211
 
212
212
  // org (read-only org summary for the prompt-builder skill, creds stripped)
213
213
  const org = program.command("org").description("Org info (read) + create a new organization");
214
+ org.command("list [search]")
215
+ .description("List organizations with their UUIDs — the lookup every other command needs (filter by name/slug)")
216
+ .option("--all", "include inactive organizations")
217
+ .option("--json", "raw JSON")
218
+ .action((search, opts) => orgCmd.list(search, opts));
214
219
  org.command("create")
215
220
  .description("Create a NEW organization (org row + admin membership; owner defaults to you)")
216
221
  .requiredOption("--name <name>", "organization name")
@@ -627,9 +632,20 @@ export function run(argv) {
627
632
  .option("--max-pages <n>", "page limit when using --all (default 10, cap 50)")
628
633
  .option("--api-version <ver>", "Shopify Admin API version (default 2024-01)")
629
634
  .option("--json", "print the full envelope (metadata + data) instead of just the payload")
635
+ .option("--table", "render the returned list as a table instead of JSON (same data, no reshaping)")
630
636
  .option("--compact", "single-line JSON")
631
637
  .option("--out <file>", "write the JSON to a file instead of stdout")
632
638
  .action((orgId, path, opts) => storeApiCmd.shopifyGet(orgId, path, opts));
639
+ // Recipes: the same passthrough calls, composed — and every call is PRINTED
640
+ // as a runnable command so the raw API stays the interface.
641
+ shopify.command("stock <organization_id> <search>")
642
+ .description("Stock per location for products matching a title — chains products → inventory_levels → locations and PRINTS each raw call it makes")
643
+ .option("--raw", "print the underlying JSON from every call instead of the summary table")
644
+ .action((orgId, search, opts) => storeApiCmd.shopifyStock(orgId, search, opts));
645
+ shopify.command("order <organization_id> <name-or-id>")
646
+ .description("One order with its fulfilments + tracking, e.g. `order <org> #14728` — PRINTS the raw call it makes")
647
+ .option("--raw", "print the full order JSON instead of the summary")
648
+ .action((orgId, ref, opts) => storeApiCmd.shopifyOrder(orgId, ref, opts));
633
649
  shopify.command("gql <organization_id>")
634
650
  .description("Run a Shopify Admin GraphQL QUERY (you write it). Any document containing a mutation is refused.")
635
651
  .option("-q, --query <graphql>", "the query document, inline")
@@ -637,6 +653,7 @@ export function run(argv) {
637
653
  .option("--var <k=v>", "GraphQL variable (repeatable; JSON values parsed)", storeApiCmd.collectVar, {})
638
654
  .option("--api-version <ver>", "Shopify Admin API version (default 2024-01)")
639
655
  .option("--json", "print the full envelope (metadata + data) instead of just the payload")
656
+ .option("--table", "render the returned list as a table instead of JSON (same data, no reshaping)")
640
657
  .option("--compact", "single-line JSON")
641
658
  .option("--out <file>", "write the JSON to a file instead of stdout")
642
659
  .action((orgId, opts) => storeApiCmd.shopifyGql(orgId, opts));
@@ -650,6 +667,7 @@ export function run(argv) {
650
667
  .option("--all", "page through every result (bounded by --max-pages and a size/time cap)")
651
668
  .option("--max-pages <n>", "page limit when using --all (default 10, cap 50)")
652
669
  .option("--json", "print the full envelope (metadata + data) instead of just the payload")
670
+ .option("--table", "render the returned list as a table instead of JSON (same data, no reshaping)")
653
671
  .option("--compact", "single-line JSON")
654
672
  .option("--out <file>", "write the JSON to a file instead of stdout")
655
673
  .action((orgId, path, opts) => storeApiCmd.wooGet(orgId, path, opts));