@botbuddy/cli 1.2.3 → 1.4.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.
- package/bin/botbuddy.mjs +5 -1
- package/package.json +1 -1
- package/src/agent-credential-store.mjs +208 -0
- package/src/api.mjs +39 -0
- package/src/auth.mjs +169 -70
- package/src/auth.test.mjs +404 -0
- package/src/codex-bridge.mjs +2 -1
- package/src/commands.mjs +206 -30
- package/src/config.mjs +5 -1
- package/src/discovery.mjs +141 -0
- package/src/discovery.test.mjs +195 -0
- package/src/locks.mjs +154 -0
- package/src/locks.test.mjs +60 -0
- package/src/oauth-loopback.mjs +228 -0
- package/src/profile-bootstrap.mjs +104 -0
- package/src/profile-bootstrap.test.mjs +205 -0
- package/src/publish-equal.mjs +207 -0
- package/src/publish-equal.test.mjs +176 -0
- package/src/publish-workflow.test.mjs +122 -0
- package/src/quiet-runner.mjs +134 -0
- package/src/quiet-runner.test.mjs +109 -0
- package/src/run.mjs +239 -0
- package/src/run.test.mjs +173 -0
- package/src/stack.mjs +572 -0
- package/src/stack.test.mjs +196 -0
- package/src/wait-core.mjs +1266 -0
- package/src/wait-profile.mjs +84 -0
- package/src/wait-profile.test.mjs +30 -0
- package/src/wait.mjs +727 -0
- package/src/wait.test.mjs +266 -0
package/src/commands.mjs
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { callTool, readResource } from "./api.mjs";
|
|
1
|
+
import { callTool, callToolJson, readResource } from "./api.mjs";
|
|
2
2
|
import { doLogin } from "./auth.mjs";
|
|
3
3
|
import { runBridge } from "./codex-bridge.mjs";
|
|
4
|
-
import { loadConfig, getConfig, clearConfig, saveConfig, getConfigPath } from "./config.mjs";
|
|
4
|
+
import { loadConfig, getConfig, clearConfig, saveConfig, getConfigPath, SERVER_URL } from "./config.mjs";
|
|
5
|
+
import { buildAcquireResourcesPayload, LocksUsageError } from "./locks.mjs";
|
|
6
|
+
import { CallUsageError, discoveryUrlFor, formatDiscovery, parseCallArgs } from "./discovery.mjs";
|
|
7
|
+
import { cmdStack } from "./stack.mjs";
|
|
8
|
+
import { cmdRun } from "./run.mjs";
|
|
9
|
+
import { runWait } from "./wait.mjs";
|
|
5
10
|
import { green, red, cyan, dim, bold, die } from "./utils.mjs";
|
|
6
11
|
import { VERSION } from "./version.mjs";
|
|
12
|
+
import { bootstrapProfile, ProfileBootstrapError, profileShellRefresh } from "./profile-bootstrap.mjs";
|
|
7
13
|
|
|
8
14
|
export async function run(argv) {
|
|
9
15
|
loadConfig();
|
|
@@ -11,7 +17,7 @@ export async function run(argv) {
|
|
|
11
17
|
|
|
12
18
|
switch (command) {
|
|
13
19
|
case "start": return cmdStart(args);
|
|
14
|
-
case "login": return
|
|
20
|
+
case "login": return cmdLogin(args);
|
|
15
21
|
case "logout": return cmdLogout();
|
|
16
22
|
case "status": return cmdStatus();
|
|
17
23
|
// Agent-only commands (used by MCP agents, not humans)
|
|
@@ -20,6 +26,11 @@ export async function run(argv) {
|
|
|
20
26
|
case "lock": return cmdLock(args);
|
|
21
27
|
case "locks": return cmdLocks(args);
|
|
22
28
|
case "unlock": return cmdUnlock(args);
|
|
29
|
+
// BOT-1220: batch-scoped local stack leases (request/park/status/touch/done).
|
|
30
|
+
case "stack": return cmdStack(args);
|
|
31
|
+
case "run": return cmdRun(args);
|
|
32
|
+
case "wait": return runWait(args);
|
|
33
|
+
case "profile": return cmdProfile(args);
|
|
23
34
|
case "resources": return callTool("list_resources");
|
|
24
35
|
case "agents": return callTool("list_agents");
|
|
25
36
|
case "tasks": return readResource("botbuddy://tasks");
|
|
@@ -27,9 +38,16 @@ export async function run(argv) {
|
|
|
27
38
|
case "hours": return cmdHours(args);
|
|
28
39
|
case "browse": return cmdBrowse(args);
|
|
29
40
|
case "codex": return cmdCodex(args);
|
|
41
|
+
// BOT-876: generic passthrough. The server advertises only `help` and
|
|
42
|
+
// `call`; these two commands reach every tool without the CLI carrying a
|
|
43
|
+
// single tool schema, so it stays at parity with the server for free.
|
|
44
|
+
case "call": return cmdCall(args);
|
|
30
45
|
case "version": case "--version": case "-v":
|
|
31
46
|
console.log(`botbuddy v${VERSION}`); return;
|
|
32
47
|
case "help": case "--help": case "-h": case undefined:
|
|
48
|
+
// `botbuddy help` with a tool name (or --tools) fetches the live
|
|
49
|
+
// catalog; bare `help` stays the local usage screen.
|
|
50
|
+
if (args.length > 0) return cmdToolHelp(args);
|
|
33
51
|
return cmdHelp();
|
|
34
52
|
default:
|
|
35
53
|
die(`Unknown command: ${command}. Run ${cyan("botbuddy help")} for usage.`);
|
|
@@ -50,26 +68,127 @@ ${bold("OPTIONS")}
|
|
|
50
68
|
--no-server Don't auto-start codex app-server
|
|
51
69
|
|
|
52
70
|
${bold("AUTH")}
|
|
53
|
-
login
|
|
71
|
+
login [--no-browser] Authenticate via OAuth (opens browser + localhost callback)
|
|
54
72
|
logout Remove saved credentials
|
|
55
73
|
status Show current auth status
|
|
74
|
+
profile setup <profile> Mint/reconnect and securely store a tenant-bound agent key
|
|
75
|
+
|
|
76
|
+
${bold("TOOLS")}
|
|
77
|
+
help --tools List every BotBuddy tool
|
|
78
|
+
help <tool> Show one tool's arguments
|
|
79
|
+
call <tool> [--key value] Invoke any tool
|
|
80
|
+
call <tool> --json '{...}' Invoke with a JSON arguments object
|
|
81
|
+
|
|
82
|
+
${bold("STACK LEASES")}
|
|
83
|
+
stack up [options] Request a batch-scoped local stack lease; park if full; hold it active
|
|
84
|
+
stack status <lease_id> Show a lease's state + connection
|
|
85
|
+
stack touch <lease_id> Bump the lease idle clock
|
|
86
|
+
stack done <lease_id> Release (done) a lease — reap the stack
|
|
87
|
+
stack help Full stack usage + exit codes
|
|
88
|
+
|
|
89
|
+
${bold("DURABLE WORKLOADS")}
|
|
90
|
+
run --session-id <id> --environment <env> -- <command>
|
|
91
|
+
Launch a receipt-bearing command under a detached owner
|
|
92
|
+
|
|
93
|
+
${bold("AGENT WAITS")}
|
|
94
|
+
wait [--any] <condition>... [options]
|
|
95
|
+
Wait once for a pushed BotBuddy signal
|
|
56
96
|
|
|
57
97
|
${bold("OTHER")}
|
|
98
|
+
locks -m [--host name] Reserve typed local resources, including Playwright MCP lanes
|
|
58
99
|
help Show this help
|
|
59
100
|
version Show version`);
|
|
60
101
|
}
|
|
61
102
|
|
|
103
|
+
// ─── BOT-876: generic tool access ───────────────────────────────
|
|
104
|
+
|
|
105
|
+
async function cmdCall(args) {
|
|
106
|
+
let parsedArgs;
|
|
107
|
+
try {
|
|
108
|
+
parsedArgs = parseCallArgs(args);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (e instanceof CallUsageError) die(e.message);
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
return callTool(parsedArgs.tool, parsedArgs.args);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function cmdToolHelp(args) {
|
|
117
|
+
// `help --tools` (or `help --all`) lists the catalog; `help <tool>` details one.
|
|
118
|
+
const tool = args[0].startsWith("-") ? null : args[0];
|
|
119
|
+
const res = await fetch(discoveryUrlFor(SERVER_URL, tool));
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
die(res.status === 404
|
|
122
|
+
? `Unknown tool: ${tool}. Run ${cyan("botbuddy help --tools")} to list them.`
|
|
123
|
+
: `Discovery failed: ${res.status} ${res.statusText}`);
|
|
124
|
+
}
|
|
125
|
+
console.log(formatDiscovery(await res.json(), { tool }));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// BOT-1383: login now runs the RFC 8252 loopback flow (opens the browser,
|
|
129
|
+
// waits for the localhost callback). Parse --no-browser and print focused help.
|
|
130
|
+
async function cmdLogin(args) {
|
|
131
|
+
if (args.includes("--help") || args.includes("-h")) return loginHelp();
|
|
132
|
+
const noBrowser = args.includes("--no-browser");
|
|
133
|
+
const unknown = args.find((a) => a.startsWith("-") && !["--no-browser", "--help", "-h"].includes(a));
|
|
134
|
+
if (unknown) die(`Unknown login option: ${unknown}. Run ${cyan("botbuddy login --help")}.`);
|
|
135
|
+
try {
|
|
136
|
+
await doLogin({ noBrowser });
|
|
137
|
+
} catch (err) {
|
|
138
|
+
die(err.message);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function loginHelp() {
|
|
143
|
+
console.log(`${bold("botbuddy login")} — authenticate via OAuth (browser + loopback callback)
|
|
144
|
+
|
|
145
|
+
${bold("USAGE")}
|
|
146
|
+
botbuddy login [--no-browser]
|
|
147
|
+
|
|
148
|
+
${bold("HOW IT WORKS")}
|
|
149
|
+
Starts a localhost callback listener on an ephemeral 127.0.0.1 port, opens
|
|
150
|
+
your default browser at the BotBuddy authorization page, and waits for you to
|
|
151
|
+
sign in as an allowed user. On success the token is saved to
|
|
152
|
+
${dim("~/.botbuddy/config.json")} and the browser tab can be closed.
|
|
153
|
+
|
|
154
|
+
${bold("OPTIONS")}
|
|
155
|
+
--no-browser Don't launch a browser; print the authorization URL to open
|
|
156
|
+
in a browser on THIS machine yourself. The callback listener
|
|
157
|
+
still runs on this machine's localhost.
|
|
158
|
+
|
|
159
|
+
${bold("NOTES")}
|
|
160
|
+
• The authorization URL is always printed so you can open it manually.
|
|
161
|
+
• Login waits up to 5 minutes for the callback, then exits with an error and
|
|
162
|
+
saves nothing.
|
|
163
|
+
• If sign-in fails, just re-run ${cyan("botbuddy login")} — each attempt uses a fresh
|
|
164
|
+
listener and state.
|
|
165
|
+
• ${bold("Remote / SSH:")} the callback URL is ${dim("http://127.0.0.1:<port>/callback")}, which
|
|
166
|
+
always points at the machine whose browser opens it. If you run login on a
|
|
167
|
+
remote host but sign in from your laptop's browser, first forward that exact
|
|
168
|
+
port back to the host, e.g. ${cyan("ssh -L <port>:127.0.0.1:<port> <host>")}
|
|
169
|
+
(the port is printed in the URL). Without the tunnel the callback can't
|
|
170
|
+
reach the listener and login will time out.
|
|
171
|
+
|
|
172
|
+
${bold("RECOVERY")}
|
|
173
|
+
If ${cyan("botbuddy profile setup <profile>")} reports an authentication error,
|
|
174
|
+
run ${cyan("botbuddy login")} first, then re-run profile setup.`);
|
|
175
|
+
}
|
|
176
|
+
|
|
62
177
|
async function cmdStart(args) {
|
|
63
178
|
console.log(`${bold("botbuddy")} ${dim(`v${VERSION}`)}\n`);
|
|
64
179
|
const cfg = getConfig();
|
|
65
180
|
|
|
66
181
|
// Auto-login if not authenticated
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
182
|
+
try {
|
|
183
|
+
if (!cfg.access_token && !cfg.api_key) {
|
|
184
|
+
console.log(dim("→ No credentials found. Starting login...\n"));
|
|
185
|
+
await doLogin();
|
|
186
|
+
} else if (cfg.token_expires_at && cfg.token_expires_at <= Date.now()) {
|
|
187
|
+
console.log(dim("→ Token expired. Re-authenticating...\n"));
|
|
188
|
+
await doLogin();
|
|
189
|
+
}
|
|
190
|
+
} catch (err) {
|
|
191
|
+
die(err.message);
|
|
73
192
|
}
|
|
74
193
|
|
|
75
194
|
// Start the bridge (which auto-starts codex app-server)
|
|
@@ -107,6 +226,44 @@ function cmdLogout() {
|
|
|
107
226
|
console.log(`${green("✓")} Logged out. Credentials removed.`);
|
|
108
227
|
}
|
|
109
228
|
|
|
229
|
+
async function cmdAgentAuth(args) {
|
|
230
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
231
|
+
console.log(`Usage: botbuddy auth <login|status|logout> [--profile <name>] [--token <key>]
|
|
232
|
+
|
|
233
|
+
auth login Store a tenant-bound agent credential in the OS keyring. On macOS,
|
|
234
|
+
it imports the existing launchd credential once when no --token or
|
|
235
|
+
profile-specific environment key is supplied.
|
|
236
|
+
auth status Show whether the profile has a stored key (never prints it).
|
|
237
|
+
auth logout Remove the profile's stored key.`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
let options;
|
|
241
|
+
try {
|
|
242
|
+
options = parseAgentAuthArgs(args);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
die(error.message);
|
|
245
|
+
}
|
|
246
|
+
const profile = options.profile || await findProfileName(process.cwd());
|
|
247
|
+
if (!profile || !getProfileDefinition(profile)) {
|
|
248
|
+
die("No supported BotBuddy agent profile found. Add .botbuddy-agent.json or pass --profile <name>.");
|
|
249
|
+
}
|
|
250
|
+
if (options.action === "login") {
|
|
251
|
+
const token = await resolveLoginToken({ profile, explicitToken: options.token });
|
|
252
|
+
const result = await loginAgentCredential({ profile, token });
|
|
253
|
+
console.log(`${green("✓")} Stored ${result.profile} (${result.tenant}) agent credential in the OS keyring.`);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (options.action === "status") {
|
|
257
|
+
const result = await getAgentAuthStatus({ profile });
|
|
258
|
+
console.log(result.authenticated
|
|
259
|
+
? `${green("✓")} ${result.profile} (${result.tenant}) machine credential is stored in the OS keyring.`
|
|
260
|
+
: `${red("✗")} ${result.profile} (${result.tenant}) has no stored machine credential.`);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const result = await logoutAgentCredential({ profile });
|
|
264
|
+
console.log(`${green("✓")} ${result.profile} (${result.tenant}) machine credential ${result.removed ? "removed" : "was not present"}.`);
|
|
265
|
+
}
|
|
266
|
+
|
|
110
267
|
async function cmdRegister(args) {
|
|
111
268
|
const name = args[0];
|
|
112
269
|
if (!name) die("Usage: botbuddy register <name> [type]");
|
|
@@ -127,33 +284,52 @@ function cmdHeartbeat(args) {
|
|
|
127
284
|
return args[0] ? callTool("heartbeat", { current_task: args[0] }) : callTool("heartbeat");
|
|
128
285
|
}
|
|
129
286
|
|
|
287
|
+
async function cmdProfile(args) {
|
|
288
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
289
|
+
console.log(`Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev>
|
|
290
|
+
|
|
291
|
+
setup <profile> Mint/reconnect and securely store a tenant-bound agent key
|
|
292
|
+
env <profile> Print shell exports that load the stored agent key`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (args[0] === "env" && args[1] && args.length === 2) {
|
|
296
|
+
try {
|
|
297
|
+
console.log(profileShellRefresh(args[1]));
|
|
298
|
+
return;
|
|
299
|
+
} catch {
|
|
300
|
+
die("Usage: botbuddy profile env <botbuddy-dev|supplyguard-dev>");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (args[0] !== "setup" || !args[1] || args.length > 2) {
|
|
304
|
+
die("Usage: botbuddy profile <setup|env> <botbuddy-dev|supplyguard-dev>");
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const receipt = await bootstrapProfile(args[1], { call: callToolJson });
|
|
308
|
+
console.log(JSON.stringify(receipt));
|
|
309
|
+
} catch (error) {
|
|
310
|
+
const code = error instanceof ProfileBootstrapError ? error.code : "profile_agent_required";
|
|
311
|
+
console.log(JSON.stringify({
|
|
312
|
+
schema_version: 1,
|
|
313
|
+
outcome: "error",
|
|
314
|
+
error: code,
|
|
315
|
+
recovery: "botbuddy login && botbuddy profile setup " + args[1],
|
|
316
|
+
}));
|
|
317
|
+
process.exitCode = 3;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
130
321
|
function cmdLock(args) {
|
|
131
322
|
if (args.length < 2) die("Usage: botbuddy lock <resource_name> <type>");
|
|
132
323
|
return callTool("acquire_lock", { resource_name: args[0], resource_type: args[1] });
|
|
133
324
|
}
|
|
134
325
|
|
|
135
326
|
function cmdLocks(args) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
resources.push({ resource_type: "port", port_type: portType });
|
|
142
|
-
} else if (args[i] === "-m" || args[i] === "--mcp") {
|
|
143
|
-
resources.push({ resource_type: "mcp_server" });
|
|
144
|
-
} else if (args[i] === "-t" || args[i] === "--ticket") {
|
|
145
|
-
ticketId = args[++i];
|
|
146
|
-
} else if (args[i] === "--pr") {
|
|
147
|
-
prId = args[++i];
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
if (!resources.length) {
|
|
151
|
-
die("Usage: botbuddy locks -p [frontend|backend] -m [-t ticket] [--pr id]");
|
|
327
|
+
try {
|
|
328
|
+
return callTool("acquire_resources", buildAcquireResourcesPayload(args));
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (error instanceof LocksUsageError) die(error.message);
|
|
331
|
+
throw error;
|
|
152
332
|
}
|
|
153
|
-
const payload = { resources };
|
|
154
|
-
if (ticketId) payload.ticket_id = ticketId;
|
|
155
|
-
if (prId) payload.pr_id = prId;
|
|
156
|
-
return callTool("acquire_resources", payload);
|
|
157
333
|
}
|
|
158
334
|
|
|
159
335
|
function cmdUnlock(args) {
|
package/src/config.mjs
CHANGED
|
@@ -5,7 +5,11 @@ import { homedir } from "os";
|
|
|
5
5
|
const CONFIG_DIR = join(homedir(), ".botbuddy");
|
|
6
6
|
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// BOT-876: overridable so the CLI can be pointed at a local Supabase stack
|
|
9
|
+
// (`BOTBUDDY_SERVER_URL=http://127.0.0.1:56321/functions/v1/mcp-server`).
|
|
10
|
+
// Production is the default; nothing changes for normal use.
|
|
11
|
+
export const SERVER_URL = process.env.BOTBUDDY_SERVER_URL
|
|
12
|
+
|| "https://api.bot-buddy.ai/functions/v1/mcp-server";
|
|
9
13
|
|
|
10
14
|
let config = {};
|
|
11
15
|
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// BOT-876: generic tool invocation + discovery for the CLI.
|
|
2
|
+
//
|
|
3
|
+
// The MCP server now advertises only `help` and `call`, and serves its full
|
|
4
|
+
// catalog from GET /discovery. The CLI is a schema-free passthrough, so it
|
|
5
|
+
// reaches every tool without knowing any of them — these two commands are all
|
|
6
|
+
// it needs to stay at parity with the server forever.
|
|
7
|
+
//
|
|
8
|
+
// Argument parsing is separated from I/O so it can be unit-tested without a
|
|
9
|
+
// server. See discovery.test.mjs.
|
|
10
|
+
|
|
11
|
+
export class CallUsageError extends Error {}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Parse `botbuddy call <tool> [--json '<obj>'] [--key value ...]`.
|
|
15
|
+
*
|
|
16
|
+
* Two input styles, because both are natural depending on who is typing:
|
|
17
|
+
* --json '{"name":"x"}' one blob, for anything nested
|
|
18
|
+
* --name x --count 3 flags, for simple scalar arguments
|
|
19
|
+
*
|
|
20
|
+
* Returns { tool, args }.
|
|
21
|
+
*/
|
|
22
|
+
export function parseCallArgs(argv) {
|
|
23
|
+
const [tool, ...rest] = argv;
|
|
24
|
+
|
|
25
|
+
if (!tool || tool.startsWith("-")) {
|
|
26
|
+
throw new CallUsageError(
|
|
27
|
+
"Usage: botbuddy call <tool> [--json '<args>'] [--key value ...]",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let args = {};
|
|
32
|
+
let sawJson = false;
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < rest.length; i++) {
|
|
35
|
+
const token = rest[i];
|
|
36
|
+
|
|
37
|
+
if (token === "--json" || token === "-j") {
|
|
38
|
+
const raw = rest[++i];
|
|
39
|
+
if (raw === undefined) throw new CallUsageError("--json requires a JSON object argument.");
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(raw);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
throw new CallUsageError(`--json is not valid JSON: ${e.message}`);
|
|
45
|
+
}
|
|
46
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
47
|
+
throw new CallUsageError("--json must be a JSON object, e.g. '{\"name\":\"value\"}'.");
|
|
48
|
+
}
|
|
49
|
+
// Flags already seen win over the blob only if they come later; merging
|
|
50
|
+
// in order keeps "last one wins" true for both styles.
|
|
51
|
+
args = { ...args, ...parsed };
|
|
52
|
+
sawJson = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (token.startsWith("--")) {
|
|
57
|
+
const key = token.slice(2);
|
|
58
|
+
if (!key) throw new CallUsageError(`Malformed flag: ${token}`);
|
|
59
|
+
|
|
60
|
+
// `--flag` with no value, or followed by another flag, is a boolean.
|
|
61
|
+
const next = rest[i + 1];
|
|
62
|
+
if (next === undefined || next.startsWith("--")) {
|
|
63
|
+
args[key] = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
args[key] = coerceScalar(next);
|
|
67
|
+
i++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new CallUsageError(
|
|
72
|
+
`Unexpected argument: ${token}. Pass tool arguments as --key value or --json '{...}'.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { tool, args, usedJson: sawJson };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Numbers and booleans typed on a command line arrive as strings; a tool whose
|
|
81
|
+
* schema says `number` would reject them. Anything that is not unambiguously a
|
|
82
|
+
* number or boolean stays a string.
|
|
83
|
+
*/
|
|
84
|
+
function coerceScalar(raw) {
|
|
85
|
+
if (raw === "true") return true;
|
|
86
|
+
if (raw === "false") return false;
|
|
87
|
+
if (raw === "null") return null;
|
|
88
|
+
if (raw !== "" && !Number.isNaN(Number(raw)) && /^-?\d+(\.\d+)?$/.test(raw)) {
|
|
89
|
+
return Number(raw);
|
|
90
|
+
}
|
|
91
|
+
return raw;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** URL of the discovery document for a given MCP server URL. */
|
|
95
|
+
export function discoveryUrlFor(serverUrl, tool) {
|
|
96
|
+
const base = `${String(serverUrl).replace(/\/+$/, "")}/discovery`;
|
|
97
|
+
return tool ? `${base}?tool=${encodeURIComponent(tool)}` : base;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Render the discovery document for a human. The full catalog is ~97 tools, so
|
|
102
|
+
* the index is one line each; a single tool gets its full schema.
|
|
103
|
+
*/
|
|
104
|
+
export function formatDiscovery(doc, { tool } = {}) {
|
|
105
|
+
if (!doc || !Array.isArray(doc.tools)) return "No tools found.";
|
|
106
|
+
|
|
107
|
+
if (tool) {
|
|
108
|
+
const match = doc.tools.find((t) => t.name === tool);
|
|
109
|
+
if (!match) return `Unknown tool: ${tool}`;
|
|
110
|
+
return [
|
|
111
|
+
match.name,
|
|
112
|
+
"",
|
|
113
|
+
match.description ?? "",
|
|
114
|
+
"",
|
|
115
|
+
// BOT-971: agent-identity-gated tools carry an `auth` note on the
|
|
116
|
+
// discovery entry — surface it so `botbuddy help <tool>` matches
|
|
117
|
+
// help({ tool }) instead of hiding the requirement.
|
|
118
|
+
...(match.auth ? ["Auth:", match.auth, ""] : []),
|
|
119
|
+
"Arguments:",
|
|
120
|
+
JSON.stringify(match.inputSchema ?? {}, null, 2),
|
|
121
|
+
"",
|
|
122
|
+
`Invoke: botbuddy call ${match.name} --json '{...}'`,
|
|
123
|
+
].join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const lines = [
|
|
127
|
+
`${doc.tools.length} tools available. botbuddy help <tool> for one tool's arguments.`,
|
|
128
|
+
"",
|
|
129
|
+
];
|
|
130
|
+
for (const t of [...doc.tools].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
131
|
+
lines.push(` ${t.name.padEnd(34)} ${firstSentence(t.description ?? "")}`);
|
|
132
|
+
}
|
|
133
|
+
return lines.join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function firstSentence(text) {
|
|
137
|
+
const trimmed = String(text).replace(/\s+/g, " ").trim();
|
|
138
|
+
const stop = trimmed.indexOf(". ");
|
|
139
|
+
const sentence = stop === -1 ? trimmed : trimmed.slice(0, stop + 1);
|
|
140
|
+
return sentence.length > 96 ? `${sentence.slice(0, 93)}...` : sentence;
|
|
141
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// BOT-876: unit coverage for the CLI's generic call + discovery commands.
|
|
2
|
+
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
CallUsageError,
|
|
8
|
+
discoveryUrlFor,
|
|
9
|
+
formatDiscovery,
|
|
10
|
+
parseCallArgs,
|
|
11
|
+
} from "./discovery.mjs";
|
|
12
|
+
|
|
13
|
+
const SERVER = "https://api.bot-buddy.ai/functions/v1/mcp-server";
|
|
14
|
+
|
|
15
|
+
// ─── parseCallArgs ──────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
test("call with no arguments yields an empty args object", () => {
|
|
18
|
+
assert.deepEqual(parseCallArgs(["list_agents"]), {
|
|
19
|
+
tool: "list_agents",
|
|
20
|
+
args: {},
|
|
21
|
+
usedJson: false,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("--json supplies the whole arguments object", () => {
|
|
26
|
+
const { tool, args } = parseCallArgs([
|
|
27
|
+
"register_agent",
|
|
28
|
+
"--json",
|
|
29
|
+
'{"name":"claude-1","type":"claude"}',
|
|
30
|
+
]);
|
|
31
|
+
assert.equal(tool, "register_agent");
|
|
32
|
+
assert.deepEqual(args, { name: "claude-1", type: "claude" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("--json accepts nested structures", () => {
|
|
36
|
+
const { args } = parseCallArgs([
|
|
37
|
+
"register_agent",
|
|
38
|
+
"--json",
|
|
39
|
+
'{"resources":[{"resource_type":"mcp_server","subtype":"playwright_lane"}]}',
|
|
40
|
+
]);
|
|
41
|
+
assert.deepEqual(args.resources, [
|
|
42
|
+
{ resource_type: "mcp_server", subtype: "playwright_lane" },
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("--key value flags build the arguments object", () => {
|
|
47
|
+
const { args } = parseCallArgs(["create_task", "--title", "Fix the thing", "--priority", "2"]);
|
|
48
|
+
assert.deepEqual(args, { title: "Fix the thing", priority: 2 });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("numeric and boolean flag values are coerced off the command line", () => {
|
|
52
|
+
// A tool whose schema says `number` would reject the string "2".
|
|
53
|
+
const { args } = parseCallArgs([
|
|
54
|
+
"t",
|
|
55
|
+
"--count", "2",
|
|
56
|
+
"--ratio", "1.5",
|
|
57
|
+
"--negative", "-3",
|
|
58
|
+
"--yes", "true",
|
|
59
|
+
"--no", "false",
|
|
60
|
+
"--nothing", "null",
|
|
61
|
+
]);
|
|
62
|
+
assert.deepEqual(args, {
|
|
63
|
+
count: 2, ratio: 1.5, negative: -3, yes: true, no: false, nothing: null,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("values that only look numeric stay strings", () => {
|
|
68
|
+
const { args } = parseCallArgs([
|
|
69
|
+
"t",
|
|
70
|
+
"--ticket", "BOT-876",
|
|
71
|
+
"--version", "1.2.3",
|
|
72
|
+
"--hex", "0x10",
|
|
73
|
+
"--padded", "007",
|
|
74
|
+
]);
|
|
75
|
+
assert.equal(args.ticket, "BOT-876");
|
|
76
|
+
assert.equal(args.version, "1.2.3");
|
|
77
|
+
assert.equal(args.hex, "0x10");
|
|
78
|
+
// "007" is unambiguously numeric; losing the padding is acceptable, but a
|
|
79
|
+
// leading-zero identifier is common enough to pin the behaviour.
|
|
80
|
+
assert.equal(args.padded, 7);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a valueless flag is a boolean true", () => {
|
|
84
|
+
const { args } = parseCallArgs(["t", "--force", "--name", "x"]);
|
|
85
|
+
assert.deepEqual(args, { force: true, name: "x" });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a trailing valueless flag is a boolean true", () => {
|
|
89
|
+
const { args } = parseCallArgs(["t", "--force"]);
|
|
90
|
+
assert.deepEqual(args, { force: true });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("flags after --json override the blob", () => {
|
|
94
|
+
const { args } = parseCallArgs(["t", "--json", '{"name":"a"}', "--name", "b"]);
|
|
95
|
+
assert.equal(args.name, "b");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a missing tool name is a usage error", () => {
|
|
99
|
+
assert.throws(() => parseCallArgs([]), CallUsageError);
|
|
100
|
+
assert.throws(() => parseCallArgs(["--json", "{}"]), CallUsageError);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("malformed --json is reported clearly, not swallowed", () => {
|
|
104
|
+
assert.throws(() => parseCallArgs(["t", "--json", "{not json}"]), (e) => {
|
|
105
|
+
assert.ok(e instanceof CallUsageError);
|
|
106
|
+
assert.match(e.message, /not valid JSON/);
|
|
107
|
+
return true;
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("--json must be an object, not an array or scalar", () => {
|
|
112
|
+
for (const bad of ["[1,2]", '"a string"', "42", "null"]) {
|
|
113
|
+
assert.throws(() => parseCallArgs(["t", "--json", bad]), CallUsageError, `accepted ${bad}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("--json with no value is a usage error", () => {
|
|
118
|
+
assert.throws(() => parseCallArgs(["t", "--json"]), CallUsageError);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a bare positional argument is rejected rather than silently ignored", () => {
|
|
122
|
+
assert.throws(() => parseCallArgs(["t", "oops"]), (e) => {
|
|
123
|
+
assert.match(e.message, /--key value or --json/);
|
|
124
|
+
return true;
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ─── discoveryUrlFor ────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
test("discovery URL is derived from the server URL", () => {
|
|
131
|
+
assert.equal(discoveryUrlFor(SERVER), `${SERVER}/discovery`);
|
|
132
|
+
assert.equal(discoveryUrlFor(`${SERVER}/`), `${SERVER}/discovery`);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("discovery URL encodes a requested tool", () => {
|
|
136
|
+
assert.equal(discoveryUrlFor(SERVER, "get_totp_code"), `${SERVER}/discovery?tool=get_totp_code`);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ─── formatDiscovery ────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
const DOC = {
|
|
142
|
+
tools: [
|
|
143
|
+
{ name: "register_agent", description: "Register an agent. Extra detail here.", inputSchema: { type: "object" } },
|
|
144
|
+
{ name: "list_agents", description: "List all agents", inputSchema: { type: "object" } },
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
test("the index lists every tool, sorted, one line each", () => {
|
|
149
|
+
const out = formatDiscovery(DOC);
|
|
150
|
+
const lines = out.split("\n").filter((l) => l.startsWith(" "));
|
|
151
|
+
assert.equal(lines.length, 2);
|
|
152
|
+
assert.match(lines[0], /list_agents/);
|
|
153
|
+
assert.match(lines[1], /register_agent/);
|
|
154
|
+
assert.match(out, /2 tools available/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("the index truncates a description to its first sentence", () => {
|
|
158
|
+
const out = formatDiscovery(DOC);
|
|
159
|
+
assert.match(out, /Register an agent\./);
|
|
160
|
+
assert.doesNotMatch(out, /Extra detail here/);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("a single tool renders its full schema and an invocation example", () => {
|
|
164
|
+
const out = formatDiscovery(DOC, { tool: "register_agent" });
|
|
165
|
+
assert.match(out, /^register_agent/);
|
|
166
|
+
assert.match(out, /botbuddy call register_agent/);
|
|
167
|
+
assert.match(out, /"type": "object"/);
|
|
168
|
+
// register_agent carries no `auth` note → no Auth section.
|
|
169
|
+
assert.doesNotMatch(out, /Auth:/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a single tool with an agent-identity note renders an Auth section", () => {
|
|
173
|
+
// BOT-971: the server stamps `auth` on discovery entries for agent-gated
|
|
174
|
+
// tools; `botbuddy help <tool>` must surface it (matching help({ tool })).
|
|
175
|
+
const doc = {
|
|
176
|
+
tools: [{
|
|
177
|
+
name: "acquire_resources",
|
|
178
|
+
description: "Batch lock",
|
|
179
|
+
inputSchema: { type: "object" },
|
|
180
|
+
auth: "Requires an agent identity — call register_agent first.",
|
|
181
|
+
}],
|
|
182
|
+
};
|
|
183
|
+
const out = formatDiscovery(doc, { tool: "acquire_resources" });
|
|
184
|
+
assert.match(out, /Auth:/);
|
|
185
|
+
assert.match(out, /register_agent/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("an unknown tool is reported, not rendered empty", () => {
|
|
189
|
+
assert.match(formatDiscovery(DOC, { tool: "nope" }), /Unknown tool: nope/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("a malformed discovery document does not throw", () => {
|
|
193
|
+
assert.equal(formatDiscovery(null), "No tools found.");
|
|
194
|
+
assert.equal(formatDiscovery({}), "No tools found.");
|
|
195
|
+
});
|