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/.cursor/plans/v1.3_parser_ergonomics_b3e91f02.plan.md +455 -0
- package/.private/scratch.md +2 -1
- package/CHANGELOG.md +27 -1
- package/README.md +20 -6
- package/docs/ai-skills.md +75 -0
- package/docs/mcp.md +12 -10
- package/index.d.ts +26 -12
- package/package.json +1 -1
- package/src/ai.ts +7 -0
- package/src/completion.ts +50 -9
- package/src/context.ts +38 -0
- package/src/index.test.ts +458 -12
- package/src/mcp/tools.ts +14 -6
- package/src/parse.ts +62 -10
- package/src/runtime.ts +42 -23
- package/src/skill/generate.ts +183 -0
- package/src/skill/install.ts +45 -0
- package/src/types.ts +23 -12
- package/src/validate.ts +21 -15
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
|
|
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"
|
|
369
|
-
expect(pr.opts["json"]).
|
|
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("
|
|
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
|
|
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: "
|
|
818
|
+
key: "ai",
|
|
817
819
|
description: "bad",
|
|
818
820
|
handler: () => {},
|
|
819
821
|
},
|
|
820
822
|
],
|
|
821
823
|
};
|
|
822
|
-
expect(() => cliValidateRoot(root)).toThrow(/Reserved command name:
|
|
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("
|
|
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 === "
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
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,
|