@flowapt/flowiq-cli 0.7.1 → 0.7.3
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 +45 -3
- package/TEAM-GUIDE.md +4 -0
- package/package.json +1 -1
- package/src/commands/plans.js +258 -0
- package/src/index.js +27 -1
package/README.md
CHANGED
|
@@ -188,6 +188,7 @@ flowiq ct list
|
|
|
188
188
|
- **Always `--dry-run` first** on a production agent — it prints added/removed/changed tool names before you commit.
|
|
189
189
|
- **Destructive pushes are blocked by default**: a push that removes a tool, disables one, or sends an empty `tools[]` (wiping everything) writes nothing and shows you exactly what it would strip — re-run with `--confirm` if intentional. Additive/no-op pushes are unaffected.
|
|
190
190
|
- Server-side validation is strict: tool `name` (`^[a-zA-Z0-9_-]{1,64}$`, no duplicates or built-in collisions), non-empty `description`, `http(s)` `endpoint`, method `GET/POST/PUT/PATCH/DELETE`, scalar headers, object-typed `parameters`/`injected_parameters`, valid auth/channels, timeout and response caps. Recursive schemas support strict nested objects, typed arrays, enums and numeric/string/item limits. Unknown tool/schema keys are rejected before anything writes.
|
|
191
|
+
- **`display_name` (optional, 10 Sep 2026):** the label the TEAM sees on the inbox tool-activity card (e.g. `Checked loyalty points`) — plain string, ≤80 chars, never sent to the AI. Leave it out and the card derives a label from the tool name.
|
|
191
192
|
- **Warnings (non-blocking):** an object with properties but no required fields. Unknown `{{placeholder}}` values are hard errors; injected parameters may additionally reference a declared top-level model parameter. Known runtime values include `organization_id`, `contact_id`, `agent_id`, `contact_whatsapp_id`, `contact_full_name`, `whatsapp_message_id`, `text`, `unique_message_id`, `unix_timestamp`, `operation_idempotency_key`, `supabase_anon_key`, and `openai_api_key`.
|
|
192
193
|
- **Retailer gateway credential:** `{{retailer_tools_internal_key}}` is super-admin-only and resolves only as the `x-api-key` value for `https://express.chatcart.io/retailer-tools/*` (or loopback in local tests). Validation and runtime both reject putting it in a body or sending it to any other host.
|
|
193
194
|
- `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
|
|
@@ -1054,7 +1055,9 @@ flowiq hours summary # every org with logged work this
|
|
|
1054
1055
|
|
|
1055
1056
|
### Client deck — `flowiq report deck status|build|narrate|generate|inputs|approve|unapprove|send|print|pull` (v0.6.5)
|
|
1056
1057
|
|
|
1057
|
-
The monthly
|
|
1058
|
+
The monthly client deck (`/reporting/deck` in the app; 10 slides, 11 for a store
|
|
1059
|
+
org — slide 8 is store health: cart abandonment, returning buyers and opt-in
|
|
1060
|
+
growth, online store only), driven from the
|
|
1058
1061
|
terminal. Everything runs server-side through the `flowiq-reporting-deck` edge
|
|
1059
1062
|
function: the metrics are built from the FROZEN month snapshot, the copy is
|
|
1060
1063
|
written by the model under the report rules and a numeric guard (every figure
|
|
@@ -1077,11 +1080,15 @@ flowiq report deck pull <org_id> 2026-08 # → ./.flowiq/repor
|
|
|
1077
1080
|
```
|
|
1078
1081
|
|
|
1079
1082
|
- Months freeze on the 1st (cron 82); a month with no snapshot cannot be built.
|
|
1080
|
-
Comparisons need the prior month's deck, which `build` creates on the fly
|
|
1083
|
+
Comparisons need the prior month's deck, which `build` creates on the fly (and rebuilds when it
|
|
1084
|
+
predates the current build logic, unless it is approved, sent or held).
|
|
1085
|
+
- Send fees are estimated at the category each template had on its send day. A send whose category
|
|
1086
|
+
cannot be known shows no fee and is left out of the broadcast return (`totals.unpriced_sends`).
|
|
1081
1087
|
- `inputs.json` shape: `{ "changes": { "<update_id>": { "hidden": false, "tag": "client_raised|flowapt_shipped", "title": "…", "description": "…" } }, "changes_reviewed": true, "action_points": { "<title>": "done|in_progress|waiting_on_you|ongoing|dropped" }, "waiting_on_you": [{ "title": "…", "unlocks": "…" }], "recipients_confirmed": true }`.
|
|
1082
1088
|
Free text (milestone, the four plan fields) is edited in the app; it is stored as overrides that survive `narrate`.
|
|
1083
1089
|
- `send --client` needs an approved deck AND `report.email.recipients` in the org's reporting config (Control center → Report delivery); it marks the deck `sent`. `--test-to` never changes status. Fees on the slides are the org's actual Meta billing when the token can read it, otherwise the rate-card estimate, and the footnote says which.
|
|
1084
1090
|
- Non-store orgs need `config.deck.outcome` (`source: handover | ticket_status | tag | keyword`, labels) — the revenue slides become outcome slides. Every verb except `status` and `pull` is audited.
|
|
1091
|
+
- Store orgs get slide 8, "Store health": cart abandonment (online checkouts started, bought, abandoned), first-time vs returning buyers and opt-in growth, each with a twelve-month strip and measured on the online store checkout only (till and other channels are named in the footer). `status --json` carries the headline figures under `store`; `config.deck.channel_labels {"<source_name>": "Label"}` renames a sales channel on the slide.
|
|
1085
1092
|
|
|
1086
1093
|
### Insights — `flowiq insights status|enable|disable|run` (v0.7.0)
|
|
1087
1094
|
|
|
@@ -1435,7 +1442,10 @@ flowiq org flags keys --section "members"
|
|
|
1435
1442
|
response prints before → after plus the exact undo command.
|
|
1436
1443
|
- A key that is on the org but not in the registry is shown by `show` (and
|
|
1437
1444
|
`--json`) but cannot be set here — it is DB-only by design; add it to the
|
|
1438
|
-
Profile tab first.
|
|
1445
|
+
Profile tab first. **The registry is ONE shared file** (`api/_shared/feature-flag-registry.js`)
|
|
1446
|
+
that the Profile tab imports too, and every build checks the two agree — so a
|
|
1447
|
+
new Profile-tab switch is settable from the CLI the moment it deploys, with no
|
|
1448
|
+
npm publish.
|
|
1439
1449
|
|
|
1440
1450
|
### Agents — `flowiq agent list|create <org>`
|
|
1441
1451
|
|
|
@@ -1576,6 +1586,38 @@ flowiq au resolve <update_id> --status declined --internal "duplicate of …"
|
|
|
1576
1586
|
- An interactive confirm shows exactly what the client will read; `--yes`
|
|
1577
1587
|
skips the confirm (but never the `--note` requirement).
|
|
1578
1588
|
|
|
1589
|
+
### Broadcast planning — `flowiq plans list|show|status` (alias `planning`) (v0.7.3)
|
|
1590
|
+
|
|
1591
|
+
The Broadcast → Planning board in the app (the campaign plans clients submit
|
|
1592
|
+
and Flowapt reviews), from the terminal.
|
|
1593
|
+
|
|
1594
|
+
```bash
|
|
1595
|
+
flowiq plans list # open plans across every ACTIVE client
|
|
1596
|
+
flowiq plans list --status pending_review # waiting for Flowapt review
|
|
1597
|
+
flowiq plans list <organization_id> --status all --since 2026-09-01
|
|
1598
|
+
flowiq plans show <plan_id> # copy, second message, buttons, creative, audience, notes
|
|
1599
|
+
flowiq plans show <plan_id> --json --out plan.json
|
|
1600
|
+
flowiq plans status <plan_id> --to approved # dry run
|
|
1601
|
+
flowiq plans status <plan_id> --to rejected --comment "Please send the image as a PNG" --commit
|
|
1602
|
+
flowiq plans status <plan_id> --to sent --commit
|
|
1603
|
+
```
|
|
1604
|
+
|
|
1605
|
+
- **`list`** defaults to open plans (`draft`, `pending_review`, `approved`,
|
|
1606
|
+
`scheduled`) across all active orgs; pass an org id for one client,
|
|
1607
|
+
`--status all` / a status / a comma list to widen or narrow, `--since` to
|
|
1608
|
+
filter by send date, `--include-inactive` for inactive orgs. The header
|
|
1609
|
+
shows the totals for every status, and each row prints the full plan id.
|
|
1610
|
+
- **`show`** prints the plan the way the dialog does: message copy, the
|
|
1611
|
+
second message sent when a button is tapped, buttons with their URLs, the
|
|
1612
|
+
creative (file name + link), audience, template link, the response to the
|
|
1613
|
+
client, internal notes and the notes thread.
|
|
1614
|
+
- **`status`** is a dry run until `--commit`. `--comment` is the response the
|
|
1615
|
+
**client reads**; `--internal` is staff-only. `approved` and `rejected`
|
|
1616
|
+
record you as the reviewer; `rejected` requires `--comment`. Every commit is
|
|
1617
|
+
in `flowiq audit --endpoint plans`.
|
|
1618
|
+
- Editing a plan's copy, buttons or links is not in the CLI yet; do that in
|
|
1619
|
+
the app.
|
|
1620
|
+
|
|
1579
1621
|
### Chat export — `flowiq export chats <organization_id> [--out <path>]`
|
|
1580
1622
|
|
|
1581
1623
|
Full chat history → TXT, byte-identical to the in-app "Export Settings TXT"
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -81,11 +81,15 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
81
81
|
| Edit the text knowledge sources (get_more_answers playbooks) | `flowiq kn pull <org_id>` → edit `sources[]` → `flowiq kn push <slug>` |
|
|
82
82
|
| Edit the custom tools (API-call tools) | `flowiq ct pull <org_id>` → edit `tools[]` → `flowiq ct push <slug> --dry-run` → `flowiq ct push <slug>` |
|
|
83
83
|
| Turn one custom tool on/off | `flowiq ct enable\|disable <org_id> <tool_name>` |
|
|
84
|
+
| Give a custom tool a friendly name on the inbox tool-activity card | add `"display_name": "Checked loyalty points"` to that tool in `tools[]` → `flowiq ct push` (≤80 chars, never shown to the AI; empty = derived from the tool name) |
|
|
84
85
|
| Edit keyword auto-replies (incl. competition entry keywords, add/remove-tag, set-agent and delay actions) | `flowiq kw pull <org_id>` → edit → `flowiq kw push <slug> --dry-run` → `flowiq kw push <slug>` |
|
|
85
86
|
| Send a **different auto-reply depending on the contact** (e.g. "we already have your email" vs "send us your email") | add `"when": {"field":"email","op":"is_not_empty"}` to one action and `is_empty` to the other — see *Keywords* in `flowiq guide --reference`. CLI-only, no dashboard field yet |
|
|
86
87
|
| Make a follow-up button **close the customer's ticket** ("Query solved") or **re-alert the team** ("I still need help") | keyword action `{"type":"update_ticket_status","status":"resolved"}` / `{"type":"renotify_ticket"}` — see *Keywords* in `flowiq guide --reference`. CLI-only, no dashboard field yet |
|
|
87
88
|
| Make a keyword/button **hand the chat to a team or person** (assign in the inbox + email/WhatsApp them) | keyword action `{"type":"assign_chat","team_id":"…","assignee_user_id":"…","notify_member":true}` — see *Keywords* in `flowiq guide --reference`. Also in the dashboard (action type "Assign Chat") |
|
|
88
89
|
| **Find an org's ID** (needed by nearly every command) | `flowiq org list` — or `flowiq org list african` to filter |
|
|
90
|
+
| See broadcast plans waiting for Flowapt review, across every client | `flowiq plans list --status pending_review`, then `flowiq plans show <plan_id>` for the copy, second message, buttons, creative and audience |
|
|
91
|
+
| See one client's broadcast plans | `flowiq plans list <org_id>` (open plans) or `flowiq plans list <org_id> --status all --since 2026-09-01` |
|
|
92
|
+
| Mark a broadcast plan approved, scheduled or sent | `flowiq plans status <plan_id> --to sent` (a dry run), then add `--commit`. Rejecting needs `--comment "..."`, which the client reads |
|
|
89
93
|
| What's the stock on a product, per branch? | `flowiq shopify stock <org_id> "olive oil"` — shows each location by NAME, and says "not tracked" rather than a confusing 0 |
|
|
90
94
|
| Has this order shipped? What's the tracking? | `flowiq shopify order <org_id> '#14728'` — status, courier, tracking number + link |
|
|
91
95
|
| **Size a customer cohort** | Use `customers(first:250, query:…)` and paginate — **NOT `customersCount(query:…)`, which Shopify ignores the filter on** and answers 10,000 every time. Any count showing `precision: AT_LEAST` is a cap, not a total; the CLI warns you. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
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,258 @@
|
|
|
1
|
+
// `flowiq plans list|show|status` — Broadcast Planning (the Broadcast → Planning
|
|
2
|
+
// board in the app, table broadcast_planning), via /cli/plans.
|
|
3
|
+
// list read: open plans across all active orgs, or one org
|
|
4
|
+
// show read: one plan in full (copy, second message, buttons, creative, notes)
|
|
5
|
+
// status write: dry run unless --commit; audited server-side
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { http } from "../http.js";
|
|
10
|
+
|
|
11
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
|
+
const STATUSES = ["draft", "pending_review", "approved", "rejected", "scheduled", "sent", "cancelled"];
|
|
13
|
+
const STATUS_ORDER = ["pending_review", "approved", "scheduled", "draft", "rejected", "sent", "cancelled"];
|
|
14
|
+
const STATUS_LABEL = {
|
|
15
|
+
pending_review: "PENDING REVIEW",
|
|
16
|
+
approved: "APPROVED",
|
|
17
|
+
scheduled: "SCHEDULED",
|
|
18
|
+
draft: "DRAFT",
|
|
19
|
+
rejected: "REJECTED",
|
|
20
|
+
sent: "SENT",
|
|
21
|
+
cancelled: "CANCELLED",
|
|
22
|
+
};
|
|
23
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
24
|
+
|
|
25
|
+
function sastParts(ts) {
|
|
26
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
27
|
+
timeZone: "Africa/Johannesburg",
|
|
28
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
29
|
+
hour: "2-digit", minute: "2-digit", hour12: false,
|
|
30
|
+
}).formatToParts(new Date(ts));
|
|
31
|
+
return Object.fromEntries(parts.map((p) => [p.type, p.value]));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function fmtTs(ts) {
|
|
35
|
+
if (!ts) return "-";
|
|
36
|
+
const p = sastParts(ts);
|
|
37
|
+
const hour = p.hour === "24" ? "00" : p.hour;
|
|
38
|
+
return `${Number(p.day)} ${MONTHS[Number(p.month) - 1]} ${p.year} ${hour}:${p.minute}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fmtDate(dateStr, timeStr) {
|
|
42
|
+
if (!dateStr) return "no date";
|
|
43
|
+
const [y, m, d] = String(dateStr).split("-");
|
|
44
|
+
return `${Number(d)} ${MONTHS[Number(m) - 1]} ${y} ${timeStr ? String(timeStr).slice(0, 5) : "--:--"}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function indent(text, pad = " ") {
|
|
48
|
+
const t = String(text ?? "").trim();
|
|
49
|
+
if (!t) return `${pad}(empty)`;
|
|
50
|
+
return t.split("\n").map((line) => `${pad}${line}`).join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fail(prefix, e) {
|
|
54
|
+
console.error(`${prefix}: ${e.message}`);
|
|
55
|
+
if (e.body?.error && !String(e.message).includes(e.body.error)) console.error(` ${e.body.error}`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function list(orgId, opts = {}) {
|
|
60
|
+
if (orgId && !UUID_RE.test(orgId)) {
|
|
61
|
+
console.error(`Error: "${orgId}" is not a valid organization UUID (find it with \`flowiq org list\`).`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
const query = {};
|
|
65
|
+
if (orgId) query.organization_id = orgId;
|
|
66
|
+
if (opts.status) query.status = opts.status;
|
|
67
|
+
if (opts.since) query.since = opts.since;
|
|
68
|
+
if (opts.includeInactive) query.include_inactive = "1";
|
|
69
|
+
if (opts.limit != null) query.limit = String(opts.limit);
|
|
70
|
+
|
|
71
|
+
let resp;
|
|
72
|
+
try {
|
|
73
|
+
resp = await http.get("plans", query);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
fail("List failed", e);
|
|
76
|
+
}
|
|
77
|
+
if (opts.json) {
|
|
78
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const scope = resp.organization_name
|
|
83
|
+
? resp.organization_name
|
|
84
|
+
: `all ${resp.include_inactive ? "" : "active "}orgs`;
|
|
85
|
+
const totals = STATUS_ORDER.filter((s) => resp.counts?.[s]).map((s) => `${s} ${resp.counts[s]}`).join(" · ");
|
|
86
|
+
console.log(`Broadcast plans · ${scope} · status ${resp.status_filter}${resp.since ? ` · from ${resp.since}` : ""}`);
|
|
87
|
+
console.log(` every status in this scope: ${totals || "none"}`);
|
|
88
|
+
|
|
89
|
+
const plans = resp.plans || [];
|
|
90
|
+
if (!plans.length) {
|
|
91
|
+
console.log(" (no matching plans)");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const s of STATUS_ORDER) {
|
|
96
|
+
const group = plans.filter((p) => p.status === s);
|
|
97
|
+
if (!group.length) continue;
|
|
98
|
+
console.log(`\n${STATUS_LABEL[s]} (${group.length})`);
|
|
99
|
+
for (const p of group) {
|
|
100
|
+
const flag = p.date_passed ? " (date passed)" : p.scheduled_date === resp.today ? " (today)" : "";
|
|
101
|
+
console.log(` ${fmtDate(p.scheduled_date, p.scheduled_time)}${flag} ${p.organization_name ?? p.organization_id} · ${p.topic}`);
|
|
102
|
+
const bits = [
|
|
103
|
+
p.type || "type not set",
|
|
104
|
+
p.audience,
|
|
105
|
+
p.origin === "flowapt" ? `Flowapt suggestion (client: ${p.client_status ?? "-"})` : null,
|
|
106
|
+
p.creative_file ? `creative ${p.creative_file}` : null,
|
|
107
|
+
p.template_id ? "template linked" : null,
|
|
108
|
+
p.has_client_comment ? "has response to client" : null,
|
|
109
|
+
p.notes_count ? `${p.notes_count} note(s)` : null,
|
|
110
|
+
].filter(Boolean);
|
|
111
|
+
console.log(` ${bits.join(" · ")}`);
|
|
112
|
+
const reviewed = p.reviewed_at
|
|
113
|
+
? ` · reviewed ${fmtTs(p.reviewed_at)}${p.reviewed_by_name ? ` by ${p.reviewed_by_name}` : ""}`
|
|
114
|
+
: "";
|
|
115
|
+
console.log(` submitted ${fmtTs(p.created_at)}${p.created_by_name ? ` by ${p.created_by_name}` : ""}${reviewed}`);
|
|
116
|
+
console.log(` id ${p.id}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (resp.truncated) {
|
|
120
|
+
console.log(`\n⚠ ${plans.length} shown and the limit (${resp.limit}) was reached: pass --limit, or narrow with --status / --since.`);
|
|
121
|
+
}
|
|
122
|
+
console.log("\nFull plan: flowiq plans show <plan_id>");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function show(planId, opts = {}) {
|
|
126
|
+
if (!UUID_RE.test(planId)) {
|
|
127
|
+
console.error(`Error: "${planId}" is not a valid plan UUID (get it from \`flowiq plans list\`).`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
let resp;
|
|
131
|
+
try {
|
|
132
|
+
resp = await http.get("plans", { plan_id: planId });
|
|
133
|
+
} catch (e) {
|
|
134
|
+
fail("Show failed", e);
|
|
135
|
+
}
|
|
136
|
+
if (opts.out) {
|
|
137
|
+
const out = path.resolve(process.cwd(), opts.out);
|
|
138
|
+
await fs.mkdir(path.dirname(out), { recursive: true });
|
|
139
|
+
await fs.writeFile(out, JSON.stringify(resp, null, 2) + "\n", "utf8");
|
|
140
|
+
console.error(`Wrote ${out}`);
|
|
141
|
+
}
|
|
142
|
+
if (opts.json) {
|
|
143
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const p = resp.plan;
|
|
148
|
+
const more = p.more && typeof p.more === "object" ? p.more : {};
|
|
149
|
+
const creative = p.creative && typeof p.creative === "object" ? p.creative : {};
|
|
150
|
+
const flag = p.scheduled_date && p.scheduled_date < resp.today
|
|
151
|
+
? " (date passed)"
|
|
152
|
+
: p.scheduled_date === resp.today ? " (today)" : "";
|
|
153
|
+
|
|
154
|
+
console.log(p.topic);
|
|
155
|
+
console.log(` org: ${p.organization_name ?? "?"} (${p.organization_id})${p.organization_inactive ? " [inactive org]" : ""}`);
|
|
156
|
+
console.log(` status: ${p.status}${p.origin === "flowapt" ? ` · Flowapt suggestion, client: ${p.client_status ?? "-"}` : ""}`);
|
|
157
|
+
console.log(` send: ${fmtDate(p.scheduled_date, p.scheduled_time)}${flag}`);
|
|
158
|
+
console.log(` type: ${more.type ?? "-"}`);
|
|
159
|
+
console.log(` audience: ${p.has_segment ? (p.segment_details || "segment (no details given)") : "whole list"}`);
|
|
160
|
+
if (p.shopify_segment_formula) console.log(` formula: ${p.shopify_segment_formula}`);
|
|
161
|
+
if (p.product_focus) console.log(` product: ${p.product_focus}`);
|
|
162
|
+
if (p.landing_page_url) console.log(` landing: ${p.landing_page_url}`);
|
|
163
|
+
console.log(` submitted: ${fmtTs(p.created_at)}${p.created_by_name ? ` by ${p.created_by_name}` : ""}`);
|
|
164
|
+
if (p.reviewed_at) console.log(` reviewed: ${fmtTs(p.reviewed_at)}${p.reviewed_by_name ? ` by ${p.reviewed_by_name}` : ""}`);
|
|
165
|
+
if (p.client_approved_at) console.log(` client OK: ${fmtTs(p.client_approved_at)}${p.client_approved_by_name ? ` by ${p.client_approved_by_name}` : ""}`);
|
|
166
|
+
console.log(` id: ${p.id}`);
|
|
167
|
+
|
|
168
|
+
console.log("\nMessage copy");
|
|
169
|
+
console.log(indent(p.copy_text));
|
|
170
|
+
|
|
171
|
+
if (more.slide2?.message) {
|
|
172
|
+
console.log("\nSecond message (sent when a button is tapped)");
|
|
173
|
+
console.log(indent(more.slide2.message));
|
|
174
|
+
}
|
|
175
|
+
if (more.slides && typeof more.slides === "object") {
|
|
176
|
+
for (const [key, slide] of Object.entries(more.slides)) {
|
|
177
|
+
if (!slide || typeof slide !== "object") continue;
|
|
178
|
+
console.log(`\n${key}${slide.triggered_by ? ` (after ${slide.triggered_by})` : ""}`);
|
|
179
|
+
if (slide.message) console.log(indent(slide.message));
|
|
180
|
+
if (slide.media_url) console.log(` media: ${slide.media_url}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const buttons = Array.isArray(more.buttons) ? more.buttons : [];
|
|
185
|
+
if (buttons.length) {
|
|
186
|
+
console.log("\nButtons");
|
|
187
|
+
for (const b of buttons) console.log(` [${b?.type ?? "?"}] ${b?.text ?? ""}${b?.url ? ` → ${b.url}` : ""}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
console.log("\nCreative");
|
|
191
|
+
if (creative.original_filename || creative.file_url) {
|
|
192
|
+
const size = creative.file_size ? ` · ${(creative.file_size / 1024 / 1024).toFixed(1)} MB` : "";
|
|
193
|
+
console.log(` ${creative.original_filename ?? "(unnamed)"} · ${creative.file_type ?? "type ?"}${size} · supplied by ${creative.supplier ?? "?"}`);
|
|
194
|
+
if (creative.file_url) console.log(` ${creative.file_url}`);
|
|
195
|
+
} else {
|
|
196
|
+
console.log(` none attached${creative.supplier ? ` (supplied by ${creative.supplier})` : ""}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (p.template) console.log(`\nTemplate: ${p.template.template_name} (${p.template.status ?? "status ?"}, ${p.template.category ?? "category ?"})`);
|
|
200
|
+
else if (p.template_id) console.log(`\nTemplate id: ${p.template_id}`);
|
|
201
|
+
if (p.send_draft_id) console.log(`Send draft id: ${p.send_draft_id}`);
|
|
202
|
+
|
|
203
|
+
if (p.superadmin_comment) {
|
|
204
|
+
console.log("\nResponse to client");
|
|
205
|
+
console.log(indent(p.superadmin_comment));
|
|
206
|
+
}
|
|
207
|
+
if (p.internal_notes) {
|
|
208
|
+
console.log("\nInternal notes (staff only)");
|
|
209
|
+
console.log(indent(p.internal_notes));
|
|
210
|
+
}
|
|
211
|
+
const notes = resp.notes || [];
|
|
212
|
+
if (notes.length) {
|
|
213
|
+
console.log(`\nNotes (${notes.length})`);
|
|
214
|
+
for (const n of notes) console.log(` ${fmtTs(n.created_at)} ${n.author_name ?? "?"} [${n.kind ?? "note"}]: ${String(n.body ?? "").replace(/\s+/g, " ").trim()}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
console.log(`\nChange status: flowiq plans status ${p.id} --to <status> [--comment "..."] [--commit]`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function status(planId, opts = {}) {
|
|
221
|
+
if (!UUID_RE.test(planId)) {
|
|
222
|
+
console.error(`Error: "${planId}" is not a valid plan UUID (get it from \`flowiq plans list\`).`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
const to = String(opts.to || "").toLowerCase();
|
|
226
|
+
if (!STATUSES.includes(to)) {
|
|
227
|
+
console.error(`Error: --to must be one of ${STATUSES.join(" | ")} (got "${opts.to}").`);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
if (to === "rejected" && !(opts.comment && opts.comment.trim())) {
|
|
231
|
+
console.error("Refusing to reject without --comment: the client reads it as the response to their plan.");
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let resp;
|
|
236
|
+
try {
|
|
237
|
+
resp = await http.post("plans", {
|
|
238
|
+
action: "status",
|
|
239
|
+
plan_id: planId,
|
|
240
|
+
status: to,
|
|
241
|
+
superadmin_comment: opts.comment ?? null,
|
|
242
|
+
internal_notes: opts.internal ?? null,
|
|
243
|
+
dry_run: !opts.commit,
|
|
244
|
+
});
|
|
245
|
+
} catch (e) {
|
|
246
|
+
fail("Status change failed", e);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const prev = resp.previous || {};
|
|
250
|
+
const next = resp.next || {};
|
|
251
|
+
console.log(`${resp.dry_run ? "DRY RUN" : "Updated"} · ${resp.topic}`);
|
|
252
|
+
console.log(` org: ${resp.organization_name ?? resp.organization_id}`);
|
|
253
|
+
console.log(` status: ${prev.status} → ${next.status}`);
|
|
254
|
+
if (next.superadmin_comment !== prev.superadmin_comment) console.log(` client reads: ${next.superadmin_comment}`);
|
|
255
|
+
if (next.internal_notes !== prev.internal_notes) console.log(` internal: ${next.internal_notes}`);
|
|
256
|
+
if (resp.reviewer_email) console.log(` reviewer: ${resp.reviewer_email} (reviewed_at ${fmtTs(next.reviewed_at)})`);
|
|
257
|
+
if (resp.dry_run) console.log("\nNothing written. Re-run with --commit to apply.");
|
|
258
|
+
}
|
package/src/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import * as teamUpdatesCmd from "./commands/team-updates.js";
|
|
|
25
25
|
import * as agentConfigCmd from "./commands/agent-config.js";
|
|
26
26
|
import * as agentsCmd from "./commands/agents.js";
|
|
27
27
|
import * as agentUpdatesCmd from "./commands/agent-updates.js";
|
|
28
|
+
import * as plansCmd from "./commands/plans.js";
|
|
28
29
|
import * as exportCmd from "./commands/export.js";
|
|
29
30
|
import * as testCmd from "./commands/agent-test.js";
|
|
30
31
|
import * as knowledgeCmd from "./commands/knowledge.js";
|
|
@@ -411,7 +412,7 @@ export function run(argv) {
|
|
|
411
412
|
.option("--model <model>", "settings.model (e.g. gpt-5.4-mini)")
|
|
412
413
|
.option("--reasoning-effort <level>", "settings.reasoning_effort — low|medium|high (\"\" clears it); pairs with reasoning models (e.g. gpt-5.6-luna + high)")
|
|
413
414
|
.option("--rename <name>", "rename the agent")
|
|
414
|
-
.option("--tool <flag=bool>", "toggle a tool flag (repeatable): woo_order_build/woo_tip_field/woo_order_note_field/view_cart_tool/restock_tool/block_tool_status/postal_code_tool_status/shopify_products_web_chat/ticket_tool_status/collapse_product_variants", agentConfigCmd.collectTool, [])
|
|
415
|
+
.option("--tool <flag=bool>", "toggle a tool flag (repeatable): woo_order_build/woo_tip_field/woo_order_note_field/view_cart_tool/restock_tool/block_tool_status/postal_code_tool_status/shopify_products_web_chat/ticket_tool_status/product_lookup/email_request_tool/collapse_product_variants", agentConfigCmd.collectTool, [])
|
|
415
416
|
.option("--discount [bool]", "agent.discount.enabled (true if bare)")
|
|
416
417
|
.option("--test-contact-number <number>", "settings.test_contact_number — the contact `flowiq test` uses (use a FAKE number; \"\" clears it → synthetic fallback)")
|
|
417
418
|
.option("--test-contact-name <name>", "settings.test_contact_name — display name for the test contact (\"\" clears it)")
|
|
@@ -445,6 +446,31 @@ export function run(argv) {
|
|
|
445
446
|
.option("--yes", "skip the interactive confirm gate (the --note requirement still applies)")
|
|
446
447
|
.action((updateId, opts) => agentUpdatesCmd.resolve(updateId, opts));
|
|
447
448
|
|
|
449
|
+
// plans (Broadcast Planning board: list across orgs, show one plan, change status)
|
|
450
|
+
const plans = program.command("plans")
|
|
451
|
+
.alias("planning")
|
|
452
|
+
.description("Broadcast Planning (Broadcast → Planning): list plans across orgs, show one in full, change a plan's status");
|
|
453
|
+
plans.command("list [organization_id]")
|
|
454
|
+
.description("List plans, default open (draft, pending_review, approved, scheduled), across all active orgs or one org")
|
|
455
|
+
.option("--status <status>", "open (default) | all | pending_review | draft | approved | rejected | scheduled | sent | cancelled (comma-separated allowed)")
|
|
456
|
+
.option("--since <date>", "only plans scheduled on or after YYYY-MM-DD")
|
|
457
|
+
.option("--include-inactive", "include plans from inactive orgs (cross-org listing)")
|
|
458
|
+
.option("--limit <n>", "max plans returned (default 100, max 500)")
|
|
459
|
+
.option("--json", "print the raw response")
|
|
460
|
+
.action((orgId, opts) => plansCmd.list(orgId, opts));
|
|
461
|
+
plans.command("show <plan_id>")
|
|
462
|
+
.description("Show one plan in full: copy, second message, buttons, creative, audience, notes")
|
|
463
|
+
.option("--json", "print the raw response")
|
|
464
|
+
.option("--out <file>", "also write the full plan JSON to a file")
|
|
465
|
+
.action((planId, opts) => plansCmd.show(planId, opts));
|
|
466
|
+
plans.command("status <plan_id>")
|
|
467
|
+
.description("Change a plan's status (dry run unless --commit); approved/rejected also record you as the reviewer")
|
|
468
|
+
.requiredOption("--to <status>", "draft | pending_review | approved | rejected | scheduled | sent | cancelled")
|
|
469
|
+
.option("--comment <text>", "response to the client, shown on their plan (required for rejected)")
|
|
470
|
+
.option("--internal <text>", "staff-only internal notes (replaces the current internal notes)")
|
|
471
|
+
.option("--commit", "apply the change (default is a dry run)")
|
|
472
|
+
.action((planId, opts) => plansCmd.status(planId, opts));
|
|
473
|
+
|
|
448
474
|
// audit (who did what, when — with the full before/after content)
|
|
449
475
|
const audit = program.command("audit")
|
|
450
476
|
.description("Staff-CLI audit trail: who changed what, when — with full before/after content")
|