@flowapt/flowiq-cli 0.3.7 → 0.3.8

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
@@ -88,6 +88,32 @@ Notes:
88
88
  single "Main" section from `settings.system_prompt` and returns
89
89
  `bootstrapped: true`. The CLI prints a heads-up before push.
90
90
 
91
+ #### Prompt version history — `flowiq prompts history|restore` (v0.3.8)
92
+
93
+ A push overwrites `prompt_sections` in place, so the previous prompt used to
94
+ be gone for good. Since v0.3.8 every push **snapshots the prompt it replaced**
95
+ into the audit log, making every past version retrievable.
96
+
97
+ ```bash
98
+ flowiq prompts history <organization_id> # every recorded push: when / who / size
99
+ flowiq prompts restore <organization_id> <audit_id> # DRY-RUN (writes a working copy)
100
+ flowiq prompts restore <organization_id> <audit_id> --commit # make it live
101
+ flowiq prompts restore <organization_id> <audit_id> --after --commit
102
+ ```
103
+
104
+ - `history` lists each push as `<prior size> → <new size>` with the audit id
105
+ to feed `restore`. `--agent <id>` scopes to a non-active agent.
106
+ - `restore` **defaults to the version the push REPLACED** (i.e. "undo this
107
+ push"). `--after` restores the version that push made live — use it to jump
108
+ back to a specific older prompt rather than undoing one step.
109
+ - **Dry-run by default.** It always writes
110
+ `.flowiq/prompts/<org>-restore-<audit8>.json` so you can read or edit the
111
+ recovered version first; `--commit` pushes it.
112
+ - A restore goes through the normal push path, so it gets the same validation
113
+ **and its own audit row** — a rollback is never an untracked edit.
114
+ - History starts at the first push after v0.3.8 shipped. Versions overwritten
115
+ before that were never snapshotted and cannot be recovered.
116
+
91
117
  ### Questionnaires — `flowiq questionnaires pull|push <slug>` (alias `q`)
92
118
 
93
119
  Round-trips `agents.questionnaire` JSONB.
@@ -390,6 +416,19 @@ flowiq kw push <slug> --prune # ALSO deletes DB keywords missing from
390
416
  collapses to 1 at runtime — rejected).
391
417
  - `field:"attributes"` actions are warned (full jsonb replace; constant
392
418
  values only) but applied — this CLI is their only safe editing surface.
419
+ - **Action types accepted** (all seven the runtime implements):
420
+ `send_message` · `update_contact_field` · `add_contact_tag` ·
421
+ `remove_contact_tag` · `set_agent` · `delay` · `combined`.
422
+ Per-type rules: tag actions need a non-empty `tags[]` (or a single `tag`
423
+ string) or the runtime writes no tag at all; `set_agent.agent_id` must be
424
+ an agent UUID, or `null`/`""` to CLEAR the contact's binding (warned, since
425
+ clearing is easy to do by accident); `delay.ms` is a number ≥ 0 and is
426
+ **clamped to 5000ms** at runtime (Meta's webhook ack window) — a larger
427
+ value is accepted with a warning that the extra wait never happens;
428
+ `combined.actions[]` sub-types are checked and warned, not blocked.
429
+ *(Before v0.3.9 only the first two + `combined` were accepted, so a
430
+ zero-edit pull→push failed for any org using tag / set_agent / delay
431
+ actions.)*
393
432
  - `action_config.link_preview: false` disables WhatsApp's link-preview card
394
433
  on that action's text send (absent/`true` = preview on, the default).
395
434
  Passed through verbatim; also toggleable per action in the dashboard.
@@ -410,6 +449,59 @@ flowiq m pull <contact_id> --count 25
410
449
 
411
450
  `--count` defaults to 25, max 200.
412
451
 
452
+ ### Audit log — `flowiq audit [org] | audit show <id>` (v0.3.8)
453
+
454
+ **Who changed what, when — with the full before/after content.** Every
455
+ mutating CLI action writes a `staff_cli_audit` row server-side: the staff user
456
+ + key, the endpoint + action, the org / agent / target, the CLI version and
457
+ IP, and the complete prior + new state of whatever it changed. It's what makes
458
+ `prompts history` / `restore` possible, and it's the answer to "who pushed
459
+ that?" and "what did this look like last week?".
460
+
461
+ ```bash
462
+ flowiq audit <organization_id> # recent activity on an org
463
+ flowiq audit # across all orgs
464
+ flowiq audit <org> --endpoint prompts --action push # one surface
465
+ flowiq audit <org> --user matt --since 2026-07-01 # one person, one window
466
+ flowiq audit <org> --status blocked # attempts that were REFUSED
467
+ flowiq audit show <audit_id> # one entry in full
468
+ flowiq audit show <audit_id> --content # + the before/after payloads
469
+ flowiq audit show <audit_id> --out entry.json # dump the whole entry
470
+ ```
471
+
472
+ Filters: `--endpoint --action --status --agent --target --user --since --until
473
+ --limit` (default 50, max 200) `--json`.
474
+
475
+ **What's captured, and how much:**
476
+
477
+ | Surface | Action | Content stored |
478
+ |---|---|---|
479
+ | `prompts` / `questionnaires` / `fine-tuning` / `knowledge` / `custom-tools` / `keywords` / `flowmod` | push | **full before + after** |
480
+ | `custom-tools` | enable / disable, **blocked** destructive push | full before + after |
481
+ | `agent-config` | config | before + after snapshot + the changed keys |
482
+ | `agents` / `org` / `meta-templates` | create | the created record / submitted request |
483
+ | `messaging-webhooks` / `webhooks` | push, reconnect | full before + after |
484
+ | `pinboard` | push | full prior row + the new one |
485
+ | `agent-updates` | resolve | prior status + the client-facing note |
486
+ | `broadcast` | **send (committed)**, retry | the exact payload sent + the resulting broadcastId |
487
+ | `segments` | apply / untag | the batch tags + their contact ids |
488
+ | `tag` | field/cohort/segment/attributes/messages/remove | the tag + the exact selector + counts (not per-contact) |
489
+ | `contacts-upsert` | upsert | the upserted rows |
490
+ | `agent-stress` | cleanup | the deleted test contacts |
491
+ | `messages` / `export-chats` | read / export | **metadata only** — who read whose chat, never a second copy of it |
492
+
493
+ Notes:
494
+ - **Dry-runs are not audited** (they change nothing) — but a **blocked**
495
+ destructive push IS, with `status=blocked`.
496
+ - Listings never include content, so they stay small over 95k-char prompts;
497
+ `audit show` fetches it.
498
+ - Payloads over 2MB are stored truncated with a marker rather than dropped —
499
+ an oversized push is never failed by the audit layer.
500
+ - An audit write can never break your action: it's best-effort and swallows
501
+ its own errors.
502
+ - **Read-only, and there is no `audit delete`.** An audit log you can edit
503
+ isn't an audit log.
504
+
413
505
  ### Webhooks (Shopify/WooCommerce) — `flowiq webhooks pull|push|reconnect` (alias `wh`)
414
506
 
415
507
  Round-trips the **inbound** Shopify or WooCommerce platform webhooks (the
package/TEAM-GUIDE.md CHANGED
@@ -77,7 +77,7 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
77
77
  | Edit the text knowledge sources (get_more_answers playbooks) | `flowiq kn pull <org_id>` → edit `sources[]` → `flowiq kn push <slug>` |
78
78
  | Edit the custom tools (API-call tools) | `flowiq ct pull <org_id>` → edit `tools[]` → `flowiq ct push <slug> --dry-run` → `flowiq ct push <slug>` |
79
79
  | Turn one custom tool on/off | `flowiq ct enable\|disable <org_id> <tool_name>` |
80
- | Edit keyword auto-replies (incl. competition entry keywords) | `flowiq kw pull <org_id>` → edit → `flowiq kw push <slug>` |
80
+ | Edit keyword auto-replies (incl. competition entry keywords, add/remove-tag, set-agent and delay actions) | `flowiq kw pull <org_id>` → edit → `flowiq kw push <slug> --dry-run` → `flowiq kw push <slug>` |
81
81
  | See an org's agents / create one | `flowiq agent list <org_id>` / `flowiq agent create <org_id> --name "…"` |
82
82
  | Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
83
83
  | Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
@@ -96,6 +96,9 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
96
96
  | Check how a broadcast is landing (read/delivered/sent/failed) | `flowiq bc status <org_id> <broadcastId>` (the `broadcastId` a `--python` send prints) |
97
97
  | See WHO failed on a broadcast + why (Meta error) | `flowiq bc status <org_id> <broadcastId> --failures` |
98
98
  | Re-send a broadcast to only the ones that failed | `flowiq bc retry <org_id> <broadcastId>` (dry-run) → `… --commit` |
99
+ | **Get an OLD version of a prompt back** | `flowiq prompts history <org_id>` (pick the version) → `flowiq prompts restore <org_id> <audit_id>` (dry-run) → `… --commit` |
100
+ | **See who changed what, and when** | `flowiq audit <org_id>` — add `--endpoint prompts`, `--user <name>`, `--since 2026-07-01` to narrow |
101
+ | See exactly what a change looked like (before → after) | `flowiq audit show <audit_id> --content` (or `--out entry.json`) |
99
102
  | Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
100
103
  | Export an org's full chat history | `flowiq export chats <org_id>` |
101
104
  | Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
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,170 @@
1
+ // `flowiq audit` — read the staff-CLI audit trail.
2
+ //
3
+ // Every mutating CLI action writes a `staff_cli_audit` row server-side
4
+ // (api/cli/_audit.js) carrying who / what / when / where PLUS the full
5
+ // before+after content. This command is the read side:
6
+ //
7
+ // flowiq audit <org> — recent activity on an org
8
+ // flowiq audit --endpoint prompts — filter by surface
9
+ // flowiq audit --user matt --since 2026-07-01
10
+ // flowiq audit show <audit_id> — one entry, full detail
11
+ // flowiq audit show <audit_id> --content — + the before/after payloads
12
+ // flowiq audit show <audit_id> --out f.json — dump the entry to a file
13
+ //
14
+ // Read-only by design: there is no `audit delete`. An audit log you can edit
15
+ // isn't an audit log.
16
+
17
+ import fs from "node:fs/promises";
18
+ import path from "node:path";
19
+ import { http } from "../http.js";
20
+
21
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+
23
+ /** 2026-07-27 14:32 SAST — the log is read by humans in SAST. */
24
+ export function fmtSast(iso) {
25
+ if (!iso) return "—";
26
+ try {
27
+ const d = new Date(iso);
28
+ const s = new Intl.DateTimeFormat("en-ZA", {
29
+ timeZone: "Africa/Johannesburg",
30
+ year: "numeric", month: "2-digit", day: "2-digit",
31
+ hour: "2-digit", minute: "2-digit", hour12: false,
32
+ }).format(d);
33
+ // en-ZA gives "2026/07/27, 14:32"
34
+ return s.replace(/\//g, "-").replace(",", "");
35
+ } catch {
36
+ return iso;
37
+ }
38
+ }
39
+
40
+ function pad(s, n) {
41
+ const str = String(s ?? "");
42
+ return str.length >= n ? str.slice(0, n) : str + " ".repeat(n - str.length);
43
+ }
44
+
45
+ const STATUS_MARK = { ok: " ", blocked: "⛔", failed: "✗" };
46
+
47
+ export async function list(orgId, opts = {}) {
48
+ if (orgId && !UUID_RE.test(orgId)) {
49
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
50
+ process.exit(1);
51
+ }
52
+ let resp;
53
+ try {
54
+ resp = await http.get("audit", {
55
+ organization_id: orgId,
56
+ agent_id: opts.agent,
57
+ endpoint: opts.endpoint,
58
+ action: opts.action,
59
+ status: opts.status,
60
+ target_id: opts.target,
61
+ user: opts.user,
62
+ since: opts.since,
63
+ until: opts.until,
64
+ limit: opts.limit,
65
+ });
66
+ } catch (e) {
67
+ console.error(`Audit query failed: ${e.message}`);
68
+ process.exit(1);
69
+ }
70
+
71
+ if (opts.json) {
72
+ console.log(JSON.stringify(resp, null, 2));
73
+ return;
74
+ }
75
+
76
+ const entries = resp.entries || [];
77
+ if (!entries.length) {
78
+ console.log("No audit entries match those filters.");
79
+ return;
80
+ }
81
+
82
+ console.log("");
83
+ console.log(`${entries.length} entr${entries.length === 1 ? "y" : "ies"} (newest first)`);
84
+ console.log("");
85
+ console.log(` ${pad("WHEN (SAST)", 17)} ${pad("WHO", 24)} ${pad("WHAT", 26)} SUMMARY`);
86
+ console.log(` ${"-".repeat(17)} ${"-".repeat(24)} ${"-".repeat(26)} ${"-".repeat(40)}`);
87
+ for (const e of entries) {
88
+ const who = (e.user_email || "—").replace(/@.*$/, "");
89
+ const what = `${e.endpoint} ${e.action}`;
90
+ const mark = STATUS_MARK[e.status] ?? " ";
91
+ console.log(`${mark} ${pad(fmtSast(e.created_at), 17)} ${pad(who, 24)} ${pad(what, 26)} ${e.summary ?? ""}`);
92
+ if (!orgId && e.organization_name) {
93
+ console.log(` ${" ".repeat(17)} ${" ".repeat(24)} ${" ".repeat(26)} └ org: ${e.organization_name}`);
94
+ }
95
+ }
96
+ console.log("");
97
+ console.log(` ids: ${entries.map((e) => e.id.slice(0, 8)).join(" ")}`);
98
+ console.log(` detail: flowiq audit show <audit_id> (full id, or the 8-char prefix won't work — copy from --json)`);
99
+ console.log("");
100
+ }
101
+
102
+ export async function show(auditId, opts = {}) {
103
+ if (!UUID_RE.test(auditId)) {
104
+ console.error(`Error: "${auditId}" is not a valid audit entry id (uuid).`);
105
+ process.exit(1);
106
+ }
107
+ let resp;
108
+ try {
109
+ resp = await http.get("audit", { id: auditId });
110
+ } catch (e) {
111
+ console.error(`Audit lookup failed: ${e.message}`);
112
+ process.exit(1);
113
+ }
114
+ const e = resp.entry;
115
+
116
+ if (opts.out) {
117
+ const outPath = path.resolve(opts.out);
118
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
119
+ await fs.writeFile(outPath, JSON.stringify(e, null, 2) + "\n", "utf8");
120
+ console.log(`Wrote ${outPath}`);
121
+ return;
122
+ }
123
+ if (opts.json) {
124
+ console.log(JSON.stringify(e, null, 2));
125
+ return;
126
+ }
127
+
128
+ console.log("");
129
+ console.log(` ${e.endpoint} ${e.action}${e.status !== "ok" ? ` [${e.status.toUpperCase()}]` : ""}`);
130
+ console.log(` ${e.summary ?? ""}`);
131
+ console.log("");
132
+ console.log(` when: ${fmtSast(e.created_at)} SAST`);
133
+ console.log(` who: ${e.user_email ?? "—"}`);
134
+ if (e.organization_name || e.organization_id) {
135
+ console.log(` org: ${e.organization_name ?? "—"} (${e.organization_id ?? "—"})`);
136
+ }
137
+ if (e.agent_id) console.log(` agent: ${e.agent_name ?? "—"} (${e.agent_id})`);
138
+ if (e.target_id) console.log(` target: ${e.target_id}`);
139
+ if (e.cli_version) console.log(` cli: v${e.cli_version}`);
140
+ if (e.ip) console.log(` ip: ${e.ip}`);
141
+ if (e.error) console.log(` error: ${e.error}`);
142
+ console.log(` id: ${e.id}`);
143
+
144
+ if (e.metadata && Object.keys(e.metadata).length) {
145
+ console.log("");
146
+ console.log(" metadata:");
147
+ for (const line of JSON.stringify(e.metadata, null, 2).split("\n")) console.log(` ${line}`);
148
+ }
149
+
150
+ const beforeChars = e.before_content ? JSON.stringify(e.before_content).length : 0;
151
+ const afterChars = e.after_content ? JSON.stringify(e.after_content).length : 0;
152
+ console.log("");
153
+ console.log(` content: before ${beforeChars.toLocaleString()} chars · after ${afterChars.toLocaleString()} chars`);
154
+
155
+ if (opts.content) {
156
+ if (e.before_content) {
157
+ console.log("");
158
+ console.log(" ── BEFORE ──────────────────────────────────────────────");
159
+ console.log(JSON.stringify(e.before_content, null, 2));
160
+ }
161
+ if (e.after_content) {
162
+ console.log("");
163
+ console.log(" ── AFTER ───────────────────────────────────────────────");
164
+ console.log(JSON.stringify(e.after_content, null, 2));
165
+ }
166
+ } else if (beforeChars || afterChars) {
167
+ console.log(` (use --content to print them, or --out <file>.json to dump the whole entry)`);
168
+ }
169
+ console.log("");
170
+ }
@@ -6,6 +6,7 @@
6
6
  import fs from "node:fs/promises";
7
7
  import path from "node:path";
8
8
  import { http } from "../http.js";
9
+ import { fmtSast } from "./audit.js";
9
10
 
10
11
  const PROMPTS_DIR = path.resolve(process.cwd(), ".flowiq", "prompts");
11
12
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -125,3 +126,175 @@ export async function push(identifier) {
125
126
  console.log(` content: ${resp.total_chars.toLocaleString()} chars`);
126
127
  console.log(` prompt now: ${resp.system_prompt_chars.toLocaleString()} chars`);
127
128
  }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // history / restore — powered by the staff-CLI audit log (staff_cli_audit).
132
+ // Every `prompts push` snapshots the prompt it replaced, so every past version
133
+ // is retrievable even though the agents row itself only holds the latest.
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /** `flowiq prompts history <org>` — every recorded push, newest first. */
137
+ export async function history(orgId, opts = {}) {
138
+ if (!UUID_RE.test(orgId)) {
139
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
140
+ process.exit(1);
141
+ }
142
+ let resp;
143
+ try {
144
+ resp = await http.get("audit", {
145
+ organization_id: orgId,
146
+ agent_id: opts.agent,
147
+ endpoint: "prompts",
148
+ action: "push",
149
+ limit: opts.limit,
150
+ });
151
+ } catch (e) {
152
+ console.error(`History lookup failed: ${e.message}`);
153
+ process.exit(1);
154
+ }
155
+
156
+ if (opts.json) {
157
+ console.log(JSON.stringify(resp, null, 2));
158
+ return;
159
+ }
160
+
161
+ const entries = resp.entries || [];
162
+ if (!entries.length) {
163
+ console.log("");
164
+ console.log("No prompt-push history recorded for this org yet.");
165
+ console.log("(History starts from the first push after the audit log shipped —");
166
+ console.log(" earlier versions were never snapshotted and can't be recovered.)");
167
+ console.log("");
168
+ return;
169
+ }
170
+
171
+ console.log("");
172
+ console.log(`Prompt history — ${entries[0].organization_name ?? orgId} (${entries.length} push${entries.length === 1 ? "" : "es"}, newest first)`);
173
+ console.log("");
174
+ for (const e of entries) {
175
+ const m = e.metadata || {};
176
+ const from = `${m.prior_sections ?? "?"} sec / ${(m.prior_total_chars ?? 0).toLocaleString()} chars`;
177
+ const to = `${m.sections ?? "?"} sec / ${(m.total_chars ?? 0).toLocaleString()} chars`;
178
+ console.log(` ${fmtSast(e.created_at)} SAST ${e.user_email ?? "—"}`);
179
+ console.log(` ${from} → ${to}${e.agent_name ? ` [agent: ${e.agent_name}]` : ""}`);
180
+ console.log(` ${e.id}`);
181
+ console.log("");
182
+ }
183
+ console.log(" Restore the prompt as it was BEFORE one of these pushes:");
184
+ console.log(` flowiq prompts restore ${orgId} <id>`);
185
+ console.log(" …or re-install the version a push made live:");
186
+ console.log(` flowiq prompts restore ${orgId} <id> --after`);
187
+ console.log("");
188
+ }
189
+
190
+ /**
191
+ * `flowiq prompts restore <org> <audit_id>` — re-push a past version.
192
+ *
193
+ * Default restores `before_content` ("undo this push"); `--after` restores the
194
+ * version that push made live. The restore itself goes through the ordinary
195
+ * /cli/prompts POST, so it gets the same validation AND its own audit row —
196
+ * a rollback is never an untracked edit.
197
+ */
198
+ export async function restore(orgId, auditId, opts = {}) {
199
+ if (!UUID_RE.test(orgId)) {
200
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
201
+ process.exit(1);
202
+ }
203
+ if (!UUID_RE.test(auditId)) {
204
+ console.error(`Error: "${auditId}" is not a valid audit entry id (get one from \`flowiq prompts history\`).`);
205
+ process.exit(1);
206
+ }
207
+
208
+ let entry;
209
+ try {
210
+ entry = (await http.get("audit", { id: auditId })).entry;
211
+ } catch (e) {
212
+ console.error(`Audit lookup failed: ${e.message}`);
213
+ process.exit(1);
214
+ }
215
+
216
+ if (entry.endpoint !== "prompts") {
217
+ console.error(`Error: audit entry ${auditId} is a "${entry.endpoint} ${entry.action}", not a prompt push.`);
218
+ process.exit(1);
219
+ }
220
+ if (entry.organization_id !== orgId) {
221
+ console.error(`Error: that audit entry belongs to a different org (${entry.organization_name ?? entry.organization_id}).`);
222
+ process.exit(1);
223
+ }
224
+
225
+ const side = opts.after ? "after_content" : "before_content";
226
+ const label = opts.after ? "the version this push MADE LIVE" : "the version this push REPLACED";
227
+ const snapshot = entry[side];
228
+ const sections = snapshot?.prompt_sections;
229
+ if (!Array.isArray(sections) || sections.length === 0) {
230
+ console.error(`Error: that entry has no ${opts.after ? "after" : "before"} prompt to restore.`);
231
+ if (!opts.after) console.error("(If this was the very first recorded push, there was no prior version — try --after on an older entry.)");
232
+ process.exit(1);
233
+ }
234
+ if (snapshot._truncated) {
235
+ console.error("Error: that snapshot was truncated when stored (oversized payload) and can't be restored safely.");
236
+ process.exit(1);
237
+ }
238
+
239
+ const totalChars = sections.reduce((a, s) => a + (s.content?.length || 0), 0);
240
+ console.log("");
241
+ console.log(`Restoring ${label}:`);
242
+ console.log(` from push: ${fmtSast(entry.created_at)} SAST by ${entry.user_email ?? "—"}`);
243
+ console.log(` org: ${entry.organization_name ?? orgId}`);
244
+ console.log(` agent: ${entry.agent_name ?? "—"} (${entry.agent_id})`);
245
+ console.log(` sections: ${sections.length}`);
246
+ console.log(` content: ${totalChars.toLocaleString()} chars`);
247
+ console.log("");
248
+
249
+ // Always write the working copy — even on a dry run, so the restored version
250
+ // can be inspected/edited before it goes live.
251
+ await fs.mkdir(PROMPTS_DIR, { recursive: true });
252
+ const orgSlug = slugify(entry.organization_name, orgId);
253
+ const filePath = path.join(PROMPTS_DIR, `${orgSlug}-restore-${auditId.slice(0, 8)}.json`);
254
+ await fs.writeFile(
255
+ filePath,
256
+ JSON.stringify(
257
+ {
258
+ organization_id: orgId,
259
+ organization_name: entry.organization_name,
260
+ agent_id: entry.agent_id,
261
+ agent_name: entry.agent_name,
262
+ restored_from_audit_id: auditId,
263
+ restored_side: opts.after ? "after" : "before",
264
+ prompt_sections: sections,
265
+ },
266
+ null,
267
+ 2
268
+ ) + "\n",
269
+ "utf8"
270
+ );
271
+ console.log(` working copy: ${filePath}`);
272
+
273
+ if (!opts.commit) {
274
+ console.log("");
275
+ console.log(" DRY RUN — nothing was pushed. Re-run with --commit to make this live:");
276
+ console.log(` flowiq prompts restore ${orgId} ${auditId}${opts.after ? " --after" : ""} --commit`);
277
+ console.log(` …or edit the file above and \`flowiq prompts push ${path.basename(filePath, ".json")}\`.`);
278
+ console.log("");
279
+ return;
280
+ }
281
+
282
+ let resp;
283
+ try {
284
+ resp = await http.post("prompts", {
285
+ organization_id: orgId,
286
+ prompt_sections: sections,
287
+ agent_id: entry.agent_id,
288
+ });
289
+ } catch (e) {
290
+ console.error(`Restore push failed: ${e.message}`);
291
+ process.exit(1);
292
+ }
293
+
294
+ console.log("");
295
+ console.log(` ✓ Restored — ${resp.agent_name} (${resp.agent_id})`);
296
+ console.log(` sections: ${resp.sections}`);
297
+ console.log(` prompt now: ${resp.system_prompt_chars.toLocaleString()} chars`);
298
+ console.log(` (this restore is itself recorded in the audit log)`);
299
+ console.log("");
300
+ }
package/src/http.js CHANGED
@@ -3,8 +3,20 @@
3
3
  // - carry Authorization: Bearer <token>
4
4
  // - parse JSON, surfacing { error, message } shape with a readable error.
5
5
 
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+ import path from "node:path";
6
9
  import { loadConfig } from "./config.js";
7
10
 
11
+ // Sent on every request as X-Flowiq-Cli-Version so the server-side audit log
12
+ // records WHICH CLI version made a change (a stale install is a real source of
13
+ // surprising pushes).
14
+ let CLI_VERSION = "unknown";
15
+ try {
16
+ const dir = path.dirname(fileURLToPath(import.meta.url));
17
+ CLI_VERSION = JSON.parse(readFileSync(path.join(dir, "..", "package.json"), "utf8")).version || "unknown";
18
+ } catch { /* version is a nice-to-have; never block a request on it */ }
19
+
8
20
  async function call(method, endpoint, { query, body } = {}) {
9
21
  const cfg = await loadConfig();
10
22
  if (!cfg.token) {
@@ -27,6 +39,7 @@ async function call(method, endpoint, { query, body } = {}) {
27
39
  headers: {
28
40
  Authorization: `Bearer ${cfg.token}`,
29
41
  Accept: "application/json",
42
+ "X-Flowiq-Cli-Version": CLI_VERSION,
30
43
  },
31
44
  };
32
45
  if (body !== undefined) {
@@ -36,8 +49,8 @@ async function call(method, endpoint, { query, body } = {}) {
36
49
 
37
50
  const resp = await fetch(url, init);
38
51
  const text = await resp.text();
39
- let parsed;
40
- try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { _raw: text }; }
52
+ let parsed, isJson = true;
53
+ try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { _raw: text }; isJson = false; }
41
54
  if (!resp.ok) {
42
55
  const err = new Error(
43
56
  parsed.message || parsed.error || `${method} ${endpoint} → HTTP ${resp.status}`
@@ -46,6 +59,28 @@ async function call(method, endpoint, { query, body } = {}) {
46
59
  err.body = parsed;
47
60
  throw err;
48
61
  }
62
+ // A 2xx that is not JSON is never a real API response — it is an HTML page
63
+ // (Vercel SSO / login / error page, or the SPA's index.html served for an
64
+ // endpoint that doesn't exist on that deployment). Returning it let callers
65
+ // "succeed" on garbage: a pull wrote `.flowiq/<topic>/undefined.json` with the
66
+ // HTML stuffed in `_raw`, masking an auth or deploy failure as a good pull.
67
+ if (!isJson) {
68
+ const ctype = resp.headers.get("content-type") || "none";
69
+ const looksHtml = /^\s*<(?:!doctype|html)\b/i.test(text);
70
+ const err = new Error(
71
+ `${method} ${endpoint} → HTTP ${resp.status} returned ${looksHtml ? "an HTML page" : "a non-JSON body"} ` +
72
+ `(content-type: ${ctype}), not an API response.\n` +
73
+ ` URL: ${url}\n` +
74
+ ` Likely: an SSO / login / error page at that host, a wrong api_url, or this endpoint ` +
75
+ `isn't deployed there.\n` +
76
+ ` Check: flowiq auth whoami · api_url in ~/.config/flowiq/auth.json\n` +
77
+ ` Body: ${text.slice(0, 140).replace(/\s+/g, " ").trim()}…`
78
+ );
79
+ err.status = resp.status;
80
+ err.code = "ENONJSON";
81
+ err.body = parsed;
82
+ throw err;
83
+ }
49
84
  return parsed;
50
85
  }
51
86
 
package/src/index.js CHANGED
@@ -29,6 +29,7 @@ import * as segmentsCmd from "./commands/segments.js";
29
29
  import * as tagCmd from "./commands/tag.js";
30
30
  import * as keywordsCmd from "./commands/keywords.js";
31
31
  import * as guideCmd from "./commands/guide.js";
32
+ import * as auditCmd from "./commands/audit.js";
32
33
  import { maybeNotifyUpdate } from "./update-check.js";
33
34
 
34
35
  // Read version from package.json so it stays in sync with the published npm
@@ -67,6 +68,17 @@ export function run(argv) {
67
68
  .description("Fetch an agent's prompt_sections into a local JSON file (default: active agent)")
68
69
  .option("--agent <id>", "target a specific agent instead of the org's active one")
69
70
  .action((orgId, opts) => promptsCmd.pull(orgId, opts));
71
+ prompts.command("history <organization_id>")
72
+ .description("Every recorded prompt push (who / when / size) — the version list for `restore`")
73
+ .option("--agent <id>", "history for a specific (non-active) agent")
74
+ .option("--limit <n>", "max entries (default 50, max 200)")
75
+ .option("--json", "raw JSON")
76
+ .action((orgId, opts) => promptsCmd.history(orgId, opts));
77
+ prompts.command("restore <organization_id> <audit_id>")
78
+ .description("Re-push a past prompt version from the audit log (DRY-RUN unless --commit)")
79
+ .option("--after", "restore the version that push MADE LIVE (default: the one it REPLACED)")
80
+ .option("--commit", "actually push it (default writes a working copy only)")
81
+ .action((orgId, auditId, opts) => promptsCmd.restore(orgId, auditId, opts));
70
82
  prompts.command("push <slug-or-path>")
71
83
  .description("Apply a local JSON file's prompt_sections back to the agent it was pulled from (regenerates system_prompt server-side)")
72
84
  .action((id) => promptsCmd.push(id));
@@ -261,6 +273,28 @@ export function run(argv) {
261
273
  .option("--yes", "skip the interactive confirm gate (the --note requirement still applies)")
262
274
  .action((updateId, opts) => agentUpdatesCmd.resolve(updateId, opts));
263
275
 
276
+ // audit (who did what, when — with the full before/after content)
277
+ const audit = program.command("audit")
278
+ .description("Staff-CLI audit trail: who changed what, when — with full before/after content")
279
+ .argument("[organization_id]", "scope to one org (omit for all orgs)")
280
+ .option("--endpoint <name>", "prompts | questionnaires | fine-tuning | knowledge | custom-tools | keywords | agent-config | broadcast | tag | …")
281
+ .option("--action <name>", "push | create | config | resolve | send | apply | read | …")
282
+ .option("--status <s>", "ok | blocked | failed")
283
+ .option("--agent <id>", "scope to one agent")
284
+ .option("--target <id>", "scope to one target (task id, tool name, tag, broadcast id)")
285
+ .option("--user <substr>", "filter by staff email (substring)")
286
+ .option("--since <date>", "YYYY-MM-DD or ISO")
287
+ .option("--until <date>", "YYYY-MM-DD or ISO")
288
+ .option("--limit <n>", "max entries (default 50, max 200)")
289
+ .option("--json", "raw JSON")
290
+ .action((orgId, opts) => auditCmd.list(orgId, opts));
291
+ audit.command("show <audit_id>")
292
+ .description("One audit entry in full")
293
+ .option("--content", "also print the before/after payloads")
294
+ .option("--out <path>", "write the whole entry (incl. content) to a JSON file")
295
+ .option("--json", "raw JSON")
296
+ .action((auditId, opts) => auditCmd.show(auditId, opts));
297
+
264
298
  // export (full chat history → TXT, byte-identical to the in-app export)
265
299
  const exp = program.command("export").description("Export data to local files");
266
300
  exp.command("chats <organization_id>")