@flowapt/flowiq-cli 0.1.10 → 0.1.12

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
@@ -16,12 +16,24 @@ flowiq --version # should print 0.1.x
16
16
 
17
17
  ## Authenticate
18
18
 
19
- Get a personal staff API key from a FlowIQ super-admin (an `fiq_staff_…`
20
- string). Then:
19
+ Default flow — browser login (like `npm login`):
21
20
 
22
21
  ```bash
23
22
  flowiq auth login
24
- # paste your key when prompted
23
+ ```
24
+
25
+ The CLI shows a short code (e.g. `FQ4X-9KTB`), opens
26
+ `https://app.flowiq.live/cli-auth` when you press ENTER, and waits. A
27
+ logged-in FlowIQ **super admin** confirms the code on the page matches the
28
+ one in your terminal and clicks Approve — the CLI then receives a
29
+ freshly-minted personal key automatically. **Never approve a code you
30
+ didn't just see in your own terminal.**
31
+
32
+ Fallbacks (CI / no browser):
33
+
34
+ ```bash
35
+ flowiq auth login --token fiq_staff_… # supply a key directly
36
+ flowiq auth login --paste # prompt for a key on stdin
25
37
  ```
26
38
 
27
39
  Your key is saved to `~/.config/flowiq/auth.json` with `chmod 600`. To
@@ -31,8 +43,16 @@ verify what you're authenticated as:
31
43
  flowiq auth whoami
32
44
  ```
33
45
 
46
+ To rotate this device's token (mints a fresh key, revokes the old one
47
+ server-side — run whenever you want a new secret):
48
+
49
+ ```bash
50
+ flowiq auth refresh
51
+ ```
52
+
34
53
  To log out (deletes the local key file, does NOT revoke the key
35
- server-side — ask your admin to revoke):
54
+ server-side — use `flowiq auth refresh` first if you want the old key
55
+ dead, or ask another super admin to revoke):
36
56
 
37
57
  ```bash
38
58
  flowiq auth logout
@@ -117,6 +137,28 @@ flowiq knowledge list
117
137
  - **Object-shape guard:** some agents use `additional_config` for Flowapt-managed config (`external_woo_build`, …) rather than text sources — a pull flags that, and a push is **refused** (never clobbers managed config).
118
138
  - `--agent` + filenames behave like `prompts` (a non-active agent saves to `<org-slug>-<agent-slug>.json`). The file carries `agent_id`, so `push` targets the agent it was pulled from.
119
139
 
140
+ ### Custom tools — `flowiq custom-tools pull|push|enable|disable|list` (alias `ct`)
141
+
142
+ Round-trips an agent's **custom tools** — the `agents.additional_tools` array
143
+ of "any API call → tool" definitions (the "Custom Tools" panel in the UI:
144
+ `get_more_answers`, CRM wrappers, loyalty tools, …). These define real HTTP
145
+ calls the agent can execute, so treat pushes with care.
146
+
147
+ ```bash
148
+ flowiq custom-tools pull <org_id> # → ./.flowiq/custom-tools/<slug>.json (tools[])
149
+ flowiq ct pull <org_id> --agent <id> # a specific (non-active) agent
150
+ flowiq ct push <slug> --dry-run # validate + per-tool diff, writes NOTHING
151
+ flowiq ct push <slug> # FULL-REPLACE tools[] onto the agent
152
+ flowiq ct enable <org_id> <tool_name> # flip one tool on (no round-trip)
153
+ flowiq ct disable <org_id> <tool_name> # flip one tool off
154
+ flowiq ct list
155
+ ```
156
+
157
+ - **Always `--dry-run` first** on a production agent — it prints added/removed/changed tool names before you commit.
158
+ - 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
+ - **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
+ - `--agent` + filenames behave like `prompts`/`knowledge`; the file carries `agent_id`, so `push` targets the agent it was pulled from.
161
+
120
162
  ### Messages — `flowiq messages pull <contact_id>` (alias `m`)
121
163
 
122
164
  Read-only pull of a contact's `helpdesk_messages` history. Anchors on the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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": {
@@ -1,8 +1,23 @@
1
- // `flowiq auth login` / `flowiq auth logout` / `flowiq auth whoami`.
2
- // Stores a `fiq_staff_…` bearer token at ~/.config/flowiq/auth.json.
1
+ // `flowiq auth login` / `logout` / `whoami` / `refresh`.
2
+ //
3
+ // Default login = browser device flow (like `npm login` / `gh auth login`):
4
+ // 1. POST /cli/login-start (unauthenticated) → device_code + user_code
5
+ // 2. Show the user code, press ENTER → open https://app.flowiq.live/cli-auth
6
+ // 3. A logged-in SUPER ADMIN confirms the codes match and approves
7
+ // 4. Poll /cli/login-poll until the key is minted server-side (mint-at-claim)
8
+ //
9
+ // Fallbacks: `--token <key>` (direct) or `--paste` (interactive paste) keep
10
+ // the original flow for CI or when no browser is handy.
11
+ //
12
+ // `flowiq auth refresh` rotates the saved key in place via /cli/token-rotate —
13
+ // run it on each device whenever you want a fresh secret; the old key is
14
+ // revoked server-side. If the saved key is already dead it falls back to the
15
+ // full browser login.
3
16
 
4
17
  import readline from "node:readline";
5
- import { loadConfig, saveConfig, clearConfig, configFilePath } from "../config.js";
18
+ import os from "node:os";
19
+ import { spawn } from "node:child_process";
20
+ import { loadConfig, saveConfig, clearConfig } from "../config.js";
6
21
  import { http } from "../http.js";
7
22
 
8
23
  function prompt(question, { silent = false } = {}) {
@@ -41,25 +56,42 @@ function prompt(question, { silent = false } = {}) {
41
56
  });
42
57
  }
43
58
 
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();
59
+ // Unauthenticated POST — login-start / login-poll run BEFORE a token exists,
60
+ // so they can't go through http.js (which requires the saved token).
61
+ async function unauthedPost(apiUrl, endpoint, body) {
62
+ const url = `${apiUrl.replace(/\/$/, "")}/cli/${endpoint}`;
63
+ const resp = await fetch(url, {
64
+ method: "POST",
65
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
66
+ body: JSON.stringify(body ?? {}),
67
+ });
68
+ const text = await resp.text();
69
+ let parsed;
70
+ try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { _raw: text }; }
71
+ if (!resp.ok) {
72
+ throw new Error(parsed.message || parsed.error || `${endpoint} → HTTP ${resp.status}`);
53
73
  }
74
+ return parsed;
75
+ }
54
76
 
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);
77
+ function openBrowser(url) {
78
+ const platform = process.platform;
79
+ let cmd, args;
80
+ if (platform === "darwin") { cmd = "open"; args = [url]; }
81
+ else if (platform === "win32") { cmd = "cmd"; args = ["/c", "start", "", url]; }
82
+ else { cmd = "xdg-open"; args = [url]; }
83
+ try {
84
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
85
+ return true;
86
+ } catch {
87
+ return false;
58
88
  }
89
+ }
59
90
 
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 });
91
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
62
92
 
93
+ async function saveAndVerify(apiUrl, token) {
94
+ const file = await saveConfig({ api_url: apiUrl, token });
63
95
  try {
64
96
  const me = await http.get("whoami");
65
97
  console.log(`Logged in as ${me.user_email || me.user_id} (super_admin=${me.is_super_admin})`);
@@ -73,6 +105,109 @@ export async function login(opts = {}) {
73
105
  }
74
106
  }
75
107
 
108
+ async function browserLogin(cfg) {
109
+ const deviceName = `${os.hostname()} (${os.platform()})`;
110
+ let start;
111
+ try {
112
+ start = await unauthedPost(cfg.api_url, "login-start", { device_name: deviceName });
113
+ } catch (e) {
114
+ console.error(`Could not start browser login: ${e.message}`);
115
+ console.error("Fallback: flowiq auth login --paste");
116
+ process.exit(1);
117
+ }
118
+
119
+ console.log("");
120
+ console.log("First, confirm this code matches the one shown in your browser:");
121
+ console.log("");
122
+ console.log(` ${start.user_code}`);
123
+ console.log("");
124
+ console.log(`Approval page: ${start.verification_url}`);
125
+ await prompt("Press ENTER to open in the browser... ");
126
+ if (!openBrowser(start.verification_url)) {
127
+ console.log("(Could not open a browser — visit the URL above manually.)");
128
+ }
129
+
130
+ const intervalMs = Math.max(2, start.interval || 5) * 1000;
131
+ const deadline = Date.now() + (start.expires_in || 600) * 1000;
132
+ process.stdout.write("Waiting for a super admin to approve");
133
+
134
+ while (Date.now() < deadline) {
135
+ await sleep(intervalMs);
136
+ let poll;
137
+ try {
138
+ poll = await unauthedPost(cfg.api_url, "login-poll", { device_code: start.device_code });
139
+ } catch (e) {
140
+ console.error(`\nPolling failed: ${e.message}`);
141
+ process.exit(1);
142
+ }
143
+
144
+ if (poll.status === "pending") { process.stdout.write("."); continue; }
145
+ if (poll.status === "slow_down") { await sleep(intervalMs); continue; }
146
+ process.stdout.write("\n");
147
+
148
+ if (poll.status === "approved") {
149
+ console.log(`Approved${poll.user_email ? ` by ${poll.user_email}` : ""}.`);
150
+ await saveAndVerify(cfg.api_url, poll.token);
151
+ return;
152
+ }
153
+ if (poll.status === "denied") { console.error("Login was DENIED in the browser."); process.exit(1); }
154
+ if (poll.status === "expired") { console.error("Login request expired. Re-run `flowiq auth login`."); process.exit(1); }
155
+ if (poll.status === "claimed") { console.error("This login was already claimed. Re-run `flowiq auth login`."); process.exit(1); }
156
+ console.error(`Unexpected status: ${poll.status}`); process.exit(1);
157
+ }
158
+
159
+ console.error("\nTimed out waiting for approval. Re-run `flowiq auth login`.");
160
+ process.exit(1);
161
+ }
162
+
163
+ export async function login(opts = {}) {
164
+ const cfg = await loadConfig();
165
+ if (opts.apiUrl) cfg.api_url = opts.apiUrl;
166
+
167
+ // Fallback paths: --token (direct) or --paste (interactive paste).
168
+ let token = opts.token;
169
+ if (!token && opts.paste) {
170
+ process.stdout.write(`API URL: ${cfg.api_url}\n`);
171
+ token = (await prompt("Paste your staff API key (fiq_staff_…): ", { silent: true })).trim();
172
+ }
173
+
174
+ if (token) {
175
+ if (!token.startsWith("fiq_staff_")) {
176
+ console.error(`Error: token must start with "fiq_staff_" (got ${token.slice(0, 12)}…).`);
177
+ process.exit(1);
178
+ }
179
+ await saveAndVerify(cfg.api_url, token);
180
+ return;
181
+ }
182
+
183
+ // Default: browser device flow.
184
+ await browserLogin(cfg);
185
+ }
186
+
187
+ export async function refresh() {
188
+ const cfg = await loadConfig();
189
+ if (!cfg.token) {
190
+ console.log("No saved token — starting browser login instead.");
191
+ await browserLogin(cfg);
192
+ return;
193
+ }
194
+
195
+ try {
196
+ const rotated = await http.post("token-rotate", {});
197
+ await saveConfig({ api_url: cfg.api_url, token: rotated.token });
198
+ console.log(`Token rotated (${rotated.rotated_from} → ${rotated.key_prefix}).`);
199
+ console.log(rotated.old_key_revoked ? "Old key revoked." : "WARNING: old key could not be revoked — revoke it manually.");
200
+ } catch (e) {
201
+ if (e.status === 401) {
202
+ console.log("Saved token is no longer valid — starting browser login…");
203
+ await browserLogin(cfg);
204
+ return;
205
+ }
206
+ console.error(`Refresh failed: ${e.message}`);
207
+ process.exit(1);
208
+ }
209
+ }
210
+
76
211
  export async function logout() {
77
212
  const removed = await clearConfig();
78
213
  console.log(removed ? "Logged out." : "Already logged out.");
@@ -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
+ }
package/src/index.js CHANGED
@@ -23,6 +23,7 @@ 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";
26
27
 
27
28
  // Read version from package.json so it stays in sync with the published npm
28
29
  // version automatically (single source of truth — bumping package.json on each
@@ -40,10 +41,14 @@ export function run(argv) {
40
41
  // auth
41
42
  const auth = program.command("auth").description("Manage CLI authentication");
42
43
  auth.command("login")
43
- .description("Save a staff API key (fiq_staff_…)")
44
- .option("--token <token>", "supply the token via flag instead of stdin")
44
+ .description("Log in via the browser (super-admin approval); --token/--paste for the old key-paste flow")
45
+ .option("--token <token>", "supply a staff API key directly instead of the browser flow")
46
+ .option("--paste", "prompt for a staff API key on stdin instead of the browser flow")
45
47
  .option("--api-url <url>", "override the API host (default https://api.flowiq.live)")
46
48
  .action((opts) => authCmd.login(opts));
49
+ auth.command("refresh")
50
+ .description("Rotate this device's token in place (old key is revoked server-side)")
51
+ .action(() => authCmd.refresh());
47
52
  auth.command("logout").description("Remove the saved token").action(() => authCmd.logout());
48
53
  auth.command("whoami").description("Show the user behind the saved token").action(() => authCmd.whoami());
49
54
 
@@ -306,5 +311,29 @@ export function run(argv) {
306
311
  .description("List local knowledge snapshots")
307
312
  .action(() => knowledgeCmd.list());
308
313
 
314
+ // custom-tools (agent custom tools: additional_tools — the "any API call → tool" defs)
315
+ const customTools = program.command("custom-tools")
316
+ .alias("ct")
317
+ .description("Round-trip an agent's CUSTOM TOOLS (additional_tools — the \"any API call → tool\" definitions)");
318
+ customTools.command("pull <organization_id>")
319
+ .description("Fetch the agent's custom tools into a local JSON file (default: active agent)")
320
+ .option("--agent <id>", "target a specific agent instead of the org's active one")
321
+ .action((orgId, opts) => customToolsCmd.pull(orgId, opts));
322
+ customTools.command("push <slug-or-path>")
323
+ .description("FULL-REPLACE the agent's custom tools with the local JSON's tools[] (validated server-side)")
324
+ .option("--dry-run", "validate + print the per-tool diff WITHOUT writing")
325
+ .action((id, opts) => customToolsCmd.push(id, opts));
326
+ customTools.command("enable <organization_id> <tool_name>")
327
+ .description("Enable one custom tool by name (no round-trip)")
328
+ .option("--agent <id>", "target a specific agent instead of the org's active one")
329
+ .action((orgId, name, opts) => customToolsCmd.enable(orgId, name, opts));
330
+ customTools.command("disable <organization_id> <tool_name>")
331
+ .description("Disable one custom tool by name (no round-trip)")
332
+ .option("--agent <id>", "target a specific agent instead of the org's active one")
333
+ .action((orgId, name, opts) => customToolsCmd.disable(orgId, name, opts));
334
+ customTools.command("list")
335
+ .description("List local custom-tools snapshots")
336
+ .action(() => customToolsCmd.list());
337
+
309
338
  program.parseAsync(argv);
310
339
  }