@flowapt/flowiq-cli 0.7.2 → 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 +32 -0
- package/TEAM-GUIDE.md +3 -0
- package/package.json +1 -1
- package/src/commands/plans.js +258 -0
- package/src/index.js +26 -0
package/README.md
CHANGED
|
@@ -1586,6 +1586,38 @@ flowiq au resolve <update_id> --status declined --internal "duplicate of …"
|
|
|
1586
1586
|
- An interactive confirm shows exactly what the client will read; `--yes`
|
|
1587
1587
|
skips the confirm (but never the `--note` requirement).
|
|
1588
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
|
+
|
|
1589
1621
|
### Chat export — `flowiq export chats <organization_id> [--out <path>]`
|
|
1590
1622
|
|
|
1591
1623
|
Full chat history → TXT, byte-identical to the in-app "Export Settings TXT"
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -87,6 +87,9 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
87
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 |
|
|
88
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") |
|
|
89
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 |
|
|
90
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 |
|
|
91
94
|
| Has this order shipped? What's the tracking? | `flowiq shopify order <org_id> '#14728'` — status, courier, tracking number + link |
|
|
92
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";
|
|
@@ -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")
|