argsbarg 1.4.0 → 1.4.2

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/index.test.ts CHANGED
@@ -9,19 +9,23 @@ shell output regressions.
9
9
 
10
10
  import { completionBashScript, completionZshScript } from "./completion.ts";
11
11
  import { cliHelpRender } from "./help.ts";
12
- import { CliCommand, CliFallbackMode, CliOptionKind } from "./index.ts";
12
+ import { CliCommand, CliFallbackMode, CliOptionKind, cliInvoke } from "./index.ts";
13
13
  import {
14
+ allMcpResources,
14
15
  collectMcpTools,
15
16
  mcpToolCallToArgv,
16
17
  mcpToolDescription,
17
18
  sanitizeToolSegment,
18
19
  } from "./mcp/tools.ts";
20
+ import { applyShellEnv, loadEnvFile } from "./mcp/env.ts";
19
21
  import { buildToolCallSuccess } from "./mcp/result.ts";
20
22
  import { ParseKind, parse, postParseValidate } from "./parse.ts";
21
23
  import { cliSchemaJson } from "./schema.ts";
22
24
  import { cliValidateRoot } from "./validate.ts";
23
25
  import { expect, test } from "bun:test";
24
26
  import { $ } from "bun";
27
+ import { mkdtempSync, writeFileSync } from "node:fs";
28
+ import { tmpdir } from "node:os";
25
29
  import { join } from "node:path";
26
30
 
27
31
  test("bundled short presence flags", () => {
@@ -330,7 +334,7 @@ test("trailing options include parent-scoped flags", () => {
330
334
  expect(pr.opts["json"]).toBe("1");
331
335
  });
332
336
 
333
- test("varargs tail does not parse trailing options", () => {
337
+ test("varargs tail parses trailing options", () => {
334
338
  const root: CliCommand = {
335
339
  key: "app",
336
340
  description: "",
@@ -361,8 +365,8 @@ test("varargs tail does not parse trailing options", () => {
361
365
  cliValidateRoot(root);
362
366
  const pr = postParseValidate(root, parse(root, ["x", "./file", "--json"]));
363
367
  expect(pr.kind).toBe(ParseKind.Ok);
364
- expect(pr.args).toEqual(["./file", "--json"]);
365
- expect(pr.opts["json"]).toBeUndefined();
368
+ expect(pr.args).toEqual(["./file"]);
369
+ expect(pr.opts["json"]).toBe("1");
366
370
  });
367
371
 
368
372
  test("stops parsing options at --", () => {
@@ -718,11 +722,16 @@ const nestedMcpFixture: CliCommand = {
718
722
  };
719
723
 
720
724
  /** Sends NDJSON MCP requests to a subprocess and collects responses by id. */
721
- async function mcpRequest(requests: object[]): Promise<Map<string | number, object>> {
722
- const proc = Bun.spawn(["bun", "run", "examples/nested.ts", "mcp"], {
725
+ async function mcpRequest(
726
+ requests: object[],
727
+ opts?: { script?: string; env?: Record<string, string> },
728
+ ): Promise<Map<string | number, object>> {
729
+ const script = opts?.script ?? "examples/nested.ts";
730
+ const proc = Bun.spawn(["bun", "run", script, "mcp"], {
723
731
  stdin: "pipe",
724
732
  stdout: "pipe",
725
733
  stderr: "pipe",
734
+ env: opts?.env ? { ...process.env, ...opts.env } : process.env,
726
735
  });
727
736
 
728
737
  const input = requests.map((r) => JSON.stringify(r) + "\n").join("");
@@ -992,4 +1001,637 @@ test("minimal.ts mcp without opt-in fails", async () => {
992
1001
  const { stderr, exitCode } = await $`bun run examples/minimal.ts mcp`.nothrow().quiet();
993
1002
  expect(exitCode).toBe(1);
994
1003
  expect(stderr.toString()).toContain("mcp");
1004
+ });
1005
+
1006
+ test("ctx.invocation is cli via cliRun", async () => {
1007
+ const indexPath = join(import.meta.dir, "index.ts");
1008
+ const { stdout } = await $`bun -e ${`
1009
+ import { cliRun, CliCommand } from ${JSON.stringify(indexPath)};
1010
+ const cli = { key: "t", description: "d", handler: (ctx) => console.log(ctx.invocation) };
1011
+ await cliRun(cli, []);
1012
+ `}`.quiet();
1013
+ expect(stdout.toString().trim()).toBe("cli");
1014
+ });
1015
+
1016
+ test("ctx.invocation is mcp via cliInvoke", async () => {
1017
+ let seen = "";
1018
+ const root: CliCommand = {
1019
+ key: "app",
1020
+ description: "",
1021
+ handler: (ctx) => {
1022
+ seen = ctx.invocation;
1023
+ },
1024
+ };
1025
+ cliValidateRoot(root);
1026
+ const result = await cliInvoke(root, []);
1027
+ expect(result.kind).toBe("ok");
1028
+ expect(seen).toBe("mcp");
1029
+ });
1030
+
1031
+ const enumMcpFixture: CliCommand = {
1032
+ key: "app",
1033
+ description: "",
1034
+ mcpServer: {},
1035
+ commands: [
1036
+ {
1037
+ key: "run",
1038
+ description: "Run with mode.",
1039
+ options: [
1040
+ {
1041
+ name: "mode",
1042
+ description: "Mode.",
1043
+ kind: CliOptionKind.Enum,
1044
+ choices: ["dev", "prod"],
1045
+ required: true,
1046
+ },
1047
+ ],
1048
+ handler: () => {},
1049
+ },
1050
+ ],
1051
+ };
1052
+
1053
+ test("Enum option inputSchema includes enum array", () => {
1054
+ const tools = collectMcpTools(enumMcpFixture);
1055
+ const run = tools.find((t) => t.name === "run")!;
1056
+ const schema = run.inputSchema as { properties: { mode: { enum?: string[] } } };
1057
+ expect(schema.properties.mode.enum).toEqual(["dev", "prod"]);
1058
+ });
1059
+
1060
+ test("cliInvoke rejects invalid Enum value", async () => {
1061
+ const root: CliCommand = {
1062
+ key: "app",
1063
+ description: "",
1064
+ handler: () => {},
1065
+ options: [
1066
+ {
1067
+ name: "mode",
1068
+ description: "Mode.",
1069
+ kind: CliOptionKind.Enum,
1070
+ choices: ["dev", "prod"],
1071
+ required: true,
1072
+ },
1073
+ ],
1074
+ };
1075
+ cliValidateRoot(root);
1076
+ const result = await cliInvoke(root, ["--mode", "staging"]);
1077
+ expect(result.kind).toBe("error");
1078
+ expect(result.errorMsg).toContain("not one of");
1079
+ });
1080
+
1081
+ test("cliInvoke accepts valid Enum value", async () => {
1082
+ const root: CliCommand = {
1083
+ key: "app",
1084
+ description: "",
1085
+ handler: (ctx) => {
1086
+ console.log(ctx.stringOpt("mode"));
1087
+ },
1088
+ options: [
1089
+ {
1090
+ name: "mode",
1091
+ description: "Mode.",
1092
+ kind: CliOptionKind.Enum,
1093
+ choices: ["dev", "prod"],
1094
+ required: true,
1095
+ },
1096
+ ],
1097
+ };
1098
+ cliValidateRoot(root);
1099
+ const result = await cliInvoke(root, ["--mode", "dev"]);
1100
+ expect(result.kind).toBe("ok");
1101
+ expect(result.stdout.trim()).toBe("dev");
1102
+ });
1103
+
1104
+ test("cliValidateRoot rejects Enum with no choices", () => {
1105
+ const root: CliCommand = {
1106
+ key: "app",
1107
+ description: "",
1108
+ handler: () => {},
1109
+ options: [{ name: "mode", description: "", kind: CliOptionKind.Enum, choices: [] }],
1110
+ };
1111
+ expect(() => cliValidateRoot(root)).toThrow(/requires non-empty choices/);
1112
+ });
1113
+
1114
+ test("cliValidateRoot rejects Enum with duplicate choices", () => {
1115
+ const root: CliCommand = {
1116
+ key: "app",
1117
+ description: "",
1118
+ handler: () => {},
1119
+ options: [{ name: "mode", description: "", kind: CliOptionKind.Enum, choices: ["a", "a"] }],
1120
+ };
1121
+ expect(() => cliValidateRoot(root)).toThrow(/choices must be distinct/);
1122
+ });
1123
+
1124
+ test("mcpTool.description override wins without requiresEnv suffix", () => {
1125
+ const root: CliCommand = {
1126
+ key: "app",
1127
+ description: "",
1128
+ mcpServer: {},
1129
+ commands: [
1130
+ {
1131
+ key: "x",
1132
+ description: "Leaf desc.",
1133
+ mcpTool: { description: "custom", requiresEnv: ["TOKEN"] },
1134
+ handler: () => {},
1135
+ },
1136
+ ],
1137
+ };
1138
+ const tools = collectMcpTools(root);
1139
+ expect(tools[0]!.description).toBe("custom");
1140
+ });
1141
+
1142
+ test("mcpTool.requiresEnv appended to auto description", () => {
1143
+ const root: CliCommand = {
1144
+ key: "app",
1145
+ description: "",
1146
+ mcpServer: {},
1147
+ commands: [
1148
+ {
1149
+ key: "x",
1150
+ description: "Leaf desc.",
1151
+ mcpTool: { requiresEnv: ["TOKEN"] },
1152
+ handler: () => {},
1153
+ },
1154
+ ],
1155
+ };
1156
+ const tools = collectMcpTools(root);
1157
+ expect(tools[0]!.description).toContain("[requires env: TOKEN]");
1158
+ });
1159
+
1160
+ test("cliValidateRoot rejects duplicate mcpResources URIs", () => {
1161
+ const root: CliCommand = {
1162
+ key: "app",
1163
+ description: "",
1164
+ mcpServer: {
1165
+ resources: [
1166
+ { uri: "a://1", name: "a", load: () => "a" },
1167
+ { uri: "a://1", name: "b", load: () => "b" },
1168
+ ],
1169
+ },
1170
+ commands: [{ key: "x", description: "", handler: () => {} }],
1171
+ };
1172
+ expect(() => cliValidateRoot(root)).toThrow(/URIs must be unique/);
1173
+ });
1174
+
1175
+ test("cliValidateRoot rejects resource URI matching schemaResourceUri", () => {
1176
+ const root: CliCommand = {
1177
+ key: "app",
1178
+ description: "",
1179
+ mcpServer: {
1180
+ schemaResourceUri: "custom://schema",
1181
+ resources: [{ uri: "custom://schema", name: "dup", load: () => "" }],
1182
+ },
1183
+ commands: [{ key: "x", description: "", handler: () => {} }],
1184
+ };
1185
+ expect(() => cliValidateRoot(root)).toThrow(/conflicts with the built-in schema resource/);
1186
+ });
1187
+
1188
+ test("allMcpResources includes custom resources", () => {
1189
+ const root: CliCommand = {
1190
+ key: "app",
1191
+ description: "",
1192
+ mcpServer: {
1193
+ resources: [{ uri: "test://x", name: "x", load: () => "body" }],
1194
+ },
1195
+ commands: [{ key: "leaf", description: "", handler: () => {} }],
1196
+ };
1197
+ const resources = allMcpResources(root);
1198
+ expect(resources.map((r) => r.uri)).toContain("argsbarg://schema");
1199
+ expect(resources.map((r) => r.uri)).toContain("test://x");
1200
+ });
1201
+
1202
+ test("applyShellEnv merges PATH and preserves host vars", () => {
1203
+ const origPath = process.env.PATH ?? "";
1204
+ const origHome = process.env.HOME;
1205
+ process.env.PATH = "/host/bin";
1206
+ process.env.HOME = "host-home";
1207
+ applyShellEnv({ PATH: "/shell/bin:/host/bin", HOME: "shell-home", NEWVAR: "yes" });
1208
+ expect(process.env.PATH?.startsWith("/shell/bin:")).toBe(true);
1209
+ expect(process.env.PATH).toContain("/host/bin");
1210
+ expect(process.env.HOME).toBe("host-home");
1211
+ expect(process.env.NEWVAR).toBe("yes");
1212
+ process.env.PATH = origPath;
1213
+ if (origHome === undefined) {
1214
+ delete process.env.HOME;
1215
+ } else {
1216
+ process.env.HOME = origHome;
1217
+ }
1218
+ delete process.env.NEWVAR;
1219
+ });
1220
+
1221
+ test("loadEnvFile overwrites existing keys", () => {
1222
+ const dir = mkdtempSync(join(tmpdir(), "argsbarg-env-"));
1223
+ const file = join(dir, ".env");
1224
+ writeFileSync(file, "FOO=fromfile\n", "utf8");
1225
+ process.env.FOO = "original";
1226
+ loadEnvFile(file);
1227
+ expect(process.env.FOO).toBe("fromfile");
1228
+ delete process.env.FOO;
1229
+ });
1230
+
1231
+ test("Enum completions list choices in bash script", () => {
1232
+ const root: CliCommand = {
1233
+ key: "app",
1234
+ description: "",
1235
+ commands: [
1236
+ {
1237
+ key: "run",
1238
+ description: "",
1239
+ options: [
1240
+ { name: "mode", description: "m", kind: CliOptionKind.Enum, choices: ["dev", "prod"] },
1241
+ ],
1242
+ handler: () => {},
1243
+ },
1244
+ ],
1245
+ };
1246
+ const bash = completionBashScript(root);
1247
+ expect(bash).toContain("--mode) COMPREPLY=");
1248
+ expect(bash).toContain("dev");
1249
+ expect(bash).toContain("prod");
1250
+ });
1251
+
1252
+ test("MCP resources/list includes custom resource", async () => {
1253
+ const responses = await mcpRequest(
1254
+ [{ jsonrpc: "2.0", id: 10, method: "resources/list", params: {} }],
1255
+ { script: "examples/mcp-test.ts" },
1256
+ );
1257
+ const res = responses.get(10) as { result: { resources: { uri: string }[] } };
1258
+ const uris = res.result.resources.map((r) => r.uri);
1259
+ expect(uris).toContain("argsbarg://schema");
1260
+ expect(uris).toContain("test://hello");
1261
+ });
1262
+
1263
+ test("MCP resources/read returns custom resource body", async () => {
1264
+ const responses = await mcpRequest(
1265
+ [{ jsonrpc: "2.0", id: 11, method: "resources/read", params: { uri: "test://hello" } }],
1266
+ { script: "examples/mcp-test.ts" },
1267
+ );
1268
+ const res = responses.get(11) as { result: { contents: { text: string }[] } };
1269
+ expect(res.result.contents[0]!.text).toBe("hello resource");
1270
+ });
1271
+
1272
+ test("MCP resources/read unknown URI returns error", async () => {
1273
+ const responses = await mcpRequest(
1274
+ [{ jsonrpc: "2.0", id: 12, method: "resources/read", params: { uri: "missing://nope" } }],
1275
+ { script: "examples/mcp-test.ts" },
1276
+ );
1277
+ const res = responses.get(12) as { error: { code: number } };
1278
+ expect(res.error.code).toBe(-32602);
1279
+ });
1280
+
1281
+ test("MCP requiresEnv fails when env missing", async () => {
1282
+ const responses = await mcpRequest(
1283
+ [
1284
+ {
1285
+ jsonrpc: "2.0",
1286
+ id: 13,
1287
+ method: "tools/call",
1288
+ params: { name: "echo_env", arguments: { name: "ARGS_TEST_SECRET" } },
1289
+ },
1290
+ ],
1291
+ { script: "examples/mcp-test.ts", env: { ARGS_TEST_SECRET: "" } },
1292
+ );
1293
+ const res = responses.get(13) as { result: { isError: boolean; content: { text: string }[] } };
1294
+ expect(res.result.isError).toBe(true);
1295
+ expect(res.result.content[0]!.text).toContain("ARGS_TEST_SECRET");
1296
+ });
1297
+
1298
+ test("MCP requiresEnv succeeds when env present", async () => {
1299
+ const responses = await mcpRequest(
1300
+ [
1301
+ {
1302
+ jsonrpc: "2.0",
1303
+ id: 14,
1304
+ method: "tools/call",
1305
+ params: { name: "echo_env", arguments: { name: "ARGS_TEST_SECRET" } },
1306
+ },
1307
+ ],
1308
+ { script: "examples/mcp-test.ts", env: { ARGS_TEST_SECRET: "sekrit" } },
1309
+ );
1310
+ const res = responses.get(14) as { result: { isError: boolean; content: { text: string }[] } };
1311
+ expect(res.result.isError).toBe(false);
1312
+ expect(res.result.content[0]!.text.trim()).toBe("sekrit");
1313
+ });
1314
+
1315
+ test("MCP envFile loads vars for tool handlers", async () => {
1316
+ const dir = mkdtempSync(join(tmpdir(), "argsbarg-mcp-"));
1317
+ const envFile = join(dir, "mcp.env");
1318
+ writeFileSync(envFile, "ARGS_FILE_TOKEN=file-value\n", "utf8");
1319
+ const responses = await mcpRequest(
1320
+ [
1321
+ {
1322
+ jsonrpc: "2.0",
1323
+ id: 15,
1324
+ method: "tools/call",
1325
+ params: { name: "echo_env", arguments: { name: "ARGS_FILE_TOKEN" } },
1326
+ },
1327
+ ],
1328
+ { script: "examples/mcp-test.ts", env: { ARGS_TEST_ENV_FILE: envFile, ARGS_TEST_SECRET: "present" } },
1329
+ );
1330
+ const res = responses.get(15) as { result: { isError: boolean; content: { text: string }[] } };
1331
+ expect(res.result.isError).toBe(false);
1332
+ expect(res.result.content[0]!.text.trim()).toBe("file-value");
1333
+ });
1334
+
1335
+ // ── v1.3 parser ergonomics ────────────────────────────────────────────────────
1336
+
1337
+ function varargsReadFixture(): CliCommand {
1338
+ return {
1339
+ key: "app",
1340
+ description: "",
1341
+ commands: [
1342
+ {
1343
+ key: "read",
1344
+ description: "Read files.",
1345
+ options: [
1346
+ {
1347
+ name: "json",
1348
+ description: "",
1349
+ kind: CliOptionKind.Presence,
1350
+ },
1351
+ ],
1352
+ positionals: [
1353
+ {
1354
+ name: "files",
1355
+ description: "",
1356
+ kind: CliOptionKind.String,
1357
+ argMin: 0,
1358
+ argMax: 0,
1359
+ },
1360
+ ],
1361
+ handler: () => {},
1362
+ },
1363
+ ],
1364
+ };
1365
+ }
1366
+
1367
+ function nestedDocsFallbackFixture(): CliCommand {
1368
+ return {
1369
+ key: "app",
1370
+ description: "",
1371
+ commands: [
1372
+ {
1373
+ key: "docs",
1374
+ description: "Documentation commands.",
1375
+ fallbackCommand: "guide",
1376
+ fallbackMode: CliFallbackMode.MissingOnly,
1377
+ commands: [
1378
+ {
1379
+ key: "guide",
1380
+ description: "User guide.",
1381
+ handler: () => {},
1382
+ },
1383
+ {
1384
+ key: "api",
1385
+ description: "API reference.",
1386
+ handler: () => {},
1387
+ },
1388
+ ],
1389
+ },
1390
+ ],
1391
+ };
1392
+ }
1393
+
1394
+ test("nested fallback routes to default when argv exhausted at router", () => {
1395
+ const root = nestedDocsFallbackFixture();
1396
+ cliValidateRoot(root);
1397
+ const pr = postParseValidate(root, parse(root, ["docs"]));
1398
+ expect(pr.kind).toBe(ParseKind.Ok);
1399
+ expect(pr.path).toEqual(["docs", "guide"]);
1400
+ });
1401
+
1402
+ test("nested fallback MissingOrUnknown routes unknown token to default", () => {
1403
+ const root: CliCommand = {
1404
+ key: "app",
1405
+ description: "",
1406
+ commands: [
1407
+ {
1408
+ key: "docs",
1409
+ description: "Documentation commands.",
1410
+ fallbackCommand: "guide",
1411
+ fallbackMode: CliFallbackMode.MissingOrUnknown,
1412
+ commands: [
1413
+ {
1414
+ key: "guide",
1415
+ description: "User guide.",
1416
+ positionals: [
1417
+ {
1418
+ name: "topic",
1419
+ description: "",
1420
+ kind: CliOptionKind.String,
1421
+ argMin: 0,
1422
+ argMax: 0,
1423
+ },
1424
+ ],
1425
+ handler: () => {},
1426
+ },
1427
+ {
1428
+ key: "api",
1429
+ description: "API reference.",
1430
+ handler: () => {},
1431
+ },
1432
+ ],
1433
+ },
1434
+ ],
1435
+ };
1436
+ cliValidateRoot(root);
1437
+ const pr = postParseValidate(root, parse(root, ["docs", "extra-topic"]));
1438
+ expect(pr.kind).toBe(ParseKind.Ok);
1439
+ expect(pr.path).toEqual(["docs", "guide"]);
1440
+ expect(pr.args).toEqual(["extra-topic"]);
1441
+ });
1442
+
1443
+ test("nested fallback MissingOnly errors on unknown subcommand", () => {
1444
+ const root = nestedDocsFallbackFixture();
1445
+ cliValidateRoot(root);
1446
+ const pr = parse(root, ["docs", "nope"]);
1447
+ expect(pr.kind).toBe(ParseKind.Error);
1448
+ expect(pr.errorMsg).toContain("Unknown subcommand");
1449
+ });
1450
+
1451
+ test("cliValidateRoot rejects invalid nested fallbackCommand", () => {
1452
+ const root: CliCommand = {
1453
+ key: "app",
1454
+ description: "",
1455
+ commands: [
1456
+ {
1457
+ key: "docs",
1458
+ description: "",
1459
+ fallbackCommand: "missing",
1460
+ commands: [
1461
+ {
1462
+ key: "guide",
1463
+ description: "",
1464
+ handler: () => {},
1465
+ },
1466
+ ],
1467
+ },
1468
+ ],
1469
+ };
1470
+ expect(() => cliValidateRoot(root)).toThrow(/fallbackCommand 'missing' is not a child of 'docs'/);
1471
+ });
1472
+
1473
+ test("cliValidateRoot accepts nested fallbackCommand when child exists", () => {
1474
+ const root = nestedDocsFallbackFixture();
1475
+ expect(() => cliValidateRoot(root)).not.toThrow();
1476
+ });
1477
+
1478
+ test("nested router scoped help does not route to fallback", () => {
1479
+ const root = nestedDocsFallbackFixture();
1480
+ cliValidateRoot(root);
1481
+ const pr = parse(root, ["docs", "--help"]);
1482
+ expect(pr.kind).toBe(ParseKind.Help);
1483
+ expect(pr.helpPath).toEqual(["docs"]);
1484
+ expect(pr.helpExplicit).toBe(true);
1485
+ const help = cliHelpRender(root, pr.helpPath, false);
1486
+ expect(help).toContain("api");
1487
+ expect(help).toContain("guide");
1488
+ });
1489
+
1490
+ test("varargs trailing option after positionals via cliInvoke", async () => {
1491
+ const root = varargsReadFixture();
1492
+ cliValidateRoot(root);
1493
+ const pr = postParseValidate(root, parse(root, ["read", "file.txt", "--json"]));
1494
+ expect(pr.kind).toBe(ParseKind.Ok);
1495
+ expect(pr.args).toEqual(["file.txt"]);
1496
+ expect(pr.opts["json"]).toBe("1");
1497
+ });
1498
+
1499
+ test("varargs option before positionals", () => {
1500
+ const root = varargsReadFixture();
1501
+ cliValidateRoot(root);
1502
+ const pr = postParseValidate(root, parse(root, ["read", "--json", "file.txt"]));
1503
+ expect(pr.kind).toBe(ParseKind.Ok);
1504
+ expect(pr.args).toEqual(["file.txt"]);
1505
+ expect(pr.opts["json"]).toBe("1");
1506
+ });
1507
+
1508
+ test("varargs multiple files then trailing option", () => {
1509
+ const root = varargsReadFixture();
1510
+ cliValidateRoot(root);
1511
+ const pr = postParseValidate(root, parse(root, ["read", "a.txt", "b.txt", "--json"]));
1512
+ expect(pr.kind).toBe(ParseKind.Ok);
1513
+ expect(pr.args).toEqual(["a.txt", "b.txt"]);
1514
+ expect(pr.opts["json"]).toBe("1");
1515
+ });
1516
+
1517
+ test("varargs double dash forces positional", () => {
1518
+ const root = varargsReadFixture();
1519
+ cliValidateRoot(root);
1520
+ const pr = postParseValidate(root, parse(root, ["read", "file.txt", "--", "--json"]));
1521
+ expect(pr.kind).toBe(ParseKind.Ok);
1522
+ expect(pr.args).toEqual(["file.txt", "--json"]);
1523
+ expect(pr.opts["json"]).toBeUndefined();
1524
+ });
1525
+
1526
+ test("varargs unknown flag errors", async () => {
1527
+ const root = varargsReadFixture();
1528
+ cliValidateRoot(root);
1529
+ const result = await cliInvoke(root, ["read", "--unknown"]);
1530
+ expect(result.kind).toBe("error");
1531
+ expect(result.stderr).toContain("--unknown");
1532
+ });
1533
+
1534
+ test("varargs scoped help in tail", () => {
1535
+ const root = varargsReadFixture();
1536
+ cliValidateRoot(root);
1537
+ const pr = parse(root, ["read", "file.txt", "--help"]);
1538
+ expect(pr.kind).toBe(ParseKind.Help);
1539
+ expect(pr.helpPath).toContain("read");
1540
+ expect(pr.helpExplicit).toBe(true);
1541
+ });
1542
+
1543
+ test("ctx.positional returns single slot value", async () => {
1544
+ const root: CliCommand = {
1545
+ key: "app",
1546
+ description: "",
1547
+ commands: [
1548
+ {
1549
+ key: "x",
1550
+ description: "",
1551
+ positionals: [{ name: "path", description: "", kind: CliOptionKind.String }],
1552
+ handler: (ctx) => {
1553
+ captured = ctx.positional("path");
1554
+ },
1555
+ },
1556
+ ],
1557
+ };
1558
+ let captured: string | string[] | undefined;
1559
+ cliValidateRoot(root);
1560
+ await cliInvoke(root, ["x", "./file"]);
1561
+ expect(captured).toBe("./file");
1562
+ });
1563
+
1564
+ test("ctx.positional returns varargs array", async () => {
1565
+ const root = varargsReadFixture();
1566
+ let captured: string | string[] | undefined;
1567
+ root.commands![0]!.handler = (ctx) => {
1568
+ captured = ctx.positional("files");
1569
+ };
1570
+ cliValidateRoot(root);
1571
+ await cliInvoke(root, ["read", "a.txt", "b.txt"]);
1572
+ expect(captured).toEqual(["a.txt", "b.txt"]);
1573
+ });
1574
+
1575
+ test("ctx.positional returns undefined for absent optional slot", async () => {
1576
+ const root: CliCommand = {
1577
+ key: "app",
1578
+ description: "",
1579
+ commands: [
1580
+ {
1581
+ key: "x",
1582
+ description: "",
1583
+ positionals: [
1584
+ { name: "opt", description: "", kind: CliOptionKind.String, argMin: 0, argMax: 1 },
1585
+ ],
1586
+ handler: (ctx) => {
1587
+ captured = ctx.positional("opt");
1588
+ },
1589
+ },
1590
+ ],
1591
+ };
1592
+ let captured: string | string[] | undefined;
1593
+ cliValidateRoot(root);
1594
+ await cliInvoke(root, ["x"]);
1595
+ expect(captured).toBeUndefined();
1596
+ });
1597
+
1598
+ test("ctx.positional varargs matches ctx.args", async () => {
1599
+ const root = varargsReadFixture();
1600
+ let positional: string | string[] | undefined;
1601
+ let args: string[] = [];
1602
+ root.commands![0]!.handler = (ctx) => {
1603
+ positional = ctx.positional("files");
1604
+ args = ctx.args;
1605
+ };
1606
+ cliValidateRoot(root);
1607
+ await cliInvoke(root, ["read", "a.txt", "b.txt"]);
1608
+ expect(positional).toEqual(args);
1609
+ });
1610
+
1611
+ test("mcpToolCallToArgv coerces comma-separated string for varargs", () => {
1612
+ const tools = collectMcpTools(nestedMcpFixture);
1613
+ const read = tools.find((t) => t.name === "read")!;
1614
+ const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: "a,b" });
1615
+ expect(argv).toEqual(["read", "a", "b"]);
1616
+ });
1617
+
1618
+ test("mcpToolCallToArgv coerces single string for varargs", () => {
1619
+ const tools = collectMcpTools(nestedMcpFixture);
1620
+ const read = tools.find((t) => t.name === "read")!;
1621
+ const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: "a" });
1622
+ expect(argv).toEqual(["read", "a"]);
1623
+ });
1624
+
1625
+ test("mcpToolCallToArgv array varargs unchanged", () => {
1626
+ const tools = collectMcpTools(nestedMcpFixture);
1627
+ const read = tools.find((t) => t.name === "read")!;
1628
+ const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: ["a", "b"] });
1629
+ expect(argv).toEqual(["read", "a", "b"]);
1630
+ });
1631
+
1632
+ test("mcpToolCallToArgv empty string varargs appends nothing", () => {
1633
+ const tools = collectMcpTools(nestedMcpFixture);
1634
+ const read = tools.find((t) => t.name === "read")!;
1635
+ const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: "" });
1636
+ expect(argv).toEqual(["read"]);
995
1637
  });