@oalo-ai/cli 0.0.0-stage → 0.1.1

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,150 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { writeConfig, writeToken, readToken, ensureGitignored, resolve, credentialsPath } from "../config.js";
3
+ import { fetchManifest, ApiError } from "../api.js";
4
+ import { OaloChannel } from "../channel.js";
5
+ import { installSkill, installedSkillVersion } from "./skill.js";
6
+
7
+ export async function link(flags) {
8
+ if (flags.status) return status(flags);
9
+
10
+ const serverUrl = (flags.server || process.env.OALO_URL || "").replace(/\/$/, "");
11
+ // After `oalo login`, the workspace's saved data token is used, so linking a
12
+ // project needs only --server.
13
+ const token = flags.token || process.env.OALO_SYNC_TOKEN || (serverUrl && readToken({ serverUrl, appId: null }, "sync"));
14
+
15
+ if (!serverUrl || !token) {
16
+ console.error(
17
+ "usage: oalo link --server https://<workspace>.oalo.ai [--token <sync token>] [--app <id>] [--env dev|prod]\n\n" +
18
+ (serverUrl
19
+ ? `No saved token for ${serverUrl}. Run \`oalo login --server ${serverUrl}\` first, or pass --token.`
20
+ : "Run `oalo login --server <url>` first, or create a connection in the dashboard under\nAccount -> CLI & agents and run the oalo link command it shows."),
21
+ );
22
+ process.exitCode = 2;
23
+ return;
24
+ }
25
+
26
+ const env = flags.env || "dev";
27
+ if (!["dev", "prod"].includes(env)) {
28
+ console.error(`--env must be dev or prod (got ${env})`);
29
+ process.exitCode = 2;
30
+ return;
31
+ }
32
+
33
+ const config = { serverUrl: serverUrl.replace(/\/$/, ""), appId: flags.app || null, env };
34
+
35
+ // Verify BEFORE saving anything. A link that reports success and then 403s on
36
+ // first real use is the worst outcome: the developer has no reason to suspect
37
+ // the credential and goes looking for the bug in their own code.
38
+ let manifest;
39
+ try {
40
+ manifest = await fetchManifest({ ...config, token });
41
+ } catch (e) {
42
+ console.error(`link failed: ${e.message}`);
43
+ if (e instanceof ApiError && e.status === 403) {
44
+ console.error("\nThe token was accepted but lacks read scope. Mint one with read access.");
45
+ }
46
+ if (e instanceof ApiError && e.status === 401) {
47
+ console.error("\nThe token was rejected. It may be revoked, expired, or for another workspace.");
48
+ }
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+
53
+ const models = Object.keys(manifest?.models || {});
54
+ config.schemaVersion = manifest?.schema_version ?? null;
55
+
56
+ // A CLI token is optional and a DIFFERENT kind (control plane, for `oalo
57
+ // mcp`). Verify it the same way rather than storing it hopefully: with two
58
+ // token kinds in play, pasting one where the other belongs is now the most
59
+ // likely mistake, and it should be caught here rather than surfacing later as
60
+ // a confusing channel auth failure.
61
+ const cliToken = flags["cli-token"] || null;
62
+ let cliOk = null;
63
+
64
+ if (cliToken) {
65
+ const ch = new OaloChannel({ url: config.serverUrl, token: cliToken, timeoutMs: 15000 });
66
+ try {
67
+ await ch.connect();
68
+ cliOk = true;
69
+ } catch (e) {
70
+ cliOk = false;
71
+ console.error(`the --cli-token was rejected: ${e.message}`);
72
+ console.error(
73
+ "\nThat token could not join the build surface. CLI tokens come from\n" +
74
+ "Account -> CLI & agents in the dashboard, and are not the same as an app's sync token.",
75
+ );
76
+ } finally {
77
+ ch.close();
78
+ }
79
+
80
+ if (!cliOk) {
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+ }
85
+
86
+ writeConfig(config);
87
+ const credPath = writeToken(config, token, "sync");
88
+ if (cliToken) writeToken(config, cliToken, "cli");
89
+ const addedRule = ensureGitignored();
90
+
91
+ console.log(`linked to ${config.serverUrl}`);
92
+ console.log(` env ${config.env}`);
93
+ if (config.appId) console.log(` app ${config.appId}`);
94
+ console.log(` schema version ${config.schemaVersion}`);
95
+ console.log(` models visible ${models.length}`);
96
+ if (cliToken) console.log(` cli token verified (oalo mcp is ready)`);
97
+ console.log(`\n config .oalo/config.json (safe to commit)`);
98
+ console.log(` token ${credPath} (never committed)`);
99
+ if (addedRule) console.log(`\n added .oalo/credentials* to .gitignore`);
100
+
101
+ // The coding agent in this project learns Oalo from the skill, the one that
102
+ // matches this CLI. --no-skill leaves the project alone.
103
+ if (!flags["no-skill"]) {
104
+ try {
105
+ const had = installedSkillVersion();
106
+ const r = installSkill();
107
+ console.log(`\n ${had ? "refreshed" : "installed"} the Oalo skill for coding agents: ${r.path}${r.agents ? ` (and AGENTS.md)` : ""}`);
108
+ } catch (e) {
109
+ console.error(`\n couldn't install the Oalo skill (${e.message}); \`oalo skill\` tries again`);
110
+ }
111
+ }
112
+ // A Flutter app from Oalo's template takes Dart, not TypeScript.
113
+ const flutter = existsSync("pubspec.yaml") && /^\s*flutter:/m.test(readFileSync("pubspec.yaml", "utf8"));
114
+ console.log(flutter ? `\nNext: oalo typegen --dart` : `\nNext: oalo typegen`);
115
+ }
116
+
117
+ async function status(flags) {
118
+ const cfg = resolve(flags);
119
+
120
+ if (!cfg.serverUrl) {
121
+ console.log("not linked. run: oalo link --server <url> --token <token>");
122
+ process.exitCode = 1;
123
+ return;
124
+ }
125
+
126
+ console.log(`workspace ${cfg.serverUrl}`);
127
+ console.log(`env ${cfg.env}`);
128
+ console.log(`app ${cfg.appId || "(none)"}`);
129
+ console.log(`sync token ${cfg.token ? "present" : "MISSING"} (${credentialsPath()})`);
130
+ console.log(`cli token ${cfg.cliToken ? "present" : "absent (oalo mcp needs one)"}`);
131
+
132
+ if (!cfg.token) {
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+
137
+ try {
138
+ const m = await fetchManifest(cfg);
139
+ const models = Object.keys(m?.models || {}).length;
140
+ console.log(`reachable yes — schema version ${m?.schema_version}, ${models} models`);
141
+ if (cfg.schemaVersion != null && m?.schema_version !== cfg.schemaVersion) {
142
+ console.log(
143
+ `\nschema moved since link (${cfg.schemaVersion} -> ${m.schema_version}). re-run oalo typegen.`,
144
+ );
145
+ }
146
+ } catch (e) {
147
+ console.log(`reachable NO — ${e.message}`);
148
+ process.exitCode = 1;
149
+ }
150
+ }
@@ -0,0 +1,180 @@
1
+ import { hostname } from "node:os";
2
+ import { spawn } from "node:child_process";
3
+ import { readConfig, writeToken, readEntry, forgetWorkspace, credentialsPath } from "../config.js";
4
+ import { OaloChannel } from "../channel.js";
5
+ import { dim } from "../events.js";
6
+
7
+ /**
8
+ * `oalo login`: connect this machine to a workspace without copying tokens.
9
+ *
10
+ * oalo login acme [--name "work laptop"] [--no-browser] the workspace acme.oalo.ai
11
+ * oalo login --server https://acme.oalo.ai any address, a custom domain too
12
+ *
13
+ * The workspace hands out a short code and the CLI opens the dashboard on it
14
+ * (Account -> CLI & agents). Once the person approves, the next poll returns a
15
+ * CLI token and a read-only data token, which are saved to the user-level
16
+ * credential store (never the project). Every command, `oalo mcp` included,
17
+ * then finds them by workspace URL.
18
+ *
19
+ * oalo logout [--server <url>] revoke this machine's connection and forget it
20
+ */
21
+
22
+ // A bare name is the workspace's own address: `oalo login acme` is
23
+ // https://acme.oalo.ai, what the signup's last screen shows.
24
+ function serverFrom(flags, rest = []) {
25
+ const cfg = readConfig() || {};
26
+ const arg = rest[1];
27
+ const fromArg = !arg ? "" : /^[a-z0-9][a-z0-9-]{0,62}$/i.test(arg) ? `https://${arg.toLowerCase()}.oalo.ai` : /^https?:\/\//.test(arg) ? arg : "";
28
+ return (flags.server || fromArg || process.env.OALO_URL || cfg.serverUrl || "").replace(/\/$/, "");
29
+ }
30
+
31
+ async function post(url, body) {
32
+ const res = await fetch(url, {
33
+ method: "POST",
34
+ headers: { "content-type": "application/json" },
35
+ body: JSON.stringify(body),
36
+ });
37
+ let json = null;
38
+ try {
39
+ json = await res.json();
40
+ } catch {
41
+ /* a proxy error page, say */
42
+ }
43
+ return { status: res.status, json };
44
+ }
45
+
46
+ function openBrowser(url) {
47
+ const [cmd, args] =
48
+ process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
49
+ try {
50
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
51
+ child.on("error", () => {});
52
+ child.unref();
53
+ } catch {
54
+ /* no browser here; the URL is printed anyway */
55
+ }
56
+ }
57
+
58
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
59
+
60
+ export async function login(flags, rest = []) {
61
+ const serverUrl = serverFrom(flags, rest);
62
+ if (!serverUrl) {
63
+ console.error('usage: oalo login <workspace> (your address is <workspace>.oalo.ai)\n oalo login --server <url> [--name "<this machine>"] [--no-browser]');
64
+ process.exitCode = 2;
65
+ return;
66
+ }
67
+
68
+ const name = typeof flags.name === "string" && flags.name.trim() ? flags.name.trim() : hostname();
69
+
70
+ let start;
71
+ try {
72
+ start = await post(`${serverUrl}/api/cli/login/start`, { name });
73
+ } catch (e) {
74
+ console.error(`login failed: could not reach ${serverUrl} (${e.message})`);
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+
79
+ if (start.status === 404) {
80
+ console.error(
81
+ `login failed: ${serverUrl} doesn't support \`oalo login\` yet.\n\n` +
82
+ "Create a connection in the dashboard under Account -> CLI & agents and run the oalo link command it shows.",
83
+ );
84
+ process.exitCode = 1;
85
+ return;
86
+ }
87
+ if (start.status !== 200 || !start.json?.device_code) {
88
+ console.error(`login failed: ${start.json?.error || `HTTP ${start.status}`}`);
89
+ process.exitCode = 1;
90
+ return;
91
+ }
92
+
93
+ const { user_code: code, device_code: device, verify_path: path, expires_in: ttl, interval } = start.json;
94
+ const verifyUrl = `${serverUrl}${path}`;
95
+
96
+ console.log(`Approve this login in your browser:\n\n ${verifyUrl}\n`);
97
+ console.log(`Your code: ${code} (the page shows the same code; approve only if it matches)`);
98
+ if (!flags["no-browser"]) openBrowser(verifyUrl);
99
+ console.error(dim(`waiting for approval, up to ${Math.round(ttl / 60)} minutes (ctrl-c to stop)`));
100
+
101
+ const deadline = Date.now() + ttl * 1000;
102
+ const every = Math.max(1, Number(interval) || 2) * 1000;
103
+
104
+ while (Date.now() < deadline) {
105
+ await sleep(every);
106
+
107
+ let poll;
108
+ try {
109
+ poll = await post(`${serverUrl}/api/cli/login/poll`, { device_code: device });
110
+ } catch {
111
+ continue; // a dropped request while waiting is not the end of the login
112
+ }
113
+
114
+ const status = poll.json?.status;
115
+ if (status === "pending") continue;
116
+
117
+ if (status === "approved") {
118
+ const config = { serverUrl, appId: null };
119
+ const path = writeToken(config, poll.json.cli_token, "cli", { connection_id: poll.json.connection_id, name: poll.json.name });
120
+ if (poll.json.sync_token) writeToken(config, poll.json.sync_token, "sync");
121
+
122
+ console.log(`\nConnected "${poll.json.name}" to ${serverUrl}.`);
123
+ console.log(` credentials ${path} (not in any project)`);
124
+ if (!poll.json.sync_token) console.log(" data token none: your access doesn't include the Sync API, so typegen and tail won't work");
125
+ console.log("\nNext: oalo pull (or oalo link --server ... in a project, then oalo typegen)");
126
+ return;
127
+ }
128
+
129
+ if (status === "denied") {
130
+ console.error("\nlogin denied in the dashboard. Nothing was saved.");
131
+ } else if (status === "expired") {
132
+ console.error("\nthe code expired before it was approved. Run oalo login again.");
133
+ } else {
134
+ console.error(`\nlogin failed: ${poll.json?.error || `HTTP ${poll.status}`}`);
135
+ }
136
+ process.exitCode = 1;
137
+ return;
138
+ }
139
+
140
+ console.error("\nthe code expired before it was approved. Run oalo login again.");
141
+ process.exitCode = 1;
142
+ }
143
+
144
+ export async function logout(flags) {
145
+ const serverUrl = serverFrom(flags);
146
+ if (!serverUrl) {
147
+ console.error("usage: oalo logout [--server https://<workspace>.oalo.ai]");
148
+ process.exitCode = 2;
149
+ return;
150
+ }
151
+
152
+ // Revoke on the server first, so a copied credentials file stops working
153
+ // too. Only a connection made by `oalo login` knows its own id.
154
+ const entry = readEntry(serverUrl);
155
+ const cli = entry?.cli;
156
+ let revoked = false;
157
+ if (cli?.token && cli.connection_id) {
158
+ const ch = new OaloChannel({ url: serverUrl, token: cli.token, timeoutMs: 15000 });
159
+ try {
160
+ await ch.connect();
161
+ // The write token `oalo data` made lives on its own, so revoke it too.
162
+ if (entry?.sync_write?.id) {
163
+ await ch.call("sync_api:revoke_token", { id: entry.sync_write.id }).catch(() => {});
164
+ }
165
+ await ch.call("cli_api:revoke_token", { id: cli.connection_id });
166
+ revoked = true;
167
+ } catch (e) {
168
+ console.error(dim(`could not revoke on the server (${e.message}); revoke it under Account -> CLI & agents`));
169
+ } finally {
170
+ ch.close();
171
+ }
172
+ }
173
+
174
+ const n = forgetWorkspace(serverUrl);
175
+ if (n === 0) {
176
+ console.log(`nothing saved for ${serverUrl}`);
177
+ return;
178
+ }
179
+ console.log(`${revoked ? "revoked this machine's connection and " : ""}removed ${serverUrl} from ${credentialsPath()}`);
180
+ }
@@ -0,0 +1,69 @@
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 logs`: what an app's own processes printed (its server, its workers,
8
+ * its release command), per env.
9
+ *
10
+ * oalo logs [--app <slug>] [--env dev|prod] [--process web|<worker>] [--limit N] [--follow] [--json]
11
+ *
12
+ * The workspace keeps the last 2,000 lines of each env in memory, so a restart
13
+ * of the workspace empties them. `oalo` lines are Oalo's own notes: starts,
14
+ * exits and their status.
15
+ */
16
+ export async function logs(flags, rest, opts = {}) {
17
+ const cfg = resolveConfig(flags);
18
+ const { url, token } = controlPlane(cfg);
19
+ if (!url || !token) return fail(`oalo logs needs a CLI token.\n\n${HOW_TO_CONNECT}`, 2);
20
+
21
+ const app = typeof flags.app === "string" ? flags.app : cfg.appId;
22
+ if (!app) return fail("which app? Pass --app <slug> (`oalo apps` lists them), or save it with `oalo link --app <slug>`.", 2);
23
+ const env = typeof flags.env === "string" ? flags.env : cfg.env || "dev";
24
+ if (!["dev", "prod"].includes(env)) return fail(`--env must be dev or prod (got ${env})`, 2);
25
+ const process_ = typeof flags.process === "string" ? flags.process : null;
26
+
27
+ if (!opts.channel) {
28
+ try {
29
+ await requireFeature(url, "logs", "oalo logs");
30
+ } catch (e) {
31
+ return fail(e.message, 1);
32
+ }
33
+ }
34
+
35
+ const ch = (opts.channel || ((o) => new OaloChannel(o)))({ url, token });
36
+ try {
37
+ await ch.connect();
38
+ const r = await ch.call("logs:get", { app, env, limit: Number(flags.limit) || 200, ...(process_ ? { process: process_ } : {}) });
39
+ print(r.lines || [], flags);
40
+ if (!flags.follow) {
41
+ if (!r.lines?.length) console.error(dim(`no output from ${r.app} (${r.env}) yet`));
42
+ return;
43
+ }
44
+
45
+ ch.onEvent((event, payload) => {
46
+ if (event !== "app_log") return;
47
+ print((payload?.lines || []).filter((l) => !process_ || l.process === process_), flags);
48
+ });
49
+ await ch.call("logs:follow", { app, env });
50
+ console.error(dim(`following ${r.app} (${r.env}); Ctrl-C to stop`));
51
+ await (opts.until || new Promise((resolve) => globalThis.process.once("SIGINT", resolve)));
52
+ } catch (e) {
53
+ fail(e.message, 1);
54
+ } finally {
55
+ ch.close();
56
+ }
57
+ }
58
+
59
+ function print(lines, flags) {
60
+ for (const l of lines) {
61
+ if (flags.json) console.log(JSON.stringify(l));
62
+ else console.log(`${dim(`${String(l.at || "").slice(11, 19)} ${l.process.padEnd(6)}`)} ${l.line}`);
63
+ }
64
+ }
65
+
66
+ function fail(message, code) {
67
+ console.error(message);
68
+ process.exitCode = code;
69
+ }
@@ -0,0 +1,125 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ } from "@modelcontextprotocol/sdk/types.js";
7
+
8
+ import { OaloChannel } from "../channel.js";
9
+ import { TOOLS, BY_NAME } from "../mcp/tools.js";
10
+ import { resolve as resolveConfig, controlPlane, HOW_TO_CONNECT } from "../config.js";
11
+ import { CLI_VERSION } from "../server.js";
12
+
13
+ /**
14
+ * `oalo mcp` — serve this workspace's build surface over MCP so Claude Code,
15
+ * Cursor, or any MCP client can drive it.
16
+ *
17
+ * NOTHING may be written to stdout except MCP protocol traffic; stdout IS the
18
+ * transport. All diagnostics go to stderr, including the Phoenix client's own
19
+ * logger (see channel.js).
20
+ */
21
+ export async function mcp(flags) {
22
+ const cfg = resolveConfig(flags);
23
+
24
+ // A CLI token is the CONTROL-plane credential. Falling back to whatever
25
+ // `oalo link --cli-token` saved means one link configures both, but a SYNC
26
+ // token must never be substituted here: it would fail to authenticate and the
27
+ // error would point at the wrong thing.
28
+ const { url, token } = controlPlane(cfg);
29
+
30
+ if (!url || !token) {
31
+ process.stderr.write(
32
+ `oalo mcp: no workspace or CLI token.\n\n${HOW_TO_CONNECT}\n\nOr set OALO_URL and OALO_CLI_TOKEN.\n`,
33
+ );
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+
38
+ const oalo = new OaloChannel({ url, token });
39
+
40
+ // Connect lazily: a client may list tools without calling one, and a
41
+ // temporarily unreachable workspace should not stop the server from starting.
42
+ let connected = null;
43
+ const ensureConnected = () => (connected ||= oalo.connect());
44
+
45
+ const server = new Server({ name: "oalo", version: CLI_VERSION }, { capabilities: { tools: {} } });
46
+
47
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
48
+ tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
49
+ }));
50
+
51
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
52
+ const spec = BY_NAME[req.params.name];
53
+ if (!spec) throw new Error(`unknown tool: ${req.params.name}`);
54
+ const args = req.params.arguments || {};
55
+
56
+ try {
57
+ await ensureConnected();
58
+ let result;
59
+
60
+ if (spec.composite) {
61
+ result = await describe(oalo, url);
62
+ } else if (spec.raw) {
63
+ if (!args.event) throw new Error("event is required");
64
+ result = await oalo.call(args.event, args.params || {});
65
+ } else if (spec.run) {
66
+ result = await spec.run(oalo, args, { cfg, url });
67
+ } else if (spec.route) {
68
+ const { event, params } = spec.route(args);
69
+ // The tool's own wait applies here too: a rollback builds for minutes.
70
+ result = spec.timeoutMs ? await oalo.call(event, params, spec.timeoutMs(args)) : await oalo.call(event, params);
71
+ } else {
72
+ const params = { ...(spec.defaults || {}), ...args };
73
+ result = spec.timeoutMs
74
+ ? await oalo.call(spec.event, params, spec.timeoutMs(args))
75
+ : await oalo.call(spec.event, params);
76
+ }
77
+
78
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
79
+ } catch (err) {
80
+ // Surface failures as tool content, not protocol errors, so the agent can
81
+ // read the reason and correct itself instead of the call just blowing up.
82
+ return {
83
+ isError: true,
84
+ content: [
85
+ {
86
+ type: "text",
87
+ text:
88
+ `${req.params.name} failed: ${err.message}` +
89
+ (spec.timeoutHint && /timed out/.test(err.message) ? `. ${spec.timeoutHint}` : "") +
90
+ (err.code === "unknown_event"
91
+ ? ". Find the right event with oalo_call event cli_api:events {\"q\": \"<text>\"}; if that is unknown too, the workspace is older than this CLI."
92
+ : ""),
93
+ },
94
+ ],
95
+ };
96
+ }
97
+ });
98
+
99
+ process.on("SIGINT", () => {
100
+ oalo.close();
101
+ process.exit(0);
102
+ });
103
+
104
+ await server.connect(new StdioServerTransport());
105
+ process.stderr.write(`oalo mcp: ready (${url})\n`);
106
+ }
107
+
108
+ /** One round trip for orientation, so an agent does not burn three tool calls. */
109
+ async function describe(oalo, url) {
110
+ const settle = async (p) => {
111
+ try {
112
+ return await p;
113
+ } catch (err) {
114
+ return { error: err.message };
115
+ }
116
+ };
117
+
118
+ const [apps, bots, loops] = await Promise.all([
119
+ settle(oalo.call("list_apps", {})),
120
+ settle(oalo.call("chat:bots:list", {})),
121
+ settle(oalo.call("loops:list_for_org", {})),
122
+ ]);
123
+
124
+ return { workspace: url, apps, bots, loops };
125
+ }