argsbarg 3.6.4 → 4.0.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.
Files changed (98) hide show
  1. package/CHANGELOG.md +32 -2
  2. package/README.md +21 -9
  3. package/docs/README.md +12 -8
  4. package/docs/bundled-docs.md +1 -1
  5. package/docs/cli-program.md +62 -2
  6. package/docs/config-schema.md +192 -0
  7. package/docs/developing.md +13 -0
  8. package/docs/install.md +38 -1
  9. package/docs/mcp.md +43 -19
  10. package/docs/output-schema.md +74 -52
  11. package/docs/templates/cursor/rules/cli-program.mdc +10 -5
  12. package/examples/config-app/main.ts +20 -0
  13. package/examples/config-app/program.ts +81 -0
  14. package/examples/config-app/schema.ts +37 -0
  15. package/examples/config-app/types.ts +19 -0
  16. package/examples/consumer-app/README.md +56 -0
  17. package/examples/consumer-app/bun.lock +75 -0
  18. package/examples/consumer-app/capabilities.test.ts +69 -0
  19. package/examples/consumer-app/package.json +17 -0
  20. package/examples/consumer-app/schemas/configSchemas.ts +6 -0
  21. package/examples/consumer-app/schemas/generated/app-config.json +40 -0
  22. package/examples/consumer-app/schemas/generated/status.json +28 -0
  23. package/examples/consumer-app/schemas/outputSchemas.ts +6 -0
  24. package/examples/consumer-app/scripts/schemagen/discover-schema-roots.test.ts +25 -0
  25. package/examples/consumer-app/scripts/schemagen/discover-schema-roots.ts +93 -0
  26. package/examples/consumer-app/scripts/schemagen/naming.ts +82 -0
  27. package/examples/consumer-app/scripts/schemagen.ts +76 -0
  28. package/examples/consumer-app/src/commands/status/types.ts +11 -0
  29. package/examples/consumer-app/src/main.ts +15 -0
  30. package/examples/consumer-app/src/program.ts +116 -0
  31. package/examples/consumer-app/src/types.ts +23 -0
  32. package/examples/consumer-app/tsconfig.json +14 -0
  33. package/examples/formats.ts +10 -3
  34. package/examples/mcp-test.ts +27 -8
  35. package/examples/minimal.ts +4 -3
  36. package/examples/nested.ts +5 -4
  37. package/examples/option-required.ts +8 -4
  38. package/index.d.ts +158 -75
  39. package/package.json +1 -1
  40. package/src/builtins/builtins.test.ts +13 -0
  41. package/src/builtins/config.test.ts +82 -0
  42. package/src/builtins/config.ts +220 -0
  43. package/src/builtins/dispatch.ts +18 -9
  44. package/src/builtins/export.ts +8 -33
  45. package/src/builtins/index.ts +1 -0
  46. package/src/builtins/install.ts +13 -0
  47. package/src/builtins/mcp.ts +1 -1
  48. package/src/builtins/presentation.ts +2 -17
  49. package/src/builtins/registry.ts +40 -0
  50. package/src/capabilities.ts +46 -0
  51. package/src/cli-errors.ts +15 -0
  52. package/src/cli.ts +389 -0
  53. package/src/config/bootstrap.ts +265 -0
  54. package/src/config/context.test.ts +79 -0
  55. package/src/config/context.ts +110 -0
  56. package/src/config/entry.ts +81 -0
  57. package/src/config/file.test.ts +112 -0
  58. package/src/config/file.ts +120 -0
  59. package/src/config/manifest.ts +62 -0
  60. package/src/config/resolve.test.ts +88 -0
  61. package/src/config/resolve.ts +167 -0
  62. package/src/config/schema.ts +101 -0
  63. package/src/config/validate.test.ts +63 -0
  64. package/src/config/validate.ts +292 -0
  65. package/src/config.integration.test.ts +100 -0
  66. package/src/context.ts +5 -0
  67. package/src/docs/docs.test.ts +16 -16
  68. package/src/docs/mcp-guide.ts +35 -11
  69. package/src/hidden-mcpb.test.ts +40 -2
  70. package/src/index.ts +4 -3
  71. package/src/install/index.ts +46 -3
  72. package/src/install/paths.ts +5 -20
  73. package/src/install/plan.ts +6 -0
  74. package/src/install/status.ts +12 -0
  75. package/src/install/uninstall.ts +11 -0
  76. package/src/install/update.test.ts +5 -5
  77. package/src/invoke.test.ts +207 -0
  78. package/src/mcp/bundle.ts +9 -116
  79. package/src/mcp/claude.test.ts +73 -0
  80. package/src/mcp/claude.ts +168 -0
  81. package/src/mcp/env.ts +3 -37
  82. package/src/mcp/server.ts +18 -10
  83. package/src/mcp/tools.ts +3 -7
  84. package/src/mcp/zip.ts +82 -0
  85. package/src/mcp.integration.test.ts +502 -0
  86. package/src/{index.test.ts → parse.test.ts} +24 -935
  87. package/src/paths/host.ts +40 -0
  88. package/src/schema.ts +1 -1
  89. package/src/skill/generate.ts +18 -5
  90. package/src/skill/hint.ts +18 -0
  91. package/src/skill/install.ts +1 -5
  92. package/src/test-fixtures.ts +192 -0
  93. package/src/types.ts +39 -13
  94. package/src/validate.ts +70 -0
  95. package/src/completion.ts +0 -13
  96. package/src/invoke.ts +0 -217
  97. package/src/mcp.ts +0 -28
  98. package/src/runtime.ts +0 -134
@@ -1,52 +1,36 @@
1
1
  /*
2
- This test file covers parsing, validation, and completion regressions.
3
- It exercises the public API rather than internal helpers so the tests follow the same
4
- paths that users and example CLIs take.
5
-
6
- It keeps the CLI contract stable by catching routing, option handling, and generated
7
- shell output regressions.
2
+ Domain-specific regression tests (split from index.test.ts).
8
3
  */
9
4
 
10
5
  import { expect, test } from "bun:test";
6
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { $ } from "bun";
10
+ import { completionBashScript, completionZshScript } from "./builtins/index.ts";
11
11
  import { cliPresentationRoot } from "./builtins/presentation.ts";
12
- import { completionBashScript, completionZshScript } from "./completion.ts";
13
12
  import { cliHelpRender } from "./help.ts";
14
- import {
15
- type CliContext,
16
- CliFallbackMode,
17
- CliOptionKind,
18
- type CliProgram,
19
- cliInvoke,
20
- } from "./index.ts";
21
- import { applyShellEnv, loadEnvFile } from "./mcp/env.ts";
22
- import { buildToolCallSuccess } from "./mcp/result.ts";
13
+ import { Cli, CliFallbackMode, CliOptionKind, type CliProgram } from "./index.ts";
14
+ import { applyShellEnv } from "./mcp/env.ts";
23
15
  import {
24
16
  allMcpResources,
25
17
  collectMcpTools,
26
18
  mcpToolCallToArgv,
27
- mcpToolDescription,
28
19
  resolveMcpSchemaUri,
29
- sanitizeToolSegment,
30
20
  } from "./mcp/tools.ts";
31
21
  import { ParseKind, parse, postParseValidate } from "./parse.ts";
32
- import { cliSchemaExport, cliSchemaJson } from "./schema.ts";
22
+ import { cliSchemaJson } from "./schema.ts";
33
23
  import { generateSkillBundle } from "./skill/generate.ts";
34
24
  import { cliSkillInstall } from "./skill/install.ts";
35
- import type { CliLeaf } from "./types.ts";
36
- import { isCliRouter } from "./types.ts";
25
+ import {
26
+ enumMcpFixture,
27
+ nestedDocsFallbackFixture,
28
+ nestedMcpFixture,
29
+ testProgram,
30
+ varargsReadFixture,
31
+ } from "./test-fixtures.ts";
37
32
  import { cliValidateProgram } from "./validate.ts";
38
33
 
39
- function testProgram(
40
- prog: Record<string, unknown> & { key: string; description: string },
41
- ): CliProgram {
42
- return { version: "0.0.0", ...prog } as CliProgram;
43
- }
44
-
45
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
46
- import { tmpdir } from "node:os";
47
- import { join } from "node:path";
48
- import { $ } from "bun";
49
-
50
34
  test("bundled short presence flags", () => {
51
35
  const root = testProgram({
52
36
  key: "app",
@@ -740,613 +724,6 @@ test("root help includes program notes and agent hint", () => {
740
724
  expect(help).toContain("myapp docs skill");
741
725
  });
742
726
 
743
- const nestedMcpFixture = testProgram({
744
- key: "nested.ts",
745
- description: "Nested groups demo.",
746
- version: "1.0.0",
747
- mcpServer: { enabled: true },
748
- commands: [
749
- {
750
- key: "stat",
751
- description: "File metadata.",
752
- options: [
753
- {
754
- name: "json",
755
- description: "Emit handler output as JSON.",
756
- kind: CliOptionKind.Presence,
757
- },
758
- ],
759
- commands: [
760
- {
761
- key: "owner",
762
- description: "Ownership helpers.",
763
- commands: [
764
- {
765
- key: "lookup",
766
- description: "Resolve owner info.",
767
- options: [
768
- {
769
- name: "user-name",
770
- description: "User to look up.",
771
- kind: CliOptionKind.String,
772
- shortName: "u",
773
- },
774
- ],
775
- positionals: [
776
- {
777
- name: "path",
778
- description: "File or directory.",
779
- kind: CliOptionKind.String,
780
- },
781
- ],
782
- handler: () => {},
783
- },
784
- ],
785
- },
786
- ],
787
- },
788
- {
789
- key: "read",
790
- description: "Print the first line of each file.",
791
- positionals: [
792
- {
793
- name: "files",
794
- description: "Paths to read.",
795
- kind: CliOptionKind.String,
796
- argMax: 0,
797
- },
798
- ],
799
- handler: () => {},
800
- },
801
- {
802
- key: "hidden",
803
- description: "Internal debug.",
804
- mcpTool: { enabled: false },
805
- handler: () => {},
806
- },
807
- ],
808
- fallbackCommand: "read",
809
- fallbackMode: CliFallbackMode.MissingOrUnknown,
810
- });
811
-
812
- /** Sends NDJSON MCP requests to a subprocess and collects responses by id. */
813
- async function mcpRequest(
814
- requests: object[],
815
- opts?: { script?: string; env?: Record<string, string> },
816
- ): Promise<Map<string | number, object>> {
817
- const script = opts?.script ?? "examples/nested.ts";
818
- const proc = Bun.spawn(["bun", "run", script, "mcp"], {
819
- stdin: "pipe",
820
- stdout: "pipe",
821
- stderr: "pipe",
822
- env: opts?.env ? { ...process.env, ...opts.env } : process.env,
823
- });
824
-
825
- const input = requests.map((r) => `${JSON.stringify(r)}\n`).join("");
826
- proc.stdin.write(input);
827
- proc.stdin.end();
828
-
829
- const timeout = setTimeout(() => proc.kill(), 10_000);
830
- const stdout = await new Response(proc.stdout).text();
831
- await proc.exited;
832
- clearTimeout(timeout);
833
-
834
- const byId = new Map<string | number, object>();
835
- for (const line of stdout.split("\n")) {
836
- const trimmed = line.trim();
837
- if (!trimmed) {
838
- continue;
839
- }
840
- const msg = JSON.parse(trimmed) as { id?: string | number };
841
- if (msg.id !== undefined) {
842
- byId.set(msg.id, msg);
843
- }
844
- }
845
- return byId;
846
- }
847
-
848
- test("sanitizeToolSegment normalizes dotted app keys", () => {
849
- expect(sanitizeToolSegment("minimal.ts")).toBe("minimal_ts");
850
- });
851
-
852
- test("mcpToolDescription formats CLI path and root-leaf prefix", () => {
853
- expect(mcpToolDescription(["stat", "owner", "lookup"], "nested.ts", "Resolve owner info.")).toBe(
854
- "stat owner lookup — Resolve owner info.",
855
- );
856
- expect(mcpToolDescription(["read"], "nested.ts", "Print files.")).toBe("read — Print files.");
857
- expect(mcpToolDescription([], "helloapp", "Tiny demo.")).toBe("helloapp — Tiny demo.");
858
- });
859
-
860
- test("collectMcpTools lists user leaf commands only", () => {
861
- const tools = collectMcpTools(nestedMcpFixture);
862
- const names = tools.map((t) => t.name);
863
- expect(names).toContain("stat_owner_lookup");
864
- expect(names).toContain("read");
865
- expect(names).not.toContain("hidden");
866
- expect(names).not.toContain("install");
867
- expect(names).not.toContain("mcp");
868
- expect(names).not.toContain("completion");
869
- const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
870
- expect(lookup.description).toBe("stat owner lookup — Resolve owner info.");
871
- });
872
-
873
- test("collectMcpTools appends leaf notes to MCP tool description", () => {
874
- const root = testProgram({
875
- key: "app",
876
- version: "1.0.0",
877
- description: "Notes demo.",
878
- mcpServer: { enabled: true },
879
- commands: [
880
- {
881
- key: "run",
882
- description: "Run.",
883
- notes: "Use `--json` for structured output.",
884
- handler: () => {},
885
- },
886
- ],
887
- });
888
- const tools = collectMcpTools(root);
889
- expect(tools[0]?.description).toBe("run — Run.\n\nUse `--json` for structured output.");
890
- });
891
-
892
- test("collectMcpTools appends notes after mcpTool.description override", () => {
893
- const root = testProgram({
894
- key: "app",
895
- version: "1.0.0",
896
- description: "Notes demo.",
897
- mcpServer: { enabled: true },
898
- commands: [
899
- {
900
- key: "run",
901
- description: "Run.",
902
- notes: "Operational hint.",
903
- mcpTool: { description: "Custom MCP text." },
904
- handler: () => {},
905
- },
906
- ],
907
- });
908
- const tools = collectMcpTools(root);
909
- expect(tools[0]?.description).toBe("Custom MCP text.\n\nOperational hint.");
910
- });
911
-
912
- test("collectMcpTools resolves {argsbarg:program} in appended notes", () => {
913
- const root = testProgram({
914
- key: "myapp",
915
- version: "1.0.0",
916
- description: "Notes demo.",
917
- mcpServer: { enabled: true },
918
- commands: [
919
- {
920
- key: "run",
921
- description: "Run.",
922
- notes: "See `{argsbarg:program} docs api`.",
923
- handler: () => {},
924
- },
925
- ],
926
- });
927
- const tools = collectMcpTools(root);
928
- expect(tools[0]?.description).toContain("See `myapp docs api`.");
929
- });
930
-
931
- test("cliSchemaExport includes leaf outputSchema", () => {
932
- const root = testProgram({
933
- key: "app",
934
- version: "1.0.0",
935
- description: "Schema export demo.",
936
- mcpServer: { enabled: true },
937
- commands: [
938
- {
939
- key: "run",
940
- description: "Run.",
941
- outputSchema: {
942
- type: "object",
943
- properties: { ok: { type: "boolean" } },
944
- },
945
- handler: () => {},
946
- },
947
- ],
948
- });
949
- const schema = cliSchemaExport(root);
950
- expect(schema.commands?.[0]?.outputSchema).toEqual({
951
- type: "object",
952
- properties: { ok: { type: "boolean" } },
953
- });
954
- });
955
-
956
- test("cliSchemaExport accepts legacy mcpTool.outputSchema", () => {
957
- const root = testProgram({
958
- key: "app",
959
- version: "1.0.0",
960
- description: "Schema export demo.",
961
- commands: [
962
- {
963
- key: "run",
964
- description: "Run.",
965
- mcpTool: {
966
- outputSchema: { type: "object", properties: { id: { type: "string" } } },
967
- },
968
- handler: () => {},
969
- },
970
- ],
971
- });
972
- expect(cliSchemaExport(root).commands?.[0]?.outputSchema).toEqual({
973
- type: "object",
974
- properties: { id: { type: "string" } },
975
- });
976
- });
977
-
978
- test("outputSchema must be a JSON Schema object", () => {
979
- const root = testProgram({
980
- key: "app",
981
- version: "1.0.0",
982
- description: "Bad output schema.",
983
- commands: [
984
- {
985
- key: "run",
986
- description: "Run.",
987
- outputSchema: [] as unknown as Record<string, unknown>,
988
- handler: () => {},
989
- },
990
- ],
991
- });
992
- expect(() => cliValidateProgram(root)).toThrow(/outputSchema must be a JSON Schema object/);
993
- });
994
-
995
- test("outputSchema cannot be set on both leaf and mcpTool", () => {
996
- const root = testProgram({
997
- key: "app",
998
- version: "1.0.0",
999
- description: "Duplicate output schema.",
1000
- commands: [
1001
- {
1002
- key: "run",
1003
- description: "Run.",
1004
- outputSchema: { type: "object" },
1005
- mcpTool: { outputSchema: { type: "object" } },
1006
- handler: () => {},
1007
- },
1008
- ],
1009
- });
1010
- expect(() => cliValidateProgram(root)).toThrow(/Set outputSchema on the leaf only/);
1011
- });
1012
-
1013
- test("collectMcpTools merges parent options into inputSchema", () => {
1014
- const tools = collectMcpTools(nestedMcpFixture);
1015
- const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
1016
- const schema = lookup.inputSchema as { properties: Record<string, unknown>; required?: string[] };
1017
- expect(schema.properties.json).toBeDefined();
1018
- expect(schema.required).toContain("path");
1019
- });
1020
-
1021
- test("collectMcpTools includes outputSchema when set on leaf", () => {
1022
- const root = testProgram({
1023
- key: "app",
1024
- version: "1.0.0",
1025
- description: "Output schema demo.",
1026
- mcpServer: { enabled: true },
1027
- commands: [
1028
- {
1029
- key: "run",
1030
- description: "Run with JSON output.",
1031
- outputSchema: {
1032
- type: "object",
1033
- properties: { ok: { type: "boolean" } },
1034
- required: ["ok"],
1035
- },
1036
- handler: () => {},
1037
- },
1038
- ],
1039
- });
1040
- const tools = collectMcpTools(root);
1041
- expect(tools).toHaveLength(1);
1042
- expect(tools[0]?.outputSchema).toEqual({
1043
- type: "object",
1044
- properties: { ok: { type: "boolean" } },
1045
- required: ["ok"],
1046
- });
1047
- });
1048
-
1049
- test("collectMcpTools omits outputSchema when leaf has none", () => {
1050
- const tools = collectMcpTools(nestedMcpFixture);
1051
- const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
1052
- expect(lookup.outputSchema).toBeUndefined();
1053
- });
1054
-
1055
- test("mcpToolCallToArgv builds nested lookup argv", () => {
1056
- const tools = collectMcpTools(nestedMcpFixture);
1057
- const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
1058
- const argv = mcpToolCallToArgv(nestedMcpFixture, lookup, {
1059
- "user-name": "alice",
1060
- path: "./x",
1061
- json: true,
1062
- });
1063
- expect(argv).toEqual(["stat", "owner", "lookup", "--json", "--user-name", "alice", "./x"]);
1064
- });
1065
-
1066
- test("mcpToolCallToArgv expands varargs positionals", () => {
1067
- const tools = collectMcpTools(nestedMcpFixture);
1068
- const read = tools.find((t) => t.name === "read")!;
1069
- const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: ["a", "b"] });
1070
- expect(argv).toEqual(["read", "a", "b"]);
1071
- });
1072
-
1073
- test("reserved command name install is rejected", () => {
1074
- const root = testProgram({
1075
- key: "app",
1076
- description: "",
1077
- commands: [
1078
- {
1079
- key: "install",
1080
- description: "bad",
1081
- handler: () => {},
1082
- },
1083
- ],
1084
- });
1085
- expect(() => cliValidateProgram(root)).toThrow(/Reserved command name: install/);
1086
- });
1087
-
1088
- test("top-level command name mcp is allowed without mcpServer", () => {
1089
- const root = testProgram({
1090
- key: "app",
1091
- description: "",
1092
- commands: [
1093
- {
1094
- key: "mcp",
1095
- description: "user command",
1096
- handler: () => {},
1097
- },
1098
- ],
1099
- });
1100
- expect(() => cliValidateProgram(root)).not.toThrow();
1101
- });
1102
-
1103
- test("top-level command name mcp is rejected when mcpServer is enabled", () => {
1104
- const root = testProgram({
1105
- key: "app",
1106
- description: "",
1107
- mcpServer: { enabled: true },
1108
- commands: [
1109
- {
1110
- key: "mcp",
1111
- description: "user command",
1112
- handler: () => {},
1113
- },
1114
- ],
1115
- });
1116
- expect(() => cliValidateProgram(root)).toThrow(/Reserved command name: mcp/);
1117
- });
1118
-
1119
- test("mcpServer on non-root node is rejected", () => {
1120
- const root = {
1121
- key: "app",
1122
- version: "0.0.0",
1123
- description: "",
1124
- commands: [
1125
- {
1126
- key: "x",
1127
- description: "cmd",
1128
- mcpServer: { enabled: true },
1129
- handler: () => {},
1130
- },
1131
- ],
1132
- } as unknown as CliProgram;
1133
- expect(() => cliValidateProgram(root)).toThrow(/mcpServer is only supported on the program root/);
1134
- });
1135
-
1136
- test("mcpTool on root is rejected", () => {
1137
- const root = testProgram({
1138
- key: "app",
1139
- description: "",
1140
- mcpTool: { enabled: false },
1141
- handler: () => {},
1142
- });
1143
- expect(() => cliValidateProgram(root)).toThrow(/mcpTool is only supported on leaf commands/);
1144
- });
1145
-
1146
- test("mcpTool on routing node is rejected", () => {
1147
- const root = testProgram({
1148
- key: "app",
1149
- description: "",
1150
- commands: [
1151
- {
1152
- key: "group",
1153
- description: "group",
1154
- mcpTool: { enabled: false },
1155
- commands: [
1156
- {
1157
- key: "leaf",
1158
- description: "leaf",
1159
- handler: () => {},
1160
- },
1161
- ],
1162
- },
1163
- ],
1164
- });
1165
- expect(() => cliValidateProgram(root)).toThrow(/mcpTool is only supported on leaf commands/);
1166
- });
1167
-
1168
- test("buildToolCallSuccess returns stdout only", () => {
1169
- const result = buildToolCallSuccess("hello\n", "");
1170
- expect(result.isError).toBe(false);
1171
- expect(result.content).toEqual([{ type: "text", text: "hello\n" }]);
1172
- expect(result.structuredContent).toBeUndefined();
1173
- });
1174
-
1175
- test("buildToolCallSuccess adds stderr as second content block", () => {
1176
- const result = buildToolCallSuccess("out\n", "warn\n");
1177
- expect(result.content).toEqual([
1178
- { type: "text", text: "out\n" },
1179
- { type: "text", text: "warn" },
1180
- ]);
1181
- expect(result.structuredContent).toBeUndefined();
1182
- });
1183
-
1184
- test("buildToolCallSuccess stderr-only still includes stdout slot", () => {
1185
- const result = buildToolCallSuccess("", "warn\n");
1186
- expect(result.content).toEqual([
1187
- { type: "text", text: "" },
1188
- { type: "text", text: "warn" },
1189
- ]);
1190
- });
1191
-
1192
- test("buildToolCallSuccess parses JSON structuredContent", () => {
1193
- const result = buildToolCallSuccess('{"a":1}\n', "");
1194
- expect(result.structuredContent).toEqual({ a: 1 });
1195
- expect(result.content[0]?.text).toBe('{"a":1}\n');
1196
- });
1197
-
1198
- test("buildToolCallSuccess skips structuredContent for plain text", () => {
1199
- const result = buildToolCallSuccess("lookup user=x\n", "");
1200
- expect(result.structuredContent).toBeUndefined();
1201
- });
1202
-
1203
- test("buildToolCallSuccess parses JSON primitives", () => {
1204
- const result = buildToolCallSuccess("true\n", "");
1205
- expect(result.structuredContent).toBe(true);
1206
- });
1207
-
1208
- test("MCP initialize returns tools and resources capabilities", async () => {
1209
- const responses = await mcpRequest([{ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }]);
1210
- const res = responses.get(1) as { result: { capabilities: Record<string, unknown> } };
1211
- expect(res.result.capabilities.tools).toBeDefined();
1212
- expect(res.result.capabilities.resources).toBeDefined();
1213
- });
1214
-
1215
- test("MCP tools/list includes stat_owner_lookup", async () => {
1216
- const responses = await mcpRequest([{ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }]);
1217
- const res = responses.get(2) as {
1218
- result: { tools: { name: string; inputSchema: { required?: string[] } }[] };
1219
- };
1220
- const lookup = res.result.tools.find((t) => t.name === "stat_owner_lookup");
1221
- expect(lookup).toBeDefined();
1222
- expect(lookup?.inputSchema.required).toContain("path");
1223
- });
1224
-
1225
- test("MCP resources/read returns schema JSON", async () => {
1226
- const responses = await mcpRequest([
1227
- { jsonrpc: "2.0", id: 3, method: "resources/read", params: { uri: "nested_ts://schema" } },
1228
- ]);
1229
- const res = responses.get(3) as { result: { contents: { text: string }[] } };
1230
- const schema = JSON.parse(res.result.contents[0]?.text);
1231
- expect(schema.key).toBe("nested.ts");
1232
- });
1233
-
1234
- test("MCP tools/call runs stat_owner_lookup", async () => {
1235
- const readme = join(import.meta.dir, "..", "README.md");
1236
- const responses = await mcpRequest([
1237
- {
1238
- jsonrpc: "2.0",
1239
- id: 4,
1240
- method: "tools/call",
1241
- params: {
1242
- name: "stat_owner_lookup",
1243
- arguments: { path: readme, "user-name": "test" },
1244
- },
1245
- },
1246
- ]);
1247
- const res = responses.get(4) as { result: { content: { text: string }[]; isError: boolean } };
1248
- expect(res.result.isError).toBe(false);
1249
- expect(res.result.content[0]?.text).toContain("lookup user=test");
1250
- });
1251
-
1252
- test("MCP tools/call returns structuredContent for JSON stdout", async () => {
1253
- const readme = join(import.meta.dir, "..", "README.md");
1254
- const responses = await mcpRequest([
1255
- {
1256
- jsonrpc: "2.0",
1257
- id: 6,
1258
- method: "tools/call",
1259
- params: {
1260
- name: "stat_owner_lookup",
1261
- arguments: { path: readme, "user-name": "test", json: true },
1262
- },
1263
- },
1264
- ]);
1265
- const res = responses.get(6) as {
1266
- result: {
1267
- content: { text: string }[];
1268
- structuredContent?: { user: string; path: string };
1269
- isError: boolean;
1270
- };
1271
- };
1272
- expect(res.result.isError).toBe(false);
1273
- expect(res.result.structuredContent).toEqual({ user: "test", path: readme });
1274
- expect(JSON.parse(res.result.content[0]?.text.trim())).toEqual({ user: "test", path: readme });
1275
- });
1276
-
1277
- test("MCP tools/call errors on missing required positional", async () => {
1278
- const responses = await mcpRequest([
1279
- {
1280
- jsonrpc: "2.0",
1281
- id: 5,
1282
- method: "tools/call",
1283
- params: { name: "stat_owner_lookup", arguments: { "user-name": "test" } },
1284
- },
1285
- ]);
1286
- const res = responses.get(5) as { result: { isError: boolean; content: { text: string }[] } };
1287
- expect(res.result.isError).toBe(true);
1288
- expect(res.result.content[0]?.text).toContain("Missing argument: path");
1289
- });
1290
-
1291
- test("MCP ping returns empty result", async () => {
1292
- const responses = await mcpRequest([{ jsonrpc: "2.0", id: 99, method: "ping", params: {} }]);
1293
- const res = responses.get(99) as { result: Record<string, never> };
1294
- expect(res.result).toEqual({});
1295
- });
1296
-
1297
- test("minimal.ts mcp without opt-in fails", async () => {
1298
- const { stderr, exitCode } = await $`bun run examples/minimal.ts mcp`.nothrow().quiet();
1299
- expect(exitCode).toBe(1);
1300
- expect(stderr.toString()).toContain("MCP is not enabled");
1301
- });
1302
-
1303
- test("ctx.invocation is cli via cliRun", async () => {
1304
- const indexPath = join(import.meta.dir, "index.ts");
1305
- const { stdout } = await $`bun -e ${`
1306
- import { cliRun, CliProgram } from ${JSON.stringify(indexPath)};
1307
- const cli = { key: "t", description: "d", version: "0.0.0", handler: (ctx) => console.log(ctx.invocation) };
1308
- await cliRun(cli, []);
1309
- `}`.quiet();
1310
- expect(stdout.toString().trim()).toBe("cli");
1311
- });
1312
-
1313
- test("ctx.invocation is mcp via cliInvoke", async () => {
1314
- let seen = "";
1315
- const root = testProgram({
1316
- key: "app",
1317
- description: "",
1318
- handler: (ctx: CliContext) => {
1319
- seen = ctx.invocation;
1320
- },
1321
- });
1322
- cliValidateProgram(root);
1323
- const result = await cliInvoke(root, []);
1324
- expect(result.kind).toBe("ok");
1325
- expect(seen).toBe("mcp");
1326
- });
1327
-
1328
- const enumMcpFixture = testProgram({
1329
- key: "app",
1330
- description: "",
1331
- mcpServer: { enabled: true },
1332
- commands: [
1333
- {
1334
- key: "run",
1335
- description: "Run with mode.",
1336
- options: [
1337
- {
1338
- name: "mode",
1339
- description: "Mode.",
1340
- kind: CliOptionKind.Enum,
1341
- choices: ["dev", "prod"],
1342
- required: true,
1343
- },
1344
- ],
1345
- handler: () => {},
1346
- },
1347
- ],
1348
- });
1349
-
1350
727
  test("Enum option inputSchema includes enum array", () => {
1351
728
  const tools = collectMcpTools(enumMcpFixture);
1352
729
  const run = tools.find((t) => t.name === "run")!;
@@ -1354,50 +731,6 @@ test("Enum option inputSchema includes enum array", () => {
1354
731
  expect(schema.properties.mode.enum).toEqual(["dev", "prod"]);
1355
732
  });
1356
733
 
1357
- test("cliInvoke rejects invalid Enum value", async () => {
1358
- const root = testProgram({
1359
- key: "app",
1360
- description: "",
1361
- handler: () => {},
1362
- options: [
1363
- {
1364
- name: "mode",
1365
- description: "Mode.",
1366
- kind: CliOptionKind.Enum,
1367
- choices: ["dev", "prod"],
1368
- required: true,
1369
- },
1370
- ],
1371
- });
1372
- cliValidateProgram(root);
1373
- const result = await cliInvoke(root, ["--mode", "staging"]);
1374
- expect(result.kind).toBe("error");
1375
- expect(result.errorMsg).toContain("not one of");
1376
- });
1377
-
1378
- test("cliInvoke accepts valid Enum value", async () => {
1379
- const root = testProgram({
1380
- key: "app",
1381
- description: "",
1382
- handler: (ctx: CliContext) => {
1383
- console.log(ctx.stringOpt("mode"));
1384
- },
1385
- options: [
1386
- {
1387
- name: "mode",
1388
- description: "Mode.",
1389
- kind: CliOptionKind.Enum,
1390
- choices: ["dev", "prod"],
1391
- required: true,
1392
- },
1393
- ],
1394
- });
1395
- cliValidateProgram(root);
1396
- const result = await cliInvoke(root, ["--mode", "dev"]);
1397
- expect(result.kind).toBe("ok");
1398
- expect(result.stdout.trim()).toBe("dev");
1399
- });
1400
-
1401
734
  test("cliValidateProgram rejects Enum with no choices", () => {
1402
735
  const root = testProgram({
1403
736
  key: "app",
@@ -1418,7 +751,7 @@ test("cliValidateProgram rejects Enum with duplicate choices", () => {
1418
751
  expect(() => cliValidateProgram(root)).toThrow(/choices must be distinct/);
1419
752
  });
1420
753
 
1421
- test("mcpTool.description override wins without requiresEnv suffix", () => {
754
+ test("mcpTool.description override wins without env suffix", () => {
1422
755
  const root = testProgram({
1423
756
  key: "app",
1424
757
  description: "",
@@ -1427,7 +760,7 @@ test("mcpTool.description override wins without requiresEnv suffix", () => {
1427
760
  {
1428
761
  key: "x",
1429
762
  description: "Leaf desc.",
1430
- mcpTool: { description: "custom", requiresEnv: ["TOKEN"] },
763
+ mcpTool: { description: "custom" },
1431
764
  handler: () => {},
1432
765
  },
1433
766
  ],
@@ -1436,22 +769,14 @@ test("mcpTool.description override wins without requiresEnv suffix", () => {
1436
769
  expect(tools[0]?.description).toBe("custom");
1437
770
  });
1438
771
 
1439
- test("mcpTool.requiresEnv appended to auto description", () => {
772
+ test("cliValidateProgram requires program.appConfig description", () => {
1440
773
  const root = testProgram({
1441
774
  key: "app",
1442
775
  description: "",
1443
- mcpServer: { enabled: true },
1444
- commands: [
1445
- {
1446
- key: "x",
1447
- description: "Leaf desc.",
1448
- mcpTool: { requiresEnv: ["TOKEN"] },
1449
- handler: () => {},
1450
- },
1451
- ],
776
+ appConfig: { entries: { token: {} as { description: string } } },
777
+ handler: () => {},
1452
778
  });
1453
- const tools = collectMcpTools(root);
1454
- expect(tools[0]?.description).toContain("[requires env: TOKEN]");
779
+ expect(() => cliValidateProgram(root)).toThrow(/description must be a non-empty string/);
1455
780
  });
1456
781
 
1457
782
  test("cliValidateProgram rejects duplicate mcpResources URIs", () => {
@@ -1561,16 +886,6 @@ test("applyShellEnv merges PATH and preserves host vars", () => {
1561
886
  delete process.env.NEWVAR;
1562
887
  });
1563
888
 
1564
- test("loadEnvFile overwrites existing keys", () => {
1565
- const dir = mkdtempSync(join(tmpdir(), "argsbarg-env-"));
1566
- const file = join(dir, ".env");
1567
- writeFileSync(file, "FOO=fromfile\n", "utf8");
1568
- process.env.FOO = "original";
1569
- loadEnvFile(file);
1570
- expect(process.env.FOO).toBe("fromfile");
1571
- delete process.env.FOO;
1572
- });
1573
-
1574
889
  test("Enum completions list choices in bash script", () => {
1575
890
  const root = testProgram({
1576
891
  key: "app",
@@ -1592,151 +907,6 @@ test("Enum completions list choices in bash script", () => {
1592
907
  expect(bash).toContain("prod");
1593
908
  });
1594
909
 
1595
- test("MCP resources/list includes custom resource", async () => {
1596
- const responses = await mcpRequest(
1597
- [{ jsonrpc: "2.0", id: 10, method: "resources/list", params: {} }],
1598
- { script: "examples/mcp-test.ts" },
1599
- );
1600
- const res = responses.get(10) as { result: { resources: { uri: string }[] } };
1601
- const uris = res.result.resources.map((r) => r.uri);
1602
- expect(uris).toContain("mcp_test://schema");
1603
- expect(uris).toContain("test://hello");
1604
- });
1605
-
1606
- test("MCP resources/read returns custom resource body", async () => {
1607
- const responses = await mcpRequest(
1608
- [{ jsonrpc: "2.0", id: 11, method: "resources/read", params: { uri: "test://hello" } }],
1609
- { script: "examples/mcp-test.ts" },
1610
- );
1611
- const res = responses.get(11) as { result: { contents: { text: string }[] } };
1612
- expect(res.result.contents[0]?.text).toBe("hello resource");
1613
- });
1614
-
1615
- test("MCP resources/read unknown URI returns error", async () => {
1616
- const responses = await mcpRequest(
1617
- [{ jsonrpc: "2.0", id: 12, method: "resources/read", params: { uri: "missing://nope" } }],
1618
- { script: "examples/mcp-test.ts" },
1619
- );
1620
- const res = responses.get(12) as { error: { code: number } };
1621
- expect(res.error.code).toBe(-32602);
1622
- });
1623
-
1624
- test("MCP requiresEnv fails when env missing", async () => {
1625
- const responses = await mcpRequest(
1626
- [
1627
- {
1628
- jsonrpc: "2.0",
1629
- id: 13,
1630
- method: "tools/call",
1631
- params: { name: "echo_env", arguments: { name: "ARGS_TEST_SECRET" } },
1632
- },
1633
- ],
1634
- { script: "examples/mcp-test.ts", env: { ARGS_TEST_SECRET: "" } },
1635
- );
1636
- const res = responses.get(13) as { result: { isError: boolean; content: { text: string }[] } };
1637
- expect(res.result.isError).toBe(true);
1638
- expect(res.result.content[0]?.text).toContain("ARGS_TEST_SECRET");
1639
- });
1640
-
1641
- test("MCP requiresEnv succeeds when env present", async () => {
1642
- const responses = await mcpRequest(
1643
- [
1644
- {
1645
- jsonrpc: "2.0",
1646
- id: 14,
1647
- method: "tools/call",
1648
- params: { name: "echo_env", arguments: { name: "ARGS_TEST_SECRET" } },
1649
- },
1650
- ],
1651
- { script: "examples/mcp-test.ts", env: { ARGS_TEST_SECRET: "sekrit" } },
1652
- );
1653
- const res = responses.get(14) as { result: { isError: boolean; content: { text: string }[] } };
1654
- expect(res.result.isError).toBe(false);
1655
- expect(res.result.content[0]?.text.trim()).toBe("sekrit");
1656
- });
1657
-
1658
- test("MCP envFile loads vars for tool handlers", async () => {
1659
- const dir = mkdtempSync(join(tmpdir(), "argsbarg-mcp-"));
1660
- const envFile = join(dir, "mcp.env");
1661
- writeFileSync(envFile, "ARGS_FILE_TOKEN=file-value\n", "utf8");
1662
- const responses = await mcpRequest(
1663
- [
1664
- {
1665
- jsonrpc: "2.0",
1666
- id: 15,
1667
- method: "tools/call",
1668
- params: { name: "echo_env", arguments: { name: "ARGS_FILE_TOKEN" } },
1669
- },
1670
- ],
1671
- {
1672
- script: "examples/mcp-test.ts",
1673
- env: { ARGS_TEST_ENV_FILE: envFile, ARGS_TEST_SECRET: "present" },
1674
- },
1675
- );
1676
- const res = responses.get(15) as { result: { isError: boolean; content: { text: string }[] } };
1677
- expect(res.result.isError).toBe(false);
1678
- expect(res.result.content[0]?.text.trim()).toBe("file-value");
1679
- });
1680
-
1681
- // ── v1.3 parser ergonomics ────────────────────────────────────────────────────
1682
-
1683
- function varargsReadFixture(): CliProgram {
1684
- return testProgram({
1685
- key: "app",
1686
- description: "",
1687
- commands: [
1688
- {
1689
- key: "read",
1690
- description: "Read files.",
1691
- options: [
1692
- {
1693
- name: "json",
1694
- description: "",
1695
- kind: CliOptionKind.Presence,
1696
- },
1697
- ],
1698
- positionals: [
1699
- {
1700
- name: "files",
1701
- description: "",
1702
- kind: CliOptionKind.String,
1703
- argMin: 0,
1704
- argMax: 0,
1705
- },
1706
- ],
1707
- handler: () => {},
1708
- },
1709
- ],
1710
- });
1711
- }
1712
-
1713
- function nestedDocsFallbackFixture(): CliProgram {
1714
- return testProgram({
1715
- key: "app",
1716
- description: "",
1717
- commands: [
1718
- {
1719
- key: "docs",
1720
- description: "Documentation commands.",
1721
- fallbackCommand: "guide",
1722
- fallbackMode: CliFallbackMode.MissingOnly,
1723
- commands: [
1724
- {
1725
- key: "guide",
1726
- description: "User guide.",
1727
- handler: () => {},
1728
- },
1729
- {
1730
- key: "api",
1731
- description: "API reference.",
1732
- handler: () => {},
1733
- },
1734
- ],
1735
- },
1736
- ],
1737
- });
1738
- }
1739
-
1740
910
  test("nested fallback routes to default when argv exhausted at router", () => {
1741
911
  const root = nestedDocsFallbackFixture();
1742
912
  cliValidateProgram(root);
@@ -1835,7 +1005,7 @@ test("nested router scoped help does not route to fallback", () => {
1835
1005
  expect(help).toContain("guide");
1836
1006
  });
1837
1007
 
1838
- test("varargs trailing option after positionals via cliInvoke", async () => {
1008
+ test("varargs trailing option after positionals via Cli.invoke", async () => {
1839
1009
  const root = varargsReadFixture();
1840
1010
  cliValidateProgram(root);
1841
1011
  const pr = postParseValidate(root, parse(root, ["read", "file.txt", "--json"]));
@@ -1874,92 +1044,11 @@ test("varargs double dash forces positional", () => {
1874
1044
  test("varargs unknown flag errors", async () => {
1875
1045
  const root = varargsReadFixture();
1876
1046
  cliValidateProgram(root);
1877
- const result = await cliInvoke(root, ["read", "--unknown"]);
1047
+ const result = await new Cli(root).invoke(["read", "--unknown"]);
1878
1048
  expect(result.kind).toBe("error");
1879
1049
  expect(result.stderr).toContain("--unknown");
1880
1050
  });
1881
1051
 
1882
- test("varargs scoped help in tail", () => {
1883
- const root = varargsReadFixture();
1884
- cliValidateProgram(root);
1885
- const pr = parse(root, ["read", "file.txt", "--help"]);
1886
- expect(pr.kind).toBe(ParseKind.Help);
1887
- expect(pr.helpPath).toContain("read");
1888
- expect(pr.helpExplicit).toBe(true);
1889
- });
1890
-
1891
- test("ctx.positional returns single slot value", async () => {
1892
- const root = testProgram({
1893
- key: "app",
1894
- description: "",
1895
- commands: [
1896
- {
1897
- key: "x",
1898
- description: "",
1899
- positionals: [{ name: "path", description: "", kind: CliOptionKind.String }],
1900
- handler: (ctx: CliContext) => {
1901
- captured = ctx.positional("path");
1902
- },
1903
- },
1904
- ],
1905
- });
1906
- let captured: string | string[] | undefined;
1907
- cliValidateProgram(root);
1908
- await cliInvoke(root, ["x", "./file"]);
1909
- expect(captured).toBe("./file");
1910
- });
1911
-
1912
- test("ctx.positional returns varargs array", async () => {
1913
- const root = varargsReadFixture();
1914
- let captured: string | string[] | undefined;
1915
- if (isCliRouter(root)) {
1916
- (root.commands[0] as CliLeaf).handler = (ctx) => {
1917
- captured = ctx.positional("files");
1918
- };
1919
- }
1920
- cliValidateProgram(root);
1921
- await cliInvoke(root, ["read", "a.txt", "b.txt"]);
1922
- expect(captured).toEqual(["a.txt", "b.txt"]);
1923
- });
1924
-
1925
- test("ctx.positional returns undefined for absent optional slot", async () => {
1926
- const root = testProgram({
1927
- key: "app",
1928
- description: "",
1929
- commands: [
1930
- {
1931
- key: "x",
1932
- description: "",
1933
- positionals: [
1934
- { name: "opt", description: "", kind: CliOptionKind.String, argMin: 0, argMax: 1 },
1935
- ],
1936
- handler: (ctx: CliContext) => {
1937
- captured = ctx.positional("opt");
1938
- },
1939
- },
1940
- ],
1941
- });
1942
- let captured: string | string[] | undefined;
1943
- cliValidateProgram(root);
1944
- await cliInvoke(root, ["x"]);
1945
- expect(captured).toBeUndefined();
1946
- });
1947
-
1948
- test("ctx.positional varargs matches ctx.args", async () => {
1949
- const root = varargsReadFixture();
1950
- let positional: string | string[] | undefined;
1951
- let args: string[] = [];
1952
- if (isCliRouter(root)) {
1953
- (root.commands[0] as CliLeaf).handler = (ctx) => {
1954
- positional = ctx.positional("files");
1955
- args = ctx.args;
1956
- };
1957
- }
1958
- cliValidateProgram(root);
1959
- await cliInvoke(root, ["read", "a.txt", "b.txt"]);
1960
- expect(positional).toEqual(args);
1961
- });
1962
-
1963
1052
  test("mcpToolCallToArgv rejects comma-separated string for varargs", () => {
1964
1053
  const tools = collectMcpTools(nestedMcpFixture);
1965
1054
  const read = tools.find((t) => t.name === "read")!;