@flowapt/flowiq-cli 0.1.11 → 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 +22 -0
- package/package.json +1 -1
- package/src/commands/custom-tools.js +197 -0
- package/src/index.js +25 -0
package/README.md
CHANGED
|
@@ -137,6 +137,28 @@ flowiq knowledge list
|
|
|
137
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).
|
|
138
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.
|
|
139
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
|
+
|
|
140
162
|
### Messages — `flowiq messages pull <contact_id>` (alias `m`)
|
|
141
163
|
|
|
142
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.
|
|
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": {
|
|
@@ -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
|
|
@@ -310,5 +311,29 @@ export function run(argv) {
|
|
|
310
311
|
.description("List local knowledge snapshots")
|
|
311
312
|
.action(() => knowledgeCmd.list());
|
|
312
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
|
+
|
|
313
338
|
program.parseAsync(argv);
|
|
314
339
|
}
|