@oalo-ai/cli 0.0.0-stage → 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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -2
  3. package/man/oalo.1 +1530 -0
  4. package/package.json +44 -4
  5. package/plugin/.claude-plugin/plugin.json +12 -0
  6. package/plugin/skills/oalo/SKILL.md +414 -0
  7. package/src/api.js +132 -0
  8. package/src/app-ref.js +11 -0
  9. package/src/args.js +67 -0
  10. package/src/bot-settings.json +468 -0
  11. package/src/channel.js +162 -0
  12. package/src/commands/apps.js +64 -0
  13. package/src/commands/bots.js +278 -0
  14. package/src/commands/call.js +179 -0
  15. package/src/commands/channels.js +110 -0
  16. package/src/commands/data.js +446 -0
  17. package/src/commands/db.js +58 -0
  18. package/src/commands/deploy.js +431 -0
  19. package/src/commands/dockerfile.js +81 -0
  20. package/src/commands/docs.js +70 -0
  21. package/src/commands/emit.js +124 -0
  22. package/src/commands/env.js +223 -0
  23. package/src/commands/graphs.js +570 -0
  24. package/src/commands/incidents.js +44 -0
  25. package/src/commands/link.js +150 -0
  26. package/src/commands/login.js +180 -0
  27. package/src/commands/logs.js +69 -0
  28. package/src/commands/mcp.js +125 -0
  29. package/src/commands/metrics.js +168 -0
  30. package/src/commands/products.js +169 -0
  31. package/src/commands/qa.js +98 -0
  32. package/src/commands/rehearse.js +100 -0
  33. package/src/commands/run.js +152 -0
  34. package/src/commands/secrets.js +68 -0
  35. package/src/commands/setup.js +96 -0
  36. package/src/commands/skill.js +142 -0
  37. package/src/commands/sources.js +377 -0
  38. package/src/commands/switches.js +156 -0
  39. package/src/commands/tail.js +84 -0
  40. package/src/commands/tokens.js +85 -0
  41. package/src/commands/typegen.js +505 -0
  42. package/src/commands/whoami.js +112 -0
  43. package/src/config.js +157 -0
  44. package/src/data.js +392 -0
  45. package/src/envfile.js +71 -0
  46. package/src/events.js +63 -0
  47. package/src/graphfiles.js +273 -0
  48. package/src/help.js +920 -0
  49. package/src/index.js +216 -0
  50. package/src/mcp/tools.js +773 -0
  51. package/src/server.js +73 -0
  52. package/src/workspace.js +44 -0
@@ -0,0 +1,64 @@
1
+ import { OaloChannel } from "../channel.js";
2
+ import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
3
+ import { dim } from "../events.js";
4
+
5
+ /**
6
+ * `oalo apps`: the workspace's apps, with their type, state, preview and live
7
+ * addresses, and what is live now. The slug is what `oalo deploy --app` takes.
8
+ */
9
+ export async function apps(flags) {
10
+ const cfg = resolveConfig(flags);
11
+ const { url, token } = controlPlane(cfg);
12
+ if (!url || !token) {
13
+ console.error(`oalo apps needs a CLI token.\n\n${HOW_TO_CONNECT}`);
14
+ process.exitCode = 2;
15
+ return;
16
+ }
17
+
18
+ const ch = new OaloChannel({ url, token });
19
+ try {
20
+ await ch.connect();
21
+ const { apps: list = [] } = await ch.call("list_apps", {});
22
+ const rows = await Promise.all(
23
+ list.map(async (a) => {
24
+ let release = null;
25
+ let containers = null;
26
+ let incident = null;
27
+ try {
28
+ const r = await ch.call("get_release", { app_id: a.id, containers: true });
29
+ release = r?.release || null;
30
+ containers = r?.containers || null;
31
+ incident = r?.incident || null;
32
+ } catch {
33
+ /* no release yet */
34
+ }
35
+ return { ...a, release, containers, incident };
36
+ }),
37
+ );
38
+
39
+ if (flags.json) return void console.log(JSON.stringify(rows, null, 2));
40
+ if (!rows.length) return void console.log(`no apps in ${url}`);
41
+
42
+ for (const a of rows.sort((x, y) => String(x.slug).localeCompare(String(y.slug)))) {
43
+ const runtime = a.app_type === "server" ? `server/${a.config?.server_runtime || "node"}` : a.app_type;
44
+ console.log(`${a.slug} ${dim(`${runtime}, ${a.status}${a.slug === cfg.appId ? ", linked" : ""}`)}`);
45
+ if (a.preview_hostname) console.log(dim(` preview https://${a.preview_hostname}/`));
46
+ const live = a.custom_hostname || a.prod_hostname;
47
+ if (a.release?.commit_hash) {
48
+ console.log(dim(` live https://${live}/ at ${String(a.release.commit_hash).slice(0, 8)}${a.release.launched_at ? `, ${String(a.release.launched_at).slice(0, 16).replace("T", " ")}` : ""}`));
49
+ if (a.incident) console.log(` ! ${a.incident.title} (since ${String(a.incident.opened_at).slice(0, 16).replace("T", " ")}): oalo incidents --app ${a.slug}`);
50
+ // Each live process that isn't running, with Docker's status.
51
+ for (const c of (a.containers || []).filter((c) => !c.running)) {
52
+ console.log(` ${c.process || "process"} isn't running: ${c.status || c.state}. oalo logs --app ${a.slug} --env prod shows why.`);
53
+ }
54
+ } else if (live) {
55
+ console.log(dim(` live not launched yet (oalo deploy --app ${a.slug} --env prod)`));
56
+ }
57
+ }
58
+ } catch (e) {
59
+ console.error(e.message);
60
+ process.exitCode = 1;
61
+ } finally {
62
+ ch.close();
63
+ }
64
+ }
@@ -0,0 +1,278 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { OaloChannel } from "../channel.js";
3
+ import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
4
+ import { requireFeature } from "../server.js";
5
+ import { dim } from "../events.js";
6
+
7
+ /**
8
+ * `oalo bots`: the workspace's bots, and the data each can search.
9
+ *
10
+ * oalo bots list them
11
+ * oalo bots knowledge <bot> what it can search, and what else could be attached
12
+ * oalo bots knowledge <bot> --add <source> [--remove <source>]
13
+ * oalo bots tools <bot> [--add <tool>] [--remove <tool>] the tools it may call
14
+ * oalo bots settings <bot> every setting and its value
15
+ * oalo bots settings <bot> set key=value ... [--yes] [--dry-run]
16
+ * oalo bots settings --explain [key] what each setting does
17
+ *
18
+ * A source is a table made searchable with `oalo data vectorize`: its handle
19
+ * (`model:products`, `table:prod.faq`) or just its name when that's unique.
20
+ * A bot always searches its own knowledge base (documents) too. Bots are named
21
+ * by their public id (`bot_...`), as `oalo bots` prints it. The changes go
22
+ * through the same actions as the bot's Knowledge pane in the dashboard.
23
+ */
24
+ export async function bots(flags, rest, opts = {}) {
25
+ const cfg = resolveConfig(flags);
26
+ const { url, token } = controlPlane(cfg);
27
+ if (!url || !token) return fail(`oalo bots needs a CLI token.\n\n${HOW_TO_CONNECT}`, 2);
28
+
29
+ const sub = rest[1];
30
+ if (sub && !["knowledge", "settings", "tools"].includes(sub)) return fail(`unknown bots command: ${sub}\n\n${USAGE}`, 2);
31
+ if ((sub === "knowledge" || sub === "tools") && !rest[2]) return fail(USAGE, 2);
32
+ if (sub === "settings" && !rest[2] && !flags.explain) return fail(USAGE, 2);
33
+
34
+ if (!opts.channel) {
35
+ try {
36
+ await requireFeature(url, "knowledge", "oalo bots");
37
+ } catch (e) {
38
+ return fail(e.message, 1);
39
+ }
40
+ }
41
+
42
+ const ch = (opts.channel || ((o) => new OaloChannel(o)))({ url, token });
43
+ try {
44
+ await ch.connect();
45
+ if (sub === "knowledge") return await knowledge(ch, rest[2], flags);
46
+ if (sub === "settings") return await settings(ch, rest.slice(2), flags);
47
+ if (sub === "tools") return await tools(ch, rest[2], flags);
48
+ return await list(ch, flags, url);
49
+ } catch (e) {
50
+ return fail(e.message, 1);
51
+ } finally {
52
+ ch.close();
53
+ }
54
+ }
55
+
56
+ const USAGE = [
57
+ "usage: oalo bots [--json]",
58
+ " oalo bots knowledge <bot> [--add <source>] [--remove <source>] [--json]",
59
+ " oalo bots tools <bot> [--add <tool>] [--remove <tool>] [--json]",
60
+ " oalo bots settings <bot> [--json]",
61
+ " oalo bots settings <bot> set key=value [key=value ...] [--yes] [--dry-run]",
62
+ " oalo bots settings --explain [key]",
63
+ ].join("\n");
64
+
65
+ async function list(ch, flags, url) {
66
+ const { bots: all = [] } = await ch.call("chat:bots:list", {});
67
+ if (flags.json) return void console.log(JSON.stringify(all.map(summary), null, 2));
68
+ if (!all.length) return void console.log(`no bots in ${url}`);
69
+
70
+ for (const b of all.sort((x, y) => String(x.name).localeCompare(String(y.name)))) {
71
+ console.log(`${b.public_bot_id} ${b.name} ${dim(b.status || "")}`);
72
+ if (Array.isArray(b.search_sources)) console.log(dim(` searches: ${searches(b.search_sources)}`));
73
+ }
74
+ console.log(dim("\nAttach a table: oalo bots knowledge <bot> --add <source>. Make one searchable first: oalo data vectorize <table> --fields ..."));
75
+ }
76
+
77
+ const summary = (b) => ({ id: b.public_bot_id, name: b.name, status: b.status, search_sources: b.search_sources ?? null });
78
+ const searches = (list) => (list.length ? `${list.join(", ")}, and its own knowledge base` : "its own knowledge base only");
79
+
80
+ async function knowledge(ch, botId, flags) {
81
+ if (!/^bot_[A-Za-z0-9_-]+$/.test(botId)) return fail(`name the bot by its public id (bot_...), as \`oalo bots\` prints it (got ${botId})`, 2);
82
+
83
+ const [{ bot }, { sources: available = [] }] = await Promise.all([
84
+ ch.call("chat:bots:get", { id: botId }),
85
+ ch.call("chat:bots:search_sources_available", {}),
86
+ ]);
87
+ const attached = Array.isArray(bot.search_sources) ? bot.search_sources : [];
88
+ const adds = values(flags.add), removes = values(flags.remove);
89
+
90
+ let now = attached;
91
+ const notes = [];
92
+ if (adds.length || removes.length) {
93
+ const toAdd = adds.map((a) => resolve(a, available.map((s) => s.handle), "searchable table"));
94
+ const toRemove = removes.map((r) => resolve(r, attached, "attached table"));
95
+ for (const h of toAdd) if (attached.includes(h)) notes.push(`${h} was already attached`);
96
+ now = [...new Set([...attached.filter((h) => !toRemove.includes(h)), ...toAdd])];
97
+
98
+ if (now.join("\n") !== attached.join("\n")) {
99
+ const r = await ch.call("chat:bots:update_search_sources", { id: botId, search_sources: now });
100
+ now = Array.isArray(r?.bot?.search_sources) ? r.bot.search_sources : now;
101
+ }
102
+ }
103
+
104
+ if (flags.json) return void console.log(JSON.stringify({ bot: botId, name: bot.name, search_sources: now, available }, null, 2));
105
+
106
+ const added = now.filter((h) => !attached.includes(h)), removed = attached.filter((h) => !now.includes(h));
107
+ for (const h of added) console.log(`attached ${h} to ${bot.name}`);
108
+ for (const h of removed) console.log(`detached ${h} from ${bot.name}`);
109
+ for (const n of notes) console.log(dim(n));
110
+ if (adds.length || removes.length) {
111
+ if (!added.length && !removed.length) console.log(`no change: ${bot.name} searches the same tables`);
112
+ console.log("");
113
+ }
114
+
115
+ const about = new Map(available.map((s) => [s.handle, s.description]));
116
+ console.log(`${bot.name} (${botId}) searches its own knowledge base${now.length ? ", and:" : " only."}`);
117
+ for (const h of now) console.log(` ${h}${about.get(h) ? dim(` ${about.get(h)}`) : about.has(h) ? "" : dim(" (no longer vectorized: searched as nothing)")}`);
118
+
119
+ const others = available.filter((s) => !now.includes(s.handle));
120
+ if (others.length) {
121
+ console.log(dim(`\nCan also attach (--add):`));
122
+ for (const s of others) console.log(dim(` ${s.handle}${s.description ? ` ${s.description}` : ""}`));
123
+ } else if (!available.length) {
124
+ console.log(dim("\nNo table is searchable yet: oalo data vectorize <table> --fields <field,...>"));
125
+ }
126
+ }
127
+
128
+ // ── tools ────────────────────────────────────────────────────────────────
129
+
130
+ const TRANSPORTS = { app: "your app", http_webhook: "webhook", builtin: "built in", wasm: "code", container_mcp: "action" };
131
+
132
+ // The same change as a toggle in the bot's Tools tab: the allowlist is every
133
+ // tool on now, plus or minus these.
134
+ async function tools(ch, botId, flags) {
135
+ if (!/^bot_[A-Za-z0-9_-]+$/.test(botId)) return fail(`name the bot by its public id (bot_...), as \`oalo bots\` prints it (got ${botId})`, 2);
136
+ const { tools: list = [] } = await ch.call("chat:bots:tools:list", { bot_id: botId });
137
+ const usable = list.filter((t) => t.enabled);
138
+ const adds = values(flags.add), removes = values(flags.remove);
139
+ const byName = (name) => {
140
+ const t = usable.find((x) => x.name === name);
141
+ if (!t) throw new Error(`${name} isn't one of this workspace's tools${list.some((x) => x.name === name) ? " that's on" : ""}. \`oalo bots tools ${botId}\` lists them.`);
142
+ return t;
143
+ };
144
+
145
+ let now = list;
146
+ if (adds.length || removes.length) {
147
+ const on = new Set(list.filter((t) => t.allowlisted).map((t) => t.id));
148
+ for (const n of adds) on.add(byName(n).id);
149
+ for (const n of removes) on.delete(byName(n).id);
150
+ await ch.call("chat:bots:tools:set_allowlist", { bot_id: botId, tool_ids: [...on] });
151
+ now = list.map((t) => ({ ...t, allowlisted: on.has(t.id) }));
152
+ for (const n of adds) console.log(`attached ${n}`);
153
+ for (const n of removes) console.log(`detached ${n}`);
154
+ }
155
+
156
+ if (flags.json) return void console.log(JSON.stringify(now.map((t) => ({ name: t.name, kind: t.transport, attached: !!t.allowlisted, enabled: t.enabled, description: t.description })), null, 2));
157
+ const row = (t) => ` ${t.name} ${dim(`${TRANSPORTS[t.transport] || t.transport}${t.requires_authenticated_user ? ", signed-in users only" : ""}`)}`;
158
+ const attached = now.filter((t) => t.allowlisted && t.enabled), others = now.filter((t) => !t.allowlisted && t.enabled);
159
+ console.log(attached.length ? `${botId} can call:` : `${botId} has no tools attached.`);
160
+ attached.forEach((t) => console.log(row(t)));
161
+ if (others.length) {
162
+ console.log(dim(`\nCan also attach (--add):`));
163
+ others.forEach((t) => console.log(dim(row(t))));
164
+ }
165
+ }
166
+
167
+ // ── settings ─────────────────────────────────────────────────────────────
168
+
169
+ async function settings(ch, args, flags) {
170
+ const [botId, verb, ...pairs] = args;
171
+
172
+ if (flags.explain) {
173
+ const { settings: spec } = await ch.call("chat:bots:settings", {});
174
+ const key = typeof flags.explain === "string" ? flags.explain : botId;
175
+ const pick = key ? spec.filter((s) => s.key === key || s.key.startsWith(`${key}.`) || s.group === key) : spec;
176
+ if (!pick.length) return fail(`no setting ${key}. \`oalo bots settings --explain\` lists them all.`, 2);
177
+ if (flags.json) return void console.log(JSON.stringify(pick, null, 2));
178
+ for (const s of pick) explain(s);
179
+ return;
180
+ }
181
+
182
+ if (!/^bot_[A-Za-z0-9_-]+$/.test(botId)) return fail(`name the bot by its public id (bot_...), as \`oalo bots\` prints it (got ${botId})`, 2);
183
+ if (verb && verb !== "set") return fail(`unknown settings command: ${verb}\n\n${USAGE}`, 2);
184
+
185
+ if (!verb) {
186
+ const r = await ch.call("chat:bots:settings", { id: botId });
187
+ if (flags.json) return void console.log(JSON.stringify(r, null, 2));
188
+ return show(r);
189
+ }
190
+
191
+ if (!pairs.length) return fail(`say what to change: oalo bots settings ${botId} set key=value`, 2);
192
+ const changes = {};
193
+ for (const p of pairs) {
194
+ const i = p.indexOf("=");
195
+ if (i < 1) return fail(`${p}: write it as key=value`, 2);
196
+ let v = p.slice(i + 1);
197
+ if (v.startsWith("@")) {
198
+ try {
199
+ v = readFileSync(v.slice(1), "utf8");
200
+ } catch (e) {
201
+ return fail(`${p.slice(0, i)}: can't read ${v.slice(1)} (${e.code || e.message})`, 2);
202
+ }
203
+ }
204
+ changes[p.slice(0, i)] = v;
205
+ }
206
+
207
+ let r;
208
+ try {
209
+ r = await ch.call("chat:bots:settings:set", { id: botId, changes, confirm: flags.yes === true, dry_run: flags["dry-run"] === true });
210
+ } catch (e) {
211
+ if (e.code === "confirm") {
212
+ console.log(`${e.message}\n`);
213
+ printDiff(e.reply.diff);
214
+ return fail("\nNothing changed. Run it again with --yes to apply.", 1);
215
+ }
216
+ if (e.code === "invalid") return fail(`nothing changed:\n${e.reply.errors.map((m) => ` ${m}`).join("\n")}`, 2);
217
+ throw e;
218
+ }
219
+
220
+ if (flags.json) return void console.log(JSON.stringify(r, null, 2));
221
+ if (!r.diff.length) return void console.log(`no change: ${r.bot.name} already has ${Object.keys(changes).length === 1 ? "that value" : "those values"}`);
222
+ console.log(r.dry_run ? `${r.bot.name} would change (nothing was saved):` : `${r.bot.name} (${r.bot.id}):`);
223
+ printDiff(r.diff);
224
+ }
225
+
226
+ function show(r) {
227
+ console.log(`${r.bot.name} (${r.bot.id}), ${r.bot.status}`);
228
+ const width = Math.max(...r.settings.map((s) => s.key.length));
229
+ for (const group of r.groups) {
230
+ const rows = r.settings.filter((s) => s.group === group);
231
+ if (!rows.length) continue;
232
+ console.log(`\n${group}`);
233
+ for (const s of rows) {
234
+ const v = format(s.value, s);
235
+ console.log(` ${s.key.padEnd(width)} ${s.is_default ? v : `${v}${dim(` (default ${format(s.default, s)})`)}`}`);
236
+ }
237
+ }
238
+ console.log(dim("\nChange one: oalo bots settings <bot> set key=value. What each does: oalo bots settings --explain <key>."));
239
+ }
240
+
241
+ function explain(s) {
242
+ const values = s.type === "one of" ? s.values.join(" | ") : s.type === "number" ? `a number from ${s.values[0]} to ${s.values[1]}` : s.type === "segments" ? `Title=mode; ... (modes: ${s.values.join(", ")})` : s.type;
243
+ console.log(`${s.key} ${dim(`[${s.group}]`)}`);
244
+ console.log(` ${s.doc}`);
245
+ console.log(dim(` values: ${values}${s.clear_word ? `, or ${s.clear_word}` : ""}. Default: ${format(s.default, s)}.${s.effect ? ` Changing it ${s.effect}.` : ""}`));
246
+ console.log("");
247
+ }
248
+
249
+ function printDiff(diff) {
250
+ for (const d of diff) console.log(` ${d.key}: ${format(d.from, d)} -> ${format(d.to, d)}${d.effect ? dim(` (${d.effect})`) : ""}`);
251
+ }
252
+
253
+ function format(v, s) {
254
+ if (v === null || v === undefined) return s?.clear_word || "(none)";
255
+ if (Array.isArray(v)) return v.length ? v.map((x) => (typeof x === "object" ? `${x.node}=${x.mode}` : x)).join(typeof v[0] === "object" ? "; " : ", ") : "(none)";
256
+ const t = String(v).replace(/\s+/g, " ");
257
+ return t.length > 60 ? `${t.slice(0, 57)}...` : t;
258
+ }
259
+
260
+ // `--add` may repeat or be comma-separated.
261
+ const values = (v) => (v === undefined || v === true ? [] : [].concat(v).flatMap((x) => String(x).split(",")).map((s) => s.trim()).filter(Boolean));
262
+
263
+ // A handle, or a bare name when exactly one handle ends in it.
264
+ function resolve(name, handles, what) {
265
+ if (handles.includes(name)) return name;
266
+ const matches = handles.filter((h) => h.endsWith(`:${name}`) || h.endsWith(`.${name}`));
267
+ if (matches.length === 1) return matches[0];
268
+ if (matches.length > 1) throw new Error(`${name} could be ${matches.join(" or ")}: say which`);
269
+ if (what === "searchable table") {
270
+ throw new Error(`${name} isn't a searchable table yet. Make it one first: oalo data vectorize ${name.replace(/^(model:|table:\w+\.)/, "")} --fields <field,...>`);
271
+ }
272
+ throw new Error(`${name} isn't attached to this bot`);
273
+ }
274
+
275
+ function fail(message, code) {
276
+ console.error(message);
277
+ process.exitCode = code;
278
+ }
@@ -0,0 +1,179 @@
1
+ import { OaloChannel } from "../channel.js";
2
+ import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
3
+ import { requireFeature } from "../server.js";
4
+ import { dim } from "../events.js";
5
+
6
+ /**
7
+ * `oalo call <event> [json]` — the escape hatch.
8
+ *
9
+ * The build surface is ~442 channel events. Wrapping each as its own command
10
+ * would be a large surface to maintain for little gain, and the same reasoning
11
+ * kept the MCP tool set curated rather than 1:1. So: first-class commands for
12
+ * what you do often, and this for everything else.
13
+ *
14
+ * Org and user identity always come from the token, never from the params, so a
15
+ * call can only reach what the minting user can reach.
16
+ */
17
+
18
+ /**
19
+ * What each namespace is for. The events themselves come from the server
20
+ * (`cli_api:events`, derived from its own handlers), so this only adds a
21
+ * description; a namespace missing here still lists. It is also the whole
22
+ * answer on a workspace too old to list its events.
23
+ */
24
+ const NAMESPACES = [
25
+ ["chat", "bots, flows, personas, prompts, knowledge, test messages"],
26
+ ["loops", "automations: list, get, create, update, run_now, runs, triggers"],
27
+ ["marketing", "campaigns, ads, metrics, pipelines, cut editor"],
28
+ ["kg", "knowledge graph: search, focus, roots, rebuild, reset"],
29
+ ["imports", "CSV/SQL import, schema mapping, make_syncable"],
30
+ ["widgets", "widget definitions, assignment, links"],
31
+ ["crm", "contacts, tags, segments"],
32
+ ["commerce", "products, orders, subscriptions"],
33
+ ["booking", "appointment types, availability, bookings"],
34
+ ["documents", "invoices, proposals, contracts"],
35
+ ["reviews", "review requests and responses"],
36
+ ["conversations", "threads, assignment, autopilot"],
37
+ ["inbox", "unified inbox listing and triage"],
38
+ ["whatsapp", "channels, templates, sends"],
39
+ ["instagram", "connection and messaging"],
40
+ ["email", "strategies, sends, suppression"],
41
+ ["fn", "custom WASM functions: source, config, secrets, expose"],
42
+ ["jobs", "background job snapshot, retry, cancel"],
43
+ ["git", "definition repos: history, clone, push"],
44
+ ["reporting", "summaries and customer reports"],
45
+ ["usage", "LLM spend ledger"],
46
+ ["org_members", "team and access"],
47
+ ["apple", "native build config"],
48
+ ["stripe", "billing connection"],
49
+ ];
50
+
51
+ const DESCRIBE = new Map(NAMESPACES);
52
+
53
+ /**
54
+ * `oalo call --list [text]`: the events you may call, from the server. With no
55
+ * text, a count per namespace; with text, each matching event and the param
56
+ * shapes it accepts. Falls back to the static namespaces on an older box.
57
+ */
58
+ async function list(flags, rest) {
59
+ const q = typeof flags.list === "string" ? flags.list : rest[1] || "";
60
+ const { url, token } = controlPlane(resolveConfig(flags));
61
+
62
+ let events = null;
63
+ if (url && token) {
64
+ try {
65
+ await requireFeature(url, "events", "oalo call --list");
66
+ const ch = new OaloChannel({ url, token });
67
+ try {
68
+ await ch.connect();
69
+ ({ events } = await ch.call("cli_api:events", { q }));
70
+ } finally {
71
+ ch.close();
72
+ }
73
+ } catch (e) {
74
+ console.error(dim(`${e.message}\nShowing the built-in namespace list instead.\n`));
75
+ }
76
+ }
77
+
78
+ if (!events) return printNamespaces();
79
+ if (flags.json) return void console.log(JSON.stringify(events, null, 2));
80
+
81
+ if (!events.length) {
82
+ console.log(`no events you can call match "${q}"`);
83
+ return;
84
+ }
85
+
86
+ if (q) {
87
+ for (const e of events) {
88
+ console.log(e.event);
89
+ for (const a of e.accepts) console.log(dim(` ${a}`));
90
+ }
91
+ console.log(dim(`\n${events.length} event${events.length > 1 ? "s" : ""}. Each line under an event is one param shape it accepts.`));
92
+ return;
93
+ }
94
+
95
+ const counts = new Map();
96
+ for (const e of events) {
97
+ // A few old events have no namespace (answer_question, browse_media...).
98
+ const ns = e.event.includes(":") ? e.event.split(":")[0] : "(none)";
99
+ counts.set(ns, (counts.get(ns) || 0) + 1);
100
+ }
101
+ const names = [...counts.keys()].sort();
102
+ const w = Math.max(...names.map((n) => n.length));
103
+ console.log(`${events.length} events you can call, by namespace:\n`);
104
+ for (const ns of names) console.log(` ${ns.padEnd(w)} ${String(counts.get(ns)).padStart(3)} ${DESCRIBE.get(ns) || ""}`);
105
+ console.log(`\nSee one namespace's events and params: oalo call --list loops:\nSearch: oalo call --list template`);
106
+ }
107
+
108
+ function printNamespaces() {
109
+ console.log("Event names look like <namespace>:<verb>, e.g. loops:run_now.\n");
110
+ const w = Math.max(...NAMESPACES.map(([n]) => n.length));
111
+ for (const [ns, desc] of NAMESPACES) console.log(` ${ns.padEnd(w)} ${desc}`);
112
+ console.log(
113
+ "\nParams go in as JSON. Org and user come from your token, never the params:\n" +
114
+ ` oalo call loops:run_now '{"name":"Nurture"}'\n` +
115
+ ` oalo call chat:bots:list\n` +
116
+ ` oalo call jobs:snapshot | jq .\n\n` +
117
+ "An unknown event returns a clean error naming what you sent, so guessing\n" +
118
+ "a verb is cheap. Anything the dashboard does has an event behind it.",
119
+ );
120
+ }
121
+
122
+ export async function call(flags, rest) {
123
+ if (flags.list || flags.namespaces) {
124
+ await list(flags, rest);
125
+ return;
126
+ }
127
+
128
+ const event = rest[1];
129
+ if (!event) {
130
+ console.error("usage: oalo call <event> ['<json params>']\n");
131
+ console.error("Run `oalo call --list` to see the events you can call.");
132
+ process.exitCode = 2;
133
+ return;
134
+ }
135
+
136
+ let params = {};
137
+ const raw = rest[2];
138
+ if (raw) {
139
+ try {
140
+ params = JSON.parse(raw);
141
+ } catch (e) {
142
+ console.error(`params must be valid JSON: ${e.message}`);
143
+ process.exitCode = 2;
144
+ return;
145
+ }
146
+ if (params === null || typeof params !== "object" || Array.isArray(params)) {
147
+ console.error("params must be a JSON object");
148
+ process.exitCode = 2;
149
+ return;
150
+ }
151
+ }
152
+
153
+ const cfg = resolveConfig(flags);
154
+ const { url, token } = controlPlane(cfg);
155
+
156
+ if (!url || !token) {
157
+ console.error(
158
+ `oalo call needs a CLI token.\n\n${HOW_TO_CONNECT}`,
159
+ );
160
+ process.exitCode = 2;
161
+ return;
162
+ }
163
+
164
+ const ch = new OaloChannel({ url, token });
165
+ try {
166
+ await ch.connect();
167
+ const reply = await ch.call(event, params);
168
+ console.log(JSON.stringify(reply, null, flags.compact ? 0 : 2));
169
+ } catch (e) {
170
+ // The server often names the event itself ("loops:get failed: ..."); don't say it twice.
171
+ console.error(e.message.startsWith(`${event} `) ? e.message : `${event} failed: ${e.message}`);
172
+ if (e.code === "unknown_event") {
173
+ console.error(dim("Find the right name with: oalo call --list <text>. If you're sure of it, the workspace may be older than your CLI (oalo whoami shows its version)."));
174
+ }
175
+ process.exitCode = 1;
176
+ } finally {
177
+ ch.close();
178
+ }
179
+ }
@@ -0,0 +1,110 @@
1
+ import { OaloChannel } from "../channel.js";
2
+ import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
3
+ import { dim } from "../events.js";
4
+
5
+ /**
6
+ * `oalo channels`: every channel the workspace has and which bot answers it.
7
+ *
8
+ * oalo channels list them
9
+ * oalo channels set whatsapp "+54 9 11 5555-0100" --bot bot_x which bot answers the number
10
+ * oalo channels set instagram @account --bot bot_x
11
+ * oalo channels check whatsapp <number> can Oalo send on it, and why not
12
+ * oalo channels connect whatsapp --bot bot_x a link to connect a number in the browser
13
+ * oalo channels connect instagram
14
+ * oalo channels disconnect whatsapp|instagram <id> --yes
15
+ *
16
+ * WhatsApp and Instagram are kept by the platform, where Meta's messages
17
+ * arrive; the Base forwards these to it. Email is the sending address (no
18
+ * bot answers it), and the web chat is each published bot's own widget.
19
+ */
20
+ export async function channels(flags, rest, opts = {}) {
21
+ const sub = rest[1] || "list";
22
+ const channel = rest[2];
23
+ const ref = rest[3];
24
+ if (!["list", "set", "check", "connect", "disconnect"].includes(sub)) return fail(`unknown channels command: ${sub}\n\n${USAGE}`, 2);
25
+ if (sub !== "list" && !["whatsapp", "instagram"].includes(channel)) return fail(`which channel? whatsapp or instagram\n\n${USAGE}`, 2);
26
+ if (sub === "check" && channel !== "whatsapp") return fail("check is for a WhatsApp number", 2);
27
+ if (["set", "check", "disconnect"].includes(sub) && !ref) return fail(`which ${channel === "whatsapp" ? "number" : "account"}? \`oalo channels\` lists them\n\n${USAGE}`, 2);
28
+ if (flags.bot === true) return fail("--bot needs a bot's public id (oalo bots lists them)", 2);
29
+ if (sub === "set" && !flags.bot) return fail("which bot? --bot <public id> (oalo bots lists them)", 2);
30
+ if (sub === "connect" && channel === "whatsapp" && !flags.bot) return fail("which bot answers the new number? --bot <public id>", 2);
31
+ if (sub === "disconnect" && !flags.yes) {
32
+ return fail(`disconnecting ${ref} stops the bot answering on it at once; its conversations stay. Run it again with --yes to go ahead.`, 2);
33
+ }
34
+
35
+ const cfg = resolveConfig(flags);
36
+ const { url, token } = controlPlane(cfg);
37
+ if (!url || !token) return fail(`oalo channels needs a CLI token.\n\n${HOW_TO_CONNECT}`, 2);
38
+
39
+ const ch = (opts.channel || ((o) => new OaloChannel(o)))({ url, token });
40
+ let r;
41
+ try {
42
+ await ch.connect();
43
+ const params = { list: {}, set: { channel, id: ref, bot: flags.bot }, check: { id: ref }, connect: { channel, bot: flags.bot }, disconnect: { channel, id: ref } }[sub];
44
+ r = await ch.call(`channels:${sub === "set" ? "set_bot" : sub}`, params, sub === "check" ? 30000 : 20000);
45
+ } catch (e) {
46
+ return fail(e.message, 1);
47
+ } finally {
48
+ ch.close();
49
+ }
50
+ if (flags.json) return void console.log(JSON.stringify(r, null, 2));
51
+ print(sub, channel, ref, r);
52
+ }
53
+
54
+ const USAGE = [
55
+ "usage: oalo channels",
56
+ ' oalo channels set whatsapp|instagram <number or @account> --bot <bot>',
57
+ " oalo channels check whatsapp <number>",
58
+ " oalo channels connect whatsapp --bot <bot> | connect instagram",
59
+ " oalo channels disconnect whatsapp|instagram <number or @account> --yes",
60
+ ].join("\n");
61
+
62
+ const botText = (b) => (b ? `${b.name}${b.bot ? ` (${b.bot})` : ""}` : "no bot");
63
+
64
+ function print(sub, channel, ref, r) {
65
+ if (sub === "list") {
66
+ const section = (title, s, line) => {
67
+ console.log(title);
68
+ if (s?.error) console.log(dim(` ${s.error}`));
69
+ else if (!s?.items?.length) console.log(dim(" none connected"));
70
+ else for (const i of s.items) console.log(` ${line(i)}`);
71
+ };
72
+ section("WhatsApp", r.whatsapp, (i) => `${i.number} ${dim(`${i.name || ""}, answered by ${botText(i.bot)}${i.sending && i.sending !== "ok" ? `, sending: ${i.sending}` : ""}`)}`);
73
+ section("Instagram", r.instagram, (i) => `@${i.username} ${dim(`answered by ${botText(i.bot)}`)}`);
74
+ console.log("Email");
75
+ console.log(
76
+ r.email
77
+ ? ` ${r.email.from} ${dim(`${r.email.domain || ""}, ${r.email.verified === "verified" ? "verified" : `domain ${r.email.verified || "not verified"}`}${r.email.paused ? ", paused" : ""}; sends, no bot answers it`)}`
78
+ : dim(" no sending address yet"),
79
+ );
80
+ console.log("Web chat");
81
+ if (!r.web?.length) console.log(dim(" no published bot"));
82
+ for (const w of r.web || []) console.log(` ${w.name} ${dim(`${w.bot}'s own widget`)}`);
83
+ return;
84
+ }
85
+ if (sub === "set") return void console.log(`${channel === "whatsapp" ? r.number : `@${r.username}`} is answered by ${botText(r.bot)} now.`);
86
+ if (sub === "disconnect") return void console.log(`${channel === "whatsapp" ? r.number : `@${r.username}`} disconnected. Its conversations stay in the Inbox.`);
87
+ if (sub === "connect") {
88
+ console.log(`Open this in a browser to connect ${channel === "whatsapp" ? "a WhatsApp number" : "an Instagram account"} (Meta's sign-in runs there):`);
89
+ console.log(r.url);
90
+ if (channel === "whatsapp") console.log(dim(`The link works for a few minutes and only for you. After, oalo channels shows the number.`));
91
+ else console.log(dim(`After, choose its bot: oalo channels set instagram @<account> --bot <bot>.`));
92
+ return;
93
+ }
94
+ if (sub === "check") {
95
+ const status = r.channel?.send_status;
96
+ console.log(`${r.number}: ${status === "ok" ? "Oalo can send on it." : `Oalo can't send on it yet (${status || "unknown"}).`}`);
97
+ const d = r.diagnosis || {};
98
+ if (d.error) console.log(dim(` ${d.error}`));
99
+ for (const f of d.findings || []) {
100
+ if (f.level === "ok") continue;
101
+ console.log(` ${f.level === "blocker" ? "!" : "-"} ${f.text}${f.side === "meta" ? dim(" (on Meta's side)") : ""}`);
102
+ }
103
+ if (status && status !== "ok") process.exitCode = 1;
104
+ }
105
+ }
106
+
107
+ function fail(message, code) {
108
+ console.error(message);
109
+ process.exitCode = code;
110
+ }