@flowapt/flowiq-cli 0.2.1 → 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.
package/README.md CHANGED
@@ -155,6 +155,7 @@ flowiq ct list
155
155
  ```
156
156
 
157
157
  - **Always `--dry-run` first** on a production agent — it prints added/removed/changed tool names before you commit.
158
+ - **Destructive pushes are blocked by default**: a push that removes a tool, disables one, or sends an empty `tools[]` (wiping everything) writes nothing and shows you exactly what it would strip — re-run with `--confirm` if intentional. Additive/no-op pushes are unaffected.
158
159
  - Server-side validation is strict: tool `name` (`^[a-zA-Z0-9_-]{1,64}$`, no duplicates), non-empty `description`, `http(s)` `endpoint`, method `GET/POST/PUT/PATCH/DELETE`, object-typed `parameters`/`headers`/`injected_parameters`, valid `auth_type`/`channels`. Bad payloads are rejected before anything writes.
159
160
  - **Warnings (non-blocking):** unknown keys (likely typos the runtime would silently ignore) and unknown `{{placeholders}}` (they will NOT be substituted at runtime — known: `organization_id`, `contact_id`, `agent_id`, `contact_whatsapp_id`, `contact_full_name`, `supabase_anon_key`, `openai_api_key`, …).
160
161
  - `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
@@ -244,6 +245,33 @@ flowiq seg untag <org_id> july-promo --commit --confirm # ROLLBACK (its own ta
244
245
  - Point-in-time warning: the plan tags snapshot ids; re-derive the cohort SQL
245
246
  if freshness matters (someone who ordered since won't auto-drop).
246
247
 
248
+ ### Keywords — `flowiq keywords pull|push|list` (alias `kw`)
249
+
250
+ Round-trips an org's **keyword auto-reply engine** — the `keywords` +
251
+ `keyword_actions` rows that fire instant replies / contact updates when a
252
+ customer message matches a trigger word. This is the **safe editor** for the
253
+ attribute-write actions (competition entries) that the dashboard editor
254
+ corrupts on save.
255
+
256
+ ```bash
257
+ flowiq kw pull <org_id> # → ./.flowiq/keywords/<slug>.json (all keywords + actions)
258
+ flowiq kw push <slug> --dry-run # plan: created/updated/deleted at both levels, no write
259
+ flowiq kw push <slug> # id-keyed upsert (never touches keywords absent from the file)
260
+ flowiq kw push <slug> --prune # ALSO deletes DB keywords missing from the file (dry-run first!)
261
+ ```
262
+
263
+ - **Identity = the `id`s.** Keep an id to update that row; drop the id to
264
+ create a new one; remove an *action* from a kept keyword to delete it.
265
+ A file id that doesn't exist for the org aborts (stale file → re-pull).
266
+ - **Pull immediately before editing** — clients edit auto-reply copy live.
267
+ - `text` is stored **verbatim** (byte-identical — required when the reply
268
+ must match a prompt quote exactly). `position` must be ≥ 1 (0 silently
269
+ collapses to 1 at runtime — rejected).
270
+ - `field:"attributes"` actions are warned (full jsonb replace; constant
271
+ values only) but applied — this CLI is their only safe editing surface.
272
+ - Scheduled keywords: the keyword-scheduler cron reconciles `active` from
273
+ `start_date`/`end_date` within ~30 min of your push.
274
+
247
275
  ### Messages — `flowiq messages pull <contact_id>` (alias `m`)
248
276
 
249
277
  Read-only pull of a contact's `helpdesk_messages` history. Anchors on the
@@ -388,15 +416,28 @@ Media headers: pass `media_header.file_url` (a public URL) — the edge function
388
416
  uploads it to Meta server-side. Approval is async; re-`pull` for the
389
417
  authoritative Meta status.
390
418
 
391
- ### Org info — `flowiq org info <organization_id>`
419
+ ### Org — `flowiq org create` / `flowiq org info <organization_id>`
392
420
 
393
- Read-only platform detect + active-agent lookup for an org (raw store/Meta
421
+ Create a brand-new organization, or look one up (read-only; raw store/Meta
394
422
  credentials are stripped server-side).
395
423
 
396
424
  ```bash
425
+ flowiq org create --name "New Client" [--slug new-client] [--owner client@email.com] [--provider wati]
426
+ # → org row + admin membership. Owner defaults to YOU; --owner requires the
427
+ # person to already have an account (otherwise invite them later in the app).
428
+ # Provider defaults to meta. Prints the new org id + the next-step commands.
429
+
397
430
  flowiq org info <organization_id> # platform, storefront url, active agent
398
431
  ```
399
432
 
433
+ Full new-client bootstrap from the terminal:
434
+ ```bash
435
+ flowiq org create --name "New Client"
436
+ flowiq agent create <new_org_id> --name "Zara" --make-active
437
+ flowiq agent config <new_org_id> --model gpt-5.4-mini --use-settings-prompt
438
+ flowiq prompts pull <new_org_id> # edit → push
439
+ ```
440
+
400
441
  ### Agents — `flowiq agent list|create <org>`
401
442
 
402
443
  List an org's agents (to discover ids) and create a new one.
package/TEAM-GUIDE.md CHANGED
@@ -67,6 +67,7 @@ flowiq auth whoami
67
67
  | Edit the text knowledge sources (get_more_answers playbooks) | `flowiq kn pull <org_id>` → edit `sources[]` → `flowiq kn push <slug>` |
68
68
  | Edit the custom tools (API-call tools) | `flowiq ct pull <org_id>` → edit `tools[]` → `flowiq ct push <slug> --dry-run` → `flowiq ct push <slug>` |
69
69
  | Turn one custom tool on/off | `flowiq ct enable\|disable <org_id> <tool_name>` |
70
+ | Edit keyword auto-replies (incl. competition entry keywords) | `flowiq kw pull <org_id>` → edit → `flowiq kw push <slug>` |
70
71
  | See an org's agents / create one | `flowiq agent list <org_id>` / `flowiq agent create <org_id> --name "…"` |
71
72
  | Change agent model / tool flags | `flowiq agent config <org_id> --model … --tool view_cart_tool=true` |
72
73
  | Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
@@ -78,6 +79,7 @@ flowiq auth whoami
78
79
  | Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
79
80
  | Manage Shopify/Woo platform webhooks | `flowiq wh pull <org_id>` → edit → `flowiq wh push <slug>` |
80
81
  | Manage outbound messaging webhooks | `flowiq mw pull <org_id>` → `flowiq mw push <slug> --dry-run` → push |
82
+ | Create a brand-new client org | `flowiq org create --name "Client Name"` → then `agent create` on the printed id |
81
83
  | See a client's pending change requests | `flowiq au pull <org_id>` |
82
84
  | Close a client's change request (after verifying the fix!) | `flowiq au resolve <update_id> --note "what changed"` — the client reads the note |
83
85
  | Check an org's platform + active agent | `flowiq org info <org_id>` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
4
4
  "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ }
package/src/index.js CHANGED
@@ -26,6 +26,7 @@ import * as knowledgeCmd from "./commands/knowledge.js";
26
26
  import * as customToolsCmd from "./commands/custom-tools.js";
27
27
  import * as broadcastCmd from "./commands/broadcast.js";
28
28
  import * as segmentsCmd from "./commands/segments.js";
29
+ import * as keywordsCmd from "./commands/keywords.js";
29
30
 
30
31
  // Read version from package.json so it stays in sync with the published npm
31
32
  // version automatically (single source of truth — bumping package.json on each
@@ -190,7 +191,14 @@ export function run(argv) {
190
191
  .action((orgId, opts) => templatesCmd.status(orgId, opts));
191
192
 
192
193
  // org (read-only org summary for the prompt-builder skill, creds stripped)
193
- 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));
194
202
  org.command("info <organization_id>")
195
203
  .description("Show the org's platform, storefront url + active agent (raw creds stripped)")
196
204
  .option("--json", "print the raw JSON payload")
@@ -330,8 +338,9 @@ export function run(argv) {
330
338
  .option("--agent <id>", "target a specific agent instead of the org's active one")
331
339
  .action((orgId, opts) => customToolsCmd.pull(orgId, opts));
332
340
  customTools.command("push <slug-or-path>")
333
- .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.")
334
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[]")
335
344
  .action((id, opts) => customToolsCmd.push(id, opts));
336
345
  customTools.command("enable <organization_id> <tool_name>")
337
346
  .description("Enable one custom tool by name (no round-trip)")
@@ -423,5 +432,21 @@ export function run(argv) {
423
432
  .option("--confirm", "required alongside --commit")
424
433
  .action((orgId, id, opts) => segmentsCmd.untag(orgId, id, opts));
425
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
+
426
451
  program.parseAsync(argv);
427
452
  }