@flowapt/flowiq-cli 0.6.8 → 0.7.1

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
@@ -555,6 +555,48 @@ flowiq tag remove <org_id> vip,old-promo --confirm # remove tag(s) from ALL con
555
555
  matches — re-run until `matched 0`. Idempotent and safe to re-run.
556
556
  - **Undo** any tag with `flowiq tag remove <org> <tag> --confirm`.
557
557
 
558
+ ### Staying current — `flowiq doctor` (v0.6.9)
559
+
560
+ ```bash
561
+ flowiq doctor # version vs npm, auth, and whether the server still supports this install
562
+ flowiq doctor --json # same, machine-readable; exits 1 when something needs fixing
563
+ ```
564
+
565
+ ```
566
+ ✓ version 0.6.9 (latest)
567
+ ✓ api https://api.flowiq.live
568
+ ✓ auth you@flowapt.com · super admin
569
+ ✓ server contract 0.6.8 or newer · build 1f0fd48c
570
+ ```
571
+
572
+ **Three ways this install tells you it is stale**, so nobody — person or AI
573
+ assistant — has to remember to check:
574
+
575
+ 1. **npm nudge.** Any command prints, on stderr, when a newer version is
576
+ published:
577
+ `⬆ FLOWIQ CLI OUT OF DATE — installed X, latest Y / Run this before continuing: npm i -g @flowapt/flowiq-cli@latest`.
578
+ The registry is polled at most once a day by a detached child, so the hint
579
+ costs nothing on the hot path and appears on the next run after a release.
580
+ 2. **Server contract.** Every `api/cli/*` response carries
581
+ `X-Flowiq-Min-Version` — the oldest client the deployed server still
582
+ supports — and the CLI warns once per process when this install is below it.
583
+ This is the half npm cannot tell you: the registry knows what is *published*,
584
+ not whether the **server** still speaks your dialect. It is instant and
585
+ offline-safe, riding on a request you were making anyway.
586
+ 3. **`flowiq doctor`**, when you want to ask deliberately.
587
+
588
+ ⚠️ Until 9 Sep 2026 the npm nudge was also gated on `process.stderr.isTTY`, so
589
+ it was invisible to exactly the audience that most needs it — an assistant
590
+ running `flowiq` through a tool call gets a pipe, not a TTY. Teammates' AI
591
+ sessions could run a months-old CLI forever without ever being told. Writing to
592
+ stderr was already the whole safety property; the TTY gate bought nothing.
593
+
594
+ **Bumping `MIN_CLI_VERSION`** (`api/cli/_auth.js`): only when a server change
595
+ would MISBEHAVE on an older client — a removed or renamed response field, a
596
+ changed request shape, a validation rule an old client cannot satisfy. Never for
597
+ additive work; a new endpoint or an opt-in query param leaves old clients
598
+ correct, and crying wolf trains people to ignore the warning.
599
+
558
600
  ### Publishing (maintainers) — publish from a CLEAN checkout
559
601
 
560
602
  `npm publish` packs the **working tree**, not your branch. This repo's local
@@ -1041,6 +1083,102 @@ flowiq report deck pull <org_id> 2026-08 # → ./.flowiq/repor
1041
1083
  - `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.
1042
1084
  - 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.
1043
1085
 
1086
+ ### Insights — `flowiq insights status|enable|disable|run` (v0.7.0)
1087
+
1088
+ The **Customer Insights Report** (the scheduled AI email: exec summary, top
1089
+ questions / complaints / topics, sentiment, product mentions — cron 72, 09:00
1090
+ SAST) per org. Reads and writes `feature_flags.export_insights` exactly the
1091
+ way the Insights Studio does, and fires a report the way the Profile tab's
1092
+ Run Now does.
1093
+
1094
+ ```bash
1095
+ flowiq insights status # every org: STATE · CHATS30D · KEY · LAST REPORT · RCPT · SCHEDULES
1096
+ flowiq insights status --off --min-chats 20 # who is missing it and has real chat volume
1097
+ flowiq insights status <organization_id> # one org + its last 10 reports
1098
+
1099
+ flowiq insights enable <organization_id> --weekly monday --recipient client@example.com --recipient matt@flowapt.com
1100
+ flowiq insights enable <organization_id> --daily --store-only --monthly last # two schedules; dailies stored, not emailed
1101
+ flowiq insights enable <organization_id> --monthly 1 --report advanced # advanced engine (PDF + Studio config)
1102
+ flowiq insights enable <organization_id> --add-recipient kiah@flowapt.com # edit recipients, keep the cadence
1103
+ flowiq insights enable <organization_id> --weekly friday --dry-run # show what would be written
1104
+
1105
+ flowiq insights disable <organization_id> # enabled=false; schedules + recipients kept
1106
+
1107
+ flowiq insights run <organization_id> --period weekly --recipient matt@flowapt.com # DRY RUN: engine, window, recipients, message count
1108
+ flowiq insights run <organization_id> --period weekly --recipient matt@flowapt.com --commit
1109
+ flowiq insights run <organization_id> --from 2026-09-01 --to 2026-09-07 --store-only --commit
1110
+ ```
1111
+
1112
+ - **`enable` composes the whole config** (`enabled` + `recipient_emails` +
1113
+ `schedules[]`). Cadence flags (`--daily` / `--weekly [day]` / `--monthly [day]`)
1114
+ REPLACE the schedules; none given keeps what exists, or applies the default
1115
+ (weekly Monday, standard, emailed). `--report` and `--store-only` apply to the
1116
+ cadence flags you pass. `--recipient` replaces the list; `--add-recipient` /
1117
+ `--remove-recipient` edit it.
1118
+ - **It refuses the three ways an enabled org silently produces nothing**
1119
+ (`--force` overrides): the org has **no OpenAI key** (both engines hard-require
1120
+ `organizations.openai_api_key`); an emailing schedule with **no recipients**
1121
+ (the engines would email a hardcoded fallback address); an **inactive** org.
1122
+ - **`status` shows schedules as the scheduler will actually run them.** An
1123
+ enabled org with no schedules stored runs *daily standard* by default — it is
1124
+ marked `(implicit)`; `enable` replaces that with an explicit schedule.
1125
+ - **`run` is a dry run by default** (it costs OpenAI tokens and can email a
1126
+ client): it prints the engine, the window, who would get it and how many
1127
+ customer messages are in the window, and refuses an empty window. `--commit`
1128
+ fires it through python-render (fire-and-forget) and prints the job id; the
1129
+ report shows under *Recent reports* in `status <org>` a few minutes later.
1130
+ - `--from` / `--to` are SAST calendar days. `--report` defaults to the org's
1131
+ schedule for that period, else standard.
1132
+ - `enable` / `disable` / `run` are audited (`flowiq audit --endpoint insights`).
1133
+
1134
+ ### Team updates — `flowiq updates status|uncovered|draft|asset|preview|send` (v0.7.1)
1135
+
1136
+ The **"what changed in FlowIQ" email to the Flowapt team** — every super admin
1137
+ gets it. An *issue* is a JSON file composed from `platform_changelog` rows:
1138
+ headline sections (a screenshot straight from the app, what it does, and a
1139
+ **For the team** note: how we use it, what to tell clients), an *Also shipped*
1140
+ list, and a *CLI and MCP* block. The renderer, the send (from
1141
+ flowiq@flowapt.com, reply-to Matt) and the record (`team_updates`, with the exact
1142
+ HTML that went out) live server-side in the `team-update-email` edge function.
1143
+
1144
+ ```bash
1145
+ flowiq updates status # uncovered changelog rows · recent sends · recipients
1146
+ flowiq updates uncovered # the rows no issue has emailed yet
1147
+ flowiq updates draft # → ./.flowiq/updates/<date>.json, a default issue to enrich
1148
+ flowiq updates draft --since 2026-09-07T22:00:00+02:00 # compose from a period instead of the last covered point
1149
+ flowiq updates asset ./api-keys-card.png # host a screenshot, prints the https URL for image.url
1150
+ flowiq updates preview .flowiq/updates/2026-09-10.json --open # render to .html exactly as emailed
1151
+ flowiq updates send .flowiq/updates/2026-09-10.json --test # one copy to YOU; nothing marked covered
1152
+ flowiq updates send .flowiq/updates/2026-09-10.json --test --to kiah@flowapt.com
1153
+ flowiq updates send .flowiq/updates/2026-09-10.json --yes # the real send, to every super admin
1154
+ ```
1155
+
1156
+ - **Issue shape** (`issue` key in the file): `subject`, `preheader`, `title`,
1157
+ `intro`, `date_label`, `headlines[]`, `also[]`, `tools[]`, `closing`,
1158
+ `changelog_ids[]`. A headline/tool section: `type` (feature | improvement |
1159
+ fix | ui | agent | cli | infra | db | mcp — the chip is the app's own), `major`,
1160
+ `title`, `what[]` (paragraphs), `team[]` (bullets), `image{url,alt,caption}`,
1161
+ `terminal{command,output}`, `link{label,url}`, `org_name`, `version`. An
1162
+ `also[]` item: `type`, `title`, `note`, `link`. Inline markup anywhere:
1163
+ `**bold**`, `` `code` ``, `[label](https://…)`.
1164
+ - **`preview` and `send` lint the file first** (required fields, unknown types,
1165
+ non-https images, em dashes) and stop with line-level help.
1166
+ - **`send` without `--test` needs `--yes`**: it emails the whole team AND moves
1167
+ the covered-up-to mark, so those changelog rows will never be auto-digested.
1168
+ `--test` sends to you (or `--to`) and marks nothing.
1169
+ - **The safety net:** pg_cron `team-update-email-daily` (08:30 SAST) emails a
1170
+ default digest of any changelog row that has gone un-emailed for 20h+, so a
1171
+ ship is never silently missed. The curated `send` is what stops it firing —
1172
+ compose the issue the same day you ship.
1173
+ - **Who may do what:** everyone with a staff key can `status`, `uncovered`,
1174
+ `draft`, `asset`, `preview` and `send --test` (a copy to yourself, nothing
1175
+ marked covered). **The real send is Matt only** — any other key gets
1176
+ `403 Only matt@flowapt.com may post the team update` and the attempt is
1177
+ audited (`ALLOWED_SENDERS` in `api/cli/team-updates.js`).
1178
+ - Every link in the email opens the app's Changelog at that entry
1179
+ (`app.flowiq.live/?changelog=1&entry=<id>`). `send` and `asset` are audited
1180
+ (`flowiq audit --endpoint team-updates`).
1181
+
1044
1182
  ### WhatsApp templates — `flowiq templates pull|list|show|create|status` (alias `tpl`)
1045
1183
 
1046
1184
  Read an org's live templates straight from Meta (read-only), render any single
@@ -1232,7 +1370,7 @@ Notes:
1232
1370
  org, which endpoint or query, how many records. **The response body is never
1233
1371
  stored**; the log records what was asked, not the customer data that came back.
1234
1372
 
1235
- ### Org — `flowiq org create` / `flowiq org info <organization_id>`
1373
+ ### Org — `flowiq org create` / `flowiq org info <organization_id>` / `flowiq org flags …`
1236
1374
 
1237
1375
  Create a brand-new organization, or look one up (read-only; raw store/Meta
1238
1376
  credentials are stripped server-side).
@@ -1259,6 +1397,46 @@ flowiq prompts pull <new_org_id> # edit → push
1259
1397
  (No `agent config` step any more — since 29 Aug 2026 a new agent is born on the
1260
1398
  house default: `gpt-5.6-luna`, `reasoning_effort high`, `use_settings_prompt` ON.)
1261
1399
 
1400
+ #### Feature flags — `flowiq org flags show|list|set|unset|keys` (v0.7.0)
1401
+
1402
+ The Settings → Profile switches (`organizations.feature_flags`) from the
1403
+ terminal. `show` reads one org, `list --key` is the cross-org census, `set` /
1404
+ `unset` write. Only keys the Profile tab manages can be set (server-side
1405
+ allowlist + type check); anything that looks like a credential is redacted on
1406
+ read and refused on write.
1407
+
1408
+ ```bash
1409
+ flowiq org flags show <organization_id> # every flag, by Settings section
1410
+ flowiq org flags list --key export_insights.enabled --off # which orgs do NOT have it (--on / --present / --absent / --all)
1411
+ flowiq org flags list --key mcp_member_access.enabled --on
1412
+
1413
+ flowiq org flags set <organization_id> mcp_member_access.enabled true
1414
+ flowiq org flags set <organization_id> wait_for_more_messages 5 # integer, 0-60
1415
+ flowiq org flags set <organization_id> UTM.excluded_campaign_keywords "test,internal" # string[] (comma list or JSON)
1416
+ flowiq org flags set <organization_id> integrations_visible.instagram true # wildcard keys take one more segment
1417
+ flowiq org flags set <organization_id> vert.active true --yes # dangerous keys need --yes
1418
+ flowiq org flags set <organization_id> export_insights @insights.json # JSON from a file (prefer `flowiq insights enable`)
1419
+
1420
+ flowiq org flags unset <organization_id> wait_for_more_messages --yes # remove the key (= platform default); always --yes
1421
+ flowiq org flags keys # what is settable, with type + danger notes
1422
+ flowiq org flags keys --section "members"
1423
+ ```
1424
+
1425
+ - **Values:** `true`/`false`, a whole number, JSON (`'["a","b"]'` / `'{"enabled":true}'`),
1426
+ `@file.json`, or a comma list for list keys. The server validates against the
1427
+ registry — a boolean given `12` is a 400, not a corrupted flag.
1428
+ - **Dangerous keys** (the Profile tab's amber-warning switches: `vert.active`,
1429
+ `ip_whitelist`, `follow_up.enabled`, `subscriptions_test_mode`,
1430
+ `api_key_member_access.enabled`, `export_insights`, `wait_for_more_messages`, …)
1431
+ are refused without `--yes`, with the reason printed.
1432
+ - **Writes are atomic per path** (the same `_jsonb_deep_set` the Profile tab
1433
+ uses), so `set a.b` never clobbers `a.c` that someone else just changed.
1434
+ - **Every set/unset is audited** (`flowiq audit --endpoint org-flags`) and the
1435
+ response prints before → after plus the exact undo command.
1436
+ - A key that is on the org but not in the registry is shown by `show` (and
1437
+ `--json`) but cannot be set here — it is DB-only by design; add it to the
1438
+ Profile tab first.
1439
+
1262
1440
  ### Agents — `flowiq agent list|create <org>`
1263
1441
 
1264
1442
  List an org's agents (to discover ids) and create a new one.
package/TEAM-GUIDE.md CHANGED
@@ -103,11 +103,31 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
103
103
  | 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
104
  | 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
105
  | Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
106
+ | **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` |
107
+ | 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 |
108
+ | 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
109
  | 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
110
  > **Publishing the CLI (maintainers only):** publish from a clean clone, never
108
111
  > from your working tree — `npm publish` packs whatever is on disk. A
109
112
  > `prepublishOnly` check now stops you if `cli/` differs from `origin/main`.
110
113
 
114
+ ### Keeping the CLI current
115
+
116
+ Run **`flowiq doctor`** any time you are unsure — it checks your version against
117
+ npm, your login, and whether the server still supports your install, and tells
118
+ you the exact command to fix anything wrong.
119
+
120
+ You will usually not need to: the CLI now tells you itself. If a newer version
121
+ is out, or if your install is older than the server supports, every command
122
+ prints a line starting `⬆ FLOWIQ CLI …` with the fix:
123
+
124
+ ```bash
125
+ npm i -g @flowapt/flowiq-cli@latest
126
+ ```
127
+
128
+ That warning is printed for AI assistants too, not just people — so if you work
129
+ with Claude in this repo, it will see it and can update for you.
130
+
111
131
  ### Campaign naming — `Date_Campaign`
112
132
 
113
133
  Every campaign tag is **the date the broadcast goes out, then the campaign
@@ -163,6 +183,14 @@ several.
163
183
  | See a client's pending change requests | `flowiq au pull <org_id>` |
164
184
  | Close a client's change request (after verifying the fix!) | `flowiq au resolve <update_id> --note "what changed"` — the client reads the note |
165
185
  | Check an org's platform + active agent | `flowiq org info <org_id>` |
186
+ | **See every Settings → Profile switch on an org** | `flowiq org flags show <org_id>` (credentials are never shown) |
187
+ | **Which orgs have a flag on / off** (insights, MCP member access, a debounce…) | `flowiq org flags list --key export_insights.enabled --off` · `--key mcp_member_access.enabled --on` |
188
+ | Flip a Settings → Profile switch from the terminal | `flowiq org flags set <org_id> mcp_member_access.enabled true` — `flowiq org flags keys` lists what you can set; the risky ones ask for `--yes`; the reply prints the undo |
189
+ | Set the agent's message debounce ("customer sends 3 fragments, agent replies 3 times") | `flowiq org flags set <org_id> wait_for_more_messages 5` (seconds, 0-60) |
190
+ | **Who is missing the Customer Insights report?** | `flowiq insights status --off --min-chats 20` — KEY `NO` means the org has no OpenAI key and the report cannot run |
191
+ | Switch the insights report on for a client | `flowiq insights enable <org_id> --weekly monday --recipient client@example.com --recipient you@flowapt.com` — it refuses if there is no OpenAI key or nobody to email |
192
+ | Store insights daily without emailing anyone (build history first) | `flowiq insights enable <org_id> --daily --store-only` |
193
+ | Send one insights report right now | `flowiq insights run <org_id> --period weekly --recipient you@flowapt.com` (dry run) → add `--commit` |
166
194
  | Work a Pin Board task | `flowiq pin list-remote open` → `pull` → edit → `push` |
167
195
  | Log hours you worked for a client (every package = 10h Flowapt work/month) | `flowiq hours log <org_id> --hours 1.5 --desc "what you did"` — plain language, the client sees it on their Client Console |
168
196
  | Check a client's package hours / the month across all clients | `flowiq hours list <org_id>` / `flowiq hours summary` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.6.8",
3
+ "version": "0.7.1",
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,150 @@
1
+ // `flowiq doctor` — one command that answers "is my CLI healthy and current?"
2
+ //
3
+ // Written because the honest answer used to need three commands and some
4
+ // knowledge: `flowiq --version` (installed), `npm view` (latest), and nothing
5
+ // at all for "does the SERVER still speak my dialect?". A teammate — or a
6
+ // teammate's AI assistant — needs one place that says what is wrong and the
7
+ // exact command to fix it.
8
+ //
9
+ // Read-only, no org needed, safe to run any time. Exit code 1 when something
10
+ // actionable is wrong, so it can gate a script.
11
+
12
+ import { http, serverContract, cliVersion } from "../http.js";
13
+ import { cachedLatest, fetchLatestNow, cmpVersion } from "../update-check.js";
14
+ import { loadConfig, configDir } from "../config.js";
15
+
16
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
17
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
18
+ const amber = (s) => `\x1b[33m${s}\x1b[0m`;
19
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
20
+
21
+ const OK = green("✓");
22
+ const WARN = amber("⚠");
23
+ const BAD = red("✗");
24
+
25
+ function ageLabel(ts) {
26
+ if (!ts) return "never";
27
+ const h = (Date.now() - ts) / 3600000;
28
+ if (h < 1) return `${Math.round(h * 60)} min ago`;
29
+ if (h < 48) return `${h.toFixed(1)} h ago`;
30
+ return `${Math.round(h / 24)} days ago`;
31
+ }
32
+
33
+ export async function doctor(opts = {}) {
34
+ const installed = cliVersion();
35
+ const problems = [];
36
+ const out = {
37
+ cli_version: installed,
38
+ npm_latest: null,
39
+ server_min_version: null,
40
+ api_build: null,
41
+ api_url: null,
42
+ authenticated: false,
43
+ user: null,
44
+ problems: [],
45
+ };
46
+
47
+ const lines = [];
48
+ lines.push(`flowiq doctor`);
49
+ lines.push("");
50
+
51
+ // ── 1. installed vs npm latest ────────────────────────────────────────────
52
+ // Ask the registry LIVE here (with a short timeout). The background cache is
53
+ // refreshed at most once a day, so on the day of a release it is stale — which
54
+ // is precisely when someone runs doctor.
55
+ const live = await fetchLatestNow(3000);
56
+ const cached = cachedLatest();
57
+ const latest = live || cached.latest;
58
+ out.npm_latest = latest;
59
+
60
+ if (!latest) {
61
+ lines.push(` ${WARN} version installed ${installed} · could not reach npm ${dim("(offline?)")}`);
62
+ } else if (cmpVersion(latest, installed) > 0) {
63
+ lines.push(` ${BAD} version installed ${installed} · latest ${latest}`);
64
+ problems.push({
65
+ what: `CLI is out of date (${installed} → ${latest})`,
66
+ fix: "npm i -g @flowapt/flowiq-cli@latest",
67
+ });
68
+ } else if (cmpVersion(installed, latest) > 0) {
69
+ // A dev machine, a worktree build, or a version bumped but not yet
70
+ // published. Saying "(latest)" here would be a lie, and it is exactly the
71
+ // state in which someone wonders why teammates do not have their feature.
72
+ lines.push(` ${WARN} version installed ${installed} is AHEAD of npm (${latest}) ${dim("— unpublished build")}`);
73
+ } else {
74
+ lines.push(` ${OK} version ${installed} ${dim(live ? "(latest)" : `(latest, cached ${ageLabel(cached.checkedAt)})`)}`);
75
+ }
76
+
77
+ // ── 2. config + auth ──────────────────────────────────────────────────────
78
+ let cfg = {};
79
+ try { cfg = await loadConfig(); } catch { /* handled below */ }
80
+ out.api_url = cfg.api_url || null;
81
+ lines.push(` ${cfg.api_url ? OK : BAD} api ${cfg.api_url || "not configured"} ${dim(configDir())}`);
82
+ if (!cfg.api_url) problems.push({ what: "no api_url configured", fix: "flowiq auth login" });
83
+
84
+ if (!cfg.token) {
85
+ lines.push(` ${BAD} auth not authenticated`);
86
+ problems.push({ what: "not authenticated", fix: "flowiq auth login" });
87
+ } else {
88
+ // whoami doubles as the live server probe — it is how the response headers
89
+ // carrying the version contract reach us at all.
90
+ try {
91
+ // `whoami` is also the live server probe: it is how the response headers
92
+ // carrying the version contract reach us at all.
93
+ const who = await http.get("whoami");
94
+ out.authenticated = true;
95
+ out.user = who?.user_email || null;
96
+ out.super_admin = who?.is_super_admin === true;
97
+ const role = who?.is_super_admin ? "super admin" : red("NOT a super admin");
98
+ lines.push(` ${OK} auth ${out.user || "authenticated"} ${dim(`· ${role}`)}`);
99
+ if (who && who.is_super_admin !== true) {
100
+ problems.push({ what: "this key is not a super-admin key — most commands will 403", fix: "ask Matt to re-issue the staff key" });
101
+ }
102
+ } catch (e) {
103
+ lines.push(` ${BAD} auth ${e.message.split("\n")[0]}`);
104
+ problems.push({
105
+ what: "the API rejected this key",
106
+ fix: e.status === 401 || e.status === 403 ? "flowiq auth login" : "check api_url / network, then retry",
107
+ });
108
+ }
109
+ }
110
+
111
+ // ── 3. the server's version contract ──────────────────────────────────────
112
+ out.server_min_version = serverContract.minVersion;
113
+ out.api_build = serverContract.apiBuild;
114
+
115
+ if (!serverContract.minVersion) {
116
+ lines.push(` ${WARN} server did not declare a minimum CLI version ${dim("(deploy predates the version contract)")}`);
117
+ } else if (cmpVersion(serverContract.minVersion, installed) > 0) {
118
+ lines.push(` ${BAD} server requires ${serverContract.minVersion}, you have ${installed}`);
119
+ problems.push({
120
+ what: `this CLI is older than the server's contract (needs ${serverContract.minVersion})`,
121
+ fix: "npm i -g @flowapt/flowiq-cli@latest",
122
+ });
123
+ } else {
124
+ lines.push(` ${OK} server contract ${serverContract.minVersion} or newer ${dim(`· build ${serverContract.apiBuild || "?"}`)}`);
125
+ }
126
+
127
+ // The REVERSE skew: a CLI newer than the deployment it is talking to. Not an
128
+ // error — it is the normal state for the minutes between a push and the
129
+ // Vercel deploy — but it explains "the command exists and still doesn't work".
130
+ if (latest && serverContract.minVersion && cmpVersion(installed, latest) === 0 && cmpVersion(installed, serverContract.minVersion) > 0) {
131
+ lines.push(` ${dim("i server min is " + serverContract.minVersion + " while you run " + installed + " — fine; newest features need the matching deploy")}`);
132
+ }
133
+
134
+ out.problems = problems;
135
+ if (opts.json) { console.log(JSON.stringify(out, null, 2)); return problems.length ? 1 : 0; }
136
+
137
+ console.log(lines.join("\n"));
138
+ console.log("");
139
+ if (!problems.length) {
140
+ console.log(green(" Everything checks out."));
141
+ } else {
142
+ console.log(red(` ${problems.length} thing${problems.length === 1 ? "" : "s"} to fix:`));
143
+ for (const p of problems) {
144
+ console.log(` • ${p.what}`);
145
+ console.log(` ${p.fix}`);
146
+ }
147
+ }
148
+ console.log("");
149
+ return problems.length ? 1 : 0;
150
+ }
@@ -0,0 +1,201 @@
1
+ // `flowiq insights …` — the Customer Insights Report from the terminal.
2
+ //
3
+ // flowiq insights status [org_id] [--off|--on] [--min-chats N] [--all] who has it, who does not, last report, chat volume
4
+ // flowiq insights enable <org_id> [--daily] [--weekly [day]] [--monthly [day]] [--report standard|advanced]
5
+ // [--store-only] [--recipient a@b.com …] [--dry-run] [--force]
6
+ // flowiq insights disable <org_id>
7
+ // flowiq insights run <org_id> [--period daily|weekly|monthly | --from D --to D] [--report …]
8
+ // [--recipient …] [--store-only] --commit
9
+ //
10
+ // `run` is DRY-RUN by default (it prints the exact engine, window and
11
+ // recipients) and fires only with --commit — it costs OpenAI tokens and can
12
+ // email a client. The server refuses the three ways an enabled org silently
13
+ // produces nothing (no OpenAI key, emailing with no recipients, an empty
14
+ // window); --force overrides all but an inactive org.
15
+
16
+ import { http } from "../http.js";
17
+ import { schedulesFromOpts, describeSchedule, sastRange } from "../insights-config.js";
18
+
19
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
+ const pad = (s, n) => String(s ?? "").padEnd(n);
21
+ const padL = (s, n) => String(s ?? "").padStart(n);
22
+
23
+ function requireUuid(orgId) {
24
+ if (!UUID_RE.test(orgId || "")) {
25
+ console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list <name>\`).`);
26
+ process.exit(1);
27
+ }
28
+ }
29
+
30
+ function fmtWhen(iso) {
31
+ if (!iso) return "never";
32
+ const d = new Date(iso);
33
+ if (Number.isNaN(d.getTime())) return String(iso);
34
+ // SAST, minute precision
35
+ const s = new Date(d.getTime() + 2 * 3600 * 1000).toISOString();
36
+ return `${s.slice(0, 10)} ${s.slice(11, 16)}`;
37
+ }
38
+
39
+ function daysAgo(iso) {
40
+ if (!iso) return null;
41
+ return Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
42
+ }
43
+
44
+ function printProblems(body) {
45
+ for (const e of body.errors || []) console.error(` ✗ ${e}`);
46
+ for (const w of body.warnings || []) console.error(` ⚠ ${w}`);
47
+ }
48
+
49
+ function fail(prefix, e) {
50
+ console.error(`${prefix}: ${e.message}`);
51
+ const b = e.body || {};
52
+ if (b.errors?.length || b.warnings?.length) printProblems(b);
53
+ if (b.needs_force) console.error(" Fix the cause, or re-run with --force to write anyway.");
54
+ process.exit(1);
55
+ }
56
+
57
+ // ── status ───────────────────────────────────────────────────────────────────
58
+
59
+ export async function status(orgId, opts = {}) {
60
+ if (orgId) return statusOne(orgId, opts);
61
+ let resp;
62
+ try { resp = await http.get("insights", { status: "1", all: opts.all ? "1" : undefined }); } catch (e) { fail("Status failed", e); }
63
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
64
+
65
+ let orgs = resp.orgs || [];
66
+ const total = orgs.length;
67
+ const enabledTotal = orgs.filter((o) => o.enabled).length;
68
+ if (opts.on) orgs = orgs.filter((o) => o.enabled);
69
+ if (opts.off) orgs = orgs.filter((o) => !o.enabled);
70
+ const minChats = Number(opts.minChats ?? 0);
71
+ if (minChats > 0) orgs = orgs.filter((o) => o.contacts_30d >= minChats);
72
+ const sort = opts.sort || "chats";
73
+ orgs.sort((a, b) =>
74
+ sort === "name" ? a.name.localeCompare(b.name)
75
+ : sort === "last" ? (new Date(b.last_report?.at || 0) - new Date(a.last_report?.at || 0))
76
+ : (b.contacts_30d - a.contacts_30d) || a.name.localeCompare(b.name));
77
+
78
+ console.log(`Customer Insights Report — ${enabledTotal} of ${total} org(s) enabled${resp.include_inactive ? " (incl. inactive)" : ""}` +
79
+ `${opts.on ? " · showing ON" : opts.off ? " · showing OFF" : ""}${minChats ? ` · ≥${minChats} chatting contacts/30d` : ""}\n`);
80
+ if (!orgs.length) { console.log("(no orgs match the filter)"); return; }
81
+
82
+ const nameW = Math.min(28, Math.max(4, ...orgs.map((o) => o.name.length)));
83
+ console.log(`${pad("NAME", nameW)} ${pad("STATE", 5)} ${padL("CHATS30D", 8)} ${pad("KEY", 3)} ${pad("LAST REPORT", 18)} ${pad("RCPT", 4)} SCHEDULES`);
84
+ for (const o of orgs) {
85
+ const state = o.enabled ? "ON" : o.configured ? "off" : "—";
86
+ const last = o.last_report ? `${fmtWhen(o.last_report.at)}${o.last_report.store_only ? " s" : ""}` : "never";
87
+ const sched = o.enabled ? o.schedules_described.join(", ") + (o.schedules_source === "implicit-daily" ? " (implicit)" : "") : "";
88
+ console.log(
89
+ `${pad(o.name.slice(0, nameW), nameW)} ${pad(state, 5)} ${padL(o.contacts_30d, 8)} ${pad(o.has_openai_key ? "yes" : "NO", 3)} ${pad(last, 18)} ${padL(o.recipients.length, 4)} ${sched}${o.inactive ? " (inactive)" : ""}`
90
+ );
91
+ }
92
+ console.log(`\n${orgs.length} org(s) shown · CHATS30D = distinct contacts who messaged in the last 30 days · KEY = has its own OpenAI key (required) · "s" = store-only`);
93
+ console.log("Enable one: flowiq insights enable <org_id> --weekly monday --recipient client@example.com");
94
+ }
95
+
96
+ async function statusOne(orgId, opts = {}) {
97
+ requireUuid(orgId);
98
+ let resp;
99
+ try { resp = await http.get("insights", { organization_id: orgId }); } catch (e) { fail("Status failed", e); }
100
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
101
+
102
+ console.log(`${resp.name} (${resp.id})${resp.inactive ? " [INACTIVE]" : ""}`);
103
+ console.log(` insights: ${resp.enabled ? "ENABLED" : resp.configured ? "disabled (config kept)" : "never configured"}`);
104
+ if (resp.enabled || resp.configured) {
105
+ console.log(` schedules: ${resp.schedules_described.join(", ") || "(none)"}${resp.schedules_source === "implicit-daily" ? " ⚠ no schedules stored — the scheduler runs DAILY standard by default" : resp.schedules_source === "legacy" ? " (legacy single-config shape)" : ""}`);
106
+ console.log(` recipients: ${resp.recipients.length ? resp.recipients.join(", ") : `(none — emails would go to ${resp.engine_fallback_email})`}`);
107
+ }
108
+ console.log(` openai key: ${resp.has_openai_key ? "own key present" : "NONE — the engines refuse to run"}${resp.has_spare_openai_key ? " (+ spare)" : ""}`);
109
+ console.log(` studio config: ${resp.studio_configured_at ? `saved ${fmtWhen(resp.studio_configured_at)} (advanced engine)` : "none (advanced runs on engine defaults)"}`);
110
+ console.log(` chats (30d): ${resp.contacts_30d} contacts · ${resp.messages_30d} messages · last inbound ${fmtWhen(resp.last_inbound_at)}`);
111
+ console.log(` reports (30d): ${resp.reports_30d}`);
112
+ if (resp.reports?.length) {
113
+ console.log("\n Recent reports (SAST):");
114
+ for (const r of resp.reports) {
115
+ console.log(` ${fmtWhen(r.at)} ${pad(r.kind || "?", 8)} ${padL(r.contacts ?? "?", 5)} contacts ${padL(r.messages ?? "?", 6)} msgs ` +
116
+ `${r.store_only ? "stored only" : `${r.emails_sent ?? 0} email(s)${r.emails_failed ? `, ${r.emails_failed} failed` : ""}`}` +
117
+ `${r.recipients?.length && !r.store_only ? ` → ${r.recipients.join(", ")}` : ""}${r.source && r.source !== "edge" ? ` [${r.source}]` : ""}`);
118
+ }
119
+ } else {
120
+ console.log("\n No reports yet.");
121
+ }
122
+ }
123
+
124
+ // ── enable / disable ─────────────────────────────────────────────────────────
125
+
126
+ export async function enable(orgId, opts = {}) {
127
+ requireUuid(orgId);
128
+ let schedules;
129
+ try { schedules = schedulesFromOpts({ daily: opts.daily, weekly: opts.weekly, monthly: opts.monthly, report: opts.report, storeOnly: opts.storeOnly }); }
130
+ catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
131
+ if (!schedules && (opts.report || opts.storeOnly)) {
132
+ console.error("Error: --report / --store-only describe a schedule — add --daily, --weekly or --monthly to say which.");
133
+ process.exit(1);
134
+ }
135
+ const recipients = {};
136
+ if (opts.recipient?.length) recipients.replace = opts.recipient;
137
+ if (opts.addRecipient?.length) recipients.add = opts.addRecipient;
138
+ if (opts.removeRecipient?.length) recipients.remove = opts.removeRecipient;
139
+
140
+ const body = { organization_id: orgId, action: "enable", dry_run: opts.dryRun === true, force: opts.force === true };
141
+ if (schedules) body.schedules = schedules;
142
+ if (Object.keys(recipients).length) body.recipients = recipients;
143
+
144
+ let resp;
145
+ try { resp = await http.post("insights", body); } catch (e) { fail("Enable failed", e); }
146
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
147
+
148
+ const o = resp.organization;
149
+ const after = resp.after;
150
+ console.log(`${resp.dry_run ? "DRY RUN — " : ""}${o.name}: insights ${resp.before?.enabled ? "already on, config updated" : "ENABLED"}${resp.forced ? " (FORCED past pre-flight)" : ""}`);
151
+ console.log(` schedules: ${(after.schedules || []).map(describeSchedule).join(", ")} [${resp.schedules_source}]`);
152
+ console.log(` recipients: ${after.recipient_emails?.length ? after.recipient_emails.join(", ") : "(none)"}`);
153
+ printProblems(resp);
154
+ if (resp.dry_run) {
155
+ console.log(resp.would_write ? "\n Would write. Re-run without --dry-run." : "\n Would NOT write — fix the ✗ items (or --force).");
156
+ } else {
157
+ console.log("\n Next run: 09:00 SAST on the next matching day (cron 72). Fire one now: flowiq insights run " + orgId + " --commit");
158
+ console.log(" Undo: flowiq insights disable " + orgId);
159
+ }
160
+ }
161
+
162
+ export async function disable(orgId, opts = {}) {
163
+ requireUuid(orgId);
164
+ let resp;
165
+ try { resp = await http.post("insights", { organization_id: orgId, action: "disable" }); } catch (e) { fail("Disable failed", e); }
166
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
167
+ console.log(`${resp.organization.name}: insights ${resp.changed ? "DISABLED" : "already disabled"} (schedules + recipients kept; re-enable with \`flowiq insights enable\`)`);
168
+ }
169
+
170
+ // ── run (Run Now) ────────────────────────────────────────────────────────────
171
+
172
+ export async function run(orgId, opts = {}) {
173
+ requireUuid(orgId);
174
+ const body = { organization_id: orgId, action: "run", commit: opts.commit === true, force: opts.force === true, store_only: opts.storeOnly === true };
175
+ if (opts.from || opts.to) {
176
+ if (!opts.from || !opts.to) { console.error("Error: --from and --to go together (YYYY-MM-DD, SAST days)."); process.exit(1); }
177
+ try { const r = sastRange(opts.from, opts.to); body.start_date = r.start; body.end_date = r.end; }
178
+ catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
179
+ } else if (opts.period) {
180
+ if (!["daily", "weekly", "monthly"].includes(opts.period)) { console.error("Error: --period must be daily, weekly or monthly."); process.exit(1); }
181
+ body.period = opts.period;
182
+ }
183
+ if (opts.report) body.report_type = opts.report;
184
+ if (opts.recipient?.length) body.recipients = opts.recipient;
185
+
186
+ let resp;
187
+ try { resp = await http.post("insights", body); } catch (e) { fail("Run failed", e); }
188
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
189
+
190
+ const o = resp.organization;
191
+ console.log(`${resp.dry_run ? "DRY RUN — " : "STARTED — "}${o.name}: ${resp.period} ${resp.report_type} report via ${resp.engine}`);
192
+ console.log(` window: ${fmtWhen(resp.window.start)} → ${fmtWhen(resp.window.end)} SAST (${resp.messages_in_window ?? "?"} customer messages)`);
193
+ console.log(` delivery: ${resp.store_only ? "store only — no emails" : `email → ${resp.recipients.join(", ")}`}`);
194
+ printProblems(resp);
195
+ if (resp.dry_run) {
196
+ console.log(resp.would_run ? "\n Would run. Add --commit to fire it." : "\n Would NOT run — fix the ✗ items (or --force).");
197
+ } else {
198
+ console.log(`\n job ${resp.job_id || "?"} — python-render is working in the background (a few minutes for a large org).`);
199
+ console.log(` Check: flowiq insights status ${orgId} (the report appears under "Recent reports")`);
200
+ }
201
+ }