@flowapt/flowiq-cli 0.3.6 → 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 +108 -4
- package/TEAM-GUIDE.md +7 -2
- package/package.json +1 -1
- package/src/commands/audit.js +170 -0
- package/src/commands/broadcast.js +42 -1
- package/src/commands/prompts.js +173 -0
- package/src/http.js +37 -2
- package/src/index.js +41 -0
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.
|
|
@@ -160,7 +186,7 @@ flowiq ct list
|
|
|
160
186
|
- **Warnings (non-blocking):** unknown keys (likely typos the runtime would silently ignore) and unknown `{{placeholders}}` (they will NOT be substituted at runtime — known: `organization_id`, `contact_id`, `agent_id`, `contact_whatsapp_id`, `contact_full_name`, `supabase_anon_key`, `openai_api_key`, …).
|
|
161
187
|
- `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
|
|
162
188
|
|
|
163
|
-
### Broadcast — `flowiq broadcast map|preview|send|resume|status|list` (alias `bc`)
|
|
189
|
+
### Broadcast — `flowiq broadcast map|preview|send|resume|status|retry|list` (alias `bc`)
|
|
164
190
|
|
|
165
191
|
Send an **APPROVED** WhatsApp template to every row of a CSV, filling the
|
|
166
192
|
template's variables **per row** from the CSV's own columns. The
|
|
@@ -198,7 +224,15 @@ flowiq bc resume <org_id> --campaign july-referrals --commit [--retry-failed]
|
|
|
198
224
|
by its id — `read` / `delivered` / `sent` / `failed` (+ % reached) from
|
|
199
225
|
`helpdesk_messages`. Built for the `--python` fire-and-forget engine (which
|
|
200
226
|
returns a `broadcastId` but has no CLI status log), but works for any broadcast
|
|
201
|
-
id (dashboard sends included). `
|
|
227
|
+
id (dashboard sends included). **`--failures` (v0.3.7)** additionally lists each
|
|
228
|
+
failed recipient + the Meta error reason (`error_code` / `error_title`) with a
|
|
229
|
+
by-reason rollup — makes a fire-and-forget send fully auditable.
|
|
230
|
+
- **`retry <org> <broadcastId>` (v0.3.7)**: re-send a broadcast to **only its
|
|
231
|
+
failed recipients** — reconstructs the send from the `broadcasts` row (template +
|
|
232
|
+
params + header) and re-fires via the python engine, creating a NEW broadcast.
|
|
233
|
+
DRY-RUN by default (shows the failed count + reason breakdown); `--commit` fires.
|
|
234
|
+
Note: permanent failures (e.g. `131026` undeliverable, opt-outs) just fail again —
|
|
235
|
+
retry earns its keep on transient (throttle/throughput) failures. Capped at 500.
|
|
202
236
|
- **Engine auto-routing (v0.3.6):** any `send --tag` of **more than 10** eligible
|
|
203
237
|
recipients **always uses the python engine** (a `>2000` tag routes there too).
|
|
204
238
|
Only a ≤10 send stays on the resumable per-row Node engine. `--python` forces
|
|
@@ -242,8 +276,12 @@ flowiq bc send <org_id> --tag july-promo-batch-01 --template fresh_drop_v1 \
|
|
|
242
276
|
```
|
|
243
277
|
|
|
244
278
|
Values are shared across the tag (use `{{first_name}}` etc. for per-contact
|
|
245
|
-
personalization — resolved server-side).
|
|
246
|
-
|
|
279
|
+
personalization — resolved server-side). Supported per-contact tokens:
|
|
280
|
+
`{{first_name}}` `{{full_name}}` `{{email}}` `{{phone_number}}`
|
|
281
|
+
`{{whatsapp_id}}` `{{contact_id}}`, plus `{{attributes.<key>}}` for any
|
|
282
|
+
top-level key of the contact's `attributes` JSON (missing / object-valued
|
|
283
|
+
keys render as empty — both engines, since 21 Jul 2026). Opted-out /
|
|
284
|
+
archived / blocked contacts under the tag are excluded automatically. Tags with >2000 contacts
|
|
247
285
|
are refused — slice them with `flowiq segments` first.
|
|
248
286
|
|
|
249
287
|
### Segments — `flowiq segments plan|apply|list|untag` (alias `seg`)
|
|
@@ -378,6 +416,19 @@ flowiq kw push <slug> --prune # ALSO deletes DB keywords missing from
|
|
|
378
416
|
collapses to 1 at runtime — rejected).
|
|
379
417
|
- `field:"attributes"` actions are warned (full jsonb replace; constant
|
|
380
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.)*
|
|
381
432
|
- `action_config.link_preview: false` disables WhatsApp's link-preview card
|
|
382
433
|
on that action's text send (absent/`true` = preview on, the default).
|
|
383
434
|
Passed through verbatim; also toggleable per action in the dashboard.
|
|
@@ -398,6 +449,59 @@ flowiq m pull <contact_id> --count 25
|
|
|
398
449
|
|
|
399
450
|
`--count` defaults to 25, max 200.
|
|
400
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
|
+
|
|
401
505
|
### Webhooks (Shopify/WooCommerce) — `flowiq webhooks pull|push|reconnect` (alias `wh`)
|
|
402
506
|
|
|
403
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?"` |
|
|
@@ -90,10 +90,15 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
90
90
|
| Split your whole broadcast list into N even cohorts (e.g. 3 for A/B/C or waves) | `flowiq seg plan <org_id> --tag-prefix bcast --from-attribute allow_broadcast_true --split 3` → `flowiq seg apply <org_id> bcast --commit` (makes `bcast-batch-01/02/03`, ~even, shuffled) |
|
|
91
91
|
| Combine existing tags → a batched send list (include some tags, drop others, split into batches of N) | `flowiq seg plan <org_id> --tag-prefix clearance-bc --from-tag "loyalty-list" --exclude "recent-campaign" --batch-size 1000` → `flowiq seg apply <org_id> clearance-bc --commit` (makes `clearance-bc-batch-01/02/…`) |
|
|
92
92
|
| Split a big id-list cohort into send-safe batch tags | `flowiq seg plan <org_id> --tag-prefix … --ids-file …` → `flowiq seg apply … --commit` |
|
|
93
|
-
| Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` |
|
|
93
|
+
| Send to one batch tag | `flowiq bc send <org_id> --tag <batch-tag> --template … --body param1="Hi {{first_name}}" --commit` (per-contact tokens: the 6 contact fields + `{{attributes.<key>}}`) |
|
|
94
94
|
| Send a broadcast whose template has an IMAGE header | Same as above — the image is automatic (the template's own header image). Override with `--header-media <public-image-url>` if needed. |
|
|
95
95
|
| Send via the SAME engine as the dashboard's "Python" toggle | add `--python` to a `bc send --tag …` (fire-and-forget; python resolves the tag + sends + tracks; no CLI resume for this engine). **Any tag send over 10 recipients uses python automatically.** |
|
|
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
|
+
| See WHO failed on a broadcast + why (Meta error) | `flowiq bc status <org_id> <broadcastId> --failures` |
|
|
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`) |
|
|
97
102
|
| Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON |
|
|
98
103
|
| Export an org's full chat history | `flowiq export chats <org_id>` |
|
|
99
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.
|
|
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
|
+
}
|
|
@@ -962,7 +962,7 @@ export async function status(orgId, broadcastId, opts = {}) {
|
|
|
962
962
|
if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
963
963
|
if (!UUID_RE.test(broadcastId)) { console.error("Error: broadcast id must be a UUID (the broadcastId a --python send printed)."); process.exit(1); }
|
|
964
964
|
let resp;
|
|
965
|
-
try { resp = await http.post("broadcast", { action: "status", organization_id: orgId, broadcast_id: broadcastId }); }
|
|
965
|
+
try { resp = await http.post("broadcast", { action: "status", organization_id: orgId, broadcast_id: broadcastId, include_failures: !!opts.failures }); }
|
|
966
966
|
catch (e) { console.error(`Status failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
967
967
|
if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
|
|
968
968
|
const b = resp.broadcast, d = resp.delivery;
|
|
@@ -976,6 +976,47 @@ export async function status(orgId, broadcastId, opts = {}) {
|
|
|
976
976
|
if (b.total_recipients) {
|
|
977
977
|
console.log(` ${((reached / b.total_recipients) * 100).toFixed(1)}% of ${b.total_recipients} recipients reached${d.linked < b.total_recipients ? " (still sending?)" : ""}`);
|
|
978
978
|
}
|
|
979
|
+
if (opts.failures && resp.failures) {
|
|
980
|
+
console.log("");
|
|
981
|
+
console.log(` Failures by reason:`);
|
|
982
|
+
for (const [reason, n] of Object.entries(resp.failure_reasons || {}).sort((a, b2) => b2[1] - a[1])) console.log(` ${String(n).padStart(5)} × ${reason}`);
|
|
983
|
+
const cap = opts.limit ? Number(opts.limit) : 40;
|
|
984
|
+
console.log("");
|
|
985
|
+
console.log(` Failed recipients${resp.failures.length > cap ? ` (showing ${cap} of ${resp.failures.length}; --json for all)` : ` (${resp.failures.length})`}:`);
|
|
986
|
+
for (const f of resp.failures.slice(0, cap)) {
|
|
987
|
+
console.log(` ${(f.number || "—").padEnd(14)} ${(f.name || "").slice(0, 22).padEnd(22)} ${f.error_code ?? "?"} ${f.error_title || ""}`);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** Re-send a broadcast to ONLY its failed recipients (transient-failure recovery).
|
|
993
|
+
* Reconstructs the send from the broadcasts row + re-fires via python. The dry-run
|
|
994
|
+
* shows the failed count + reason breakdown; --commit creates a NEW broadcast. */
|
|
995
|
+
export async function retry(orgId, broadcastId, opts = {}) {
|
|
996
|
+
if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
|
|
997
|
+
if (!UUID_RE.test(broadcastId)) { console.error("Error: broadcast id must be a UUID (the broadcastId to retry)."); process.exit(1); }
|
|
998
|
+
let dry;
|
|
999
|
+
try { dry = await http.post("broadcast", { action: "retry", organization_id: orgId, broadcast_id: broadcastId, dry_run: true }); }
|
|
1000
|
+
catch (e) { console.error(`Retry check failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
1001
|
+
console.log(`Broadcast ${broadcastId} (${dry.broadcast?.template_name ?? "?"}) — ${dry.failed_count} failed recipient(s).`);
|
|
1002
|
+
for (const [reason, n] of Object.entries(dry.failure_reasons || {}).sort((a, b) => b[1] - a[1])) console.log(` ${String(n).padStart(5)} × ${reason}`);
|
|
1003
|
+
if (!dry.failed_count) { console.log("Nothing to retry."); return; }
|
|
1004
|
+
console.log("");
|
|
1005
|
+
console.log("Note: permanent failures (131026 undeliverable / opt-outs) just fail again — retry helps transient (throttle/throughput) failures.");
|
|
1006
|
+
if (!opts.commit) {
|
|
1007
|
+
console.log(`DRY RUN — would re-send to ${dry.failed_count} failed recipient(s) via the python engine. Add --commit to retry.`);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (!opts.yes) {
|
|
1011
|
+
const a = await ask(`Type "retry" to re-send to ${dry.failed_count} failed recipient(s): `);
|
|
1012
|
+
if (a !== "retry") { console.log("Aborted — nothing re-sent."); process.exit(0); }
|
|
1013
|
+
}
|
|
1014
|
+
let out;
|
|
1015
|
+
try { out = await http.post("broadcast", { action: "retry", organization_id: orgId, broadcast_id: broadcastId, dry_run: false }); }
|
|
1016
|
+
catch (e) { console.error(`Retry failed: ${e.message}`); if (e.body?.error) console.error(` ${e.body.error}`); process.exit(1); }
|
|
1017
|
+
console.log("");
|
|
1018
|
+
console.log(`✅ ${out.message || `Retry fired for ${out.retried} recipient(s)`}${out.broadcastId ? ` · new broadcastId ${out.broadcastId}` : ""}`);
|
|
1019
|
+
if (out.broadcastId) console.log(` Watch it: flowiq bc status ${orgId} ${out.broadcastId} --failures`);
|
|
979
1020
|
}
|
|
980
1021
|
|
|
981
1022
|
export async function list() {
|
package/src/commands/prompts.js
CHANGED
|
@@ -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>")
|
|
@@ -413,8 +447,15 @@ export function run(argv) {
|
|
|
413
447
|
.action((orgId, opts) => broadcastCmd.resume(orgId, opts));
|
|
414
448
|
broadcast.command("status <organization_id> <broadcast_id>")
|
|
415
449
|
.description("Live delivery counts (read/delivered/sent/failed) for a broadcast by its broadcastId — e.g. from a --python fire-and-forget send")
|
|
450
|
+
.option("--failures", "also list each FAILED recipient + the Meta error reason (makes a python send auditable)")
|
|
451
|
+
.option("--limit <n>", "with --failures: how many failed rows to print", "40")
|
|
416
452
|
.option("--json", "raw JSON")
|
|
417
453
|
.action((orgId, broadcastId, opts) => broadcastCmd.status(orgId, broadcastId, opts));
|
|
454
|
+
broadcast.command("retry <organization_id> <broadcast_id>")
|
|
455
|
+
.description("Re-send a broadcast to ONLY its failed recipients (transient-failure recovery). DRY-RUN by default; --commit re-fires via python")
|
|
456
|
+
.option("--commit", "actually re-send (omit to see the failed count + reasons)")
|
|
457
|
+
.option("--yes", "skip the type-'retry' confirm gate")
|
|
458
|
+
.action((orgId, broadcastId, opts) => broadcastCmd.retry(orgId, broadcastId, opts));
|
|
418
459
|
broadcast.command("list")
|
|
419
460
|
.description("List local campaigns + sent counts")
|
|
420
461
|
.action(() => broadcastCmd.list());
|