@flowapt/flowiq-cli 0.6.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,54 @@ 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
+
1044
1134
  ### WhatsApp templates — `flowiq templates pull|list|show|create|status` (alias `tpl`)
1045
1135
 
1046
1136
  Read an org's live templates straight from Meta (read-only), render any single
@@ -1232,7 +1322,7 @@ Notes:
1232
1322
  org, which endpoint or query, how many records. **The response body is never
1233
1323
  stored**; the log records what was asked, not the customer data that came back.
1234
1324
 
1235
- ### Org — `flowiq org create` / `flowiq org info <organization_id>`
1325
+ ### Org — `flowiq org create` / `flowiq org info <organization_id>` / `flowiq org flags …`
1236
1326
 
1237
1327
  Create a brand-new organization, or look one up (read-only; raw store/Meta
1238
1328
  credentials are stripped server-side).
@@ -1259,6 +1349,46 @@ flowiq prompts pull <new_org_id> # edit → push
1259
1349
  (No `agent config` step any more — since 29 Aug 2026 a new agent is born on the
1260
1350
  house default: `gpt-5.6-luna`, `reasoning_effort high`, `use_settings_prompt` ON.)
1261
1351
 
1352
+ #### Feature flags — `flowiq org flags show|list|set|unset|keys` (v0.7.0)
1353
+
1354
+ The Settings → Profile switches (`organizations.feature_flags`) from the
1355
+ terminal. `show` reads one org, `list --key` is the cross-org census, `set` /
1356
+ `unset` write. Only keys the Profile tab manages can be set (server-side
1357
+ allowlist + type check); anything that looks like a credential is redacted on
1358
+ read and refused on write.
1359
+
1360
+ ```bash
1361
+ flowiq org flags show <organization_id> # every flag, by Settings section
1362
+ flowiq org flags list --key export_insights.enabled --off # which orgs do NOT have it (--on / --present / --absent / --all)
1363
+ flowiq org flags list --key mcp_member_access.enabled --on
1364
+
1365
+ flowiq org flags set <organization_id> mcp_member_access.enabled true
1366
+ flowiq org flags set <organization_id> wait_for_more_messages 5 # integer, 0-60
1367
+ flowiq org flags set <organization_id> UTM.excluded_campaign_keywords "test,internal" # string[] (comma list or JSON)
1368
+ flowiq org flags set <organization_id> integrations_visible.instagram true # wildcard keys take one more segment
1369
+ flowiq org flags set <organization_id> vert.active true --yes # dangerous keys need --yes
1370
+ flowiq org flags set <organization_id> export_insights @insights.json # JSON from a file (prefer `flowiq insights enable`)
1371
+
1372
+ flowiq org flags unset <organization_id> wait_for_more_messages --yes # remove the key (= platform default); always --yes
1373
+ flowiq org flags keys # what is settable, with type + danger notes
1374
+ flowiq org flags keys --section "members"
1375
+ ```
1376
+
1377
+ - **Values:** `true`/`false`, a whole number, JSON (`'["a","b"]'` / `'{"enabled":true}'`),
1378
+ `@file.json`, or a comma list for list keys. The server validates against the
1379
+ registry — a boolean given `12` is a 400, not a corrupted flag.
1380
+ - **Dangerous keys** (the Profile tab's amber-warning switches: `vert.active`,
1381
+ `ip_whitelist`, `follow_up.enabled`, `subscriptions_test_mode`,
1382
+ `api_key_member_access.enabled`, `export_insights`, `wait_for_more_messages`, …)
1383
+ are refused without `--yes`, with the reason printed.
1384
+ - **Writes are atomic per path** (the same `_jsonb_deep_set` the Profile tab
1385
+ uses), so `set a.b` never clobbers `a.c` that someone else just changed.
1386
+ - **Every set/unset is audited** (`flowiq audit --endpoint org-flags`) and the
1387
+ response prints before → after plus the exact undo command.
1388
+ - A key that is on the org but not in the registry is shown by `show` (and
1389
+ `--json`) but cannot be set here — it is DB-only by design; add it to the
1390
+ Profile tab first.
1391
+
1262
1392
  ### Agents — `flowiq agent list|create <org>`
1263
1393
 
1264
1394
  List an org's agents (to discover ids) and create a new one.
package/TEAM-GUIDE.md CHANGED
@@ -108,6 +108,23 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
108
108
  > from your working tree — `npm publish` packs whatever is on disk. A
109
109
  > `prepublishOnly` check now stops you if `cli/` differs from `origin/main`.
110
110
 
111
+ ### Keeping the CLI current
112
+
113
+ Run **`flowiq doctor`** any time you are unsure — it checks your version against
114
+ npm, your login, and whether the server still supports your install, and tells
115
+ you the exact command to fix anything wrong.
116
+
117
+ You will usually not need to: the CLI now tells you itself. If a newer version
118
+ is out, or if your install is older than the server supports, every command
119
+ prints a line starting `⬆ FLOWIQ CLI …` with the fix:
120
+
121
+ ```bash
122
+ npm i -g @flowapt/flowiq-cli@latest
123
+ ```
124
+
125
+ That warning is printed for AI assistants too, not just people — so if you work
126
+ with Claude in this repo, it will see it and can update for you.
127
+
111
128
  ### Campaign naming — `Date_Campaign`
112
129
 
113
130
  Every campaign tag is **the date the broadcast goes out, then the campaign
@@ -163,6 +180,14 @@ several.
163
180
  | See a client's pending change requests | `flowiq au pull <org_id>` |
164
181
  | Close a client's change request (after verifying the fix!) | `flowiq au resolve <update_id> --note "what changed"` — the client reads the note |
165
182
  | Check an org's platform + active agent | `flowiq org info <org_id>` |
183
+ | **See every Settings → Profile switch on an org** | `flowiq org flags show <org_id>` (credentials are never shown) |
184
+ | **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` |
185
+ | 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 |
186
+ | 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) |
187
+ | **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 |
188
+ | 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 |
189
+ | Store insights daily without emailing anyone (build history first) | `flowiq insights enable <org_id> --daily --store-only` |
190
+ | Send one insights report right now | `flowiq insights run <org_id> --period weekly --recipient you@flowapt.com` (dry run) → add `--commit` |
166
191
  | Work a Pin Board task | `flowiq pin list-remote open` → `pull` → edit → `push` |
167
192
  | 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
193
  | 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.0",
4
4
  "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,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
+ }
@@ -0,0 +1,151 @@
1
+ // `flowiq org flags …` — an org's feature flags (the Settings → Profile
2
+ // switches) from the terminal.
3
+ //
4
+ // flowiq org flags show <org_id> what this org carries (secrets redacted)
5
+ // flowiq org flags list --key <path> [--all] which orgs carry ONE flag, and its value
6
+ // flowiq org flags set <org_id> <key> <value> [--yes] write a registered key (type-checked server-side)
7
+ // flowiq org flags unset <org_id> <key> --yes remove a key (revert to the platform default)
8
+ // flowiq org flags keys what is settable, by Settings section
9
+ //
10
+ // The server (api/cli/org-flags + _org-flags-registry) is the only validator:
11
+ // only keys the Profile tab manages can be set, credentials are never shown
12
+ // or written, dangerous keys need --yes.
13
+
14
+ import { http } from "../http.js";
15
+ import { parseFlagValue, fmtValue } from "../flag-values.js";
16
+
17
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
18
+ const pad = (s, n) => String(s ?? "").padEnd(n);
19
+
20
+ function requireUuid(orgId) {
21
+ if (!UUID_RE.test(orgId || "")) {
22
+ console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list <name>\`).`);
23
+ process.exit(1);
24
+ }
25
+ }
26
+
27
+ function fail(prefix, e) {
28
+ console.error(`${prefix}: ${e.message}`);
29
+ const b = e.body || {};
30
+ if (b.needs_confirm) {
31
+ if (b.danger) console.error(` ⚠ ${b.danger}`);
32
+ if (b.before !== undefined) console.error(` current: ${fmtValue(b.before)}`);
33
+ if (b.would_set !== undefined) console.error(` would set: ${fmtValue(b.would_set)}`);
34
+ console.error(" Re-run with --yes to confirm.");
35
+ } else if (b.settable === false) {
36
+ console.error(" `flowiq org flags keys` lists what can be set; `flowiq org flags show <org>` still READS it.");
37
+ }
38
+ process.exit(1);
39
+ }
40
+
41
+ export async function keys(opts = {}) {
42
+ let resp;
43
+ try { resp = await http.get("org-flags", { keys: "1" }); } catch (e) { fail("Lookup failed", e); }
44
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
45
+ const rows = (resp.keys || []).filter((k) => !opts.section || k.section.toLowerCase().includes(opts.section.toLowerCase()));
46
+ const bySection = new Map();
47
+ for (const k of rows) {
48
+ if (!bySection.has(k.section)) bySection.set(k.section, []);
49
+ bySection.get(k.section).push(k);
50
+ }
51
+ console.log(`Settable feature flags (${rows.length}) — the Settings → Profile switches, by section.\n`);
52
+ for (const [section, list] of bySection) {
53
+ console.log(`${section}`);
54
+ for (const k of list) {
55
+ const type = k.type === "integer" && k.min !== undefined ? `integer ${k.min}-${k.max}` : k.type;
56
+ console.log(` ${pad(k.key, 46)} ${pad(type, 14)}${k.danger ? " ⚠ needs --yes" : ""}`);
57
+ if (k.note) console.log(` ${pad("", 46)} ${k.note}`);
58
+ }
59
+ console.log("");
60
+ }
61
+ console.log("Wildcards (`x.*`) take one more segment, e.g. integrations_visible.instagram.");
62
+ console.log("Anything else on an org is readable via `flowiq org flags show` but not settable here.");
63
+ }
64
+
65
+ export async function show(orgId, opts = {}) {
66
+ requireUuid(orgId);
67
+ let resp;
68
+ try { resp = await http.get("org-flags", { organization_id: orgId }); } catch (e) { fail("Lookup failed", e); }
69
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
70
+
71
+ const o = resp.organization;
72
+ console.log(`${o.name} (${o.id})${o.inactive ? " [INACTIVE]" : ""} — ${resp.key_count} top-level flag key(s)\n`);
73
+ const known = Object.entries(resp.known || {});
74
+ if (known.length) {
75
+ const bySection = new Map();
76
+ for (const [key, v] of known) {
77
+ if (!bySection.has(v.section)) bySection.set(v.section, []);
78
+ bySection.get(v.section).push([key, v.value]);
79
+ }
80
+ for (const [section, list] of bySection) {
81
+ console.log(section);
82
+ for (const [key, value] of list) console.log(` ${pad(key, 46)} ${fmtValue(value, 70)}`);
83
+ console.log("");
84
+ }
85
+ } else {
86
+ console.log("(none of the registered flags are set on this org)\n");
87
+ }
88
+ if (resp.unknown_keys?.length) {
89
+ console.log(`Other keys on this org (readable with --json, not settable from the CLI): ${resp.unknown_keys.join(", ")}`);
90
+ }
91
+ if (resp.secret_keys?.length) {
92
+ console.log(`Credentials present (never shown): ${resp.secret_keys.join(", ")}`);
93
+ }
94
+ console.log("\nChange one: flowiq org flags set <org_id> <key> <value> (see `flowiq org flags keys`)");
95
+ }
96
+
97
+ export async function list(opts = {}) {
98
+ if (!opts.key) { console.error("Error: --key <path> is required (e.g. --key export_insights.enabled)."); process.exit(1); }
99
+ let resp;
100
+ try { resp = await http.get("org-flags", { list: "1", key: opts.key, all: opts.all ? "1" : undefined }); } catch (e) { fail("Lookup failed", e); }
101
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
102
+
103
+ let rows = resp.rows || [];
104
+ if (opts.on) rows = rows.filter((r) => r.value === true);
105
+ if (opts.off) rows = rows.filter((r) => r.value !== true);
106
+ if (opts.present) rows = rows.filter((r) => r.present);
107
+ if (opts.absent) rows = rows.filter((r) => !r.present);
108
+
109
+ const s = resp.summary;
110
+ const label = resp.registered ? ""
111
+ : resp.registered_parent ? ` (sub-key of \`${resp.registered_parent}\` — set it through the parent${resp.registered_parent === "export_insights" ? ", or \`flowiq insights enable\`" : ""})`
112
+ : " (not a registered key — read-only)";
113
+ console.log(`${resp.key}${label} across ${s.orgs} org(s)${resp.include_inactive ? " incl. inactive" : ""}: ` +
114
+ `${s.present} present · ${s.absent} absent` + (s.true || s.false ? ` · ${s.true} true · ${s.false} false` : "") + "\n");
115
+ if (!rows.length) { console.log("(no orgs match the filter)"); return; }
116
+ const nameW = Math.min(34, Math.max(4, ...rows.map((r) => (r.name || "").length)));
117
+ console.log(`${pad("NAME", nameW)} ${pad("ID", 36)} VALUE`);
118
+ for (const r of rows) {
119
+ console.log(`${pad((r.name || "").slice(0, nameW), nameW)} ${pad(r.id, 36)} ${r.present ? fmtValue(r.value, 60) : "(absent)"}${r.inactive ? " (inactive)" : ""}`);
120
+ }
121
+ console.log(`\n${rows.length} org(s) shown`);
122
+ }
123
+
124
+ export async function set(orgId, key, value, opts = {}) {
125
+ requireUuid(orgId);
126
+ let parsed;
127
+ try { parsed = parseFlagValue(value); } catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
128
+ let resp;
129
+ try {
130
+ resp = await http.post("org-flags", { organization_id: orgId, action: "set", key, value: parsed, confirm: opts.yes === true });
131
+ } catch (e) { fail("Set failed", e); }
132
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
133
+ console.log(`${resp.organization.name} — ${resp.key}`);
134
+ console.log(` ${fmtValue(resp.before, 70)} → ${fmtValue(resp.after, 70)}${resp.changed ? "" : " (no change)"}`);
135
+ if (resp.danger) console.log(` ⚠ ${resp.danger}`);
136
+ for (const w of resp.warnings || []) console.log(` ⚠ ${w}`);
137
+ console.log(` Undo: flowiq org flags ${resp.before === null ? `unset ${orgId} ${resp.key} --yes` : `set ${orgId} ${resp.key} '${JSON.stringify(resp.before)}'${resp.danger ? " --yes" : ""}`}`);
138
+ }
139
+
140
+ export async function unset(orgId, key, opts = {}) {
141
+ requireUuid(orgId);
142
+ let resp;
143
+ try {
144
+ resp = await http.post("org-flags", { organization_id: orgId, action: "unset", key, confirm: opts.yes === true });
145
+ } catch (e) { fail("Unset failed", e); }
146
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
147
+ console.log(`${resp.organization.name} — removed ${resp.key}`);
148
+ console.log(` was: ${fmtValue(resp.before, 70)}`);
149
+ for (const w of resp.warnings || []) console.log(` ⚠ ${w}`);
150
+ console.log(` Undo: flowiq org flags set ${orgId} ${resp.key} '${JSON.stringify(resp.before)}'${resp.danger ? " --yes" : ""}`);
151
+ }
@@ -0,0 +1,42 @@
1
+ // Parse the VALUE argument of `flowiq org flags set <org> <key> <value>`.
2
+ //
3
+ // The SERVER is the validator (api/cli/_org-flags-registry.js type-checks
4
+ // against the registry), so this only has to turn what a human typed into the
5
+ // most obvious JSON: `true`/`false` → booleans, a bare integer → number,
6
+ // `[…]`/`{…}` → parsed JSON, `@file.json` → that file's JSON, `null` → null,
7
+ // everything else → the string as typed (the server splits "a,b" for list
8
+ // keys and coerces "true" for booleans, so a plain string is never wrong).
9
+
10
+ import { readFileSync } from "node:fs";
11
+
12
+ export function parseFlagValue(raw) {
13
+ if (raw === undefined || raw === null) throw new Error("a value is required");
14
+ const s = String(raw).trim();
15
+ if (s === "") throw new Error("value must not be empty (use `unset` to remove a key)");
16
+
17
+ if (s.startsWith("@")) {
18
+ const file = s.slice(1);
19
+ let text;
20
+ try { text = readFileSync(file, "utf8"); } catch (e) { throw new Error(`cannot read ${file}: ${e.message}`); }
21
+ try { return JSON.parse(text); } catch (e) { throw new Error(`${file} is not valid JSON: ${e.message}`); }
22
+ }
23
+ const lower = s.toLowerCase();
24
+ if (["true", "yes", "on"].includes(lower)) return true;
25
+ if (["false", "no", "off"].includes(lower)) return false;
26
+ if (lower === "null") return null;
27
+ if (/^-?\d+$/.test(s)) return Number(s);
28
+ if (s.startsWith("[") || s.startsWith("{")) {
29
+ try { return JSON.parse(s); } catch (e) { throw new Error(`value looks like JSON but does not parse: ${e.message}`); }
30
+ }
31
+ return s;
32
+ }
33
+
34
+ /** Render a flag value for a table cell: short, one line, no surprises. */
35
+ export function fmtValue(v, width = 60) {
36
+ if (v === undefined) return "(absent)";
37
+ if (v === null) return "null";
38
+ if (typeof v === "string") return v.length > width ? `${v.slice(0, width - 1)}…` : v;
39
+ if (typeof v !== "object") return String(v);
40
+ const s = JSON.stringify(v);
41
+ return s.length > width ? `${s.slice(0, width - 1)}…` : s;
42
+ }
@@ -0,0 +1,51 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { writeFileSync, mkdtempSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { parseFlagValue, fmtValue } from "./flag-values.js";
7
+
8
+ test("booleans in every spelling a human types", () => {
9
+ for (const t of ["true", "TRUE", "yes", "on"]) assert.equal(parseFlagValue(t), true);
10
+ for (const f of ["false", "no", "off", "OFF"]) assert.equal(parseFlagValue(f), false);
11
+ });
12
+
13
+ test("integers become numbers, decimals stay strings (the server decides)", () => {
14
+ assert.equal(parseFlagValue("5"), 5);
15
+ assert.equal(parseFlagValue("-1"), -1);
16
+ assert.equal(parseFlagValue("1.5"), "1.5");
17
+ });
18
+
19
+ test("JSON arrays and objects parse; a broken one is an error, not a string", () => {
20
+ assert.deepEqual(parseFlagValue('["a","b"]'), ["a", "b"]);
21
+ assert.deepEqual(parseFlagValue('{"enabled":true}'), { enabled: true });
22
+ assert.throws(() => parseFlagValue("[a,b"), /does not parse/);
23
+ });
24
+
25
+ test("comma lists stay strings — the server splits them for string[] keys", () => {
26
+ assert.equal(parseFlagValue("summer,winter"), "summer,winter");
27
+ });
28
+
29
+ test("@file reads JSON from disk", () => {
30
+ const dir = mkdtempSync(path.join(tmpdir(), "flowiq-flags-"));
31
+ const f = path.join(dir, "v.json");
32
+ writeFileSync(f, JSON.stringify({ enabled: true, schedules: [] }));
33
+ assert.deepEqual(parseFlagValue(`@${f}`), { enabled: true, schedules: [] });
34
+ assert.throws(() => parseFlagValue("@/nonexistent/x.json"), /cannot read/);
35
+ });
36
+
37
+ test("empty is refused (unset is a separate verb)", () => {
38
+ assert.throws(() => parseFlagValue(""), /unset/);
39
+ assert.throws(() => parseFlagValue(" "), /unset/);
40
+ });
41
+
42
+ test("null is explicit null", () => {
43
+ assert.equal(parseFlagValue("null"), null);
44
+ });
45
+
46
+ test("fmtValue keeps table cells on one line and marks absence", () => {
47
+ assert.equal(fmtValue(undefined), "(absent)");
48
+ assert.equal(fmtValue(true), "true");
49
+ assert.equal(fmtValue({ a: 1 }), '{"a":1}');
50
+ assert.equal(fmtValue("x".repeat(80), 10).length, 10);
51
+ });
package/src/http.js CHANGED
@@ -7,6 +7,7 @@ import { readFileSync } from "node:fs";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import path from "node:path";
9
9
  import { loadConfig } from "./config.js";
10
+ import { cmpVersion } from "./update-check.js";
10
11
 
11
12
  // Sent on every request as X-Flowiq-Cli-Version so the server-side audit log
12
13
  // records WHICH CLI version made a change (a stale install is a real source of
@@ -17,6 +18,42 @@ try {
17
18
  CLI_VERSION = JSON.parse(readFileSync(path.join(dir, "..", "package.json"), "utf8")).version || "unknown";
18
19
  } catch { /* version is a nice-to-have; never block a request on it */ }
19
20
 
21
+ export const cliVersion = () => CLI_VERSION;
22
+
23
+ // ── Server version contract ─────────────────────────────────────────────────
24
+ // Every api/cli/* response carries X-Flowiq-Min-Version (the oldest client the
25
+ // current server contract supports) and X-Flowiq-Api-Build. We record them on
26
+ // every call, and warn ONCE per process when this install is below the minimum.
27
+ //
28
+ // This is the half npm cannot tell you: `npm i -g` compares against the
29
+ // registry, which says nothing about whether the SERVER still speaks your
30
+ // dialect. It is also instant and offline-safe — it rides on a request you were
31
+ // making anyway, with no registry round-trip and no 24h cache to go stale.
32
+ export const serverContract = { minVersion: null, apiBuild: null };
33
+ let warnedBelowMinimum = false;
34
+
35
+ function noteServerContract(resp) {
36
+ const min = resp.headers.get("x-flowiq-min-version");
37
+ const build = resp.headers.get("x-flowiq-api-build");
38
+ if (min) serverContract.minVersion = min;
39
+ if (build) serverContract.apiBuild = build;
40
+
41
+ if (min && CLI_VERSION !== "unknown" && cmpVersion(min, CLI_VERSION) > 0 && !warnedBelowMinimum) {
42
+ warnedBelowMinimum = true;
43
+ process.stderr.write(
44
+ `\n ⬆ FLOWIQ CLI TOO OLD FOR THE SERVER — installed ${CLI_VERSION}, server requires ${min}\n` +
45
+ ` Run this before continuing: npm i -g @flowapt/flowiq-cli@latest\n` +
46
+ ` (results from this run may be wrong or incomplete)\n\n`
47
+ );
48
+ }
49
+ }
50
+
51
+ /** True when this install is older than the server's declared minimum. */
52
+ export function isBelowServerMinimum() {
53
+ const min = serverContract.minVersion;
54
+ return !!(min && CLI_VERSION !== "unknown" && cmpVersion(min, CLI_VERSION) > 0);
55
+ }
56
+
20
57
  /**
21
58
  * Turn an error body into a STRING a human can act on.
22
59
  *
@@ -97,6 +134,7 @@ async function call(method, endpoint, { query, body } = {}) {
97
134
  }
98
135
 
99
136
  const resp = await fetch(url, init);
137
+ noteServerContract(resp);
100
138
  const text = await resp.text();
101
139
  let parsed, isJson = true;
102
140
  try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { _raw: text }; isJson = false; }
package/src/index.js CHANGED
@@ -19,6 +19,8 @@ import * as hoursCmd from "./commands/hours.js";
19
19
  import * as reportCmd from "./commands/report.js";
20
20
  import * as templatesCmd from "./commands/templates.js";
21
21
  import * as orgCmd from "./commands/org.js";
22
+ import * as orgFlagsCmd from "./commands/org-flags.js";
23
+ import * as insightsCmd from "./commands/insights.js";
22
24
  import * as agentConfigCmd from "./commands/agent-config.js";
23
25
  import * as agentsCmd from "./commands/agents.js";
24
26
  import * as agentUpdatesCmd from "./commands/agent-updates.js";
@@ -35,6 +37,7 @@ import * as guideCmd from "./commands/guide.js";
35
37
  import * as auditCmd from "./commands/audit.js";
36
38
  import * as storeApiCmd from "./commands/store-api.js";
37
39
  import { maybeNotifyUpdate } from "./update-check.js";
40
+ import * as doctorCmd from "./commands/doctor.js";
38
41
 
39
42
  // Read version from package.json so it stays in sync with the published npm
40
43
  // version automatically (single source of truth — bumping package.json on each
@@ -291,7 +294,7 @@ export function run(argv) {
291
294
  .action((orgId, name, opts) => templatesCmd.show(orgId, name, opts));
292
295
 
293
296
  // org (read-only org summary for the prompt-builder skill, creds stripped)
294
- const org = program.command("org").description("Org info (read) + create a new organization");
297
+ const org = program.command("org").description("Org info (read), create a new organization, read/set its feature flags");
295
298
  org.command("list [search]")
296
299
  .description("List organizations with their UUIDs — the lookup every other command needs (filter by name/slug)")
297
300
  .option("--all", "include inactive organizations")
@@ -309,6 +312,84 @@ export function run(argv) {
309
312
  .option("--json", "print the raw JSON payload")
310
313
  .action((orgId, opts) => orgCmd.info(orgId, opts));
311
314
 
315
+ // org flags — organizations.feature_flags (the Settings → Profile switches).
316
+ // Registry-allowlisted writes, credentials never read or written, dangerous
317
+ // keys need --yes. `list --key` is the cross-org census that was SQL-only
318
+ // until 10 Sep 2026.
319
+ const orgFlags = org.command("flags").description("Read + set an org's feature flags (Settings → Profile switches); `list --key` = cross-org census");
320
+ orgFlags.command("show <organization_id>")
321
+ .description("Every flag on the org, by Settings section (credentials redacted)")
322
+ .option("--json", "raw JSON")
323
+ .action((orgId, opts) => orgFlagsCmd.show(orgId, opts));
324
+ orgFlags.command("list")
325
+ .description("Cross-org census of ONE flag: which orgs carry it, and its value")
326
+ .requiredOption("--key <path>", "dotted flag path, e.g. export_insights.enabled or mcp_member_access.enabled")
327
+ .option("--all", "include inactive organizations")
328
+ .option("--on", "only orgs where the value is true")
329
+ .option("--off", "only orgs where the value is not true (false or absent)")
330
+ .option("--present", "only orgs that carry the key")
331
+ .option("--absent", "only orgs that do not carry the key")
332
+ .option("--json", "raw JSON")
333
+ .action((opts) => orgFlagsCmd.list(opts));
334
+ orgFlags.command("set <organization_id> <key> <value>")
335
+ .description("Set a registered flag (true/false, a number, JSON, or @file.json); type-checked server-side")
336
+ .option("--yes", "confirm a dangerous flag (the Profile tab's amber-warning switches)")
337
+ .option("--json", "raw JSON")
338
+ .action((orgId, key, value, opts) => orgFlagsCmd.set(orgId, key, value, opts));
339
+ orgFlags.command("unset <organization_id> <key>")
340
+ .description("Remove a flag key from the org (reverts to the platform default) — always needs --yes")
341
+ .option("--yes", "confirm")
342
+ .option("--json", "raw JSON")
343
+ .action((orgId, key, opts) => orgFlagsCmd.unset(orgId, key, opts));
344
+ orgFlags.command("keys")
345
+ .description("What is settable: every registered flag with its type, section and danger note")
346
+ .option("--section <name>", "filter by Settings section (substring)")
347
+ .option("--json", "raw JSON")
348
+ .action((opts) => orgFlagsCmd.keys(opts));
349
+
350
+ // insights — the Customer Insights Report (feature_flags.export_insights)
351
+ const collectEmail = (v, acc) => (acc || []).concat(v);
352
+ const insights = program.command("insights").description("Customer Insights Report: who has it, enable/disable per org, run one now");
353
+ insights.command("status [organization_id]")
354
+ .description("All orgs (or one): enabled, schedules as the scheduler reads them, recipients, own OpenAI key, last report, chats in 30d")
355
+ .option("--all", "include inactive organizations")
356
+ .option("--on", "only orgs with insights enabled")
357
+ .option("--off", "only orgs with insights NOT enabled")
358
+ .option("--min-chats <n>", "only orgs with at least n chatting contacts in the last 30 days")
359
+ .option("--sort <by>", "chats (default) | name | last")
360
+ .option("--json", "raw JSON")
361
+ .action((orgId, opts) => insightsCmd.status(orgId, opts));
362
+ insights.command("enable <organization_id>")
363
+ .description("Enable the report (or update its config). Cadence flags REPLACE the schedules; none given = keep existing, else weekly Monday")
364
+ .option("--daily", "add a daily schedule")
365
+ .option("--weekly [day]", "add a weekly schedule (day: monday … sunday; default monday)")
366
+ .option("--monthly [day]", "add a monthly schedule (day: 1-28 or last; default last)")
367
+ .option("--report <type>", "standard (default) | advanced — applies to the cadence flags given")
368
+ .option("--store-only", "the schedules given store the report without emailing anyone")
369
+ .option("--recipient <email>", "recipient list (repeatable; REPLACES the existing list)", collectEmail)
370
+ .option("--add-recipient <email>", "add to the existing recipients (repeatable)", collectEmail)
371
+ .option("--remove-recipient <email>", "remove from the existing recipients (repeatable)", collectEmail)
372
+ .option("--dry-run", "show what would be written, write nothing")
373
+ .option("--force", "write even if pre-flight fails (no OpenAI key / no recipients)")
374
+ .option("--json", "raw JSON")
375
+ .action((orgId, opts) => insightsCmd.enable(orgId, opts));
376
+ insights.command("disable <organization_id>")
377
+ .description("Turn the scheduled report off (schedules + recipients are kept)")
378
+ .option("--json", "raw JSON")
379
+ .action((orgId, opts) => insightsCmd.disable(orgId, opts));
380
+ insights.command("run <organization_id>")
381
+ .description("Run one report now (DRY RUN by default — add --commit). Same path as the Profile tab's Run Now")
382
+ .option("--period <p>", "daily (default: last 24h) | weekly (7d) | monthly (30d)")
383
+ .option("--from <date>", "custom window start (YYYY-MM-DD, SAST) — with --to")
384
+ .option("--to <date>", "custom window end (YYYY-MM-DD, SAST) — with --from")
385
+ .option("--report <type>", "standard | advanced (default: the org's schedule for that period, else standard)")
386
+ .option("--recipient <email>", "send to these instead of the configured recipients (repeatable)", collectEmail)
387
+ .option("--store-only", "store the report, email nobody")
388
+ .option("--commit", "actually fire it")
389
+ .option("--force", "fire even if pre-flight fails")
390
+ .option("--json", "raw JSON")
391
+ .action((orgId, opts) => insightsCmd.run(orgId, opts));
392
+
312
393
  // agent (list / create / config — config defaults to the active agent, or --agent <id>)
313
394
  const agent = program.command("agent").description("List, create, and configure an org's agents");
314
395
  agent.command("list <organization_id>")
@@ -783,6 +864,12 @@ export function run(argv) {
783
864
  .option("--out <file>", "write the JSON to a file instead of stdout")
784
865
  .action((orgId, path, opts) => storeApiCmd.wooGet(orgId, path, opts));
785
866
 
867
+ // doctor (version + auth + server contract in one read-only command)
868
+ program.command("doctor")
869
+ .description("Check this install: version vs npm, auth, and whether the server still supports it. Exits 1 if something needs fixing")
870
+ .option("--json", "machine-readable output")
871
+ .action(async (opts) => { process.exitCode = await doctorCmd.doctor(opts); });
872
+
786
873
  // guide (bundled docs — always match the installed version)
787
874
  program.command("guide")
788
875
  .description("Read the team guide (how we use this CLI); --reference for the full command reference")
@@ -0,0 +1,69 @@
1
+ // Pure helpers for `flowiq insights` — turning flags a human types
2
+ // (`--weekly monday`, `--monthly last`) into the `schedules[]` entries the
3
+ // export-insights scheduler reads, and describing a config the way the
4
+ // scheduler will actually run it. No I/O; unit-tested in insights-config.test.mjs.
5
+
6
+ export const DOW = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
7
+ export const DOW_LABEL = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
8
+
9
+ /** "monday" | "mon" | "1" → 1. Bare `--weekly` (true) → Monday, the scheduler's default. */
10
+ export function parseWeekday(v) {
11
+ if (v === true || v === undefined || v === null || v === "") return 1;
12
+ const s = String(v).trim().toLowerCase();
13
+ if (/^[0-6]$/.test(s)) return Number(s);
14
+ const idx = DOW.findIndex((d) => d === s || d.slice(0, 3) === s);
15
+ if (idx >= 0) return idx;
16
+ throw new Error(`--weekly expects a day (monday … sunday, or 0-6), got "${v}"`);
17
+ }
18
+
19
+ /** "last" | "15" → "last" | 15. Bare `--monthly` (true) → "last", the scheduler's default. */
20
+ export function parseMonthlyDay(v) {
21
+ if (v === true || v === undefined || v === null || v === "") return "last";
22
+ const s = String(v).trim().toLowerCase();
23
+ if (s === "last") return "last";
24
+ if (/^\d{1,2}$/.test(s)) {
25
+ const n = Number(s);
26
+ if (n >= 1 && n <= 28) return n;
27
+ }
28
+ throw new Error(`--monthly expects a day of the month (1-28) or "last", got "${v}"`);
29
+ }
30
+
31
+ /**
32
+ * Build schedules[] from the enable flags. Returns null when no cadence flag
33
+ * was given (the server then keeps what exists, or applies its default).
34
+ * opts: { daily?, weekly?, monthly?, report?, storeOnly? }
35
+ */
36
+ export function schedulesFromOpts(opts = {}) {
37
+ const report = opts.report ?? "standard";
38
+ if (!["standard", "advanced"].includes(report)) throw new Error(`--report must be standard or advanced, got "${report}"`);
39
+ const storeOnly = opts.storeOnly === true;
40
+ const out = [];
41
+ if (opts.daily) out.push({ frequency: "daily", report_type: report, store_only: storeOnly });
42
+ if (opts.weekly !== undefined) out.push({ frequency: "weekly", weekly_day: parseWeekday(opts.weekly), report_type: report, store_only: storeOnly });
43
+ if (opts.monthly !== undefined) out.push({ frequency: "monthly", monthly_day: parseMonthlyDay(opts.monthly), report_type: report, store_only: storeOnly });
44
+ return out.length ? out : null;
45
+ }
46
+
47
+ /** One line per schedule, in the scheduler's own vocabulary. */
48
+ export function describeSchedule(s) {
49
+ const tag = `${s.report_type ?? "standard"}${s.store_only ? "/store-only" : ""}`;
50
+ if (s.frequency === "daily") return `daily [${tag}]`;
51
+ if (s.frequency === "weekly") return `weekly ${DOW_LABEL[typeof s.weekly_day === "number" ? s.weekly_day : 1]} [${tag}]`;
52
+ if (s.frequency === "monthly") return `monthly day=${s.monthly_day ?? "last"} [${tag}]`;
53
+ return `${s.frequency} [${tag}]`;
54
+ }
55
+
56
+ /**
57
+ * A YYYY-MM-DD typed by a South African becomes the SAST day boundary, not
58
+ * UTC's — otherwise `--from 2026-09-01` silently starts at 02:00 SAST.
59
+ * `end` covers the whole day. Full ISO strings pass through untouched.
60
+ */
61
+ export function sastRange(from, to) {
62
+ const day = /^\d{4}-\d{2}-\d{2}$/;
63
+ const start = day.test(from) ? new Date(`${from}T00:00:00+02:00`) : new Date(from);
64
+ const end = day.test(to) ? new Date(`${to}T23:59:59.999+02:00`) : new Date(to);
65
+ if (Number.isNaN(start.getTime())) throw new Error(`--from "${from}" is not a date`);
66
+ if (Number.isNaN(end.getTime())) throw new Error(`--to "${to}" is not a date`);
67
+ if (start >= end) throw new Error("--from must be before --to");
68
+ return { start: start.toISOString(), end: end.toISOString() };
69
+ }
@@ -0,0 +1,51 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseWeekday, parseMonthlyDay, schedulesFromOpts, describeSchedule, sastRange } from "./insights-config.js";
4
+
5
+ test("weekday parsing: names, abbreviations, digits, and the bare default", () => {
6
+ assert.equal(parseWeekday("monday"), 1);
7
+ assert.equal(parseWeekday("Fri"), 5);
8
+ assert.equal(parseWeekday("0"), 0);
9
+ assert.equal(parseWeekday(true), 1);
10
+ assert.throws(() => parseWeekday("someday"), /--weekly expects/);
11
+ });
12
+
13
+ test("monthly day: last, 1-28, bare default; 29+ refused (the scheduler never fires them)", () => {
14
+ assert.equal(parseMonthlyDay("last"), "last");
15
+ assert.equal(parseMonthlyDay("15"), 15);
16
+ assert.equal(parseMonthlyDay(true), "last");
17
+ assert.throws(() => parseMonthlyDay("31"), /1-28/);
18
+ });
19
+
20
+ test("no cadence flag → null (server keeps existing / applies default)", () => {
21
+ assert.equal(schedulesFromOpts({}), null);
22
+ assert.equal(schedulesFromOpts({ report: "advanced" }), null);
23
+ });
24
+
25
+ test("cadence flags build scheduler-shaped entries, store-only applies to all", () => {
26
+ const s = schedulesFromOpts({ daily: true, weekly: "monday", monthly: "last", report: "advanced", storeOnly: true });
27
+ assert.deepEqual(s, [
28
+ { frequency: "daily", report_type: "advanced", store_only: true },
29
+ { frequency: "weekly", weekly_day: 1, report_type: "advanced", store_only: true },
30
+ { frequency: "monthly", monthly_day: "last", report_type: "advanced", store_only: true },
31
+ ]);
32
+ });
33
+
34
+ test("bad report type is refused before anything reaches the server", () => {
35
+ assert.throws(() => schedulesFromOpts({ daily: true, report: "fancy" }), /--report must be/);
36
+ });
37
+
38
+ test("describeSchedule speaks the scheduler's vocabulary", () => {
39
+ assert.equal(describeSchedule({ frequency: "daily", report_type: "standard", store_only: true }), "daily [standard/store-only]");
40
+ assert.equal(describeSchedule({ frequency: "weekly", weekly_day: 3 }), "weekly Wednesday [standard]");
41
+ assert.equal(describeSchedule({ frequency: "weekly" }), "weekly Monday [standard]");
42
+ assert.equal(describeSchedule({ frequency: "monthly", report_type: "advanced" }), "monthly day=last [advanced]");
43
+ });
44
+
45
+ test("date-only --from/--to are SAST day boundaries, not UTC", () => {
46
+ const r = sastRange("2026-09-01", "2026-09-07");
47
+ assert.equal(r.start, "2026-08-31T22:00:00.000Z");
48
+ assert.equal(r.end, "2026-09-07T21:59:59.999Z");
49
+ assert.throws(() => sastRange("2026-09-07", "2026-09-01"), /before/);
50
+ assert.throws(() => sastRange("yesterday", "2026-09-01"), /not a date/);
51
+ });
@@ -4,6 +4,14 @@
4
4
  // can never corrupt piped / --json output) when the installed version is older
5
5
  // than the latest published on npm.
6
6
  //
7
+ // ⚠️ It used to be gated on `process.stderr.isTTY` as well, which meant it was
8
+ // invisible to exactly the audience that most needs it: an AI assistant running
9
+ // `flowiq` through a tool call gets a PIPE, not a TTY, so isTTY is undefined and
10
+ // the hint never printed. Teammates' Claude sessions could therefore run a
11
+ // months-old CLI forever without ever being told. Writing to stderr is already
12
+ // the whole safety property (it cannot corrupt piped stdout or --json), so the
13
+ // TTY gate bought nothing and cost everything. Removed 9 Sep 2026.
14
+ //
7
15
  // The nudge is driven by a CACHED value read SYNCHRONOUSLY — zero network on the
8
16
  // hot path, so no command is ever slowed. The cache is refreshed at most once a
9
17
  // day by a DETACHED, unref'd child process, so even `flowiq --version` returns
@@ -88,16 +96,42 @@ async function refreshLatest(prevLatest) {
88
96
 
89
97
  // Print the nudge (from cache, synchronously) and, if the cache is stale, spawn
90
98
  // a detached child to refresh it for the NEXT run. Never throws, never blocks.
99
+ /** The cached npm `latest` + when it was refreshed — used by `flowiq doctor`. */
100
+ export function cachedLatest() {
101
+ const c = readCacheSync();
102
+ return { latest: c?.latest || null, checkedAt: c?.checked_at || null };
103
+ }
104
+
105
+ /** Ask npm for the latest version NOW (doctor only — never on the hot path). */
106
+ export async function fetchLatestNow(timeoutMs = FETCH_TIMEOUT_MS) {
107
+ const ctrl = new AbortController();
108
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
109
+ try {
110
+ const res = await fetch(REGISTRY, { signal: ctrl.signal, headers: { Accept: "application/json" } });
111
+ if (!res.ok) return null;
112
+ const body = await res.json();
113
+ if (body?.version) await writeCache({ checked_at: Date.now(), latest: body.version });
114
+ return body?.version || null;
115
+ } catch {
116
+ return null;
117
+ } finally {
118
+ clearTimeout(timer);
119
+ }
120
+ }
121
+
91
122
  export function maybeNotifyUpdate(currentVersion) {
92
123
  try {
93
124
  if (disabled()) return;
94
125
  const cache = readCacheSync();
95
126
  const latest = cache?.latest;
96
127
 
97
- if (latest && process.stderr.isTTY && cmpVersion(latest, currentVersion) > 0) {
128
+ if (latest && cmpVersion(latest, currentVersion) > 0) {
129
+ // The marker line is deliberately stable and unambiguous: an assistant
130
+ // reading this in a tool result should update without needing to be asked.
98
131
  process.stderr.write(
99
- `\n ⬆ flowiq ${latest} is available (you're on ${currentVersion})\n` +
100
- ` update: npm i -g ${PKG}\n\n`
132
+ `\n ⬆ FLOWIQ CLI OUT OF DATE — installed ${currentVersion}, latest ${latest}\n` +
133
+ ` Run this before continuing: npm i -g ${PKG}@latest\n` +
134
+ ` (commands still work; newer ones may be missing or behave differently)\n\n`
101
135
  );
102
136
  }
103
137