argsbarg 1.4.1 → 1.4.3

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
@@ -19,12 +19,14 @@ import {
19
19
  } from "./mcp/tools.ts";
20
20
  import { applyShellEnv, loadEnvFile } from "./mcp/env.ts";
21
21
  import { buildToolCallSuccess } from "./mcp/result.ts";
22
+ import { generateSkillBundle } from "./skill/generate.ts";
23
+ import { cliSkillInstall } from "./skill/install.ts";
22
24
  import { ParseKind, parse, postParseValidate } from "./parse.ts";
23
25
  import { cliSchemaJson } from "./schema.ts";
24
26
  import { cliValidateRoot } from "./validate.ts";
25
27
  import { expect, test } from "bun:test";
26
28
  import { $ } from "bun";
27
- import { mkdtempSync, writeFileSync } from "node:fs";
29
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
28
30
  import { tmpdir } from "node:os";
29
31
  import { join } from "node:path";
30
32
 
@@ -334,7 +336,7 @@ test("trailing options include parent-scoped flags", () => {
334
336
  expect(pr.opts["json"]).toBe("1");
335
337
  });
336
338
 
337
- test("varargs tail does not parse trailing options", () => {
339
+ test("varargs tail parses trailing options", () => {
338
340
  const root: CliCommand = {
339
341
  key: "app",
340
342
  description: "",
@@ -365,8 +367,8 @@ test("varargs tail does not parse trailing options", () => {
365
367
  cliValidateRoot(root);
366
368
  const pr = postParseValidate(root, parse(root, ["x", "./file", "--json"]));
367
369
  expect(pr.kind).toBe(ParseKind.Ok);
368
- expect(pr.args).toEqual(["./file", "--json"]);
369
- expect(pr.opts["json"]).toBeUndefined();
370
+ expect(pr.args).toEqual(["./file"]);
371
+ expect(pr.opts["json"]).toBe("1");
370
372
  });
371
373
 
372
374
  test("stops parsing options at --", () => {
@@ -727,7 +729,7 @@ async function mcpRequest(
727
729
  opts?: { script?: string; env?: Record<string, string> },
728
730
  ): Promise<Map<string | number, object>> {
729
731
  const script = opts?.script ?? "examples/nested.ts";
730
- const proc = Bun.spawn(["bun", "run", script, "mcp"], {
732
+ const proc = Bun.spawn(["bun", "run", script, "ai", "mcp"], {
731
733
  stdin: "pipe",
732
734
  stdout: "pipe",
733
735
  stderr: "pipe",
@@ -775,7 +777,7 @@ test("collectMcpTools lists user leaf commands only", () => {
775
777
  expect(names).toContain("stat_owner_lookup");
776
778
  expect(names).toContain("read");
777
779
  expect(names).not.toContain("hidden");
778
- expect(names).not.toContain("mcp");
780
+ expect(names).not.toContain("ai");
779
781
  expect(names).not.toContain("completion");
780
782
  const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
781
783
  expect(lookup.description).toBe("stat owner lookup — Resolve owner info.");
@@ -807,19 +809,34 @@ test("mcpToolCallToArgv expands varargs positionals", () => {
807
809
  expect(argv).toEqual(["read", "a", "b"]);
808
810
  });
809
811
 
810
- test("reserved command name mcp is rejected", () => {
812
+ test("reserved command name ai is rejected", () => {
811
813
  const root: CliCommand = {
812
814
  key: "app",
813
815
  description: "",
814
816
  commands: [
815
817
  {
816
- key: "mcp",
818
+ key: "ai",
817
819
  description: "bad",
818
820
  handler: () => {},
819
821
  },
820
822
  ],
821
823
  };
822
- expect(() => cliValidateRoot(root)).toThrow(/Reserved command name: mcp/);
824
+ expect(() => cliValidateRoot(root)).toThrow(/Reserved command name: ai/);
825
+ });
826
+
827
+ test("top-level command name mcp is allowed", () => {
828
+ const root: CliCommand = {
829
+ key: "app",
830
+ description: "",
831
+ commands: [
832
+ {
833
+ key: "mcp",
834
+ description: "user command",
835
+ handler: () => {},
836
+ },
837
+ ],
838
+ };
839
+ expect(() => cliValidateRoot(root)).not.toThrow();
823
840
  });
824
841
 
825
842
  test("mcpServer on non-root node is rejected", () => {
@@ -997,10 +1014,10 @@ test("MCP ping returns empty result", async () => {
997
1014
  expect(res.result).toEqual({});
998
1015
  });
999
1016
 
1000
- test("minimal.ts mcp without opt-in fails", async () => {
1001
- const { stderr, exitCode } = await $`bun run examples/minimal.ts mcp`.nothrow().quiet();
1017
+ test("minimal.ts ai mcp without opt-in fails", async () => {
1018
+ const { stderr, exitCode } = await $`bun run examples/minimal.ts ai mcp`.nothrow().quiet();
1002
1019
  expect(exitCode).toBe(1);
1003
- expect(stderr.toString()).toContain("mcp");
1020
+ expect(stderr.toString()).toContain("MCP is not enabled");
1004
1021
  });
1005
1022
 
1006
1023
  test("ctx.invocation is cli via cliRun", async () => {
@@ -1330,4 +1347,433 @@ test("MCP envFile loads vars for tool handlers", async () => {
1330
1347
  const res = responses.get(15) as { result: { isError: boolean; content: { text: string }[] } };
1331
1348
  expect(res.result.isError).toBe(false);
1332
1349
  expect(res.result.content[0]!.text.trim()).toBe("file-value");
1350
+ });
1351
+
1352
+ // ── v1.3 parser ergonomics ────────────────────────────────────────────────────
1353
+
1354
+ function varargsReadFixture(): CliCommand {
1355
+ return {
1356
+ key: "app",
1357
+ description: "",
1358
+ commands: [
1359
+ {
1360
+ key: "read",
1361
+ description: "Read files.",
1362
+ options: [
1363
+ {
1364
+ name: "json",
1365
+ description: "",
1366
+ kind: CliOptionKind.Presence,
1367
+ },
1368
+ ],
1369
+ positionals: [
1370
+ {
1371
+ name: "files",
1372
+ description: "",
1373
+ kind: CliOptionKind.String,
1374
+ argMin: 0,
1375
+ argMax: 0,
1376
+ },
1377
+ ],
1378
+ handler: () => {},
1379
+ },
1380
+ ],
1381
+ };
1382
+ }
1383
+
1384
+ function nestedDocsFallbackFixture(): CliCommand {
1385
+ return {
1386
+ key: "app",
1387
+ description: "",
1388
+ commands: [
1389
+ {
1390
+ key: "docs",
1391
+ description: "Documentation commands.",
1392
+ fallbackCommand: "guide",
1393
+ fallbackMode: CliFallbackMode.MissingOnly,
1394
+ commands: [
1395
+ {
1396
+ key: "guide",
1397
+ description: "User guide.",
1398
+ handler: () => {},
1399
+ },
1400
+ {
1401
+ key: "api",
1402
+ description: "API reference.",
1403
+ handler: () => {},
1404
+ },
1405
+ ],
1406
+ },
1407
+ ],
1408
+ };
1409
+ }
1410
+
1411
+ test("nested fallback routes to default when argv exhausted at router", () => {
1412
+ const root = nestedDocsFallbackFixture();
1413
+ cliValidateRoot(root);
1414
+ const pr = postParseValidate(root, parse(root, ["docs"]));
1415
+ expect(pr.kind).toBe(ParseKind.Ok);
1416
+ expect(pr.path).toEqual(["docs", "guide"]);
1417
+ });
1418
+
1419
+ test("nested fallback MissingOrUnknown routes unknown token to default", () => {
1420
+ const root: CliCommand = {
1421
+ key: "app",
1422
+ description: "",
1423
+ commands: [
1424
+ {
1425
+ key: "docs",
1426
+ description: "Documentation commands.",
1427
+ fallbackCommand: "guide",
1428
+ fallbackMode: CliFallbackMode.MissingOrUnknown,
1429
+ commands: [
1430
+ {
1431
+ key: "guide",
1432
+ description: "User guide.",
1433
+ positionals: [
1434
+ {
1435
+ name: "topic",
1436
+ description: "",
1437
+ kind: CliOptionKind.String,
1438
+ argMin: 0,
1439
+ argMax: 0,
1440
+ },
1441
+ ],
1442
+ handler: () => {},
1443
+ },
1444
+ {
1445
+ key: "api",
1446
+ description: "API reference.",
1447
+ handler: () => {},
1448
+ },
1449
+ ],
1450
+ },
1451
+ ],
1452
+ };
1453
+ cliValidateRoot(root);
1454
+ const pr = postParseValidate(root, parse(root, ["docs", "extra-topic"]));
1455
+ expect(pr.kind).toBe(ParseKind.Ok);
1456
+ expect(pr.path).toEqual(["docs", "guide"]);
1457
+ expect(pr.args).toEqual(["extra-topic"]);
1458
+ });
1459
+
1460
+ test("nested fallback MissingOnly errors on unknown subcommand", () => {
1461
+ const root = nestedDocsFallbackFixture();
1462
+ cliValidateRoot(root);
1463
+ const pr = parse(root, ["docs", "nope"]);
1464
+ expect(pr.kind).toBe(ParseKind.Error);
1465
+ expect(pr.errorMsg).toContain("Unknown subcommand");
1466
+ });
1467
+
1468
+ test("cliValidateRoot rejects invalid nested fallbackCommand", () => {
1469
+ const root: CliCommand = {
1470
+ key: "app",
1471
+ description: "",
1472
+ commands: [
1473
+ {
1474
+ key: "docs",
1475
+ description: "",
1476
+ fallbackCommand: "missing",
1477
+ commands: [
1478
+ {
1479
+ key: "guide",
1480
+ description: "",
1481
+ handler: () => {},
1482
+ },
1483
+ ],
1484
+ },
1485
+ ],
1486
+ };
1487
+ expect(() => cliValidateRoot(root)).toThrow(/fallbackCommand 'missing' is not a child of 'docs'/);
1488
+ });
1489
+
1490
+ test("cliValidateRoot accepts nested fallbackCommand when child exists", () => {
1491
+ const root = nestedDocsFallbackFixture();
1492
+ expect(() => cliValidateRoot(root)).not.toThrow();
1493
+ });
1494
+
1495
+ test("nested router scoped help does not route to fallback", () => {
1496
+ const root = nestedDocsFallbackFixture();
1497
+ cliValidateRoot(root);
1498
+ const pr = parse(root, ["docs", "--help"]);
1499
+ expect(pr.kind).toBe(ParseKind.Help);
1500
+ expect(pr.helpPath).toEqual(["docs"]);
1501
+ expect(pr.helpExplicit).toBe(true);
1502
+ const help = cliHelpRender(root, pr.helpPath, false);
1503
+ expect(help).toContain("api");
1504
+ expect(help).toContain("guide");
1505
+ });
1506
+
1507
+ test("varargs trailing option after positionals via cliInvoke", async () => {
1508
+ const root = varargsReadFixture();
1509
+ cliValidateRoot(root);
1510
+ const pr = postParseValidate(root, parse(root, ["read", "file.txt", "--json"]));
1511
+ expect(pr.kind).toBe(ParseKind.Ok);
1512
+ expect(pr.args).toEqual(["file.txt"]);
1513
+ expect(pr.opts["json"]).toBe("1");
1514
+ });
1515
+
1516
+ test("varargs option before positionals", () => {
1517
+ const root = varargsReadFixture();
1518
+ cliValidateRoot(root);
1519
+ const pr = postParseValidate(root, parse(root, ["read", "--json", "file.txt"]));
1520
+ expect(pr.kind).toBe(ParseKind.Ok);
1521
+ expect(pr.args).toEqual(["file.txt"]);
1522
+ expect(pr.opts["json"]).toBe("1");
1523
+ });
1524
+
1525
+ test("varargs multiple files then trailing option", () => {
1526
+ const root = varargsReadFixture();
1527
+ cliValidateRoot(root);
1528
+ const pr = postParseValidate(root, parse(root, ["read", "a.txt", "b.txt", "--json"]));
1529
+ expect(pr.kind).toBe(ParseKind.Ok);
1530
+ expect(pr.args).toEqual(["a.txt", "b.txt"]);
1531
+ expect(pr.opts["json"]).toBe("1");
1532
+ });
1533
+
1534
+ test("varargs double dash forces positional", () => {
1535
+ const root = varargsReadFixture();
1536
+ cliValidateRoot(root);
1537
+ const pr = postParseValidate(root, parse(root, ["read", "file.txt", "--", "--json"]));
1538
+ expect(pr.kind).toBe(ParseKind.Ok);
1539
+ expect(pr.args).toEqual(["file.txt", "--json"]);
1540
+ expect(pr.opts["json"]).toBeUndefined();
1541
+ });
1542
+
1543
+ test("varargs unknown flag errors", async () => {
1544
+ const root = varargsReadFixture();
1545
+ cliValidateRoot(root);
1546
+ const result = await cliInvoke(root, ["read", "--unknown"]);
1547
+ expect(result.kind).toBe("error");
1548
+ expect(result.stderr).toContain("--unknown");
1549
+ });
1550
+
1551
+ test("varargs scoped help in tail", () => {
1552
+ const root = varargsReadFixture();
1553
+ cliValidateRoot(root);
1554
+ const pr = parse(root, ["read", "file.txt", "--help"]);
1555
+ expect(pr.kind).toBe(ParseKind.Help);
1556
+ expect(pr.helpPath).toContain("read");
1557
+ expect(pr.helpExplicit).toBe(true);
1558
+ });
1559
+
1560
+ test("ctx.positional returns single slot value", async () => {
1561
+ const root: CliCommand = {
1562
+ key: "app",
1563
+ description: "",
1564
+ commands: [
1565
+ {
1566
+ key: "x",
1567
+ description: "",
1568
+ positionals: [{ name: "path", description: "", kind: CliOptionKind.String }],
1569
+ handler: (ctx) => {
1570
+ captured = ctx.positional("path");
1571
+ },
1572
+ },
1573
+ ],
1574
+ };
1575
+ let captured: string | string[] | undefined;
1576
+ cliValidateRoot(root);
1577
+ await cliInvoke(root, ["x", "./file"]);
1578
+ expect(captured).toBe("./file");
1579
+ });
1580
+
1581
+ test("ctx.positional returns varargs array", async () => {
1582
+ const root = varargsReadFixture();
1583
+ let captured: string | string[] | undefined;
1584
+ root.commands![0]!.handler = (ctx) => {
1585
+ captured = ctx.positional("files");
1586
+ };
1587
+ cliValidateRoot(root);
1588
+ await cliInvoke(root, ["read", "a.txt", "b.txt"]);
1589
+ expect(captured).toEqual(["a.txt", "b.txt"]);
1590
+ });
1591
+
1592
+ test("ctx.positional returns undefined for absent optional slot", async () => {
1593
+ const root: CliCommand = {
1594
+ key: "app",
1595
+ description: "",
1596
+ commands: [
1597
+ {
1598
+ key: "x",
1599
+ description: "",
1600
+ positionals: [
1601
+ { name: "opt", description: "", kind: CliOptionKind.String, argMin: 0, argMax: 1 },
1602
+ ],
1603
+ handler: (ctx) => {
1604
+ captured = ctx.positional("opt");
1605
+ },
1606
+ },
1607
+ ],
1608
+ };
1609
+ let captured: string | string[] | undefined;
1610
+ cliValidateRoot(root);
1611
+ await cliInvoke(root, ["x"]);
1612
+ expect(captured).toBeUndefined();
1613
+ });
1614
+
1615
+ test("ctx.positional varargs matches ctx.args", async () => {
1616
+ const root = varargsReadFixture();
1617
+ let positional: string | string[] | undefined;
1618
+ let args: string[] = [];
1619
+ root.commands![0]!.handler = (ctx) => {
1620
+ positional = ctx.positional("files");
1621
+ args = ctx.args;
1622
+ };
1623
+ cliValidateRoot(root);
1624
+ await cliInvoke(root, ["read", "a.txt", "b.txt"]);
1625
+ expect(positional).toEqual(args);
1626
+ });
1627
+
1628
+ test("mcpToolCallToArgv coerces comma-separated string for varargs", () => {
1629
+ const tools = collectMcpTools(nestedMcpFixture);
1630
+ const read = tools.find((t) => t.name === "read")!;
1631
+ const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: "a,b" });
1632
+ expect(argv).toEqual(["read", "a", "b"]);
1633
+ });
1634
+
1635
+ test("mcpToolCallToArgv coerces single string for varargs", () => {
1636
+ const tools = collectMcpTools(nestedMcpFixture);
1637
+ const read = tools.find((t) => t.name === "read")!;
1638
+ const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: "a" });
1639
+ expect(argv).toEqual(["read", "a"]);
1640
+ });
1641
+
1642
+ test("mcpToolCallToArgv array varargs unchanged", () => {
1643
+ const tools = collectMcpTools(nestedMcpFixture);
1644
+ const read = tools.find((t) => t.name === "read")!;
1645
+ const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: ["a", "b"] });
1646
+ expect(argv).toEqual(["read", "a", "b"]);
1647
+ });
1648
+
1649
+ test("mcpToolCallToArgv empty string varargs appends nothing", () => {
1650
+ const tools = collectMcpTools(nestedMcpFixture);
1651
+ const read = tools.find((t) => t.name === "read")!;
1652
+ const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: "" });
1653
+ expect(argv).toEqual(["read"]);
1654
+ });
1655
+
1656
+ // ── AI builtins and skills ────────────────────────────────────────────────────
1657
+
1658
+ test("aiSkill on non-root node is rejected", () => {
1659
+ const root: CliCommand = {
1660
+ key: "app",
1661
+ description: "",
1662
+ commands: [
1663
+ {
1664
+ key: "x",
1665
+ description: "",
1666
+ aiSkill: { enabled: false },
1667
+ handler: () => {},
1668
+ },
1669
+ ],
1670
+ };
1671
+ expect(() => cliValidateRoot(root)).toThrow(/aiSkill is only supported on the program root/);
1672
+ });
1673
+
1674
+ test("generateSkillBundle includes frontmatter and command catalog", () => {
1675
+ const bundle = generateSkillBundle(nestedMcpFixture, "cursor");
1676
+ expect(bundle.dirName).toBe("nested_ts");
1677
+ expect(bundle.skillMd).toMatch(/^---\nname: nested_ts\n/);
1678
+ expect(bundle.skillMd).toContain("stat owner lookup");
1679
+ expect(bundle.skillMd).toContain("ai mcp");
1680
+ expect(bundle.referenceMd).toContain("```json");
1681
+ expect(() => JSON.parse(bundle.referenceMd.match(/```json\n([\s\S]*?)\n```/)![1]!)).not.toThrow();
1682
+ });
1683
+
1684
+ test("cliSkillInstall writes project Cursor skill files", () => {
1685
+ const cwd = mkdtempSync(join(tmpdir(), "argsbarg-skill-"));
1686
+ const prev = process.cwd();
1687
+ process.chdir(cwd);
1688
+ try {
1689
+ const msg = cliSkillInstall(nestedMcpFixture, "cursor", { force: true });
1690
+ expect(msg).toContain(".cursor/skills/nested_ts/");
1691
+ const skillDir = join(cwd, ".cursor", "skills", "nested_ts");
1692
+ expect(existsSync(join(skillDir, "SKILL.md"))).toBe(true);
1693
+ expect(existsSync(join(skillDir, "reference.md"))).toBe(true);
1694
+ expect(readFileSync(join(skillDir, "SKILL.md"), "utf8")).toContain("stat owner lookup");
1695
+ } finally {
1696
+ process.chdir(prev);
1697
+ rmSync(cwd, { recursive: true, force: true });
1698
+ }
1699
+ });
1700
+
1701
+ test("cliSkillInstall global uses HOME skills directory", () => {
1702
+ const home = mkdtempSync(join(tmpdir(), "argsbarg-home-"));
1703
+ const prevHome = process.env.HOME;
1704
+ process.env.HOME = home;
1705
+ try {
1706
+ const msg = cliSkillInstall(nestedMcpFixture, "cursor", { global: true, force: true });
1707
+ expect(msg).toContain(join(home, ".cursor", "skills", "nested_ts"));
1708
+ expect(existsSync(join(home, ".cursor", "skills", "nested_ts", "SKILL.md"))).toBe(true);
1709
+ } finally {
1710
+ if (prevHome === undefined) {
1711
+ delete process.env.HOME;
1712
+ } else {
1713
+ process.env.HOME = prevHome;
1714
+ }
1715
+ rmSync(home, { recursive: true, force: true });
1716
+ }
1717
+ });
1718
+
1719
+ test("cliSkillInstall fails when directory exists without force", () => {
1720
+ const cwd = mkdtempSync(join(tmpdir(), "argsbarg-skill-dup-"));
1721
+ const prev = process.cwd();
1722
+ process.chdir(cwd);
1723
+ const prevExit = process.exit;
1724
+ let exitCode = 0;
1725
+ process.exit = ((code?: number) => {
1726
+ exitCode = code ?? 0;
1727
+ throw new Error("exit");
1728
+ }) as typeof process.exit;
1729
+ try {
1730
+ cliSkillInstall(nestedMcpFixture, "cursor", { force: true });
1731
+ try {
1732
+ cliSkillInstall(nestedMcpFixture, "cursor", {});
1733
+ } catch {
1734
+ // expected exit throw
1735
+ }
1736
+ expect(exitCode).toBe(1);
1737
+ } finally {
1738
+ process.exit = prevExit;
1739
+ process.chdir(prev);
1740
+ rmSync(cwd, { recursive: true, force: true });
1741
+ }
1742
+ });
1743
+
1744
+ test("cliSkillInstall claude target uses .claude/skills", () => {
1745
+ const cwd = mkdtempSync(join(tmpdir(), "argsbarg-skill-claude-"));
1746
+ const prev = process.cwd();
1747
+ process.chdir(cwd);
1748
+ try {
1749
+ const msg = cliSkillInstall(nestedMcpFixture, "claude", { force: true });
1750
+ expect(msg).toContain(".claude/skills/nested_ts/");
1751
+ expect(readFileSync(join(cwd, ".claude", "skills", "nested_ts", "SKILL.md"), "utf8")).toContain(
1752
+ "Claude Code",
1753
+ );
1754
+ } finally {
1755
+ process.chdir(prev);
1756
+ rmSync(cwd, { recursive: true, force: true });
1757
+ }
1758
+ });
1759
+
1760
+ test("ai skill cursor fails when aiSkill disabled", async () => {
1761
+ const dir = mkdtempSync(join(tmpdir(), "argsbarg-skill-off-"));
1762
+ const script = join(dir, "skill-off.ts");
1763
+ writeFileSync(
1764
+ script,
1765
+ `import { cliRun, CliCommand } from ${JSON.stringify(join(import.meta.dir, "index.ts"))};
1766
+ const cli: CliCommand = {
1767
+ key: "offapp",
1768
+ description: "demo",
1769
+ aiSkill: { enabled: false },
1770
+ commands: [{ key: "x", description: "x", handler: () => {} }],
1771
+ };
1772
+ await cliRun(cli);
1773
+ `,
1774
+ "utf8",
1775
+ );
1776
+ const { stderr, exitCode } = await $`bun run ${script} ai skill cursor`.nothrow().quiet();
1777
+ expect(exitCode).toBe(1);
1778
+ expect(stderr.toString()).toContain("AI skills are disabled");
1333
1779
  });
package/src/mcp/tools.ts CHANGED
@@ -150,7 +150,7 @@ export function collectMcpTools(root: CliCommand): McpToolDef[] {
150
150
  /** Walks the command tree and appends leaf tools. */
151
151
  function walk(cmd: CliCommand, path: string[]): void {
152
152
  if ("handler" in cmd && cmd.handler) {
153
- if (cmd.key === "completion" || cmd.key === "mcp") {
153
+ if (cmd.key === "completion" || cmd.key === "ai") {
154
154
  return;
155
155
  }
156
156
  if (cmd.mcpTool?.enabled === false) {
@@ -232,12 +232,20 @@ export function mcpToolCallToArgv(
232
232
  const { argMin = 1, argMax = 1 } = p;
233
233
 
234
234
  if (argMax === 0) {
235
- if (!Array.isArray(val)) {
236
- return { error: `Missing argument: ${p.name}` };
237
- }
238
- for (const item of val) {
239
- argv.push(String(item));
235
+ const raw = args[p.name];
236
+ let items: string[];
237
+ if (Array.isArray(raw)) {
238
+ items = raw.map(String);
239
+ } else if (typeof raw === "string") {
240
+ items = raw.includes(",")
241
+ ? raw.split(",").map((s) => s.trim()).filter(Boolean)
242
+ : raw.trim()
243
+ ? [raw.trim()]
244
+ : [];
245
+ } else {
246
+ items = [];
240
247
  }
248
+ argv.push(...items);
241
249
  continue;
242
250
  }
243
251
 
package/src/parse.ts CHANGED
@@ -231,11 +231,6 @@ export function collectOptionDefs(root: CliCommand, path: string[]): CliOption[]
231
231
  return defs;
232
232
  }
233
233
 
234
- /** True when every positional slot has bounded arity (no `argMax: 0` varargs tail). */
235
- function allowsTrailingOptions(positionals: CliCommand["positionals"]): boolean {
236
- return (positionals ?? []).every((p) => (p.argMax ?? 1) !== 0);
237
- }
238
-
239
234
  /** Fills `args` for a leaf from `startIdx` according to `node.positionals`. */
240
235
  function finishLeaf(
241
236
  node: CliCommand,
@@ -244,7 +239,7 @@ function finishLeaf(
244
239
  path: string[],
245
240
  opts: Record<string, string>,
246
241
  optionDefs: CliOption[],
247
- forcePositionals: boolean,
242
+ forcePositionalsIn: boolean,
248
243
  ): ParseResult {
249
244
  /** Builds a parse error for positional consumption failures. */
250
245
  function errorResult(msg: string): ParseResult {
@@ -263,6 +258,7 @@ function finishLeaf(
263
258
 
264
259
  let idx = startIdx;
265
260
  const args: string[] = [];
261
+ let forcePositionals = forcePositionalsIn;
266
262
 
267
263
  for (const p of node.positionals ?? []) {
268
264
  const { argMin = 1, argMax = 1 } = p;
@@ -288,9 +284,37 @@ function finishLeaf(
288
284
  let count = 0;
289
285
  if (argMax === 0) {
290
286
  while (idx < argv.length) {
291
- args.push(argv[idx]);
292
- idx += 1;
293
- count += 1;
287
+ const tok = argv[idx];
288
+
289
+ if (!forcePositionals && tok === "--") {
290
+ forcePositionals = true;
291
+ idx++;
292
+ continue;
293
+ }
294
+
295
+ if (!forcePositionals && isHelpTok(tok)) {
296
+ return helpResult(path, true);
297
+ }
298
+
299
+ if (!forcePositionals && tok.startsWith("-")) {
300
+ // MUST be false — lenient mode swallows unknown flags as positionals silently
301
+ const tailRep = consumeOptions(optionDefs, false, argv, idx, opts);
302
+ if (tailRep.report.err) {
303
+ return errorResult(tailRep.report.err);
304
+ }
305
+ if (tailRep.report.sawDoubleDash) {
306
+ forcePositionals = true;
307
+ }
308
+ if (tailRep.nextIndex > idx) {
309
+ idx = tailRep.nextIndex;
310
+ continue;
311
+ }
312
+ return errorResult(`Unexpected option token: ${tok}`);
313
+ }
314
+
315
+ args.push(tok);
316
+ idx++;
317
+ count++;
294
318
  }
295
319
  } else {
296
320
  while (count < argMax && idx < argv.length) {
@@ -305,7 +329,7 @@ function finishLeaf(
305
329
  }
306
330
 
307
331
  if (idx < argv.length) {
308
- if (forcePositionals || !allowsTrailingOptions(node.positionals)) {
332
+ if (forcePositionals) {
309
333
  return errorResult("Unexpected extra arguments");
310
334
  }
311
335
 
@@ -483,6 +507,19 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
483
507
 
484
508
  if (i >= argv.length) {
485
509
  if ((current.commands ?? []).length > 0) {
510
+ const fb = current.fallbackCommand;
511
+ const fm = current.fallbackMode ?? CliFallbackMode.MissingOnly;
512
+ if (
513
+ fb !== undefined &&
514
+ (fm === CliFallbackMode.MissingOnly || fm === CliFallbackMode.MissingOrUnknown)
515
+ ) {
516
+ const fbNode = findChild(current.commands ?? [], fb);
517
+ if (fbNode) {
518
+ path.push(fb);
519
+ current = fbNode;
520
+ continue;
521
+ }
522
+ }
486
523
  return helpResult(path, false);
487
524
  }
488
525
  return finishLeaf(current, i, argv, path, opts, collectOptionDefs(root, path), forcePositionals);
@@ -513,6 +550,21 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
513
550
  }
514
551
 
515
552
  if ((current.commands ?? []).length > 0) {
553
+ const fb = current.fallbackCommand;
554
+ const fm = current.fallbackMode ?? CliFallbackMode.MissingOnly;
555
+ const canRouteUnknown =
556
+ fb !== undefined &&
557
+ (fm === CliFallbackMode.MissingOrUnknown || fm === CliFallbackMode.UnknownOnly);
558
+
559
+ if (canRouteUnknown) {
560
+ const fbNode = findChild(current.commands ?? [], fb!);
561
+ if (fbNode) {
562
+ path.push(fb!);
563
+ current = fbNode;
564
+ continue;
565
+ }
566
+ }
567
+
516
568
  return {
517
569
  kind: ParseKind.Error,
518
570
  path,