@flowapt/flowiq-cli 0.1.0

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 ADDED
@@ -0,0 +1,69 @@
1
+ # flowiq
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.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm i -g @flowapt/flowiq-cli
11
+ ```
12
+
13
+ ## Authenticate
14
+
15
+ Get a personal staff API key from your FlowIQ admin (an `fiq_staff_…`
16
+ string). Then:
17
+
18
+ ```bash
19
+ flowiq auth login
20
+ # paste your key when prompted
21
+ ```
22
+
23
+ Your key is saved to `~/.config/flowiq/auth.json` with `chmod 600`. To
24
+ verify what you're authenticated as:
25
+
26
+ ```bash
27
+ flowiq auth whoami
28
+ ```
29
+
30
+ To log out (deletes the local key file, does NOT revoke the key
31
+ server-side — ask your admin to revoke):
32
+
33
+ ```bash
34
+ flowiq auth logout
35
+ ```
36
+
37
+ ## Commands
38
+
39
+ ### `flowiq prompts pull <organization_id>`
40
+
41
+ Fetches the active WhatsApp agent's `prompt_sections` and writes them to
42
+ `./.flowiq/prompts/<org-slug>.json`.
43
+
44
+ ```bash
45
+ flowiq prompts pull 0689d0be-ba76-43d9-a0d5-f4d59c1db537
46
+ # → wrote .flowiq/prompts/kickz-sa-african-oils.json (18 sections, 18,747 chars)
47
+ ```
48
+
49
+ ### `flowiq prompts push <slug-or-path>`
50
+
51
+ Reads the JSON file and sends the `prompt_sections` array back to the
52
+ server, which regenerates the agent's `system_prompt`.
53
+
54
+ ```bash
55
+ flowiq prompts push kickz-sa-african-oils
56
+ # → pushed 18 sections, system_prompt now 17,337 chars
57
+ ```
58
+
59
+ ## Environment variables
60
+
61
+ | Var | Default | Use |
62
+ |---|---|---|
63
+ | `FLOWIQ_API_URL` | `https://api.flowiq.live` | Override the API host (e.g. local dev). |
64
+ | `FLOWIQ_TOKEN` | (saved in `~/.config/flowiq/auth.json`) | Override the auth token, useful for CI. |
65
+
66
+ ## License
67
+
68
+ UNLICENSED. Internal staff tool — install requires a valid `fiq_staff_…`
69
+ key issued by a FlowIQ super-admin.
package/bin/flowiq.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ // Entry point — node shebang. Symlinked into PATH by `npm i -g`.
3
+ import { run } from "../src/index.js";
4
+ run(process.argv);
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@flowapt/flowiq-cli",
3
+ "version": "0.1.0",
4
+ "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, webhooks, and more without ever touching service-role credentials.",
5
+ "type": "module",
6
+ "bin": {
7
+ "flowiq": "bin/flowiq.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "scripts": {
18
+ "start": "node bin/flowiq.js"
19
+ },
20
+ "dependencies": {
21
+ "commander": "^12.1.0"
22
+ },
23
+ "homepage": "https://github.com/MattFlowapt/flow-v1/tree/main/cli",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/MattFlowapt/flow-v1.git",
27
+ "directory": "cli"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/MattFlowapt/flow-v1/issues"
31
+ },
32
+ "keywords": [
33
+ "flowapt",
34
+ "flowiq",
35
+ "cli",
36
+ "whatsapp",
37
+ "internal-tool"
38
+ ],
39
+ "license": "UNLICENSED",
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }
@@ -0,0 +1,89 @@
1
+ // `flowiq auth login` / `flowiq auth logout` / `flowiq auth whoami`.
2
+ // Stores a `fiq_staff_…` bearer token at ~/.config/flowiq/auth.json.
3
+
4
+ import readline from "node:readline";
5
+ import { loadConfig, saveConfig, clearConfig, configFilePath } from "../config.js";
6
+ import { http } from "../http.js";
7
+
8
+ function prompt(question, { silent = false } = {}) {
9
+ return new Promise((resolve) => {
10
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
11
+ if (silent) {
12
+ // Best-effort masking: turn off terminal echo for the duration of the read.
13
+ const stdin = process.stdin;
14
+ const wasRaw = stdin.isRaw;
15
+ stdin.setRawMode?.(true);
16
+ let buf = "";
17
+ stdin.resume();
18
+ stdin.setEncoding("utf8");
19
+ process.stdout.write(question);
20
+ const onData = (ch) => {
21
+ if (ch === "\r" || ch === "\n" || ch === "") {
22
+ stdin.setRawMode?.(wasRaw);
23
+ stdin.removeListener("data", onData);
24
+ process.stdout.write("\n");
25
+ rl.close();
26
+ resolve(buf);
27
+ } else if (ch === "") {
28
+ process.stdout.write("\n");
29
+ process.exit(130);
30
+ } else if (ch === "" || ch === "\b") {
31
+ if (buf.length) { buf = buf.slice(0, -1); process.stdout.write("\b \b"); }
32
+ } else {
33
+ buf += ch;
34
+ process.stdout.write("*");
35
+ }
36
+ };
37
+ stdin.on("data", onData);
38
+ } else {
39
+ rl.question(question, (answer) => { rl.close(); resolve(answer); });
40
+ }
41
+ });
42
+ }
43
+
44
+ export async function login(opts = {}) {
45
+ const cfg = await loadConfig();
46
+ if (opts.apiUrl) cfg.api_url = opts.apiUrl;
47
+
48
+ let token = opts.token;
49
+ if (!token) {
50
+ process.stdout.write(`API URL: ${cfg.api_url}\n`);
51
+ token = await prompt("Paste your staff API key (fiq_staff_…): ", { silent: true });
52
+ token = token.trim();
53
+ }
54
+
55
+ if (!token.startsWith("fiq_staff_")) {
56
+ console.error(`Error: token must start with "fiq_staff_" (got ${token.slice(0, 12)}…).`);
57
+ process.exit(1);
58
+ }
59
+
60
+ // Save first so http.js can use it, then verify with /cli/whoami.
61
+ const file = await saveConfig({ api_url: cfg.api_url, token });
62
+
63
+ try {
64
+ const me = await http.get("whoami");
65
+ console.log(`Logged in as ${me.user_email || me.user_id} (super_admin=${me.is_super_admin})`);
66
+ console.log(`Token saved to ${file}`);
67
+ } catch (e) {
68
+ // Roll back the save if verification failed — leaving a bad token on disk
69
+ // is worse than no token at all.
70
+ await clearConfig();
71
+ console.error(`Login failed: ${e.message}`);
72
+ process.exit(1);
73
+ }
74
+ }
75
+
76
+ export async function logout() {
77
+ const removed = await clearConfig();
78
+ console.log(removed ? "Logged out." : "Already logged out.");
79
+ }
80
+
81
+ export async function whoami() {
82
+ try {
83
+ const me = await http.get("whoami");
84
+ console.log(JSON.stringify(me, null, 2));
85
+ } catch (e) {
86
+ console.error(`whoami failed: ${e.message}`);
87
+ process.exit(e.code === "ENOAUTH" ? 0 : 1);
88
+ }
89
+ }
@@ -0,0 +1,112 @@
1
+ // `flowiq prompts pull <organization_id>` / `flowiq prompts push <slug>`.
2
+ // Round-trips an org's active WhatsApp agent's prompt_sections through a
3
+ // local JSON file. No DB credentials on disk — all work happens server-side
4
+ // via /cli/prompts on api.flowiq.live.
5
+
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { http } from "../http.js";
9
+
10
+ const PROMPTS_DIR = path.resolve(process.cwd(), ".flowiq", "prompts");
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(PROMPTS_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("prompts", { organization_id: orgId });
51
+ } catch (e) {
52
+ console.error(`Pull failed: ${e.message}`);
53
+ process.exit(1);
54
+ }
55
+
56
+ await fs.mkdir(PROMPTS_DIR, { recursive: true });
57
+ const slug = slugify(resp.organization_name, resp.organization_id);
58
+ const filePath = path.join(PROMPTS_DIR, `${slug}.json`);
59
+ const overwriting = await fileExists(filePath);
60
+ await fs.writeFile(filePath, JSON.stringify(resp, null, 2) + "\n", "utf8");
61
+
62
+ const totalChars = resp.prompt_sections.reduce((a, s) => a + (s.content?.length || 0), 0);
63
+ console.log(`${overwriting ? "Overwrote" : "Wrote"} ${filePath}`);
64
+ console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
65
+ console.log(` agent: ${resp.agent_name} (${resp.agent_id})`);
66
+ console.log(` sections: ${resp.prompt_sections.length}`);
67
+ console.log(` content: ${totalChars.toLocaleString()} chars`);
68
+ }
69
+
70
+ export async function push(identifier) {
71
+ let filePath;
72
+ try {
73
+ filePath = await resolvePushPath(identifier);
74
+ } catch (e) {
75
+ console.error(`Error: ${e.message}`);
76
+ process.exit(1);
77
+ }
78
+ const raw = await fs.readFile(filePath, "utf8");
79
+ let data;
80
+ try { data = JSON.parse(raw); }
81
+ catch (e) {
82
+ console.error(`Invalid JSON in ${filePath}: ${e.message}`);
83
+ process.exit(1);
84
+ }
85
+
86
+ if (!data.organization_id || !UUID_RE.test(data.organization_id)) {
87
+ console.error(`${filePath}: organization_id missing or invalid`);
88
+ process.exit(1);
89
+ }
90
+ if (!Array.isArray(data.prompt_sections) || data.prompt_sections.length === 0) {
91
+ console.error(`${filePath}: prompt_sections must be a non-empty array`);
92
+ process.exit(1);
93
+ }
94
+
95
+ let resp;
96
+ try {
97
+ resp = await http.post("prompts", {
98
+ organization_id: data.organization_id,
99
+ prompt_sections: data.prompt_sections,
100
+ });
101
+ } catch (e) {
102
+ console.error(`Push failed: ${e.message}`);
103
+ process.exit(1);
104
+ }
105
+
106
+ console.log(`Pushed ${filePath}`);
107
+ console.log(` org: ${resp.organization_name} (${resp.organization_id})`);
108
+ console.log(` agent: ${resp.agent_name} (${resp.agent_id})`);
109
+ console.log(` sections: ${resp.sections}`);
110
+ console.log(` content: ${resp.total_chars.toLocaleString()} chars`);
111
+ console.log(` prompt now: ${resp.system_prompt_chars.toLocaleString()} chars`);
112
+ }
package/src/config.js ADDED
@@ -0,0 +1,70 @@
1
+ // Token + base-URL config for the flowiq CLI.
2
+ //
3
+ // Storage: ~/.config/flowiq/auth.json, chmod 600 (mode 0600). One file holds:
4
+ // { api_url: "https://api.flowiq.live", token: "fiq_staff_..." }
5
+ //
6
+ // Env-var overrides (handy for CI or local dev):
7
+ // FLOWIQ_API_URL → base URL (default https://api.flowiq.live)
8
+ // FLOWIQ_TOKEN → bearer token (takes precedence over the saved file)
9
+
10
+ import fs from "node:fs/promises";
11
+ import path from "node:path";
12
+ import os from "node:os";
13
+
14
+ const DEFAULT_API_URL = "https://api.flowiq.live";
15
+
16
+ function configDir() {
17
+ // Honour XDG_CONFIG_HOME if set; else ~/.config (Linux/Mac default).
18
+ const xdg = process.env.XDG_CONFIG_HOME;
19
+ const base = xdg && xdg.trim() ? xdg : path.join(os.homedir(), ".config");
20
+ return path.join(base, "flowiq");
21
+ }
22
+
23
+ function configFile() {
24
+ return path.join(configDir(), "auth.json");
25
+ }
26
+
27
+ async function readFile() {
28
+ try {
29
+ const raw = await fs.readFile(configFile(), "utf8");
30
+ return JSON.parse(raw);
31
+ } catch (e) {
32
+ if (e.code === "ENOENT") return null;
33
+ throw e;
34
+ }
35
+ }
36
+
37
+ export async function loadConfig() {
38
+ const saved = (await readFile()) || {};
39
+ const envToken = process.env.FLOWIQ_TOKEN?.trim();
40
+ const envApi = process.env.FLOWIQ_API_URL?.trim();
41
+ return {
42
+ api_url: envApi || saved.api_url || DEFAULT_API_URL,
43
+ token: envToken || saved.token || null,
44
+ };
45
+ }
46
+
47
+ export async function saveConfig({ api_url, token }) {
48
+ const dir = configDir();
49
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
50
+ const file = configFile();
51
+ const payload = JSON.stringify({ api_url, token }, null, 2) + "\n";
52
+ await fs.writeFile(file, payload, { encoding: "utf8", mode: 0o600 });
53
+ // Defensive chmod for hosts where the open() mode was honoured loosely.
54
+ try { await fs.chmod(file, 0o600); } catch { /* best-effort */ }
55
+ return file;
56
+ }
57
+
58
+ export async function clearConfig() {
59
+ try {
60
+ await fs.unlink(configFile());
61
+ return true;
62
+ } catch (e) {
63
+ if (e.code === "ENOENT") return false;
64
+ throw e;
65
+ }
66
+ }
67
+
68
+ export function configFilePath() {
69
+ return configFile();
70
+ }
package/src/http.js ADDED
@@ -0,0 +1,57 @@
1
+ // Tiny fetch wrapper for the flowiq CLI. All requests:
2
+ // - go to ${config.api_url}/cli/<endpoint>
3
+ // - carry Authorization: Bearer <token>
4
+ // - parse JSON, surfacing { error, message } shape with a readable error.
5
+
6
+ import { loadConfig } from "./config.js";
7
+
8
+ async function call(method, endpoint, { query, body } = {}) {
9
+ const cfg = await loadConfig();
10
+ if (!cfg.token) {
11
+ const err = new Error(
12
+ "Not authenticated. Run `flowiq auth login` and paste your staff API key."
13
+ );
14
+ err.code = "ENOAUTH";
15
+ throw err;
16
+ }
17
+
18
+ const qs = query
19
+ ? "?" + new URLSearchParams(
20
+ Object.fromEntries(Object.entries(query).filter(([, v]) => v != null))
21
+ ).toString()
22
+ : "";
23
+ const url = `${cfg.api_url.replace(/\/$/, "")}/cli/${endpoint}${qs}`;
24
+
25
+ const init = {
26
+ method,
27
+ headers: {
28
+ Authorization: `Bearer ${cfg.token}`,
29
+ Accept: "application/json",
30
+ },
31
+ };
32
+ if (body !== undefined) {
33
+ init.headers["Content-Type"] = "application/json";
34
+ init.body = JSON.stringify(body);
35
+ }
36
+
37
+ const resp = await fetch(url, init);
38
+ const text = await resp.text();
39
+ let parsed;
40
+ try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { _raw: text }; }
41
+ if (!resp.ok) {
42
+ const err = new Error(
43
+ parsed.message || parsed.error || `${method} ${endpoint} → HTTP ${resp.status}`
44
+ );
45
+ err.status = resp.status;
46
+ err.body = parsed;
47
+ throw err;
48
+ }
49
+ return parsed;
50
+ }
51
+
52
+ export const http = {
53
+ get: (endpoint, query) => call("GET", endpoint, { query }),
54
+ post: (endpoint, body) => call("POST", endpoint, { body }),
55
+ put: (endpoint, body) => call("PUT", endpoint, { body }),
56
+ del: (endpoint, body) => call("DELETE", endpoint, { body }),
57
+ };
package/src/index.js ADDED
@@ -0,0 +1,35 @@
1
+ // flowiq — staff CLI entry point.
2
+ // commander dispatches to src/commands/* based on argv.
3
+
4
+ import { Command } from "commander";
5
+ import * as authCmd from "./commands/auth.js";
6
+ import * as promptsCmd from "./commands/prompts.js";
7
+
8
+ export function run(argv) {
9
+ const program = new Command();
10
+ program
11
+ .name("flowiq")
12
+ .description("FlowIQ staff CLI — round-trip agent prompts, webhooks, and more, without service-role credentials.")
13
+ .version("0.1.0");
14
+
15
+ // auth
16
+ const auth = program.command("auth").description("Manage CLI authentication");
17
+ auth.command("login")
18
+ .description("Save a staff API key (fiq_staff_…)")
19
+ .option("--token <token>", "supply the token via flag instead of stdin")
20
+ .option("--api-url <url>", "override the API host (default https://api.flowiq.live)")
21
+ .action((opts) => authCmd.login(opts));
22
+ auth.command("logout").description("Remove the saved token").action(() => authCmd.logout());
23
+ auth.command("whoami").description("Show the user behind the saved token").action(() => authCmd.whoami());
24
+
25
+ // prompts
26
+ const prompts = program.command("prompts").description("Round-trip an org's active WhatsApp agent system prompt");
27
+ prompts.command("pull <organization_id>")
28
+ .description("Fetch the active agent's prompt_sections into a local JSON file")
29
+ .action((orgId) => promptsCmd.pull(orgId));
30
+ prompts.command("push <slug-or-path>")
31
+ .description("Apply a local JSON file's prompt_sections to the agent (regenerates system_prompt server-side)")
32
+ .action((id) => promptsCmd.push(id));
33
+
34
+ program.parseAsync(argv);
35
+ }