@flowapt/flowiq-cli 0.1.12 → 0.2.4

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.
@@ -129,6 +129,7 @@ export async function push(identifier, opts = {}) {
129
129
  agent_id: data.agent_id, // push back to the agent this file was pulled from
130
130
  tools: data.tools,
131
131
  dry_run: !!opts.dryRun,
132
+ confirm: !!opts.confirm,
132
133
  });
133
134
  } catch (e) {
134
135
  console.error(`Push failed: ${e.message}`);
@@ -142,6 +143,16 @@ export async function push(identifier, opts = {}) {
142
143
  console.log(` tools: ${resp.tool_count}`);
143
144
  printDiff(resp.diff);
144
145
  printWarnings(resp.warnings);
146
+ if (resp.requires_confirm) {
147
+ const d = resp.destructive || {};
148
+ console.log("");
149
+ console.log(" ⛔ DESTRUCTIVE PUSH — nothing was written:");
150
+ if (d.wipe_all) console.log(" this file would WIPE EVERY custom tool off the agent");
151
+ if (d.removed?.length) console.log(` removes: ${d.removed.join(", ")}`);
152
+ if (d.disabled?.length) console.log(` disables: ${d.disabled.join(", ")}`);
153
+ console.log(" Re-run with --confirm if this is intentional.");
154
+ process.exit(1);
155
+ }
145
156
  if (resp.dry_run) console.log(" re-run without --dry-run to apply.");
146
157
  }
147
158
 
@@ -0,0 +1,135 @@
1
+ // `flowiq keywords pull <org_id>` / `push <slug> [--prune] [--dry-run]` / `list`.
2
+ // Round-trips an org's keyword auto-replies (keywords + keyword_actions rows)
3
+ // via /cli/keywords — the SAFE editing path for the attribute-write actions
4
+ // the dashboard editor corrupts.
5
+ //
6
+ // Push is an ID-KEYED diff upsert: keep an `id` to update, drop the `id` to
7
+ // create, remove an ACTION from a kept keyword to delete it. A DB keyword
8
+ // absent from the file is left alone unless --prune. ALWAYS pull right before
9
+ // editing (clients edit auto-reply copy live) and --dry-run before --prune.
10
+
11
+ import fs from "node:fs/promises";
12
+ import path from "node:path";
13
+ import { http } from "../http.js";
14
+
15
+ const KW_DIR = path.resolve(process.cwd(), ".flowiq", "keywords");
16
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
17
+
18
+ function slugify(name, fallback) {
19
+ const s = String(name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
20
+ return s || fallback;
21
+ }
22
+
23
+ async function fileExists(p) {
24
+ try { await fs.access(p); return true; } catch { return false; }
25
+ }
26
+
27
+ async function resolvePushPath(identifier) {
28
+ const candidates = [];
29
+ if (path.isAbsolute(identifier)) candidates.push(identifier);
30
+ else if (identifier.includes("/") || identifier.includes("\\")) candidates.push(path.resolve(identifier));
31
+ else {
32
+ const base = identifier.endsWith(".json") ? identifier : `${identifier}.json`;
33
+ candidates.push(path.join(KW_DIR, base));
34
+ candidates.push(path.resolve(base));
35
+ }
36
+ for (const c of candidates) if (await fileExists(c)) return c;
37
+ throw new Error(`File not found. Tried:\n ${candidates.join("\n ")}`);
38
+ }
39
+
40
+ export async function pull(orgId) {
41
+ if (!UUID_RE.test(orgId)) {
42
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
43
+ process.exit(1);
44
+ }
45
+ let resp;
46
+ try { resp = await http.get("keywords", { organization_id: orgId }); }
47
+ catch (e) {
48
+ console.error(`Pull failed: ${e.message}`);
49
+ if (e.body?.error) console.error(` ${e.body.error}`);
50
+ process.exit(1);
51
+ }
52
+
53
+ await fs.mkdir(KW_DIR, { recursive: true });
54
+ const slug = slugify(resp.organization_name, resp.organization_id);
55
+ const filePath = path.join(KW_DIR, `${slug}.json`);
56
+ const overwriting = await fileExists(filePath);
57
+ await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
58
+
59
+ console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
60
+ console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
61
+ console.log(` keywords: ${resp.keyword_count}`);
62
+ for (const k of resp.keywords || []) {
63
+ const acts = (k.keyword_actions || []).length;
64
+ console.log(` ${String(k.type).padEnd(28)} [${(k.keywords || []).join(", ")}] · ${acts} action(s)${k.active === false ? " · INACTIVE" : ""}${k.end_date ? ` · ends ${k.end_date.slice(0, 10)}` : ""}`);
65
+ }
66
+ if (resp.has_attribute_actions) {
67
+ console.log("");
68
+ console.log(" ⚠ this org has attribute-write keyword action(s) — the dashboard editor");
69
+ console.log(" CANNOT render field:\"attributes\" and corrupts it on save. Edit those");
70
+ console.log(" keywords via this CLI only.");
71
+ }
72
+ }
73
+
74
+ export async function push(identifier, opts = {}) {
75
+ let filePath;
76
+ try { filePath = await resolvePushPath(identifier); }
77
+ catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
78
+ const raw = await fs.readFile(filePath, "utf8");
79
+ let data;
80
+ try { data = JSON.parse(raw); }
81
+ catch (e) { console.error(`Invalid JSON in ${filePath}: ${e.message}`); process.exit(1); }
82
+ if (!data.organization_id || !UUID_RE.test(data.organization_id)) {
83
+ console.error(`${filePath}: organization_id missing or invalid`);
84
+ process.exit(1);
85
+ }
86
+ if (!Array.isArray(data.keywords)) {
87
+ console.error(`${filePath}: keywords must be an array`);
88
+ process.exit(1);
89
+ }
90
+
91
+ let resp;
92
+ try {
93
+ resp = await http.post("keywords", {
94
+ organization_id: data.organization_id,
95
+ keywords: data.keywords,
96
+ prune: !!opts.prune,
97
+ dry_run: !!opts.dryRun,
98
+ });
99
+ } catch (e) {
100
+ console.error(`Push failed: ${e.message}`);
101
+ if (e.body?.error) console.error(` ${e.body.error}`);
102
+ process.exit(1);
103
+ }
104
+
105
+ const p = resp.plan;
106
+ console.log(`${resp.dry_run ? "DRY RUN (nothing written) —" : "Pushed"} ${filePath}`);
107
+ console.log(` org: ${resp.organization_name} (${data.organization_id})`);
108
+ console.log(` keywords: +${p.keywords.create} created · ~${p.keywords.update} updated · ${p.keywords.unchanged} unchanged · -${p.keywords.delete} deleted · ${p.keywords.orphan_kept} orphan(s) kept`);
109
+ console.log(` actions: +${p.actions.create} created · ~${p.actions.update} updated · ${p.actions.unchanged} unchanged · -${p.actions.delete} deleted`);
110
+ for (const w of resp.warnings || []) console.log(` ⚠ ${w}`);
111
+ if (resp.dry_run) console.log(" re-run without --dry-run to apply.");
112
+ else if (resp.prune && p.keywords.delete) console.log(` pruned ${p.keywords.delete} keyword(s) — re-pull to confirm.`);
113
+ if (!resp.dry_run && p.keywords.orphan_kept) {
114
+ console.log(` note: ${p.keywords.orphan_kept} DB keyword(s) not in the file were left untouched (--prune to delete).`);
115
+ }
116
+ }
117
+
118
+ export async function list() {
119
+ let files;
120
+ try { files = (await fs.readdir(KW_DIR)).filter((f) => f.endsWith(".json")); }
121
+ catch { console.log("(no local keyword snapshots; run `keywords pull <org_id>`)"); return; }
122
+ if (!files.length) { console.log("(no local keyword snapshots)"); return; }
123
+ files.sort();
124
+ for (const f of files) {
125
+ try {
126
+ const d = JSON.parse(await fs.readFile(path.join(KW_DIR, f), "utf8"));
127
+ console.log(f);
128
+ console.log(` org: ${d.organization_name ?? "?"} (${d.organization_id ?? "?"})`);
129
+ console.log(` keywords: ${d.keyword_count ?? (Array.isArray(d.keywords) ? d.keywords.length : "?")}`);
130
+ console.log(` pulled: ${d.pulled_at ?? "?"}`);
131
+ } catch (e) {
132
+ console.log(`${f} [invalid JSON: ${e.message}]`);
133
+ }
134
+ }
135
+ }
@@ -31,3 +31,39 @@ export async function info(orgId, opts = {}) {
31
31
  console.log(` provider: ${resp.provider || "(unset → meta)"}`);
32
32
  console.log(` active agent: ${resp.active_whatsapp_agent || "(NONE — set one before building a prompt)"}`);
33
33
  }
34
+
35
+ // `flowiq org create --name "…" [--slug …] [--owner email] [--provider meta|wati]`
36
+ // Creates a NEW organization (org row + admin membership), mirroring the app's
37
+ // Create Organization dialog. Owner defaults to YOU (the staff key's user) —
38
+ // the client gets invited later via the app's member invite flow.
39
+ export async function create(opts = {}) {
40
+ if (!opts.name || !opts.name.trim()) {
41
+ console.error("Error: --name is required.");
42
+ process.exit(1);
43
+ }
44
+ let resp;
45
+ try {
46
+ resp = await http.post("org", {
47
+ action: "create",
48
+ name: opts.name.trim(),
49
+ slug: opts.slug,
50
+ owner_email: opts.owner,
51
+ provider: opts.provider,
52
+ });
53
+ } catch (e) {
54
+ console.error(`Create failed: ${e.message}`);
55
+ if (e.body?.error) console.error(` ${e.body.error}`);
56
+ process.exit(1);
57
+ }
58
+
59
+ console.log(`Created "${resp.name}"`);
60
+ console.log(` id: ${resp.id}`);
61
+ console.log(` slug: ${resp.slug}`);
62
+ console.log(` owner: ${resp.owner_email ?? resp.owner_id}${resp.member_added ? " (admin member)" : " ⚠ membership insert failed — add via the app"}`);
63
+ console.log(` provider: ${resp.provider}`);
64
+ console.log("");
65
+ console.log("Next steps:");
66
+ console.log(` flowiq agent create ${resp.id} --name "<AgentName>" --make-active`);
67
+ console.log(` flowiq agent config ${resp.id} --model gpt-5.4-mini --use-settings-prompt`);
68
+ console.log(` flowiq prompts pull ${resp.id} # then edit + push`);
69
+ }
@@ -0,0 +1,252 @@
1
+ // `flowiq segments` (alias `seg`) — slice a contact cohort into fixed-size
2
+ // batch tags and bulk-apply them (spec 01 / the broadcast-tag-batching
3
+ // runbook). This command TAGS ONLY — it never sends. The send per tag is
4
+ // `flowiq broadcast send --tag <batch-tag>` (or the dashboard broadcaster).
5
+ //
6
+ // plan <org> --tag-prefix X (--from-segment file | --ids-file file) # exclusions → slice → plan file (no DB write)
7
+ // apply <org> <slug> [--commit] [--yes] # append the batch tags (dry-run default)
8
+ // list <org> [--prefix X] # VERIFY: tag → contact count
9
+ // untag <org> <slug> --commit --confirm # ROLLBACK: remove the plan's own tags
10
+ //
11
+ // Golden rules encoded: exclusions (opt-out/archived/blocked) are applied
12
+ // BEFORE slicing; apply is append-only + idempotent (`already_had` reported,
13
+ // never double-added); untag only ever removes the plan's own tags.
14
+
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
17
+ import readline from "node:readline";
18
+ import { http } from "../http.js";
19
+
20
+ const SEG_DIR = path.resolve(process.cwd(), ".flowiq", "segments");
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
+ const TAG_PREFIX_RE = /^[a-z0-9][a-z0-9-]*$/;
23
+
24
+ function ask(question) {
25
+ return new Promise((resolve) => {
26
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
27
+ rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
28
+ });
29
+ }
30
+
31
+ function slugify(name, fallback) {
32
+ const s = String(name || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
33
+ return s || fallback;
34
+ }
35
+
36
+ async function fileExists(p) {
37
+ try { await fs.access(p); return true; } catch { return false; }
38
+ }
39
+
40
+ const planPath = (slug) => path.join(SEG_DIR, `${slug}.json`);
41
+
42
+ async function resolvePlan(identifier) {
43
+ const candidates = [];
44
+ if (path.isAbsolute(identifier)) candidates.push(identifier);
45
+ else if (identifier.includes("/")) candidates.push(path.resolve(identifier));
46
+ else {
47
+ const base = identifier.endsWith(".json") ? identifier : `${identifier}.json`;
48
+ candidates.push(path.join(SEG_DIR, base));
49
+ candidates.push(path.resolve(base));
50
+ }
51
+ for (const c of candidates) if (await fileExists(c)) return c;
52
+ throw new Error(`Plan file not found. Tried:\n ${candidates.join("\n ")}`);
53
+ }
54
+
55
+ /** Read the cohort ids from a christiaan/segments snapshot or a plain file. */
56
+ async function readCohort(opts, orgId) {
57
+ if (!!opts.fromSegment === !!opts.idsFile) {
58
+ throw new Error("provide exactly one of --from-segment / --ids-file");
59
+ }
60
+ let ids = [], sourceMeta;
61
+ if (opts.fromSegment) {
62
+ const raw = JSON.parse(await fs.readFile(path.resolve(opts.fromSegment), "utf8"));
63
+ if (raw.organization_id && raw.organization_id !== orgId) {
64
+ throw new Error(`snapshot organization_id (${raw.organization_id}) ≠ the org you passed (${orgId}) — refusing (V-3)`);
65
+ }
66
+ ids = Array.isArray(raw.contact_ids) ? raw.contact_ids : [];
67
+ sourceMeta = { type: "segment_file", path: opts.fromSegment, input_count: ids.length, generated_at: raw.generated_at ?? null, definition: raw.definition ?? null };
68
+ } else {
69
+ const text = await fs.readFile(path.resolve(opts.idsFile), "utf8");
70
+ ids = text.split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
71
+ sourceMeta = { type: "ids_file", path: opts.idsFile, input_count: ids.length };
72
+ }
73
+ const bad = ids.filter((id) => !UUID_RE.test(id));
74
+ const good = ids.filter((id) => UUID_RE.test(id));
75
+ return { ids: good, badUuids: bad, sourceMeta };
76
+ }
77
+
78
+ export async function plan(orgId, opts = {}) {
79
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
80
+ const prefix = String(opts.tagPrefix || "").trim();
81
+ if (!TAG_PREFIX_RE.test(prefix)) {
82
+ console.error(`Error: --tag-prefix must be slug-safe lowercase ([a-z0-9-], got "${prefix}").`);
83
+ process.exit(1);
84
+ }
85
+ const batchSize = Number(opts.batchSize ?? 75);
86
+ if (!Number.isInteger(batchSize) || batchSize < 1) { console.error("Error: --batch-size must be ≥ 1."); process.exit(1); }
87
+ if (batchSize > 500) console.log(`⚠ batch size ${batchSize} is large — Meta tier risk; the runbook default is 75.`);
88
+
89
+ let cohort;
90
+ try { cohort = await readCohort(opts, orgId); }
91
+ catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
92
+ if (cohort.badUuids.length) console.log(`⚠ skipped ${cohort.badUuids.length} non-UUID line(s) in the cohort source.`);
93
+ if (!cohort.ids.length) { console.error("Error: no valid contact ids in the cohort source."); process.exit(1); }
94
+
95
+ let resp;
96
+ try {
97
+ resp = await http.post("segments", {
98
+ mode: "plan", organization_id: orgId,
99
+ contact_ids: cohort.ids, include_unsafe: !!opts.includeUnsafe,
100
+ });
101
+ } catch (e) {
102
+ console.error(`Plan failed: ${e.message}`);
103
+ if (e.body?.error) console.error(` ${e.body.error}`);
104
+ process.exit(1);
105
+ }
106
+ if (!resp.safe_count) { console.error("Nothing to tag — 0 safe contacts after exclusions (V-13)."); process.exit(1); }
107
+
108
+ // Slice safe_ids into contiguous batches (client-side, per the spec split).
109
+ const batches = [];
110
+ for (let i = 0; i < resp.safe_ids.length; i += batchSize) {
111
+ const nn = String(batches.length + 1).padStart(2, "0");
112
+ batches.push({ tag: `${prefix}-batch-${nn}`, count: Math.min(batchSize, resp.safe_ids.length - i), contact_ids: resp.safe_ids.slice(i, i + batchSize) });
113
+ }
114
+
115
+ const slug = slugify(opts.segment || prefix, prefix);
116
+ const planDoc = {
117
+ schema_version: 1,
118
+ segment: slug,
119
+ organization_id: resp.organization_id,
120
+ organization_slug: slugify(resp.organization_name, resp.organization_id.slice(0, 8)),
121
+ tag_prefix: prefix,
122
+ batch_size: batchSize,
123
+ created_at: new Date().toISOString(),
124
+ source: cohort.sourceMeta,
125
+ exclusions: {
126
+ policy: opts.includeUnsafe ? "include_unsafe" : "broadcast_safe",
127
+ excluded_counts: Object.fromEntries(Object.entries(resp.excluded).map(([k, v]) => [k, v.length])),
128
+ excluded_ids: resp.excluded,
129
+ },
130
+ safe_count: resp.safe_count,
131
+ batch_count: batches.length,
132
+ batches,
133
+ applied: null,
134
+ };
135
+ await fs.mkdir(SEG_DIR, { recursive: true });
136
+ await fs.writeFile(planPath(slug), JSON.stringify(planDoc, null, 2) + "\n", "utf8");
137
+
138
+ const ex = planDoc.exclusions.excluded_counts;
139
+ console.log(`Wrote ${planPath(slug)}`);
140
+ console.log(` org: ${resp.organization_name}`);
141
+ console.log(` input: ${resp.input_count} · safe: ${resp.safe_count} · excluded: ${ex.not_allow_broadcast + ex.archived + ex.blocked} (allow_broadcast ${ex.not_allow_broadcast}, archived ${ex.archived}, blocked ${ex.blocked}) · not_found ${ex.not_found_in_org} · dupes ${ex.duplicate_in_input}`);
142
+ console.log(` batches: ${batches.length} × ≤${batchSize} (${batches[0].tag} … ${batches[batches.length - 1].tag})`);
143
+ if (cohort.sourceMeta.generated_at) {
144
+ console.log(` ⚠ tagging point-in-time ids from ${cohort.sourceMeta.generated_at} — re-derive the cohort SQL if freshness matters.`);
145
+ }
146
+ console.log("");
147
+ console.log(`Next: flowiq segments apply ${orgId} ${slug} [--commit]`);
148
+ }
149
+
150
+ export async function apply(orgId, identifier, opts = {}) {
151
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
152
+ let filePath;
153
+ try { filePath = await resolvePlan(identifier); }
154
+ catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
155
+ const planDoc = JSON.parse(await fs.readFile(filePath, "utf8"));
156
+ if (planDoc.organization_id !== orgId) {
157
+ console.error(`Plan is for org ${planDoc.organization_id}, you passed ${orgId} — refusing (V-10).`);
158
+ process.exit(1);
159
+ }
160
+ if (!Array.isArray(planDoc.batches) || !planDoc.batches.length) {
161
+ console.error("Plan has no batches — re-run `segments plan` (V-10).");
162
+ process.exit(1);
163
+ }
164
+
165
+ const total = planDoc.batches.reduce((a, b) => a + b.contact_ids.length, 0);
166
+ console.log(`Plan ${planDoc.segment}: ${planDoc.batches.length} batch tag(s), ${total} contact(s) on ${planDoc.organization_slug}.`);
167
+
168
+ // V-11: warn when the batch tags already carry members (tag reuse merges counts).
169
+ try {
170
+ const before = await http.post("segments", { mode: "list", organization_id: orgId, prefix: planDoc.tag_prefix });
171
+ const existing = (before.tags || []).filter((t) => Number(t.count) > 0);
172
+ if (existing.length) {
173
+ console.log(`⚠ ${existing.length} of these tags already have members (counts will MERGE):`);
174
+ for (const t of existing.slice(0, 5)) console.log(` ${t.tag}: ${t.count}`);
175
+ if (!opts.yes && opts.commit) {
176
+ const a = await ask("Type y to proceed anyway: ");
177
+ if (a.toLowerCase() !== "y") { console.log("Aborted — nothing tagged."); process.exit(0); }
178
+ }
179
+ }
180
+ } catch { /* the pre-check is best-effort */ }
181
+
182
+ if (!opts.commit) {
183
+ console.log(`DRY RUN — would tag ${total} contact(s) across ${planDoc.batches.length} batch tag(s). Add --commit to apply.`);
184
+ return;
185
+ }
186
+ if (!opts.yes) {
187
+ const a = await ask(`Type the segment name ("${planDoc.segment}") to tag ${total} contact(s): `);
188
+ if (a !== planDoc.segment) { console.log("Mismatch — aborted, nothing tagged."); process.exit(0); }
189
+ }
190
+
191
+ let resp;
192
+ try {
193
+ resp = await http.post("segments", {
194
+ mode: "apply", organization_id: orgId,
195
+ batches: planDoc.batches.map((b) => ({ tag: b.tag, contact_ids: b.contact_ids })),
196
+ });
197
+ } catch (e) {
198
+ console.error(`Apply failed: ${e.message}`);
199
+ if (e.body?.error) console.error(` ${e.body.error}`);
200
+ process.exit(1);
201
+ }
202
+
203
+ for (const r of resp.results || []) {
204
+ console.log(` ${r.tag.padEnd(40)} tagged ${r.tagged}${r.already_had ? ` · already_had ${r.already_had}` : ""}${r.not_found ? ` · not_found ${r.not_found}` : ""}`);
205
+ }
206
+ console.log(`Totals: tagged ${resp.totals.tagged} · already_had ${resp.totals.already_had} · not_found ${resp.totals.not_found}`);
207
+
208
+ planDoc.applied = { at: new Date().toISOString(), tag_counts: Object.fromEntries((resp.results || []).map((r) => [r.tag, r.tagged + r.already_had])) };
209
+ await fs.writeFile(filePath, JSON.stringify(planDoc, null, 2) + "\n", "utf8");
210
+ console.log("");
211
+ console.log(`Send per tag, one batch at a time: flowiq broadcast send ${orgId} --tag ${planDoc.batches[0].tag} --template <name> …`);
212
+ }
213
+
214
+ export async function list(orgId, opts = {}) {
215
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
216
+ let resp;
217
+ try { resp = await http.post("segments", { mode: "list", organization_id: orgId, prefix: opts.prefix }); }
218
+ catch (e) { console.error(`List failed: ${e.message}`); process.exit(1); }
219
+ if (opts.json) { console.log(JSON.stringify(resp, null, 2)); return; }
220
+ if (!resp.tags?.length) { console.log(`(no tags${opts.prefix ? ` with prefix "${opts.prefix}"` : ""} on ${resp.organization_name})`); return; }
221
+ for (const t of resp.tags) console.log(` ${String(t.tag).padEnd(44)} ${t.count}`);
222
+ console.log(` ${"TOTAL".padEnd(44)} ${resp.total_tagged}`);
223
+ }
224
+
225
+ export async function untag(orgId, identifier, opts = {}) {
226
+ if (!UUID_RE.test(orgId)) { console.error(`Error: "${orgId}" is not a valid organization UUID.`); process.exit(1); }
227
+ let filePath;
228
+ try { filePath = await resolvePlan(identifier); }
229
+ catch (e) { console.error(`Error: ${e.message}`); process.exit(1); }
230
+ const planDoc = JSON.parse(await fs.readFile(filePath, "utf8"));
231
+ if (planDoc.organization_id !== orgId) {
232
+ console.error(`Plan is for org ${planDoc.organization_id}, you passed ${orgId} — refusing.`);
233
+ process.exit(1);
234
+ }
235
+ const tags = (planDoc.batches || []).map((b) => b.tag);
236
+ if (!tags.length) { console.error("Plan has no batch tags."); process.exit(1); }
237
+
238
+ if (!opts.commit) {
239
+ console.log(`DRY RUN — would remove ${tags.length} batch tag(s): ${tags.join(", ")}. Add --commit --confirm to remove.`);
240
+ return;
241
+ }
242
+ if (!opts.confirm) {
243
+ console.error("untag is destructive — add --confirm alongside --commit (V-12).");
244
+ process.exit(1);
245
+ }
246
+
247
+ let resp;
248
+ try { resp = await http.post("segments", { mode: "untag", organization_id: orgId, tags }); }
249
+ catch (e) { console.error(`Untag failed: ${e.message}`); process.exit(1); }
250
+ for (const r of resp.results || []) console.log(` ${r.tag.padEnd(40)} removed ${r.removed}`);
251
+ console.log(`Totals: removed ${resp.totals.removed}`);
252
+ }
package/src/index.js CHANGED
@@ -24,6 +24,9 @@ import * as exportCmd from "./commands/export.js";
24
24
  import * as testCmd from "./commands/agent-test.js";
25
25
  import * as knowledgeCmd from "./commands/knowledge.js";
26
26
  import * as customToolsCmd from "./commands/custom-tools.js";
27
+ import * as broadcastCmd from "./commands/broadcast.js";
28
+ import * as segmentsCmd from "./commands/segments.js";
29
+ import * as keywordsCmd from "./commands/keywords.js";
27
30
 
28
31
  // Read version from package.json so it stays in sync with the published npm
29
32
  // version automatically (single source of truth — bumping package.json on each
@@ -188,7 +191,14 @@ export function run(argv) {
188
191
  .action((orgId, opts) => templatesCmd.status(orgId, opts));
189
192
 
190
193
  // org (read-only org summary for the prompt-builder skill, creds stripped)
191
- const org = program.command("org").description("Read-only org info (platform detect + active agent)");
194
+ const org = program.command("org").description("Org info (read) + create a new organization");
195
+ org.command("create")
196
+ .description("Create a NEW organization (org row + admin membership; owner defaults to you)")
197
+ .requiredOption("--name <name>", "organization name")
198
+ .option("--slug <slug>", "URL slug (default: name lowercased, spaces→dashes; must be unique)")
199
+ .option("--owner <email>", "owner's auth email (must already have an account; default: you)")
200
+ .option("--provider <p>", "meta (default) | wati")
201
+ .action((opts) => orgCmd.create(opts));
192
202
  org.command("info <organization_id>")
193
203
  .description("Show the org's platform, storefront url + active agent (raw creds stripped)")
194
204
  .option("--json", "print the raw JSON payload")
@@ -217,10 +227,10 @@ export function run(argv) {
217
227
  .option("--discount [bool]", "agent.discount.enabled (true if bare)")
218
228
  .action((orgId, opts) => agentConfigCmd.config(orgId, opts));
219
229
 
220
- // agent-updates (pending client change-requests + chat context; read-only)
230
+ // agent-updates (pending client change-requests + chat context; pull + resolve)
221
231
  const au = program.command("agent-updates")
222
232
  .alias("au")
223
- .description("Pull pending agent_updates + the chat context around each (read-only)");
233
+ .description("Pull pending agent_updates + the chat context around each; resolve a ticket with a client-facing note");
224
234
  au.command("pull <organization_id>")
225
235
  .description("Fetch updates + linked contact + trigger-message window; downloads attachments locally")
226
236
  .option("--status <status>", "pending (default) | resolved | in_progress | all")
@@ -234,6 +244,14 @@ export function run(argv) {
234
244
  au.command("list")
235
245
  .description("List local agent-updates snapshots")
236
246
  .action(() => agentUpdatesCmd.list());
247
+ au.command("resolve <update_id>")
248
+ .description("Flip a client-raised ticket to resolved/declined + write the client-facing note (the client reads \"FlowIQ: <note>\")")
249
+ .option("--note <text>", "client-facing note (required when resolving)")
250
+ .option("--internal <text>", "staff-only note (never shown to the client)")
251
+ .option("--image <url>", "client-facing response image URL")
252
+ .option("--status <status>", "resolved (default) | declined", "resolved")
253
+ .option("--yes", "skip the interactive confirm gate (the --note requirement still applies)")
254
+ .action((updateId, opts) => agentUpdatesCmd.resolve(updateId, opts));
237
255
 
238
256
  // export (full chat history → TXT, byte-identical to the in-app export)
239
257
  const exp = program.command("export").description("Export data to local files");
@@ -320,8 +338,9 @@ export function run(argv) {
320
338
  .option("--agent <id>", "target a specific agent instead of the org's active one")
321
339
  .action((orgId, opts) => customToolsCmd.pull(orgId, opts));
322
340
  customTools.command("push <slug-or-path>")
323
- .description("FULL-REPLACE the agent's custom tools with the local JSON's tools[] (validated server-side)")
341
+ .description("FULL-REPLACE the agent's custom tools with the local JSON's tools[] (validated server-side). Destructive pushes (remove/disable/wipe) are BLOCKED unless --confirm.")
324
342
  .option("--dry-run", "validate + print the per-tool diff WITHOUT writing")
343
+ .option("--confirm", "required when the push removes or disables tools, or sends an empty tools[]")
325
344
  .action((id, opts) => customToolsCmd.push(id, opts));
326
345
  customTools.command("enable <organization_id> <tool_name>")
327
346
  .description("Enable one custom tool by name (no round-trip)")
@@ -335,5 +354,99 @@ export function run(argv) {
335
354
  .description("List local custom-tools snapshots")
336
355
  .action(() => customToolsCmd.list());
337
356
 
357
+ // broadcast (CSV → per-row WhatsApp template broadcast; spec: flowiq-broadcast-send-SPEC)
358
+ const broadcast = program.command("broadcast")
359
+ .alias("bc")
360
+ .description("Send an APPROVED WhatsApp template to every row of a CSV (per-row column→param mapping)");
361
+ broadcast.command("map <organization_id>")
362
+ .description("Introspect the template + CSV and build/refresh the saved mapping interactively (no send)")
363
+ .requiredOption("--template <name>", "approved Meta template name")
364
+ .requiredOption("--csv <file>", "path to the recipients CSV")
365
+ .option("--campaign <name>", "campaign id / config file slug (default: CSV filename)")
366
+ .option("--force-remap", "ignore the saved mapping and rebuild interactively")
367
+ .action((orgId, opts) => broadcastCmd.map(orgId, opts));
368
+ broadcast.command("preview <organization_id>")
369
+ .description("Dry-run render of sample rows (exact message text + resolved button URL); never sends")
370
+ .option("--template <name>", "template (default: from the saved campaign)")
371
+ .option("--csv <file>", "CSV (default: from the saved campaign)")
372
+ .option("--campaign <name>", "campaign id / config file slug")
373
+ .option("--rows <n>", "how many sample rows to render", "3")
374
+ .action((orgId, opts) => broadcastCmd.preview(orgId, opts));
375
+ broadcast.command("send <organization_id>")
376
+ .description("Run the full pipeline. DRY-RUN by default; --commit sends (type the campaign name to confirm). Recipients from --csv OR --tag.")
377
+ .requiredOption("--template <name>", "approved Meta template name")
378
+ .option("--csv <file>", "path to the recipients CSV (per-row values)")
379
+ .option("--tag <tag>", "send to every broadcast-safe contact carrying this tag (e.g. a segments batch tag)")
380
+ .option("--body <k=v>", "with --tag: body param (repeatable), e.g. --body param1=\"Hi {{first_name}}\"", broadcastCmd.collectKV, {})
381
+ .option("--button <k=v>", "with --tag: dynamic URL button param, e.g. --button param1=<short-code>", broadcastCmd.collectKV, {})
382
+ .option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
383
+ .option("--commit", "actually send (omit to dry-run)")
384
+ .option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
385
+ .option("--force-remap", "ignore the saved mapping and rebuild interactively")
386
+ .option("--illegal-chars <mode>", "reject | strip — newlines/tabs/4+ spaces in values (Meta #100)", "reject")
387
+ .option("--rate <n>", "max messages per second (hard cap 10)", "8")
388
+ .option("--upsert-batch <n>", "contacts per upsert call (max 4000)", "4000")
389
+ .option("--skip-upsert", "assume contacts already exist; skip the upsert stage")
390
+ .option("--limit <n>", "only process the first N valid rows (smoke test)")
391
+ .option("--resume", "continue from the existing status log instead of starting fresh")
392
+ .option("--retry-failed", "with --resume: also re-attempt rows previously marked failed")
393
+ .action((orgId, opts) => broadcastCmd.send(orgId, opts));
394
+ broadcast.command("resume <organization_id>")
395
+ .description("Continue an interrupted campaign: only unsent rows from the status log; ambiguous in-flight rows are NEVER auto-resent")
396
+ .requiredOption("--campaign <name>", "campaign id / config file slug")
397
+ .option("--commit", "actually send (omit to dry-run the remaining rows)")
398
+ .option("--yes", "skip the confirm gate")
399
+ .option("--rate <n>", "max messages per second (hard cap 10)", "8")
400
+ .option("--retry-failed", "also re-attempt rows previously marked failed (confirmed failures only)")
401
+ .action((orgId, opts) => broadcastCmd.resume(orgId, opts));
402
+ broadcast.command("list")
403
+ .description("List local campaigns + sent counts")
404
+ .action(() => broadcastCmd.list());
405
+
406
+ // segments (cohort → batch tags; tags only — the send is `broadcast send --tag`)
407
+ const segments = program.command("segments")
408
+ .alias("seg")
409
+ .description("Slice a contact cohort into fixed-size batch tags and bulk-apply them (broadcast batching)");
410
+ segments.command("plan <organization_id>")
411
+ .description("Ingest a cohort id list, apply broadcast-safety exclusions, slice into N-sized batch tags, write the plan (no DB write)")
412
+ .requiredOption("--tag-prefix <name>", "campaign tag stem; batches become <prefix>-batch-NN")
413
+ .option("--from-segment <path>", "a segments snapshot JSON (uses its contact_ids[])")
414
+ .option("--ids-file <path>", "a plain newline/CSV file of contact UUIDs")
415
+ .option("--batch-size <n>", "contacts per batch tag", "75")
416
+ .option("--segment <name>", "plan file slug (default: the tag prefix)")
417
+ .option("--include-unsafe", "do NOT exclude non-broadcast-safe contacts (rare; default excludes)")
418
+ .action((orgId, opts) => segmentsCmd.plan(orgId, opts));
419
+ segments.command("apply <organization_id> <slug-or-path>")
420
+ .description("Append the plan's batch tags to their contacts. DRY-RUN by default; --commit to write (append-only, idempotent)")
421
+ .option("--commit", "actually write the tags")
422
+ .option("--yes", "skip the type-the-segment-name confirm gate")
423
+ .action((orgId, id, opts) => segmentsCmd.apply(orgId, id, opts));
424
+ segments.command("list <organization_id>")
425
+ .description("VERIFY: print tag → contact count (read-only)")
426
+ .option("--prefix <substr>", "only tags starting with this prefix (recommended)")
427
+ .option("--json", "raw JSON")
428
+ .action((orgId, opts) => segmentsCmd.list(orgId, opts));
429
+ segments.command("untag <organization_id> <slug-or-path>")
430
+ .description("ROLLBACK: remove the plan's own batch tags. Destructive — needs --commit AND --confirm")
431
+ .option("--commit", "actually remove (omit to dry-run)")
432
+ .option("--confirm", "required alongside --commit")
433
+ .action((orgId, id, opts) => segmentsCmd.untag(orgId, id, opts));
434
+
435
+ // keywords (org auto-reply engine: keywords + keyword_actions; id-keyed diff push)
436
+ const keywords = program.command("keywords")
437
+ .alias("kw")
438
+ .description("Round-trip an org's keyword auto-replies + actions (the safe editor for attribute-write actions)");
439
+ keywords.command("pull <organization_id>")
440
+ .description("Fetch every keyword + its actions into a local JSON file (ALWAYS pull right before editing)")
441
+ .action((orgId) => keywordsCmd.pull(orgId));
442
+ keywords.command("push <slug-or-path>")
443
+ .description("Apply the local JSON: id-keyed UPSERT (keep ids to update, drop ids to create). --prune also DELETES keywords absent from the file.")
444
+ .option("--prune", "also delete DB keywords whose id is not in the file (destructive; cascade-deletes their actions)")
445
+ .option("--dry-run", "compute + print the plan without writing (ALWAYS before --prune)")
446
+ .action((id, opts) => keywordsCmd.push(id, opts));
447
+ keywords.command("list")
448
+ .description("List local keyword snapshots")
449
+ .action(() => keywordsCmd.list());
450
+
338
451
  program.parseAsync(argv);
339
452
  }