@flowapt/flowiq-cli 0.1.1 → 0.1.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.
package/README.md CHANGED
@@ -1,18 +1,20 @@
1
1
  # flowiq
2
2
 
3
- FlowIQ staff CLI. Round-trips agent prompts, questionnaires, webhooks, and
4
- other internal config through `https://api.flowiq.live/cli/*` so the Supabase
5
- service role never leaves the server.
3
+ FlowIQ staff CLI. Round-trips agent prompts, questionnaires, fine-tuning,
4
+ contact chats, platform webhooks, and outbound messaging webhooks through
5
+ `https://api.flowiq.live/cli/*` so the Supabase service role never leaves
6
+ the server.
6
7
 
7
8
  ## Install
8
9
 
9
10
  ```bash
10
11
  npm i -g @flowapt/flowiq-cli
12
+ flowiq --version # should print 0.1.x
11
13
  ```
12
14
 
13
15
  ## Authenticate
14
16
 
15
- Get a personal staff API key from your FlowIQ admin (an `fiq_staff_…`
17
+ Get a personal staff API key from a FlowIQ super-admin (an `fiq_staff_…`
16
18
  string). Then:
17
19
 
18
20
  ```bash
@@ -36,33 +38,137 @@ flowiq auth logout
36
38
 
37
39
  ## Commands
38
40
 
39
- ### `flowiq prompts pull <organization_id>`
41
+ All `pull` commands write to `./.flowiq/<topic>/<slug>.json` relative to
42
+ your current working directory. We recommend picking a dedicated folder
43
+ for your CLI work:
40
44
 
41
- Fetches the active WhatsApp agent's `prompt_sections` and writes them to
42
- `./.flowiq/prompts/<org-slug>.json`.
45
+ ```bash
46
+ mkdir -p ~/flowapt-cli && cd ~/flowapt-cli
47
+ ```
48
+
49
+ ### Prompts — `flowiq prompts pull|push <slug>`
50
+
51
+ Round-trips an org's active WhatsApp agent's system prompt
52
+ (`agents.settings.prompt_sections`).
53
+
54
+ ```bash
55
+ flowiq prompts pull <organization_id>
56
+ # edit ./.flowiq/prompts/<slug>.json — modify section titles/content
57
+ flowiq prompts push <slug>
58
+ ```
59
+
60
+ Notes:
61
+ - `system_prompt` is regenerated server-side from `prompt_sections` on
62
+ push using the `## SECTION: <title>` format.
63
+ - Sections support optional `hidden: boolean` + `channels: string[]`
64
+ (`web` / `whatsapp` / `messenger` / `instagram`) — validated on push.
65
+ - For legacy agents without a sections array, pull auto-bootstraps a
66
+ single "Main" section from `settings.system_prompt` and returns
67
+ `bootstrapped: true`. The CLI prints a heads-up before push.
68
+
69
+ ### Questionnaires — `flowiq questionnaires pull|push <slug>` (alias `q`)
70
+
71
+ Round-trips `agents.questionnaire` JSONB.
72
+
73
+ ```bash
74
+ flowiq q pull <organization_id>
75
+ # edit ./.flowiq/questionnaires/<slug>.json — fill in answers
76
+ flowiq q push <slug>
77
+ ```
78
+
79
+ If the DB column is null, pull seeds the local file from a default
80
+ template (9 sections / 23 questions). Optional per-question `note` field
81
+ is preserved round-trip.
82
+
83
+ ### Fine-tuning — `flowiq fine-tuning pull|push <slug>` (alias `ft`)
84
+
85
+ Round-trips `agents.fine_tuning` (text instructions) + `agents.fine_tuning_entries`
86
+ (structured Q&A entries).
87
+
88
+ ```bash
89
+ flowiq ft pull <organization_id>
90
+ # edit ./.flowiq/fine-tuning/<slug>.json
91
+ flowiq ft push <slug>
92
+ ```
93
+
94
+ Each entry has: `id`, `customer_question`, `correct_answer`, `agent_answer`,
95
+ `notes`, `date` (YYYY-MM-DD), `category` (Product Info / Pricing / Warranty /
96
+ Shipping / Returns / Promotions / Technical Support / Tone Adjustment / Other),
97
+ `suggested_tone` (Friendly / Professional / Encouraging / Brief / Detailed),
98
+ `status` (Pending / Resolved), `messages: []`, `media: []`.
99
+
100
+ ### Messages — `flowiq messages pull <contact_id>` (alias `m`)
101
+
102
+ Read-only pull of a contact's `helpdesk_messages` history. Anchors on the
103
+ Nth-most-recent `user-*` message, then grabs every row from that anchor
104
+ onward (all sender types — bot-*, tool-call, member, human-*, broadcast,
105
+ call-*, system, etc).
106
+
107
+ ```bash
108
+ flowiq m pull <contact_id> --count 25
109
+ # writes ./.flowiq/messages/<contact-slug>-<org-slug>.json
110
+ ```
111
+
112
+ `--count` defaults to 25, max 200.
113
+
114
+ ### Webhooks (Shopify/WooCommerce) — `flowiq webhooks pull|push|reconnect` (alias `wh`)
115
+
116
+ Round-trips the **inbound** Shopify or WooCommerce platform webhooks (the
117
+ "Active Webhooks" panel on the Configuration page).
43
118
 
44
119
  ```bash
45
- flowiq prompts pull 0689d0be-ba76-43d9-a0d5-f4d59c1db537
46
- # → wrote .flowiq/prompts/kickz-sa-african-oils.json (18 sections, 18,747 chars)
120
+ flowiq wh pull <organization_id>
121
+ # auto-detects platform from the org row
122
+ flowiq wh push <slug> # create any topic in the JSON not currently live
123
+ flowiq wh push <slug> --prune # also delete any live webhook not in the JSON
124
+ flowiq wh reconnect <slug> <webhook_id> # WooCommerce-only: flip back to status=active
47
125
  ```
48
126
 
49
- ### `flowiq prompts push <slug-or-path>`
127
+ - For orgs with `feature_flags.ip_whitelist === true` (e.g. customers on
128
+ Wordfence), all calls are routed via python-render's static-IP proxy.
129
+ - `--prune` is **direct-mode only** (python-render proxy has no delete
130
+ route) — on ip_whitelist orgs prune prints a clear "delete via store
131
+ admin UI" message instead.
132
+ - Reconnect is WC-only. Shopify webhooks are always active when present.
133
+
134
+ ### Messaging webhooks — `flowiq messaging-webhooks pull|push` (alias `mw`)
50
135
 
51
- Reads the JSON file and sends the `prompt_sections` array back to the
52
- server, which regenerates the agent's `system_prompt`.
136
+ Round-trips the **outbound** messaging webhooks in the `meta_webhooks`
137
+ table (the right-hand panel on the Configuration page — these tell
138
+ FlowIQ where to POST when a customer messaging event fires).
53
139
 
54
140
  ```bash
55
- flowiq prompts push kickz-sa-african-oils
56
- # → pushed 18 sections, system_prompt now 17,337 chars
141
+ flowiq mw pull <organization_id>
142
+ flowiq mw push <slug> --dry-run # diff preview, no writes
143
+ flowiq mw push <slug> # FULL-REPLACE: deletes every row, then inserts the JSON
57
144
  ```
58
145
 
59
- ## Environment variables
146
+ Push is full-replace — if someone added a row via the UI between your
147
+ pull and your push, it gets wiped. Pull-edit-push within one session.
148
+
149
+ Validation: `platform` ∈ `{whatsapp, web}`, `type` from the per-platform
150
+ allowed enum, valid http(s) URL, no duplicate (platform, type, url)
151
+ tuples.
152
+
153
+ ## Environment variable overrides
60
154
 
61
155
  | Var | Default | Use |
62
156
  |---|---|---|
63
- | `FLOWIQ_API_URL` | `https://api.flowiq.live` | Override the API host (e.g. local dev). |
157
+ | `FLOWIQ_API_URL` | `https://api.flowiq.live` | Override the API host (local dev, staging). |
64
158
  | `FLOWIQ_TOKEN` | (saved in `~/.config/flowiq/auth.json`) | Override the auth token, useful for CI. |
65
159
 
160
+ ## Upgrading
161
+
162
+ When a new version ships, just re-run:
163
+
164
+ ```bash
165
+ npm i -g @flowapt/flowiq-cli
166
+ flowiq --version
167
+ ```
168
+
169
+ Your saved token in `~/.config/flowiq/auth.json` is preserved across
170
+ upgrades.
171
+
66
172
  ## License
67
173
 
68
174
  UNLICENSED. Internal staff tool — install requires a valid `fiq_staff_…`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, webhooks, and more without ever touching service-role credentials.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,132 @@
1
+ // `flowiq fine-tuning pull <organization_id>` /
2
+ // `flowiq fine-tuning push <slug>`.
3
+ // Round-trips the dedicated `agents.fine_tuning` + `agents.fine_tuning_entries`
4
+ // columns through a local JSON file. No DB credentials on disk.
5
+
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { http } from "../http.js";
9
+
10
+ const FT_DIR = path.resolve(process.cwd(), ".flowiq", "fine-tuning");
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
+
13
+ function slugify(name, fallback) {
14
+ const s = String(name || "")
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, "-")
17
+ .replace(/-+/g, "-")
18
+ .replace(/^-|-$/g, "");
19
+ return s || fallback;
20
+ }
21
+
22
+ async function fileExists(p) {
23
+ try { await fs.access(p); return true; } catch { return false; }
24
+ }
25
+
26
+ async function resolvePushPath(identifier) {
27
+ const candidates = [];
28
+ if (path.isAbsolute(identifier)) {
29
+ candidates.push(identifier);
30
+ } else if (identifier.includes("/") || identifier.includes("\\")) {
31
+ candidates.push(path.resolve(identifier));
32
+ } else {
33
+ const base = identifier.endsWith(".json") ? identifier : `${identifier}.json`;
34
+ candidates.push(path.join(FT_DIR, base));
35
+ candidates.push(path.resolve(base));
36
+ }
37
+ for (const c of candidates) {
38
+ if (await fileExists(c)) return c;
39
+ }
40
+ throw new Error(`File not found. Tried:\n ${candidates.join("\n ")}`);
41
+ }
42
+
43
+ function countEntries(entries) {
44
+ if (!Array.isArray(entries)) return { total: 0, pending: 0, resolved: 0, byCategory: {} };
45
+ const byCategory = {};
46
+ let pending = 0, resolved = 0;
47
+ for (const e of entries) {
48
+ if (e?.status === "Pending") pending += 1;
49
+ else if (e?.status === "Resolved") resolved += 1;
50
+ const cat = e?.category ?? "(none)";
51
+ byCategory[cat] = (byCategory[cat] ?? 0) + 1;
52
+ }
53
+ return { total: entries.length, pending, resolved, byCategory };
54
+ }
55
+
56
+ export async function pull(orgId) {
57
+ if (!UUID_RE.test(orgId)) {
58
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
59
+ process.exit(1);
60
+ }
61
+ let resp;
62
+ try {
63
+ resp = await http.get("fine-tuning", { organization_id: orgId });
64
+ } catch (e) {
65
+ console.error(`Pull failed: ${e.message}`);
66
+ process.exit(1);
67
+ }
68
+
69
+ await fs.mkdir(FT_DIR, { recursive: true });
70
+ const slug = slugify(resp.organization_name, resp.organization_id);
71
+ const filePath = path.join(FT_DIR, `${slug}.json`);
72
+ const overwriting = await fileExists(filePath);
73
+ await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
74
+
75
+ const stats = countEntries(resp.fine_tuning_entries);
76
+ console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
77
+ console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
78
+ console.log(` agent: ${resp.agent_name} (${resp.agent_id})`);
79
+ console.log(` instructions: ${resp.fine_tuning.length} chars`);
80
+ console.log(` entries: ${stats.total} (${stats.pending} pending, ${stats.resolved} resolved)`);
81
+ if (stats.total > 0) {
82
+ const cats = Object.entries(stats.byCategory)
83
+ .sort((a, b) => b[1] - a[1])
84
+ .map(([k, v]) => `${k}=${v}`)
85
+ .join(", ");
86
+ console.log(` by category: ${cats}`);
87
+ }
88
+ }
89
+
90
+ export async function push(identifier) {
91
+ let filePath;
92
+ try {
93
+ filePath = await resolvePushPath(identifier);
94
+ } catch (e) {
95
+ console.error(`Error: ${e.message}`);
96
+ process.exit(1);
97
+ }
98
+ const raw = await fs.readFile(filePath, "utf8");
99
+ let data;
100
+ try { data = JSON.parse(raw); }
101
+ catch (e) {
102
+ console.error(`Invalid JSON in ${filePath}: ${e.message}`);
103
+ process.exit(1);
104
+ }
105
+
106
+ if (!data.organization_id || !UUID_RE.test(data.organization_id)) {
107
+ console.error(`${filePath}: organization_id missing or invalid`);
108
+ process.exit(1);
109
+ }
110
+ if (typeof data.fine_tuning !== "string") {
111
+ console.error(`${filePath}: fine_tuning must be a string (use "" if none)`);
112
+ process.exit(1);
113
+ }
114
+
115
+ let resp;
116
+ try {
117
+ resp = await http.post("fine-tuning", {
118
+ organization_id: data.organization_id,
119
+ fine_tuning: data.fine_tuning,
120
+ fine_tuning_entries: data.fine_tuning_entries ?? [],
121
+ });
122
+ } catch (e) {
123
+ console.error(`Push failed: ${e.message}`);
124
+ process.exit(1);
125
+ }
126
+
127
+ console.log(`Pushed ${filePath}`);
128
+ console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
129
+ console.log(` agent: ${resp.agent_name} (${resp.agent_id})`);
130
+ console.log(` instructions: ${resp.instructions_chars} chars`);
131
+ console.log(` entries: ${resp.entries.total} (${resp.entries.pending} pending, ${resp.entries.resolved} resolved)`);
132
+ }
@@ -0,0 +1,66 @@
1
+ // `flowiq messages pull <contact_id> [--count N]`.
2
+ // Read-only chat fetch (no push — chat history is the system of record).
3
+
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { http } from "../http.js";
7
+
8
+ const MSG_DIR = path.resolve(process.cwd(), ".flowiq", "messages");
9
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
+
11
+ function slugify(name, fallback) {
12
+ const s = String(name || "")
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, "-")
15
+ .replace(/-+/g, "-")
16
+ .replace(/^-|-$/g, "");
17
+ return s || fallback;
18
+ }
19
+
20
+ async function fileExists(p) {
21
+ try { await fs.access(p); return true; } catch { return false; }
22
+ }
23
+
24
+ export async function pull(contactId, opts = {}) {
25
+ if (!UUID_RE.test(contactId)) {
26
+ console.error(`Error: "${contactId}" is not a valid contact UUID.`);
27
+ process.exit(1);
28
+ }
29
+ const query = { contact_id: contactId };
30
+ if (opts.count) query.count = String(opts.count);
31
+
32
+ let resp;
33
+ try {
34
+ resp = await http.get("messages", query);
35
+ } catch (e) {
36
+ console.error(`Pull failed: ${e.message}`);
37
+ process.exit(1);
38
+ }
39
+
40
+ await fs.mkdir(MSG_DIR, { recursive: true });
41
+ const nameSlug = slugify(
42
+ resp.contact_name || resp.contact_whatsapp_id || resp.contact_id,
43
+ resp.contact_id
44
+ );
45
+ const orgSlug = resp.organization_name ? slugify(resp.organization_name, "") : "";
46
+ const slug = orgSlug ? `${nameSlug}-${orgSlug}` : nameSlug;
47
+ const filePath = path.join(MSG_DIR, `${slug}.json`);
48
+ const overwriting = await fileExists(filePath);
49
+ await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
50
+
51
+ console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
52
+ console.log(` contact: ${resp.contact_name ?? "(no name)"} (${resp.contact_id})`);
53
+ if (resp.contact_whatsapp_id) {
54
+ console.log(` whatsapp_id: ${resp.contact_whatsapp_id}`);
55
+ }
56
+ if (resp.organization_name) {
57
+ console.log(` organization: ${resp.organization_name} (${resp.organization_id})`);
58
+ }
59
+ console.log(` user msgs: ${resp.user_messages_found}/${resp.user_messages_requested} requested`);
60
+ console.log(` anchor: ${resp.anchor_created_at}`);
61
+ console.log(` total rows: ${resp.total_rows}`);
62
+ console.log(` by sender:`);
63
+ for (const [k, v] of Object.entries(resp.sender_breakdown).sort()) {
64
+ console.log(` ${k.padEnd(16)} ${v}`);
65
+ }
66
+ }
@@ -0,0 +1,123 @@
1
+ // `flowiq messaging-webhooks pull <organization_id>` /
2
+ // `flowiq messaging-webhooks push <slug> [--dry-run]`.
3
+ // Full-replace round-trip of `meta_webhooks` rows.
4
+
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { http } from "../http.js";
8
+
9
+ const MW_DIR = path.resolve(process.cwd(), ".flowiq", "messaging-webhooks");
10
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+
12
+ function slugify(name, fallback) {
13
+ const s = String(name || "")
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9]+/g, "-")
16
+ .replace(/-+/g, "-")
17
+ .replace(/^-|-$/g, "");
18
+ return s || fallback;
19
+ }
20
+
21
+ async function fileExists(p) {
22
+ try { await fs.access(p); return true; } catch { return false; }
23
+ }
24
+
25
+ async function resolvePushPath(identifier) {
26
+ const candidates = [];
27
+ if (path.isAbsolute(identifier)) {
28
+ candidates.push(identifier);
29
+ } else if (identifier.includes("/") || identifier.includes("\\")) {
30
+ candidates.push(path.resolve(identifier));
31
+ } else {
32
+ const base = identifier.endsWith(".json") ? identifier : `${identifier}.json`;
33
+ candidates.push(path.join(MW_DIR, base));
34
+ candidates.push(path.resolve(base));
35
+ }
36
+ for (const c of candidates) {
37
+ if (await fileExists(c)) return c;
38
+ }
39
+ throw new Error(`File not found. Tried:\n ${candidates.join("\n ")}`);
40
+ }
41
+
42
+ export async function pull(orgId) {
43
+ if (!UUID_RE.test(orgId)) {
44
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
45
+ process.exit(1);
46
+ }
47
+ let resp;
48
+ try {
49
+ resp = await http.get("messaging-webhooks", { organization_id: orgId });
50
+ } catch (e) {
51
+ console.error(`Pull failed: ${e.message}`);
52
+ process.exit(1);
53
+ }
54
+
55
+ await fs.mkdir(MW_DIR, { recursive: true });
56
+ const slug = slugify(resp.organization_name, resp.organization_id);
57
+ const filePath = path.join(MW_DIR, `${slug}.json`);
58
+ const overwriting = await fileExists(filePath);
59
+ await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
60
+
61
+ const rows = Array.isArray(resp.messaging_webhooks) ? resp.messaging_webhooks : [];
62
+ console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
63
+ console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
64
+ console.log(` rows: ${rows.length}`);
65
+ if (rows.length && resp.by_platform) {
66
+ const platStr = Object.entries(resp.by_platform).map(([k, v]) => `${k}=${v}`).join(", ");
67
+ console.log(` by plat: ${platStr}`);
68
+ }
69
+ }
70
+
71
+ export async function push(identifier, opts = {}) {
72
+ let filePath;
73
+ try {
74
+ filePath = await resolvePushPath(identifier);
75
+ } catch (e) {
76
+ console.error(`Error: ${e.message}`);
77
+ process.exit(1);
78
+ }
79
+ const raw = await fs.readFile(filePath, "utf8");
80
+ let data;
81
+ try { data = JSON.parse(raw); }
82
+ catch (e) {
83
+ console.error(`Invalid JSON in ${filePath}: ${e.message}`);
84
+ process.exit(1);
85
+ }
86
+
87
+ if (!data.organization_id || !UUID_RE.test(data.organization_id)) {
88
+ console.error(`${filePath}: organization_id missing or invalid`);
89
+ process.exit(1);
90
+ }
91
+ if (!Array.isArray(data.messaging_webhooks)) {
92
+ console.error(`${filePath}: messaging_webhooks must be an array`);
93
+ process.exit(1);
94
+ }
95
+
96
+ let resp;
97
+ try {
98
+ resp = await http.post("messaging-webhooks", {
99
+ organization_id: data.organization_id,
100
+ messaging_webhooks: data.messaging_webhooks.map((w) => ({
101
+ url: w.url, type: w.type, platform: w.platform,
102
+ })),
103
+ dry_run: !!opts.dryRun,
104
+ });
105
+ } catch (e) {
106
+ console.error(`Push failed: ${e.message}`);
107
+ process.exit(1);
108
+ }
109
+
110
+ const isDryRun = !!opts.dryRun;
111
+ console.log(`${isDryRun ? "DRY RUN" : "Pushed"} ${filePath}`);
112
+ console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
113
+ console.log(` desired: ${resp.diff.desired}`);
114
+ console.log(` existing: ${resp.diff.existing}`);
115
+ console.log(` to insert: ${resp.diff.to_insert}`);
116
+ console.log(` to delete: ${resp.diff.to_delete}`);
117
+ console.log(` unchanged: ${resp.diff.unchanged}`);
118
+ if (isDryRun) {
119
+ console.log("\nDry-run only — no changes written. Re-run without --dry-run to apply.");
120
+ } else {
121
+ console.log(`\n✓ Replaced ${resp.replaced} → ${resp.now} row(s)`);
122
+ }
123
+ }
@@ -65,6 +65,14 @@ export async function pull(orgId) {
65
65
  console.log(` agent: ${resp.agent_name} (${resp.agent_id})`);
66
66
  console.log(` sections: ${resp.prompt_sections.length}`);
67
67
  console.log(` content: ${totalChars.toLocaleString()} chars`);
68
+ if (resp.bootstrapped) {
69
+ console.log("");
70
+ console.log(" ⚠ this agent had no prompt_sections — seeded a single \"Main\"");
71
+ console.log(" section from settings.system_prompt. on push, system_prompt");
72
+ console.log(" will be regenerated with a \"## SECTION: Main\" header wrap.");
73
+ console.log(" split the JSON into proper sections before push if you want");
74
+ console.log(" different headers.");
75
+ }
68
76
  }
69
77
 
70
78
  export async function push(identifier) {
@@ -0,0 +1,186 @@
1
+ // `flowiq webhooks pull <organization_id>` /
2
+ // `flowiq webhooks push <slug> [--prune]` /
3
+ // `flowiq webhooks reconnect <slug> <webhook_id>`.
4
+ // Round-trips Shopify/WooCommerce platform webhooks via /cli/webhooks.
5
+
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { http } from "../http.js";
9
+
10
+ const WH_DIR = path.resolve(process.cwd(), ".flowiq", "webhooks");
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
+
13
+ function slugify(name, fallback) {
14
+ const s = String(name || "")
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, "-")
17
+ .replace(/-+/g, "-")
18
+ .replace(/^-|-$/g, "");
19
+ return s || fallback;
20
+ }
21
+
22
+ async function fileExists(p) {
23
+ try { await fs.access(p); return true; } catch { return false; }
24
+ }
25
+
26
+ async function resolvePushPath(identifier) {
27
+ const candidates = [];
28
+ if (path.isAbsolute(identifier)) {
29
+ candidates.push(identifier);
30
+ } else if (identifier.includes("/") || identifier.includes("\\")) {
31
+ candidates.push(path.resolve(identifier));
32
+ } else {
33
+ const base = identifier.endsWith(".json") ? identifier : `${identifier}.json`;
34
+ candidates.push(path.join(WH_DIR, base));
35
+ candidates.push(path.resolve(base));
36
+ }
37
+ for (const c of candidates) {
38
+ if (await fileExists(c)) return c;
39
+ }
40
+ throw new Error(`File not found. Tried:\n ${candidates.join("\n ")}`);
41
+ }
42
+
43
+ export async function pull(orgId) {
44
+ if (!UUID_RE.test(orgId)) {
45
+ console.error(`Error: "${orgId}" is not a valid organization UUID.`);
46
+ process.exit(1);
47
+ }
48
+ let resp;
49
+ try {
50
+ resp = await http.get("webhooks", { organization_id: orgId });
51
+ } catch (e) {
52
+ console.error(`Pull failed: ${e.message}`);
53
+ process.exit(1);
54
+ }
55
+
56
+ await fs.mkdir(WH_DIR, { recursive: true });
57
+ const slug = slugify(resp.organization_name, resp.organization_id);
58
+ const filePath = path.join(WH_DIR, `${slug}.json`);
59
+ const overwriting = await fileExists(filePath);
60
+ await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
61
+
62
+ console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
63
+ console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
64
+ console.log(` platform: ${resp.platform}${resp.ip_whitelist ? " (via python-render)" : ""}`);
65
+ console.log(` webhooks: ${resp.webhooks.length} live`);
66
+ if (resp.webhooks.length && resp.by_status) {
67
+ const s = Object.entries(resp.by_status).map(([k, v]) => `${k}=${v}`).join(", ");
68
+ console.log(` by status: ${s}`);
69
+ }
70
+ }
71
+
72
+ export async function push(identifier, opts = {}) {
73
+ let filePath;
74
+ try {
75
+ filePath = await resolvePushPath(identifier);
76
+ } catch (e) {
77
+ console.error(`Error: ${e.message}`);
78
+ process.exit(1);
79
+ }
80
+ const raw = await fs.readFile(filePath, "utf8");
81
+ let data;
82
+ try { data = JSON.parse(raw); }
83
+ catch (e) {
84
+ console.error(`Invalid JSON in ${filePath}: ${e.message}`);
85
+ process.exit(1);
86
+ }
87
+
88
+ if (!data.organization_id || !UUID_RE.test(data.organization_id)) {
89
+ console.error(`${filePath}: organization_id missing or invalid`);
90
+ process.exit(1);
91
+ }
92
+ if (!["shopify", "woocommerce"].includes(data.platform)) {
93
+ console.error(`${filePath}: platform must be 'shopify' or 'woocommerce'`);
94
+ process.exit(1);
95
+ }
96
+ if (!Array.isArray(data.webhooks)) {
97
+ console.error(`${filePath}: webhooks must be an array`);
98
+ process.exit(1);
99
+ }
100
+
101
+ let resp;
102
+ try {
103
+ resp = await http.post("webhooks", {
104
+ action: "push",
105
+ organization_id: data.organization_id,
106
+ platform: data.platform,
107
+ webhooks: data.webhooks.map((w) => ({ topic: w.topic })),
108
+ prune: !!opts.prune,
109
+ });
110
+ } catch (e) {
111
+ console.error(`Push failed: ${e.message}`);
112
+ process.exit(1);
113
+ }
114
+
115
+ console.log(`Pushed ${filePath}`);
116
+ console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
117
+ console.log(` platform: ${resp.platform}${resp.ip_whitelist ? " (via python-render)" : ""}`);
118
+ const cr = resp.create_results || {};
119
+ const dr = resp.prune_results || {};
120
+ console.log(` to create: ${resp.diff.to_create.length}${resp.diff.to_create.length ? " → " + resp.diff.to_create.join(", ") : ""}`);
121
+ console.log(` to delete: ${resp.diff.to_delete.length}${resp.diff.to_delete.length && !opts.prune ? " (pass --prune to delete)" : ""}`);
122
+ if (cr.created !== undefined && resp.diff.to_create.length) {
123
+ console.log(` ✓ created ${cr.created}/${resp.diff.to_create.length}`);
124
+ }
125
+ if (cr.failures?.length) {
126
+ console.log(` ⚠ ${cr.failures.length} create failure(s):`);
127
+ for (const f of cr.failures) console.log(` - ${f.topic}: ${f.error}`);
128
+ }
129
+ if (opts.prune && resp.diff.to_delete.length) {
130
+ if (dr.skipped_reason) {
131
+ console.log(` ⚠ prune skipped: ${dr.skipped_reason}`);
132
+ } else {
133
+ console.log(` ✓ deleted ${dr.deleted}/${resp.diff.to_delete.length}`);
134
+ if (dr.failures?.length) {
135
+ console.log(` ⚠ ${dr.failures.length} delete failure(s):`);
136
+ for (const f of dr.failures) console.log(` - ${f.topic}: ${f.error}`);
137
+ }
138
+ }
139
+ }
140
+ console.log(`Done. created=${cr.created || 0}, deleted=${dr.deleted || 0}, untouched=${resp.diff.untouched}`);
141
+ }
142
+
143
+ export async function reconnect(identifier, webhookIdRaw) {
144
+ let filePath;
145
+ try {
146
+ filePath = await resolvePushPath(identifier);
147
+ } catch (e) {
148
+ console.error(`Error: ${e.message}`);
149
+ process.exit(1);
150
+ }
151
+ const raw = await fs.readFile(filePath, "utf8");
152
+ let data;
153
+ try { data = JSON.parse(raw); }
154
+ catch (e) {
155
+ console.error(`Invalid JSON in ${filePath}: ${e.message}`);
156
+ process.exit(1);
157
+ }
158
+ if (data.platform !== "woocommerce") {
159
+ console.error("Reconnect is WooCommerce-only. Shopify webhooks are always active when present.");
160
+ process.exit(1);
161
+ }
162
+ const webhookId = Number(webhookIdRaw);
163
+ if (!Number.isFinite(webhookId) || webhookId <= 0) {
164
+ console.error(`Invalid webhook id "${webhookIdRaw}" (must be a positive number).`);
165
+ process.exit(1);
166
+ }
167
+
168
+ let resp;
169
+ try {
170
+ resp = await http.post("webhooks", {
171
+ action: "reconnect",
172
+ organization_id: data.organization_id,
173
+ platform: "woocommerce",
174
+ webhook_id: webhookId,
175
+ });
176
+ } catch (e) {
177
+ console.error(`Reconnect failed: ${e.message}`);
178
+ process.exit(1);
179
+ }
180
+
181
+ console.log(
182
+ `Reconnecting WooCommerce webhook id=${webhookId} for ` +
183
+ `${resp.organization_name}${resp.ip_whitelist ? " (via python-render)" : ""}`
184
+ );
185
+ console.log(` ✓ ${resp.topic || "(unknown topic)"} → status=${resp.status}`);
186
+ }
package/src/index.js CHANGED
@@ -5,6 +5,10 @@ import { Command } from "commander";
5
5
  import * as authCmd from "./commands/auth.js";
6
6
  import * as promptsCmd from "./commands/prompts.js";
7
7
  import * as questionnairesCmd from "./commands/questionnaires.js";
8
+ import * as messagesCmd from "./commands/messages.js";
9
+ import * as fineTuningCmd from "./commands/fine-tuning.js";
10
+ import * as messagingWebhooksCmd from "./commands/messaging-webhooks.js";
11
+ import * as webhooksCmd from "./commands/webhooks.js";
8
12
 
9
13
  export function run(argv) {
10
14
  const program = new Command();
@@ -43,5 +47,52 @@ export function run(argv) {
43
47
  .description("Apply a local JSON file's questionnaire to the agent")
44
48
  .action((id) => questionnairesCmd.push(id));
45
49
 
50
+ // messages (read-only: pull a contact's helpdesk_messages history)
51
+ const msgs = program.command("messages")
52
+ .alias("m")
53
+ .description("Pull a contact's helpdesk_messages history (read-only)");
54
+ msgs.command("pull <contact_id>")
55
+ .description("Pull last N user-message windows of conversation for a contact (default N=25, max 200)")
56
+ .option("--count <n>", "number of most-recent user-* messages to anchor on", "25")
57
+ .action((contactId, opts) => messagesCmd.pull(contactId, opts));
58
+
59
+ // fine-tuning (agents.fine_tuning text + agents.fine_tuning_entries jsonb)
60
+ const ft = program.command("fine-tuning")
61
+ .alias("ft")
62
+ .description("Round-trip the active agent's fine-tuning instructions + Q&A entries");
63
+ ft.command("pull <organization_id>")
64
+ .description("Fetch agents.fine_tuning + agents.fine_tuning_entries into a local JSON file")
65
+ .action((orgId) => fineTuningCmd.pull(orgId));
66
+ ft.command("push <slug-or-path>")
67
+ .description("Apply a local JSON file's fine_tuning + fine_tuning_entries to the agent")
68
+ .action((id) => fineTuningCmd.push(id));
69
+
70
+ // messaging-webhooks (outbound notifications via meta_webhooks table)
71
+ const mw = program.command("messaging-webhooks")
72
+ .alias("mw")
73
+ .description("Round-trip outbound messaging webhooks (meta_webhooks table)");
74
+ mw.command("pull <organization_id>")
75
+ .description("Pull all meta_webhooks rows for the org into a local JSON file")
76
+ .action((orgId) => messagingWebhooksCmd.pull(orgId));
77
+ mw.command("push <slug-or-path>")
78
+ .description("FULL-REPLACE: deletes every existing row for the org then inserts the JSON. Pass --dry-run for diff preview.")
79
+ .option("--dry-run", "preview the diff without writing anything")
80
+ .action((id, opts) => messagingWebhooksCmd.push(id, opts));
81
+
82
+ // webhooks (Shopify/WooCommerce platform webhooks; auto-detects platform + ip_whitelist proxy)
83
+ const wh = program.command("webhooks")
84
+ .alias("wh")
85
+ .description("Round-trip Shopify or WooCommerce platform webhooks (auto-detects platform; routes via python-render when feature_flags.ip_whitelist is on)");
86
+ wh.command("pull <organization_id>")
87
+ .description("Fetch the FlowIQ-pointing webhooks live from Shopify/WC into a local JSON file")
88
+ .action((orgId) => webhooksCmd.pull(orgId));
89
+ wh.command("push <slug-or-path>")
90
+ .description("Create any topic in the JSON not currently live on the store. With --prune, also DELETES any live webhook not in the JSON.")
91
+ .option("--prune", "also delete live webhooks that aren't in the JSON (direct-mode only; python-render proxy has no delete route)")
92
+ .action((id, opts) => webhooksCmd.push(id, opts));
93
+ wh.command("reconnect <slug-or-path> <webhook_id>")
94
+ .description("WooCommerce-only: flip a webhook back to status=active (WC auto-disables after delivery failures)")
95
+ .action((id, webhookId) => webhooksCmd.reconnect(id, webhookId));
96
+
46
97
  program.parseAsync(argv);
47
98
  }