@flowapt/flowiq-cli 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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,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
+ }
@@ -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,128 @@
1
+ // `flowiq questionnaires pull <organization_id>` /
2
+ // `flowiq questionnaires push <slug>`.
3
+ // Round-trips an org's active WhatsApp agent's `agents.questionnaire`
4
+ // jsonb column 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 QS_DIR = path.resolve(process.cwd(), ".flowiq", "questionnaires");
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(QS_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 countQuestions(sections) {
44
+ let total = 0, answered = 0;
45
+ if (!Array.isArray(sections)) return { total, answered };
46
+ for (const s of sections) {
47
+ if (!Array.isArray(s?.questions)) continue;
48
+ for (const q of s.questions) {
49
+ total += 1;
50
+ if ((q?.answer ?? "").trim().length > 0) answered += 1;
51
+ }
52
+ }
53
+ return { total, answered };
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("questionnaires", { organization_id: orgId });
64
+ } catch (e) {
65
+ console.error(`Pull failed: ${e.message}`);
66
+ process.exit(1);
67
+ }
68
+
69
+ await fs.mkdir(QS_DIR, { recursive: true });
70
+ const slug = slugify(resp.organization_name, resp.organization_id);
71
+ const filePath = path.join(QS_DIR, `${slug}.json`);
72
+ const overwriting = await fileExists(filePath);
73
+ await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
74
+
75
+ const { total, answered } = countQuestions(resp.questionnaire?.sections);
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(` sections: ${resp.questionnaire?.sections?.length ?? 0}`);
80
+ console.log(` questions: ${answered}/${total} answered`);
81
+ if (resp.default_used) {
82
+ console.log(` note: DB had no questionnaire — seeded from DEFAULT_QUESTIONNAIRE.`);
83
+ console.log(` Pushing the file unchanged will write the full default.`);
84
+ }
85
+ }
86
+
87
+ export async function push(identifier) {
88
+ let filePath;
89
+ try {
90
+ filePath = await resolvePushPath(identifier);
91
+ } catch (e) {
92
+ console.error(`Error: ${e.message}`);
93
+ process.exit(1);
94
+ }
95
+ const raw = await fs.readFile(filePath, "utf8");
96
+ let data;
97
+ try { data = JSON.parse(raw); }
98
+ catch (e) {
99
+ console.error(`Invalid JSON in ${filePath}: ${e.message}`);
100
+ process.exit(1);
101
+ }
102
+
103
+ if (!data.organization_id || !UUID_RE.test(data.organization_id)) {
104
+ console.error(`${filePath}: organization_id missing or invalid`);
105
+ process.exit(1);
106
+ }
107
+ if (!data.questionnaire || !Array.isArray(data.questionnaire.sections)) {
108
+ console.error(`${filePath}: questionnaire.sections must be an array`);
109
+ process.exit(1);
110
+ }
111
+
112
+ let resp;
113
+ try {
114
+ resp = await http.post("questionnaires", {
115
+ organization_id: data.organization_id,
116
+ questionnaire: data.questionnaire,
117
+ });
118
+ } catch (e) {
119
+ console.error(`Push failed: ${e.message}`);
120
+ process.exit(1);
121
+ }
122
+
123
+ console.log(`Pushed ${filePath}`);
124
+ console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
125
+ console.log(` agent: ${resp.agent_name} (${resp.agent_id})`);
126
+ console.log(` sections: ${resp.sections}`);
127
+ console.log(` questions: ${resp.answered_questions}/${resp.total_questions} answered`);
128
+ }
package/src/index.js CHANGED
@@ -4,6 +4,8 @@
4
4
  import { Command } from "commander";
5
5
  import * as authCmd from "./commands/auth.js";
6
6
  import * as promptsCmd from "./commands/prompts.js";
7
+ import * as questionnairesCmd from "./commands/questionnaires.js";
8
+ import * as messagesCmd from "./commands/messages.js";
7
9
 
8
10
  export function run(argv) {
9
11
  const program = new Command();
@@ -31,5 +33,25 @@ export function run(argv) {
31
33
  .description("Apply a local JSON file's prompt_sections to the agent (regenerates system_prompt server-side)")
32
34
  .action((id) => promptsCmd.push(id));
33
35
 
36
+ // questionnaires
37
+ const qs = program.command("questionnaires")
38
+ .alias("q")
39
+ .description("Round-trip an org's active WhatsApp agent's questionnaire answers (agents.questionnaire JSONB)");
40
+ qs.command("pull <organization_id>")
41
+ .description("Fetch the active agent's questionnaire into a local JSON file (seeds from DEFAULT_QUESTIONNAIRE if the DB column is empty)")
42
+ .action((orgId) => questionnairesCmd.pull(orgId));
43
+ qs.command("push <slug-or-path>")
44
+ .description("Apply a local JSON file's questionnaire to the agent")
45
+ .action((id) => questionnairesCmd.push(id));
46
+
47
+ // messages (read-only: pull a contact's helpdesk_messages history)
48
+ const msgs = program.command("messages")
49
+ .alias("m")
50
+ .description("Pull a contact's helpdesk_messages history (read-only)");
51
+ msgs.command("pull <contact_id>")
52
+ .description("Pull last N user-message windows of conversation for a contact (default N=25, max 200)")
53
+ .option("--count <n>", "number of most-recent user-* messages to anchor on", "25")
54
+ .action((contactId, opts) => messagesCmd.pull(contactId, opts));
55
+
34
56
  program.parseAsync(argv);
35
57
  }