@flowapt/flowiq-cli 0.7.0 → 0.7.2

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
@@ -188,6 +188,7 @@ flowiq ct list
188
188
  - **Always `--dry-run` first** on a production agent — it prints added/removed/changed tool names before you commit.
189
189
  - **Destructive pushes are blocked by default**: a push that removes a tool, disables one, or sends an empty `tools[]` (wiping everything) writes nothing and shows you exactly what it would strip — re-run with `--confirm` if intentional. Additive/no-op pushes are unaffected.
190
190
  - Server-side validation is strict: tool `name` (`^[a-zA-Z0-9_-]{1,64}$`, no duplicates or built-in collisions), non-empty `description`, `http(s)` `endpoint`, method `GET/POST/PUT/PATCH/DELETE`, scalar headers, object-typed `parameters`/`injected_parameters`, valid auth/channels, timeout and response caps. Recursive schemas support strict nested objects, typed arrays, enums and numeric/string/item limits. Unknown tool/schema keys are rejected before anything writes.
191
+ - **`display_name` (optional, 10 Sep 2026):** the label the TEAM sees on the inbox tool-activity card (e.g. `Checked loyalty points`) — plain string, ≤80 chars, never sent to the AI. Leave it out and the card derives a label from the tool name.
191
192
  - **Warnings (non-blocking):** an object with properties but no required fields. Unknown `{{placeholder}}` values are hard errors; injected parameters may additionally reference a declared top-level model parameter. Known runtime values include `organization_id`, `contact_id`, `agent_id`, `contact_whatsapp_id`, `contact_full_name`, `whatsapp_message_id`, `text`, `unique_message_id`, `unix_timestamp`, `operation_idempotency_key`, `supabase_anon_key`, and `openai_api_key`.
192
193
  - **Retailer gateway credential:** `{{retailer_tools_internal_key}}` is super-admin-only and resolves only as the `x-api-key` value for `https://express.chatcart.io/retailer-tools/*` (or loopback in local tests). Validation and runtime both reject putting it in a body or sending it to any other host.
193
194
  - `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
@@ -1054,7 +1055,9 @@ flowiq hours summary # every org with logged work this
1054
1055
 
1055
1056
  ### Client deck — `flowiq report deck status|build|narrate|generate|inputs|approve|unapprove|send|print|pull` (v0.6.5)
1056
1057
 
1057
- The monthly 10-slide client deck (`/reporting/deck` in the app), driven from the
1058
+ The monthly client deck (`/reporting/deck` in the app; 10 slides, 11 for a store
1059
+ org — slide 8 is store health: cart abandonment, returning buyers and opt-in
1060
+ growth, online store only), driven from the
1058
1061
  terminal. Everything runs server-side through the `flowiq-reporting-deck` edge
1059
1062
  function: the metrics are built from the FROZEN month snapshot, the copy is
1060
1063
  written by the model under the report rules and a numeric guard (every figure
@@ -1077,11 +1080,15 @@ flowiq report deck pull <org_id> 2026-08 # → ./.flowiq/repor
1077
1080
  ```
1078
1081
 
1079
1082
  - Months freeze on the 1st (cron 82); a month with no snapshot cannot be built.
1080
- Comparisons need the prior month's deck, which `build` creates on the fly.
1083
+ Comparisons need the prior month's deck, which `build` creates on the fly (and rebuilds when it
1084
+ predates the current build logic, unless it is approved, sent or held).
1085
+ - Send fees are estimated at the category each template had on its send day. A send whose category
1086
+ cannot be known shows no fee and is left out of the broadcast return (`totals.unpriced_sends`).
1081
1087
  - `inputs.json` shape: `{ "changes": { "<update_id>": { "hidden": false, "tag": "client_raised|flowapt_shipped", "title": "…", "description": "…" } }, "changes_reviewed": true, "action_points": { "<title>": "done|in_progress|waiting_on_you|ongoing|dropped" }, "waiting_on_you": [{ "title": "…", "unlocks": "…" }], "recipients_confirmed": true }`.
1082
1088
  Free text (milestone, the four plan fields) is edited in the app; it is stored as overrides that survive `narrate`.
1083
1089
  - `send --client` needs an approved deck AND `report.email.recipients` in the org's reporting config (Control center → Report delivery); it marks the deck `sent`. `--test-to` never changes status. Fees on the slides are the org's actual Meta billing when the token can read it, otherwise the rate-card estimate, and the footnote says which.
1084
1090
  - Non-store orgs need `config.deck.outcome` (`source: handover | ticket_status | tag | keyword`, labels) — the revenue slides become outcome slides. Every verb except `status` and `pull` is audited.
1091
+ - Store orgs get slide 8, "Store health": cart abandonment (online checkouts started, bought, abandoned), first-time vs returning buyers and opt-in growth, each with a twelve-month strip and measured on the online store checkout only (till and other channels are named in the footer). `status --json` carries the headline figures under `store`; `config.deck.channel_labels {"<source_name>": "Label"}` renames a sales channel on the slide.
1085
1092
 
1086
1093
  ### Insights — `flowiq insights status|enable|disable|run` (v0.7.0)
1087
1094
 
@@ -1131,6 +1138,54 @@ flowiq insights run <organization_id> --from 2026-09-01 --to 2026-09-07 --store-
1131
1138
  schedule for that period, else standard.
1132
1139
  - `enable` / `disable` / `run` are audited (`flowiq audit --endpoint insights`).
1133
1140
 
1141
+ ### Team updates — `flowiq updates status|uncovered|draft|asset|preview|send` (v0.7.1)
1142
+
1143
+ The **"what changed in FlowIQ" email to the Flowapt team** — every super admin
1144
+ gets it. An *issue* is a JSON file composed from `platform_changelog` rows:
1145
+ headline sections (a screenshot straight from the app, what it does, and a
1146
+ **For the team** note: how we use it, what to tell clients), an *Also shipped*
1147
+ list, and a *CLI and MCP* block. The renderer, the send (from
1148
+ flowiq@flowapt.com, reply-to Matt) and the record (`team_updates`, with the exact
1149
+ HTML that went out) live server-side in the `team-update-email` edge function.
1150
+
1151
+ ```bash
1152
+ flowiq updates status # uncovered changelog rows · recent sends · recipients
1153
+ flowiq updates uncovered # the rows no issue has emailed yet
1154
+ flowiq updates draft # → ./.flowiq/updates/<date>.json, a default issue to enrich
1155
+ flowiq updates draft --since 2026-09-07T22:00:00+02:00 # compose from a period instead of the last covered point
1156
+ flowiq updates asset ./api-keys-card.png # host a screenshot, prints the https URL for image.url
1157
+ flowiq updates preview .flowiq/updates/2026-09-10.json --open # render to .html exactly as emailed
1158
+ flowiq updates send .flowiq/updates/2026-09-10.json --test # one copy to YOU; nothing marked covered
1159
+ flowiq updates send .flowiq/updates/2026-09-10.json --test --to kiah@flowapt.com
1160
+ flowiq updates send .flowiq/updates/2026-09-10.json --yes # the real send, to every super admin
1161
+ ```
1162
+
1163
+ - **Issue shape** (`issue` key in the file): `subject`, `preheader`, `title`,
1164
+ `intro`, `date_label`, `headlines[]`, `also[]`, `tools[]`, `closing`,
1165
+ `changelog_ids[]`. A headline/tool section: `type` (feature | improvement |
1166
+ fix | ui | agent | cli | infra | db | mcp — the chip is the app's own), `major`,
1167
+ `title`, `what[]` (paragraphs), `team[]` (bullets), `image{url,alt,caption}`,
1168
+ `terminal{command,output}`, `link{label,url}`, `org_name`, `version`. An
1169
+ `also[]` item: `type`, `title`, `note`, `link`. Inline markup anywhere:
1170
+ `**bold**`, `` `code` ``, `[label](https://…)`.
1171
+ - **`preview` and `send` lint the file first** (required fields, unknown types,
1172
+ non-https images, em dashes) and stop with line-level help.
1173
+ - **`send` without `--test` needs `--yes`**: it emails the whole team AND moves
1174
+ the covered-up-to mark, so those changelog rows will never be auto-digested.
1175
+ `--test` sends to you (or `--to`) and marks nothing.
1176
+ - **The safety net:** pg_cron `team-update-email-daily` (08:30 SAST) emails a
1177
+ default digest of any changelog row that has gone un-emailed for 20h+, so a
1178
+ ship is never silently missed. The curated `send` is what stops it firing —
1179
+ compose the issue the same day you ship.
1180
+ - **Who may do what:** everyone with a staff key can `status`, `uncovered`,
1181
+ `draft`, `asset`, `preview` and `send --test` (a copy to yourself, nothing
1182
+ marked covered). **The real send is Matt only** — any other key gets
1183
+ `403 Only matt@flowapt.com may post the team update` and the attempt is
1184
+ audited (`ALLOWED_SENDERS` in `api/cli/team-updates.js`).
1185
+ - Every link in the email opens the app's Changelog at that entry
1186
+ (`app.flowiq.live/?changelog=1&entry=<id>`). `send` and `asset` are audited
1187
+ (`flowiq audit --endpoint team-updates`).
1188
+
1134
1189
  ### WhatsApp templates — `flowiq templates pull|list|show|create|status` (alias `tpl`)
1135
1190
 
1136
1191
  Read an org's live templates straight from Meta (read-only), render any single
@@ -1387,7 +1442,10 @@ flowiq org flags keys --section "members"
1387
1442
  response prints before → after plus the exact undo command.
1388
1443
  - A key that is on the org but not in the registry is shown by `show` (and
1389
1444
  `--json`) but cannot be set here — it is DB-only by design; add it to the
1390
- Profile tab first.
1445
+ Profile tab first. **The registry is ONE shared file** (`api/_shared/feature-flag-registry.js`)
1446
+ that the Profile tab imports too, and every build checks the two agree — so a
1447
+ new Profile-tab switch is settable from the CLI the moment it deploys, with no
1448
+ npm publish.
1391
1449
 
1392
1450
  ### Agents — `flowiq agent list|create <org>`
1393
1451
 
package/TEAM-GUIDE.md CHANGED
@@ -81,6 +81,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
81
81
  | Edit the text knowledge sources (get_more_answers playbooks) | `flowiq kn pull <org_id>` → edit `sources[]` → `flowiq kn push <slug>` |
82
82
  | Edit the custom tools (API-call tools) | `flowiq ct pull <org_id>` → edit `tools[]` → `flowiq ct push <slug> --dry-run` → `flowiq ct push <slug>` |
83
83
  | Turn one custom tool on/off | `flowiq ct enable\|disable <org_id> <tool_name>` |
84
+ | Give a custom tool a friendly name on the inbox tool-activity card | add `"display_name": "Checked loyalty points"` to that tool in `tools[]` → `flowiq ct push` (≤80 chars, never shown to the AI; empty = derived from the tool name) |
84
85
  | 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>` |
85
86
  | 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 |
86
87
  | 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 |
@@ -103,6 +104,9 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
103
104
  | Agent can't quote ANY price / "I can't pull the live menu" | `flowiq agent config <org_id> --tool product_lookup=true` — name-based fuzzy lookup that works without embeddings or a live OpenAI key (semantic `get_product_info` needs both) |
104
105
  | Client wants leads/requests emailed to their team instead of a human escalation | `flowiq agent config <org_id> --tool email_request_tool=true`, then set the recipient list in Agents → Tool Library → Email Request to Team (no CLI path for the addresses yet) |
105
106
  | Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
107
+ | **Tell the team what shipped** (the FlowIQ team update email) | `flowiq updates draft` → enrich the JSON (screenshots via `flowiq updates asset`, a *For the team* note per headline) → `flowiq updates preview <file> --open` → `flowiq updates send <file> --test` → `flowiq updates send <file> --yes` |
108
+ | Can I use `flowiq updates`? | Yes: `status`, `uncovered`, `draft`, `preview` and `send --test` (a copy to yourself) work for everyone. Only the real `send --yes` to the whole team is Matt's; anyone else gets a 403 there |
109
+ | 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 |
106
110
  | Set which contact `flowiq test` uses (use a FAKE number!) | `flowiq agent config <org_id> --test-contact-number 27000000001 --test-contact-name "QA Bot"` |
107
111
  > **Publishing the CLI (maintainers only):** publish from a clean clone, never
108
112
  > from your working tree — `npm publish` packs whatever is on disk. A
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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,169 @@
1
+ // `flowiq updates <verb>` — the "what changed in FlowIQ" email to the Flowapt team.
2
+ // status uncovered changelog rows, last sends, who receives it
3
+ // uncovered list the changelog rows no issue has covered yet
4
+ // draft [--out f] write ./.flowiq/updates/<date>.json — a default issue
5
+ // composed from the uncovered rows, ready to enrich
6
+ // (screenshots, "for the team" notes, a CLI block)
7
+ // asset <file> host an image for the email; prints the public URL
8
+ // preview <file> render the issue to <file>.html (--open shows it)
9
+ // send <file> --test → one copy to you (or --to a@b) without
10
+ // marking anything covered; without --test the issue
11
+ // goes to every super admin and needs --yes
12
+ // The renderer, the send and the record all live server-side
13
+ // (/cli/team-updates → the team-update-email edge fn); every send is audited.
14
+
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+ import { spawn } from "node:child_process";
18
+ import { http } from "../http.js";
19
+
20
+ const DIR = path.resolve(process.cwd(), ".flowiq", "updates");
21
+ const TYPES = ["feature", "improvement", "fix", "ui", "agent", "cli", "infra", "db", "mcp"];
22
+
23
+ function sastStamp() {
24
+ return new Date().toLocaleDateString("en-CA", { timeZone: "Africa/Johannesburg" }); // YYYY-MM-DD
25
+ }
26
+
27
+ async function readIssue(file) {
28
+ const p = path.resolve(process.cwd(), file);
29
+ let raw;
30
+ try { raw = await fs.readFile(p, "utf8"); }
31
+ catch { console.error(`Error: cannot read ${p}`); process.exit(1); }
32
+ let doc;
33
+ try { doc = JSON.parse(raw); }
34
+ catch (e) { console.error(`Error: ${p} is not valid JSON (${e.message})`); process.exit(1); }
35
+ const issue = doc.issue && typeof doc.issue === "object" ? doc.issue : doc;
36
+ const problems = lint(issue);
37
+ if (problems.length) {
38
+ console.error(`Error: ${p} has ${problems.length} problem(s):`);
39
+ for (const m of problems) console.error(` - ${m}`);
40
+ process.exit(1);
41
+ }
42
+ return { issue, file: p };
43
+ }
44
+
45
+ /** The same checks the server applies, so a bad file fails here with line-level help. */
46
+ function lint(issue) {
47
+ const out = [];
48
+ if (!issue.subject) out.push("subject is required");
49
+ if (!issue.title) out.push("title is required");
50
+ const secs = [...(issue.headlines ?? []).map((s, i) => [`headlines[${i}]`, s]), ...(issue.tools ?? []).map((s, i) => [`tools[${i}]`, s])];
51
+ for (const [where, s] of secs) {
52
+ if (!s.title) out.push(`${where}: title is required`);
53
+ if (s.type && !TYPES.includes(s.type)) out.push(`${where}: type '${s.type}' is not one of ${TYPES.join("|")}`);
54
+ if (s.image && !/^https:\/\//.test(s.image.url || "")) out.push(`${where}: image.url must be https (host it with \`flowiq updates asset\`)`);
55
+ if (s.what && !Array.isArray(s.what)) out.push(`${where}: what must be an array of paragraphs`);
56
+ if (s.team && !Array.isArray(s.team)) out.push(`${where}: team must be an array of bullets`);
57
+ }
58
+ (issue.also ?? []).forEach((a, i) => {
59
+ if (!a.title) out.push(`also[${i}]: title is required`);
60
+ if (a.type && !TYPES.includes(a.type)) out.push(`also[${i}]: type '${a.type}' is not valid`);
61
+ });
62
+ const text = JSON.stringify(issue);
63
+ if (/—/.test(text)) out.push("contains an em dash (—) — house style is no em dashes in anything a teammate reads");
64
+ return out;
65
+ }
66
+
67
+ function fmtWhen(iso) {
68
+ return new Date(iso).toLocaleString("en-GB", { timeZone: "Africa/Johannesburg", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" });
69
+ }
70
+
71
+ export async function status(opts) {
72
+ let r;
73
+ try { r = await http.get("team-updates"); }
74
+ catch (e) { console.error(`Status failed: ${e.message}`); process.exit(1); }
75
+ if (opts?.json) { console.log(JSON.stringify(r, null, 2)); return; }
76
+ console.log(`Team updates`);
77
+ console.log(` uncovered: ${r.uncovered} changelog row(s) not yet emailed${r.covered_until ? ` (covered up to ${fmtWhen(r.covered_until)} SAST)` : " (nothing has ever been sent)"}`);
78
+ console.log(` recipients: ${(r.recipients ?? []).map((x) => x.email).join(", ") || "none"}`);
79
+ if (Array.isArray(r.recent) && r.recent.length) {
80
+ console.log(` recent:`);
81
+ for (const s of r.recent) console.log(` ${fmtWhen(s.created_at)} ${s.status.padEnd(6)} ${s.source.padEnd(9)} ${String(s.subject).slice(0, 70)} → ${s.recipients?.length ?? 0}`);
82
+ }
83
+ }
84
+
85
+ export async function uncovered(opts) {
86
+ let r;
87
+ try { r = await http.get("team-updates", { uncovered: 1, limit: opts?.limit ?? 60 }); }
88
+ catch (e) { console.error(`Failed: ${e.message}`); process.exit(1); }
89
+ if (opts?.json) { console.log(JSON.stringify(r, null, 2)); return; }
90
+ if (!r.rows?.length) { console.log("Nothing uncovered — every changelog row has been emailed."); return; }
91
+ console.log(`${r.rows.length} uncovered row(s)${r.since ? ` since ${fmtWhen(r.since)} SAST` : ""}:`);
92
+ for (const row of r.rows) console.log(` ${fmtWhen(row.created_at)} ${row.type.padEnd(11)} ${row.is_major ? "MAJOR " : " "} ${row.title}${row.org_name ? ` [${row.org_name}]` : ""}`);
93
+ }
94
+
95
+ export async function draft(opts) {
96
+ let r;
97
+ try { r = await http.get("team-updates", { draft: 1, limit: opts?.limit ?? 60, since: opts?.since }); }
98
+ catch (e) { console.error(`Draft failed: ${e.message}`); process.exit(1); }
99
+ if (!r.count) { console.log("Nothing to draft — every changelog row has been emailed. Use --since <iso> to re-cover a period."); return; }
100
+ await fs.mkdir(DIR, { recursive: true });
101
+ const out = path.resolve(process.cwd(), opts?.out || path.join(DIR, `${sastStamp()}.json`));
102
+ const doc = {
103
+ _help: [
104
+ "This is a default issue composed from the uncovered changelog rows. Enrich it, then `flowiq updates preview` / `send`.",
105
+ "headlines[]: type, major, title, what[] (paragraphs), team[] (bullets: how WE use it / what to tell clients), image{url,alt,caption} (host with `flowiq updates asset`), terminal{command,output}, link{label,url}.",
106
+ "also[]: short rows (title, note, type). tools[]: CLI / MCP sections, same shape as headlines.",
107
+ "Inline markup in any text: **bold**, `code`, [label](https://…). No emoji, no em dashes.",
108
+ ],
109
+ issue: r.issue,
110
+ };
111
+ await fs.writeFile(out, JSON.stringify(doc, null, 2) + "\n");
112
+ console.log(`Drafted ${r.count} row(s) → ${path.relative(process.cwd(), out)}`);
113
+ console.log(` headlines ${r.issue.headlines?.length ?? 0} · also ${r.issue.also?.length ?? 0} · cli/mcp ${r.issue.tools?.length ?? 0}`);
114
+ console.log(` next: enrich it, then \`flowiq updates preview ${path.relative(process.cwd(), out)} --open\``);
115
+ }
116
+
117
+ export async function asset(file, opts) {
118
+ const p = path.resolve(process.cwd(), file);
119
+ let buf;
120
+ try { buf = await fs.readFile(p); }
121
+ catch { console.error(`Error: cannot read ${p}`); process.exit(1); }
122
+ const ext = path.extname(p).toLowerCase();
123
+ const type = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif" }[ext];
124
+ if (!type) { console.error(`Error: ${ext || "(no extension)"} is not an image type the email can use (png, jpg, webp, gif)`); process.exit(1); }
125
+ let r;
126
+ try { r = await http.post("team-updates", { action: "asset", name: opts?.name || path.basename(p), content_type: type, data_base64: buf.toString("base64") }); }
127
+ catch (e) { console.error(`Upload failed: ${e.message}`); process.exit(1); }
128
+ console.log(r.url);
129
+ }
130
+
131
+ export async function preview(file, opts) {
132
+ const { issue, file: p } = await readIssue(file);
133
+ let r;
134
+ try { r = await http.post("team-updates", { action: "preview", issue, to_name: opts?.as || "Matt" }); }
135
+ catch (e) { console.error(`Preview failed: ${e.message}`); process.exit(1); }
136
+ const out = p.replace(/\.json$/i, "") + ".html";
137
+ await fs.writeFile(out, r.html);
138
+ console.log(`Subject: ${r.subject}`);
139
+ console.log(`Rendered → ${path.relative(process.cwd(), out)} (${(r.html.length / 1024).toFixed(1)} KB)`);
140
+ if (opts?.open) spawn(process.platform === "darwin" ? "open" : "xdg-open", [out], { stdio: "ignore", detached: true }).unref();
141
+ }
142
+
143
+ export async function send(file, opts) {
144
+ const { issue } = await readIssue(file);
145
+ const to = (opts?.to || "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
146
+ const test = !!opts?.test;
147
+ if (test && !to.length) {
148
+ let me;
149
+ try { me = await http.get("whoami"); }
150
+ catch (e) { console.error(`whoami failed: ${e.message}`); process.exit(1); }
151
+ const email = me?.user_email || me?.email || me?.userEmail;
152
+ if (!email) { console.error("Could not resolve your email for the test send; pass --to."); process.exit(1); }
153
+ to.push(email);
154
+ }
155
+ if (!test && !opts?.yes) {
156
+ console.error("Refusing: this sends to the whole Flowapt team and marks the changelog rows as covered. Re-run with --yes, or use --test first.");
157
+ process.exit(1);
158
+ }
159
+ let r;
160
+ try { r = await http.post("team-updates", { action: "send", issue, to: to.length ? to : undefined, test }); }
161
+ catch (e) { console.error(`Send failed: ${e.message}`); process.exit(1); }
162
+ const ok = (r.recipients ?? []).filter((x) => x.status === "sent");
163
+ const bad = (r.recipients ?? []).filter((x) => x.status !== "sent");
164
+ console.log(`${test ? "Test sent" : "Sent"}: "${issue.subject}"`);
165
+ for (const x of ok) console.log(` ✓ ${x.to}`);
166
+ for (const x of bad) console.log(` ✗ ${x.to} ${x.error ?? x.status}`);
167
+ if (!test) console.log(r.covers_until ? ` covered up to ${fmtWhen(r.covers_until)} SAST · record ${r.id}` : ` NOT marked as covered (every send failed)`);
168
+ if (bad.length) process.exitCode = 1;
169
+ }
package/src/index.js CHANGED
@@ -21,6 +21,7 @@ import * as templatesCmd from "./commands/templates.js";
21
21
  import * as orgCmd from "./commands/org.js";
22
22
  import * as orgFlagsCmd from "./commands/org-flags.js";
23
23
  import * as insightsCmd from "./commands/insights.js";
24
+ import * as teamUpdatesCmd from "./commands/team-updates.js";
24
25
  import * as agentConfigCmd from "./commands/agent-config.js";
25
26
  import * as agentsCmd from "./commands/agents.js";
26
27
  import * as agentUpdatesCmd from "./commands/agent-updates.js";
@@ -410,7 +411,7 @@ export function run(argv) {
410
411
  .option("--model <model>", "settings.model (e.g. gpt-5.4-mini)")
411
412
  .option("--reasoning-effort <level>", "settings.reasoning_effort — low|medium|high (\"\" clears it); pairs with reasoning models (e.g. gpt-5.6-luna + high)")
412
413
  .option("--rename <name>", "rename the agent")
413
- .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, [])
414
+ .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/product_lookup/email_request_tool/collapse_product_variants", agentConfigCmd.collectTool, [])
414
415
  .option("--discount [bool]", "agent.discount.enabled (true if bare)")
415
416
  .option("--test-contact-number <number>", "settings.test_contact_number — the contact `flowiq test` uses (use a FAKE number; \"\" clears it → synthetic fallback)")
416
417
  .option("--test-contact-name <name>", "settings.test_contact_name — display name for the test contact (\"\" clears it)")
@@ -864,6 +865,39 @@ export function run(argv) {
864
865
  .option("--out <file>", "write the JSON to a file instead of stdout")
865
866
  .action((orgId, path, opts) => storeApiCmd.wooGet(orgId, path, opts));
866
867
 
868
+ // updates — the "what changed in FlowIQ" email to the Flowapt team
869
+ const updates = program.command("updates").description("Team update email: what changed in FlowIQ, sent to every super admin (status / draft / preview / send)");
870
+ updates.command("status")
871
+ .description("uncovered changelog rows, recent sends and who receives the email")
872
+ .option("--json", "machine-readable output")
873
+ .action((opts) => teamUpdatesCmd.status(opts));
874
+ updates.command("uncovered")
875
+ .description("list the changelog rows no team update has covered yet")
876
+ .option("--limit <n>", "rows to list (default 60)")
877
+ .option("--json", "machine-readable output")
878
+ .action((opts) => teamUpdatesCmd.uncovered(opts));
879
+ updates.command("draft")
880
+ .description("write ./.flowiq/updates/<date>.json: a default issue composed from the uncovered rows, ready to enrich")
881
+ .option("--since <iso>", "compose from rows created after this time instead of the last covered point")
882
+ .option("--limit <n>", "rows to include (default 60)")
883
+ .option("--out <file>", "write the issue here instead of ./.flowiq/updates/<date>.json")
884
+ .action((opts) => teamUpdatesCmd.draft(opts));
885
+ updates.command("asset <file>")
886
+ .description("host an image (png/jpg/webp/gif, max 6 MB) for the email and print its public URL")
887
+ .option("--name <name>", "file name to store under (default: the file's own name)")
888
+ .action((file, opts) => teamUpdatesCmd.asset(file, opts));
889
+ updates.command("preview <file>")
890
+ .description("render the issue JSON to <file>.html exactly as it will be emailed")
891
+ .option("--as <first-name>", "greet this name in the preview (default Matt)")
892
+ .option("--open", "open the rendered HTML in the browser")
893
+ .action((file, opts) => teamUpdatesCmd.preview(file, opts));
894
+ updates.command("send <file>")
895
+ .description("email the issue: --test sends one copy to you (or --to) and marks nothing; without --test it goes to every super admin and needs --yes")
896
+ .option("--test", "send a single test copy only (to you unless --to is given); nothing is marked as covered")
897
+ .option("--to <emails>", "comma-separated recipients instead of the team list")
898
+ .option("--yes", "confirm the real send to the whole team")
899
+ .action((file, opts) => teamUpdatesCmd.send(file, opts));
900
+
867
901
  // doctor (version + auth + server contract in one read-only command)
868
902
  program.command("doctor")
869
903
  .description("Check this install: version vs npm, auth, and whether the server still supports it. Exits 1 if something needs fixing")