@flowapt/flowiq-cli 0.1.11 → 0.2.1

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.
@@ -0,0 +1,197 @@
1
+ // `flowiq custom-tools pull <org_id> [--agent <id>]` / `push <slug> [--dry-run]`
2
+ // / `enable|disable <org_id> <tool_name>` / `list`.
3
+ // Round-trips an agent's CUSTOM TOOLS — the `agents.additional_tools` array of
4
+ // "any API call → tool" definitions (get_more_answers, zoho_* wrappers, …).
5
+ // Via /cli/custom-tools; no DB creds on disk.
6
+ //
7
+ // These define real HTTP calls the agent can execute, so push is validated
8
+ // hard server-side and `--dry-run` prints the per-tool diff without writing.
9
+
10
+ import fs from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { http } from "../http.js";
13
+
14
+ const CT_DIR = path.resolve(process.cwd(), ".flowiq", "custom-tools");
15
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
16
+
17
+ function slugify(name, fallback) {
18
+ const s = String(name || "")
19
+ .toLowerCase()
20
+ .replace(/[^a-z0-9]+/g, "-")
21
+ .replace(/-+/g, "-")
22
+ .replace(/^-|-$/g, "");
23
+ return s || fallback;
24
+ }
25
+
26
+ async function fileExists(p) {
27
+ try { await fs.access(p); return true; } catch { return false; }
28
+ }
29
+
30
+ async function resolvePushPath(identifier) {
31
+ const candidates = [];
32
+ if (path.isAbsolute(identifier)) {
33
+ candidates.push(identifier);
34
+ } else if (identifier.includes("/") || identifier.includes("\\")) {
35
+ candidates.push(path.resolve(identifier));
36
+ } else {
37
+ const base = identifier.endsWith(".json") ? identifier : `${identifier}.json`;
38
+ candidates.push(path.join(CT_DIR, base));
39
+ candidates.push(path.resolve(base));
40
+ }
41
+ for (const c of candidates) {
42
+ if (await fileExists(c)) return c;
43
+ }
44
+ throw new Error(`File not found. Tried:\n ${candidates.join("\n ")}`);
45
+ }
46
+
47
+ function printWarnings(warnings) {
48
+ for (const w of warnings || []) console.log(` ⚠ ${w}`);
49
+ }
50
+
51
+ function printDiff(diff) {
52
+ if (!diff) return;
53
+ const parts = [];
54
+ if (diff.added?.length) parts.push(`+${diff.added.length} added (${diff.added.join(", ")})`);
55
+ if (diff.removed?.length) parts.push(`-${diff.removed.length} removed (${diff.removed.join(", ")})`);
56
+ if (diff.changed?.length) parts.push(`~${diff.changed.length} changed (${diff.changed.join(", ")})`);
57
+ parts.push(`${diff.unchanged ?? 0} unchanged`);
58
+ console.log(` diff: ${parts.join("; ")}`);
59
+ }
60
+
61
+ export async function pull(orgId, opts = {}) {
62
+ if (!UUID_RE.test(orgId)) {
63
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
64
+ process.exit(1);
65
+ }
66
+ let resp;
67
+ try {
68
+ resp = await http.get("custom-tools", { organization_id: orgId, agent_id: opts.agent });
69
+ } catch (e) {
70
+ console.error(`Pull failed: ${e.message}`);
71
+ process.exit(1);
72
+ }
73
+
74
+ await fs.mkdir(CT_DIR, { recursive: true });
75
+ // Non-active agents get an agent-suffixed filename (same as prompts) so
76
+ // pulling multiple agents from one org doesn't clobber each other.
77
+ const orgSlug = slugify(resp.organization_name, resp.organization_id);
78
+ const slug = resp.is_active === false
79
+ ? `${orgSlug}-${slugify(resp.agent_name, resp.agent_id.slice(0, 8))}`
80
+ : orgSlug;
81
+ const filePath = path.join(CT_DIR, `${slug}.json`);
82
+ const overwriting = await fileExists(filePath);
83
+ await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
84
+
85
+ console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
86
+ console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
87
+ console.log(` agent: ${resp.agent_name} (${resp.agent_id})${resp.is_active === false ? " [NON-active]" : ""}`);
88
+ console.log(` tools: ${resp.tool_count} (${resp.enabled_count} enabled)`);
89
+ for (const t of resp.tools || []) {
90
+ const flags = [
91
+ t.enabled === false ? "DISABLED" : null,
92
+ t.super_admin_only ? "super-admin" : null,
93
+ t.channels?.length ? `channels: ${t.channels.join(",")}` : null,
94
+ ].filter(Boolean).join(", ");
95
+ let host = t.endpoint;
96
+ try { host = new URL(t.endpoint).host; } catch { /* keep raw */ }
97
+ console.log(` ${String(t.name).padEnd(32)} ${String(t.method || "?").padEnd(6)} ${host}${flags ? ` [${flags}]` : ""}`);
98
+ }
99
+ }
100
+
101
+ export async function push(identifier, opts = {}) {
102
+ let filePath;
103
+ try {
104
+ filePath = await resolvePushPath(identifier);
105
+ } catch (e) {
106
+ console.error(`Error: ${e.message}`);
107
+ process.exit(1);
108
+ }
109
+ const raw = await fs.readFile(filePath, "utf8");
110
+ let data;
111
+ try { data = JSON.parse(raw); }
112
+ catch (e) {
113
+ console.error(`Invalid JSON in ${filePath}: ${e.message}`);
114
+ process.exit(1);
115
+ }
116
+ if (!data.organization_id || !UUID_RE.test(data.organization_id)) {
117
+ console.error(`${filePath}: organization_id missing or invalid`);
118
+ process.exit(1);
119
+ }
120
+ if (!Array.isArray(data.tools)) {
121
+ console.error(`${filePath}: tools must be an array of tool definitions`);
122
+ process.exit(1);
123
+ }
124
+
125
+ let resp;
126
+ try {
127
+ resp = await http.post("custom-tools", {
128
+ organization_id: data.organization_id,
129
+ agent_id: data.agent_id, // push back to the agent this file was pulled from
130
+ tools: data.tools,
131
+ dry_run: !!opts.dryRun,
132
+ });
133
+ } catch (e) {
134
+ console.error(`Push failed: ${e.message}`);
135
+ if (e.body?.error) console.error(` ${e.body.error}`);
136
+ process.exit(1);
137
+ }
138
+
139
+ console.log(`${resp.dry_run ? "DRY RUN (nothing written) —" : "Pushed"} ${filePath}`);
140
+ console.log(` org: ${resp.organization_name} (${data.organization_id})`);
141
+ console.log(` agent: ${resp.agent_name}${resp.is_active === false ? " [NON-active]" : ""}`);
142
+ console.log(` tools: ${resp.tool_count}`);
143
+ printDiff(resp.diff);
144
+ printWarnings(resp.warnings);
145
+ if (resp.dry_run) console.log(" re-run without --dry-run to apply.");
146
+ }
147
+
148
+ async function toggle(orgId, toolName, enabled, opts = {}) {
149
+ if (!UUID_RE.test(orgId)) {
150
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
151
+ process.exit(1);
152
+ }
153
+ let resp;
154
+ try {
155
+ resp = await http.post("custom-tools", {
156
+ organization_id: orgId,
157
+ agent_id: opts.agent,
158
+ toggle: { name: toolName, enabled },
159
+ });
160
+ } catch (e) {
161
+ console.error(`${enabled ? "Enable" : "Disable"} failed: ${e.message}`);
162
+ if (e.body?.error) console.error(` ${e.body.error}`);
163
+ process.exit(1);
164
+ }
165
+ console.log(`${enabled ? "Enabled" : "Disabled"} "${resp.name}" on ${resp.agent_name} (${resp.organization_name})${resp.is_active === false ? " [NON-active]" : ""}`);
166
+ }
167
+
168
+ export const enable = (orgId, toolName, opts) => toggle(orgId, toolName, true, opts);
169
+ export const disable = (orgId, toolName, opts) => toggle(orgId, toolName, false, opts);
170
+
171
+ export async function list() {
172
+ let files;
173
+ try {
174
+ files = (await fs.readdir(CT_DIR)).filter((f) => f.endsWith(".json"));
175
+ } catch {
176
+ console.log("(no local custom-tools files; run `custom-tools pull <org_id>`)");
177
+ return;
178
+ }
179
+ if (!files.length) {
180
+ console.log("(no local custom-tools files; run `custom-tools pull <org_id>`)");
181
+ return;
182
+ }
183
+ files.sort();
184
+ for (const f of files) {
185
+ const fp = path.join(CT_DIR, f);
186
+ try {
187
+ const d = JSON.parse(await fs.readFile(fp, "utf8"));
188
+ console.log(f);
189
+ console.log(` org: ${d.organization_name ?? "(unknown)"} (${d.organization_id ?? "?"})`);
190
+ console.log(` agent: ${d.agent_name ?? "?"} (${d.agent_id ?? "?"})`);
191
+ console.log(` tools: ${d.tool_count ?? (Array.isArray(d.tools) ? d.tools.length : "?")}`);
192
+ console.log(` pulled: ${d.pulled_at ?? "(unknown)"}`);
193
+ } catch (e) {
194
+ console.log(`${f} [invalid JSON: ${e.message}]`);
195
+ }
196
+ }
197
+ }
@@ -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
@@ -23,6 +23,9 @@ import * as agentUpdatesCmd from "./commands/agent-updates.js";
23
23
  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
+ import * as customToolsCmd from "./commands/custom-tools.js";
27
+ import * as broadcastCmd from "./commands/broadcast.js";
28
+ import * as segmentsCmd from "./commands/segments.js";
26
29
 
27
30
  // Read version from package.json so it stays in sync with the published npm
28
31
  // version automatically (single source of truth — bumping package.json on each
@@ -216,10 +219,10 @@ export function run(argv) {
216
219
  .option("--discount [bool]", "agent.discount.enabled (true if bare)")
217
220
  .action((orgId, opts) => agentConfigCmd.config(orgId, opts));
218
221
 
219
- // agent-updates (pending client change-requests + chat context; read-only)
222
+ // agent-updates (pending client change-requests + chat context; pull + resolve)
220
223
  const au = program.command("agent-updates")
221
224
  .alias("au")
222
- .description("Pull pending agent_updates + the chat context around each (read-only)");
225
+ .description("Pull pending agent_updates + the chat context around each; resolve a ticket with a client-facing note");
223
226
  au.command("pull <organization_id>")
224
227
  .description("Fetch updates + linked contact + trigger-message window; downloads attachments locally")
225
228
  .option("--status <status>", "pending (default) | resolved | in_progress | all")
@@ -233,6 +236,14 @@ export function run(argv) {
233
236
  au.command("list")
234
237
  .description("List local agent-updates snapshots")
235
238
  .action(() => agentUpdatesCmd.list());
239
+ au.command("resolve <update_id>")
240
+ .description("Flip a client-raised ticket to resolved/declined + write the client-facing note (the client reads \"FlowIQ: <note>\")")
241
+ .option("--note <text>", "client-facing note (required when resolving)")
242
+ .option("--internal <text>", "staff-only note (never shown to the client)")
243
+ .option("--image <url>", "client-facing response image URL")
244
+ .option("--status <status>", "resolved (default) | declined", "resolved")
245
+ .option("--yes", "skip the interactive confirm gate (the --note requirement still applies)")
246
+ .action((updateId, opts) => agentUpdatesCmd.resolve(updateId, opts));
236
247
 
237
248
  // export (full chat history → TXT, byte-identical to the in-app export)
238
249
  const exp = program.command("export").description("Export data to local files");
@@ -310,5 +321,107 @@ export function run(argv) {
310
321
  .description("List local knowledge snapshots")
311
322
  .action(() => knowledgeCmd.list());
312
323
 
324
+ // custom-tools (agent custom tools: additional_tools — the "any API call → tool" defs)
325
+ const customTools = program.command("custom-tools")
326
+ .alias("ct")
327
+ .description("Round-trip an agent's CUSTOM TOOLS (additional_tools — the \"any API call → tool\" definitions)");
328
+ customTools.command("pull <organization_id>")
329
+ .description("Fetch the agent's custom tools into a local JSON file (default: active agent)")
330
+ .option("--agent <id>", "target a specific agent instead of the org's active one")
331
+ .action((orgId, opts) => customToolsCmd.pull(orgId, opts));
332
+ customTools.command("push <slug-or-path>")
333
+ .description("FULL-REPLACE the agent's custom tools with the local JSON's tools[] (validated server-side)")
334
+ .option("--dry-run", "validate + print the per-tool diff WITHOUT writing")
335
+ .action((id, opts) => customToolsCmd.push(id, opts));
336
+ customTools.command("enable <organization_id> <tool_name>")
337
+ .description("Enable one custom tool by name (no round-trip)")
338
+ .option("--agent <id>", "target a specific agent instead of the org's active one")
339
+ .action((orgId, name, opts) => customToolsCmd.enable(orgId, name, opts));
340
+ customTools.command("disable <organization_id> <tool_name>")
341
+ .description("Disable one custom tool by name (no round-trip)")
342
+ .option("--agent <id>", "target a specific agent instead of the org's active one")
343
+ .action((orgId, name, opts) => customToolsCmd.disable(orgId, name, opts));
344
+ customTools.command("list")
345
+ .description("List local custom-tools snapshots")
346
+ .action(() => customToolsCmd.list());
347
+
348
+ // broadcast (CSV → per-row WhatsApp template broadcast; spec: flowiq-broadcast-send-SPEC)
349
+ const broadcast = program.command("broadcast")
350
+ .alias("bc")
351
+ .description("Send an APPROVED WhatsApp template to every row of a CSV (per-row column→param mapping)");
352
+ broadcast.command("map <organization_id>")
353
+ .description("Introspect the template + CSV and build/refresh the saved mapping interactively (no send)")
354
+ .requiredOption("--template <name>", "approved Meta template name")
355
+ .requiredOption("--csv <file>", "path to the recipients CSV")
356
+ .option("--campaign <name>", "campaign id / config file slug (default: CSV filename)")
357
+ .option("--force-remap", "ignore the saved mapping and rebuild interactively")
358
+ .action((orgId, opts) => broadcastCmd.map(orgId, opts));
359
+ broadcast.command("preview <organization_id>")
360
+ .description("Dry-run render of sample rows (exact message text + resolved button URL); never sends")
361
+ .option("--template <name>", "template (default: from the saved campaign)")
362
+ .option("--csv <file>", "CSV (default: from the saved campaign)")
363
+ .option("--campaign <name>", "campaign id / config file slug")
364
+ .option("--rows <n>", "how many sample rows to render", "3")
365
+ .action((orgId, opts) => broadcastCmd.preview(orgId, opts));
366
+ broadcast.command("send <organization_id>")
367
+ .description("Run the full pipeline. DRY-RUN by default; --commit sends (type the campaign name to confirm). Recipients from --csv OR --tag.")
368
+ .requiredOption("--template <name>", "approved Meta template name")
369
+ .option("--csv <file>", "path to the recipients CSV (per-row values)")
370
+ .option("--tag <tag>", "send to every broadcast-safe contact carrying this tag (e.g. a segments batch tag)")
371
+ .option("--body <k=v>", "with --tag: body param (repeatable), e.g. --body param1=\"Hi {{first_name}}\"", broadcastCmd.collectKV, {})
372
+ .option("--button <k=v>", "with --tag: dynamic URL button param, e.g. --button param1=<short-code>", broadcastCmd.collectKV, {})
373
+ .option("--campaign <name>", "campaign id / config file slug (default: CSV filename / tag)")
374
+ .option("--commit", "actually send (omit to dry-run)")
375
+ .option("--yes", "skip the type-the-campaign-name confirm gate (CI)")
376
+ .option("--force-remap", "ignore the saved mapping and rebuild interactively")
377
+ .option("--illegal-chars <mode>", "reject | strip — newlines/tabs/4+ spaces in values (Meta #100)", "reject")
378
+ .option("--rate <n>", "max messages per second (hard cap 10)", "8")
379
+ .option("--upsert-batch <n>", "contacts per upsert call (max 4000)", "4000")
380
+ .option("--skip-upsert", "assume contacts already exist; skip the upsert stage")
381
+ .option("--limit <n>", "only process the first N valid rows (smoke test)")
382
+ .option("--resume", "continue from the existing status log instead of starting fresh")
383
+ .option("--retry-failed", "with --resume: also re-attempt rows previously marked failed")
384
+ .action((orgId, opts) => broadcastCmd.send(orgId, opts));
385
+ broadcast.command("resume <organization_id>")
386
+ .description("Continue an interrupted campaign: only unsent rows from the status log; ambiguous in-flight rows are NEVER auto-resent")
387
+ .requiredOption("--campaign <name>", "campaign id / config file slug")
388
+ .option("--commit", "actually send (omit to dry-run the remaining rows)")
389
+ .option("--yes", "skip the confirm gate")
390
+ .option("--rate <n>", "max messages per second (hard cap 10)", "8")
391
+ .option("--retry-failed", "also re-attempt rows previously marked failed (confirmed failures only)")
392
+ .action((orgId, opts) => broadcastCmd.resume(orgId, opts));
393
+ broadcast.command("list")
394
+ .description("List local campaigns + sent counts")
395
+ .action(() => broadcastCmd.list());
396
+
397
+ // segments (cohort → batch tags; tags only — the send is `broadcast send --tag`)
398
+ const segments = program.command("segments")
399
+ .alias("seg")
400
+ .description("Slice a contact cohort into fixed-size batch tags and bulk-apply them (broadcast batching)");
401
+ segments.command("plan <organization_id>")
402
+ .description("Ingest a cohort id list, apply broadcast-safety exclusions, slice into N-sized batch tags, write the plan (no DB write)")
403
+ .requiredOption("--tag-prefix <name>", "campaign tag stem; batches become <prefix>-batch-NN")
404
+ .option("--from-segment <path>", "a segments snapshot JSON (uses its contact_ids[])")
405
+ .option("--ids-file <path>", "a plain newline/CSV file of contact UUIDs")
406
+ .option("--batch-size <n>", "contacts per batch tag", "75")
407
+ .option("--segment <name>", "plan file slug (default: the tag prefix)")
408
+ .option("--include-unsafe", "do NOT exclude non-broadcast-safe contacts (rare; default excludes)")
409
+ .action((orgId, opts) => segmentsCmd.plan(orgId, opts));
410
+ segments.command("apply <organization_id> <slug-or-path>")
411
+ .description("Append the plan's batch tags to their contacts. DRY-RUN by default; --commit to write (append-only, idempotent)")
412
+ .option("--commit", "actually write the tags")
413
+ .option("--yes", "skip the type-the-segment-name confirm gate")
414
+ .action((orgId, id, opts) => segmentsCmd.apply(orgId, id, opts));
415
+ segments.command("list <organization_id>")
416
+ .description("VERIFY: print tag → contact count (read-only)")
417
+ .option("--prefix <substr>", "only tags starting with this prefix (recommended)")
418
+ .option("--json", "raw JSON")
419
+ .action((orgId, opts) => segmentsCmd.list(orgId, opts));
420
+ segments.command("untag <organization_id> <slug-or-path>")
421
+ .description("ROLLBACK: remove the plan's own batch tags. Destructive — needs --commit AND --confirm")
422
+ .option("--commit", "actually remove (omit to dry-run)")
423
+ .option("--confirm", "required alongside --commit")
424
+ .action((orgId, id, opts) => segmentsCmd.untag(orgId, id, opts));
425
+
313
426
  program.parseAsync(argv);
314
427
  }