argsbarg 7.1.1 → 7.1.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +38 -1
  2. package/README.md +7 -7
  3. package/docs/README.md +1 -1
  4. package/docs/cli-program.md +1 -1
  5. package/docs/configure.md +2 -2
  6. package/docs/mcp.md +53 -4
  7. package/docs/output-schema.md +6 -0
  8. package/examples/full-example/AGENTS.md +1 -1
  9. package/examples/full-example/bun.lock +83 -1
  10. package/examples/full-example/justfile +5 -3
  11. package/examples/full-example-json/AGENTS.md +1 -1
  12. package/examples/full-example-json/README.md +1 -0
  13. package/examples/full-example-json/bun.lock +83 -1
  14. package/examples/full-example-json/docs/cli-schema.json +284 -9
  15. package/examples/full-example-json/docs/cli.md +236 -18
  16. package/examples/full-example-json/docs/http.md +1 -0
  17. package/examples/full-example-json/docs/mcp.md +18 -0
  18. package/examples/full-example-json/docs/openapi.json +155 -0
  19. package/examples/full-example-json/justfile +5 -3
  20. package/examples/full-example-json/src/commands/shape-area/__generated__/ShapeAreaInputSchema.json +59 -0
  21. package/examples/full-example-json/src/commands/shape-area/__generated__/index.ts +5 -0
  22. package/examples/full-example-json/src/commands/shape-area/command.test.ts +34 -0
  23. package/examples/full-example-json/src/commands/shape-area/command.ts +31 -0
  24. package/examples/full-example-json/src/commands/shape-area/types.ts +28 -0
  25. package/examples/full-example-json/src/program.ts +2 -1
  26. package/examples/mcp-plugin/.claude-plugin/plugin.json +7 -1
  27. package/examples/mcp-plugin/.cursor-plugin/plugin.json +7 -1
  28. package/examples/mcp-plugin/AGENTS.md +14 -1
  29. package/examples/mcp-plugin/README.md +19 -10
  30. package/examples/mcp-plugin/bun.lock +83 -1
  31. package/examples/mcp-plugin/bunfig.toml +4 -0
  32. package/examples/mcp-plugin/docs/node-distro.md +97 -0
  33. package/examples/mcp-plugin/justfile +12 -11
  34. package/examples/mcp-plugin/package.json +2 -1
  35. package/examples/mcp-plugin/scripts/release.ts +10 -11
  36. package/index.d.ts +62 -0
  37. package/package.json +1 -1
  38. package/src/cli-tool/create.test.ts +14 -0
  39. package/src/cli-tool/create.ts +9 -0
  40. package/src/cli-tool/main.ts +1 -1
  41. package/src/cli-tool/schemagen/run.ts +41 -2
  42. package/src/cli-tool/schemagen/schemagen.test.ts +87 -2
  43. package/src/config/validate.test.ts +157 -0
  44. package/src/config/validate.ts +353 -26
  45. package/src/core/document-leaf.test.ts +53 -0
  46. package/src/core/json-pointer.ts +46 -0
  47. package/src/core/types.ts +33 -0
  48. package/src/core/validate.ts +68 -1
  49. package/src/docs/docs.test.ts +7 -0
  50. package/src/docs/mcp-guide.ts +43 -1
  51. package/src/headless/tool-call.test.ts +74 -2
  52. package/src/headless/tool-call.ts +44 -22
  53. package/src/http/schema-deref.ts +1 -23
  54. package/src/index.ts +3 -0
  55. package/src/mcp/server.ts +28 -4
  56. package/src/mcp/tools.test.ts +292 -0
  57. package/src/mcp/tools.ts +144 -6
  58. package/src/runtime/cli.ts +6 -1
  59. package/src/server/context.ts +6 -0
  60. package/src/test/integration/mcp.test.ts +73 -0
  61. package/src/test/mcp-integration-fixture.ts +1 -0
  62. package/src/test/mcp-size-fixture.ts +31 -0
  63. package/examples/mcp-plugin/.mcp.json +0 -6
  64. package/examples/mcp-plugin/mcp.json +0 -8
  65. package/examples/mcp-plugin/scripts/mcp.mjs +0 -11106
  66. package/examples/mcp-plugin/src/commands/render-json/__generated__/RenderJsonInputSchema.json +0 -15
  67. package/examples/mcp-plugin/src/commands/render-json/__generated__/index.ts +0 -5
  68. package/examples/mcp-plugin/src/commands/status/__generated__/StatusJsonOutputSchema.json +0 -15
  69. package/examples/mcp-plugin/src/commands/status/__generated__/index.ts +0 -5
  70. package/examples/mcp-plugin/src/commands/workspaces/__generated__/WorkspaceNameInputSchema.json +0 -15
  71. package/examples/mcp-plugin/src/commands/workspaces/__generated__/index.ts +0 -5
@@ -10,6 +10,7 @@ import {
10
10
  applyCreate,
11
11
  type CreateOptions,
12
12
  classNameFromKey,
13
+ DEV_ONLY_MARKER,
13
14
  diffCreate,
14
15
  diffCreateDetails,
15
16
  renderCreateTree,
@@ -38,6 +39,19 @@ function baseOpts(overrides: Partial<CreateOptions> = {}): CreateOptions {
38
39
 
39
40
  /** Tests for argsbarg create. */
40
41
  describe("argsbarg create", () => {
42
+ test("in-repo example templates match their own create output", () => {
43
+ for (const example of ["full-example", "full-example-json", "mcp-plugin"]) {
44
+ const dir = join(import.meta.dir, "../../examples", example);
45
+ expect({ example, drift: diffCreate(dir, { check: true }) }).toEqual({ example, drift: [] });
46
+ }
47
+ });
48
+
49
+ test("drops argsbarg-dev-only lines outside the in-repo template", () => {
50
+ const content = `setup:\n bun install\n ln -sf a b ${DEV_ONLY_MARKER}: fix link\n just schemagen\n`;
51
+ expect(substituteTemplateContent(content, baseOpts())).toBe("setup:\n bun install\n just schemagen\n");
52
+ expect(substituteTemplateContent(content, baseOpts({ devTemplate: true }))).toBe(content);
53
+ });
54
+
41
55
  test("substitutes {key} tokens", () => {
42
56
  const out = substituteTemplateContent(
43
57
  "key={key} class={className} env={envPrefix}_API_TOKEN tap={tap} org={tapOrg}",
@@ -263,6 +263,12 @@ function tokenMap(opts: CreateOptions): Record<string, string> {
263
263
  };
264
264
  }
265
265
 
266
+ /**
267
+ * Trailing comment marking a template line that only makes sense inside the argsbarg repo (e.g. the
268
+ * `.bin/argsbarg` symlink fix for `file:../..` installs). `create` drops these lines from new projects.
269
+ */
270
+ export const DEV_ONLY_MARKER = "# argsbarg-dev-only";
271
+
266
272
  /** Substitute \`{key}\`-style placeholders; also replace template identity literals. */
267
273
  export function substituteTemplateContent(content: string, opts: CreateOptions): string {
268
274
  const tmpl = templateIdentity(opts.templateId);
@@ -303,6 +309,9 @@ export function substituteTemplateContent(content: string, opts: CreateOptions):
303
309
 
304
310
  if (!opts.devTemplate) {
305
311
  out = out
312
+ .split("\n")
313
+ .filter((line) => !line.includes(DEV_ONLY_MARKER))
314
+ .join("\n")
306
315
  .replace(/"argsbarg":\s*"workspace:\*"/, `"argsbarg": "^${argsbargVersion}"`)
307
316
  .replace(/"argsbarg":\s*"file:\.\.\/\.\."/, `"argsbarg": "^${argsbargVersion}"`);
308
317
  }
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- /** Argsbarg package CLI (`bunx argsbarg`) — bootstrap and tooling; library API is `import from "argsbarg"`. */
2
+ /** Argsbarg package CLI (`bun x argsbarg`) — bootstrap and tooling; library API is `import from "argsbarg"`. */
3
3
 
4
4
  import { Cli } from "../index.ts";
5
5
  import { program } from "./program.ts";
@@ -5,6 +5,7 @@ Generate JSON Schema artifacts under __generated__/ and write index.ts re-export
5
5
  import { mkdirSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join, relative } from "node:path";
7
7
  import { createGenerator } from "ts-json-schema-generator";
8
+ import { resolveJsonPointer } from "../../core/json-pointer.ts";
8
9
  import { cleanStaleGenerated } from "./cleanup.ts";
9
10
  import { discoverSchemaRoots, type SchemaRoot } from "./discover-schema-roots.ts";
10
11
  import { GENERATED_DIR, schemaExportName, schemaJsonBasename, schemaJsonImportVar } from "./names.ts";
@@ -46,11 +47,49 @@ function generateJson(projectRoot: string, tsconfigPath: string, root: SchemaRoo
46
47
  jsDoc: "extended",
47
48
  additionalProperties: false,
48
49
  });
49
- const schema = generator.createSchema(root.typeName) as Record<string, unknown>;
50
- schema.additionalProperties = false;
50
+ const schema = hoistRootRef(generator.createSchema(root.typeName) as Record<string, unknown>, root.typeName);
51
+ // Only object roots: on a union root with no root `properties`, this would reject every property.
52
+ if (schema.type === "object") {
53
+ schema.additionalProperties = false;
54
+ }
51
55
  return schema;
52
56
  }
53
57
 
58
+ /**
59
+ * Replaces a root `$ref` with the definition it names, following alias chains
60
+ * (`export type Input = Inner` emits `{ $ref: "#/definitions/Inner" }` even with `topRef: false`).
61
+ * Keeps `definitions` so nested and recursive references still resolve.
62
+ */
63
+ function hoistRootRef(
64
+ /** Schema from ts-json-schema-generator. */
65
+ schema: Record<string, unknown>,
66
+ /** Root type name, for error messages. */
67
+ typeName: string,
68
+ ): Record<string, unknown> {
69
+ let target = schema;
70
+ const seen = new Set<string>();
71
+ while (typeof target.$ref === "string") {
72
+ const ref = target.$ref;
73
+ if (seen.has(ref)) {
74
+ throw new Error(`schemagen: ${typeName} root $ref cycle at ${ref}`);
75
+ }
76
+ seen.add(ref);
77
+ const next = resolveJsonPointer(schema, ref);
78
+ if (typeof next !== "object" || next === null || Array.isArray(next)) {
79
+ throw new Error(`schemagen: ${typeName} root $ref does not resolve: ${ref}`);
80
+ }
81
+ target = next as Record<string, unknown>;
82
+ }
83
+ if (target === schema) {
84
+ return schema;
85
+ }
86
+ return {
87
+ ...(schema.$schema === undefined ? {} : { $schema: schema.$schema }),
88
+ ...target,
89
+ ...(schema.definitions === undefined ? {} : { definitions: schema.definitions }),
90
+ };
91
+ }
92
+
54
93
  function writeGeneratedIndex(generatedDir: string, roots: SchemaRoot[]): void {
55
94
  const sorted = [...roots].sort((left, right) => left.typeName.localeCompare(right.typeName));
56
95
  const lines = ["// Auto-generated by argsbarg schemagen — do not edit by hand.", ""];
@@ -48,7 +48,12 @@ function writeSrcFile(root: string, relPath: string, body: string): void {
48
48
  describe("schemagen", () => {
49
49
  test("discovers @sg types in full-example-json", () => {
50
50
  const roots = discoverSchemaRoots(exampleRoot);
51
- expect(roots.map((r) => r.typeName).sort()).toEqual(["RenderJsonInput", "StatusJsonOutput", "WorkspaceNameInput"]);
51
+ expect(roots.map((r) => r.typeName).sort()).toEqual([
52
+ "RenderJsonInput",
53
+ "ShapeAreaInput",
54
+ "StatusJsonOutput",
55
+ "WorkspaceNameInput",
56
+ ]);
52
57
  });
53
58
 
54
59
  test("maps type names to __generated__ filenames and export names", () => {
@@ -58,7 +63,7 @@ describe("schemagen", () => {
58
63
 
59
64
  test("runSchemagen writes __generated__ artifacts in full-example-json", () => {
60
65
  const counts = runSchemagen({ projectRoot: exampleRoot });
61
- expect(counts).toEqual({ schemas: 3 });
66
+ expect(counts).toEqual({ schemas: 4 });
62
67
  });
63
68
 
64
69
  test("discovers @sg roots and writes named schema artifacts", () => {
@@ -230,4 +235,84 @@ export interface DupType {
230
235
 
231
236
  expect(() => discoverSchemaRoots(root)).toThrow("duplicate schema root type DupType");
232
237
  });
238
+
239
+ describe("root normalization", () => {
240
+ /** Writes one `@sg` type (plus helper types) and returns its generated schema. */
241
+ function generate(typeName: string, body: string): Record<string, unknown> {
242
+ const root = makeTempProject();
243
+ writeSrcFile(root, "src/commands/demo/types.ts", body);
244
+ runSchemagen({ projectRoot: root });
245
+ const path = join(root, "src/commands/demo/__generated__", schemaJsonBasename(typeName));
246
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
247
+ }
248
+
249
+ test("hoists an alias root $ref to an object root", () => {
250
+ const schema = generate(
251
+ "AliasInput",
252
+ `export interface Inner {
253
+ name: string;
254
+ }
255
+ /** @sg */
256
+ export type AliasInput = Inner;
257
+ `,
258
+ );
259
+ expect(schema.$ref).toBeUndefined();
260
+ expect(schema.type).toBe("object");
261
+ expect(schema.properties).toEqual({ name: { type: "string" } });
262
+ expect(schema.additionalProperties).toBe(false);
263
+ });
264
+
265
+ test("follows alias chains and percent-encoded generic refs", () => {
266
+ const chain = generate(
267
+ "ChainInput",
268
+ `export interface Inner {
269
+ name: string;
270
+ }
271
+ export type Mid = Inner;
272
+ /** @sg */
273
+ export type ChainInput = Mid;
274
+ `,
275
+ );
276
+ expect(chain.type).toBe("object");
277
+ expect(chain.properties).toEqual({ name: { type: "string" } });
278
+
279
+ const generic = generate(
280
+ "GenericInput",
281
+ `export interface Box<T> {
282
+ value: T;
283
+ }
284
+ /** @sg */
285
+ export type GenericInput = Box<string>;
286
+ `,
287
+ );
288
+ expect(generic.type).toBe("object");
289
+ expect(generic.properties).toEqual({ value: { type: "string" } });
290
+ });
291
+
292
+ test("keeps definitions so recursive references still resolve", () => {
293
+ const schema = generate(
294
+ "TreeInput",
295
+ `export interface Tree {
296
+ name: string;
297
+ children?: Tree[];
298
+ }
299
+ /** @sg */
300
+ export type TreeInput = Tree;
301
+ `,
302
+ );
303
+ expect(schema.type).toBe("object");
304
+ expect((schema.definitions as Record<string, unknown>).Tree).toBeDefined();
305
+ });
306
+
307
+ test("leaves union roots without a root additionalProperties", () => {
308
+ const schema = generate(
309
+ "UnionInput",
310
+ `/** @sg */
311
+ export type UnionInput = { kind: "a"; a: string } | { kind: "b"; b: number };
312
+ `,
313
+ );
314
+ expect(Array.isArray(schema.anyOf)).toBe(true);
315
+ expect(schema.additionalProperties).toBeUndefined();
316
+ });
317
+ });
233
318
  });
@@ -51,6 +51,163 @@ describe("config/validate", () => {
51
51
  expect(result.valid).toBe(false);
52
52
  });
53
53
 
54
+ describe("discriminated unions", () => {
55
+ const stepSchema = {
56
+ $schema: "http://json-schema.org/draft-07/schema#",
57
+ type: "object",
58
+ properties: { steps: { type: "array", items: { $ref: "#/definitions/Step" } } },
59
+ required: ["steps"],
60
+ additionalProperties: false,
61
+ definitions: {
62
+ Step: {
63
+ anyOf: [
64
+ {
65
+ type: "object",
66
+ properties: { kind: { const: "alpha" }, title: { type: "string" } },
67
+ required: ["kind", "title"],
68
+ additionalProperties: false,
69
+ },
70
+ {
71
+ type: "object",
72
+ properties: { kind: { enum: ["beta", "bravo"] }, count: { type: "number" } },
73
+ required: ["kind"],
74
+ additionalProperties: false,
75
+ },
76
+ {
77
+ type: "object",
78
+ properties: { kind: { const: "gamma" }, flag: { type: "boolean" } },
79
+ required: ["kind"],
80
+ additionalProperties: false,
81
+ },
82
+ ],
83
+ },
84
+ },
85
+ };
86
+
87
+ test("valid mix of branches passes", () => {
88
+ const result = validateConfigDocument(
89
+ {
90
+ steps: [
91
+ { kind: "alpha", title: "x" },
92
+ { kind: "beta", count: 3 },
93
+ ],
94
+ },
95
+ stepSchema,
96
+ );
97
+ expect(result.valid).toBe(true);
98
+ expect(result.errors).toEqual([]);
99
+ });
100
+
101
+ test("unknown property in the matched branch reports only that branch", () => {
102
+ const result = validateConfigDocument({ steps: [{ kind: "alpha", titel: "x" }] }, stepSchema);
103
+ expect(result.errors).toEqual([
104
+ 'steps.0: missing required property "title"',
105
+ 'steps.0: unknown property "titel" (allowed: kind, title)',
106
+ ]);
107
+ });
108
+
109
+ test("unmapped discriminator value reports one synthetic error", () => {
110
+ const result = validateConfigDocument({ steps: [{ kind: "alfa" }] }, stepSchema);
111
+ expect(result.errors).toEqual(['steps.0.kind: unknown kind "alfa" (expected one of: alpha, beta, bravo, gamma)']);
112
+ });
113
+
114
+ test("missing discriminator reports one synthetic error", () => {
115
+ const result = validateConfigDocument({ steps: [{ title: "x" }] }, stepSchema);
116
+ expect(result.errors).toEqual(['steps.0: missing "kind" (expected one of: alpha, beta, bravo, gamma)']);
117
+ });
118
+
119
+ test("non-object instance reports one synthetic error", () => {
120
+ const result = validateConfigDocument({ steps: ["alpha"] }, stepSchema);
121
+ expect(result.errors).toEqual(['steps.0: expected an object with "kind" (one of: alpha, beta, bravo, gamma)']);
122
+ });
123
+
124
+ test("type mismatch in the matched branch reports only that field", () => {
125
+ const result = validateConfigDocument({ steps: [{ kind: "beta", count: "x" }] }, stepSchema);
126
+ expect(result.errors).toEqual(["steps.0.count: must be number (got string)"]);
127
+ });
128
+
129
+ test("a non-discriminated union still reports errors from every branch", () => {
130
+ const nonDiscriminatedSchema = {
131
+ type: "object",
132
+ properties: {
133
+ x: {
134
+ anyOf: [
135
+ { type: "object", properties: { a: { type: "string" } }, required: ["a"], additionalProperties: false },
136
+ { type: "object", properties: { b: { type: "number" } }, required: ["b"], additionalProperties: false },
137
+ ],
138
+ },
139
+ },
140
+ additionalProperties: false,
141
+ };
142
+ const result = validateConfigDocument({ x: {} }, nonDiscriminatedSchema);
143
+ expect(result.errors).toEqual(['x: missing required property "a"', 'x: missing required property "b"']);
144
+ });
145
+
146
+ test("collects one error per bad step, in order, without cross-contamination between array indices", () => {
147
+ const result = validateConfigDocument({ steps: [{ kind: "alfa" }, { kind: "beta", count: "x" }] }, stepSchema);
148
+ expect(result.errors).toEqual([
149
+ 'steps.0.kind: unknown kind "alfa" (expected one of: alpha, beta, bravo, gamma)',
150
+ "steps.1.count: must be number (got string)",
151
+ ]);
152
+ });
153
+
154
+ test("resolves discriminators through $ref'd branches (ts-json-schema-generator output shape)", () => {
155
+ // ts-json-schema-generator (and similar tools) write each anyOf branch as a bare `{ $ref }` pointing
156
+ // into `definitions`, rather than inlining the branch schema. This is the shape gdocsmith's real
157
+ // `run` tool schema uses for its 27 step kinds, and it silently defeated discriminator detection
158
+ // (every branch looked property-less) until unionDiscriminator started resolving branch refs.
159
+ const refBranchSchema = {
160
+ $schema: "http://json-schema.org/draft-07/schema#",
161
+ type: "object",
162
+ properties: {
163
+ steps: { type: "array", items: { anyOf: [{ $ref: "#/definitions/A" }, { $ref: "#/definitions/B" }] } },
164
+ },
165
+ required: ["steps"],
166
+ additionalProperties: false,
167
+ definitions: {
168
+ A: {
169
+ type: "object",
170
+ properties: { kind: { const: "alpha" }, title: { type: "string" } },
171
+ required: ["kind", "title"],
172
+ additionalProperties: false,
173
+ },
174
+ B: {
175
+ type: "object",
176
+ properties: { kind: { const: "beta" }, count: { type: "number" } },
177
+ required: ["kind"],
178
+ additionalProperties: false,
179
+ },
180
+ },
181
+ };
182
+ const result = validateConfigDocument({ steps: [{ kind: "alfa" }] }, refBranchSchema);
183
+ expect(result.errors).toEqual(['steps.0.kind: unknown kind "alfa" (expected one of: alpha, beta)']);
184
+ });
185
+
186
+ test("caps at 10 errors plus a count of the remainder", () => {
187
+ const manySchema = {
188
+ type: "object",
189
+ properties: {
190
+ steps: {
191
+ type: "array",
192
+ items: {
193
+ type: "object",
194
+ properties: { kind: { enum: ["a"] } },
195
+ required: ["kind"],
196
+ additionalProperties: false,
197
+ },
198
+ },
199
+ },
200
+ additionalProperties: false,
201
+ };
202
+ const result = validateConfigDocument({ steps: Array.from({ length: 12 }, () => ({})) }, manySchema);
203
+ expect(result.errors).toHaveLength(11);
204
+ expect(result.errors.slice(0, 10)).toEqual(
205
+ Array.from({ length: 10 }, (_, i) => `steps.${i}: missing required property "kind"`),
206
+ );
207
+ expect(result.errors[10]).toBe("…and 2 more errors");
208
+ });
209
+ });
210
+
54
211
  test("parseConfigSetValue coerces number and boolean", () => {
55
212
  expect(parseConfigSetValue("5", { type: "integer" }, rootSchema, false)).toBe(5);
56
213
  expect(parseConfigSetValue("true", { type: "boolean" }, rootSchema, false)).toBe(true);