@botbuddy/cli 1.2.2 → 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/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 doLogin();
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 Re-authenticate via OAuth
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
- if (!cfg.access_token && !cfg.api_key) {
68
- console.log(dim("→ No credentials found. Starting login...\n"));
69
- await doLogin();
70
- } else if (cfg.token_expires_at && cfg.token_expires_at <= Date.now()) {
71
- console.log(dim("→ Token expired. Re-authenticating...\n"));
72
- await doLogin();
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
- const resources = [];
137
- let ticketId, prId;
138
- for (let i = 0; i < args.length; i++) {
139
- if (args[i] === "-p" || args[i] === "--port") {
140
- const portType = (args[i + 1] && !args[i + 1].startsWith("-")) ? args[++i] : "frontend";
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
- export const SERVER_URL = "https://tpnivjpoayjmexclfpav.supabase.co/functions/v1/mcp-server";
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
+ });