argsbarg 7.1.2 → 7.1.4
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/CHANGELOG.md +31 -1
- package/README.md +7 -7
- package/docs/README.md +1 -1
- package/docs/cli-program.md +1 -1
- package/docs/configure.md +2 -2
- package/docs/mcp.md +20 -0
- package/docs/output-schema.md +6 -0
- package/examples/full-example/AGENTS.md +1 -1
- package/examples/full-example/bun.lock +83 -1
- package/examples/full-example/justfile +5 -3
- package/examples/full-example-json/AGENTS.md +1 -1
- package/examples/full-example-json/README.md +1 -0
- package/examples/full-example-json/bun.lock +83 -1
- package/examples/full-example-json/docs/cli-schema.json +284 -9
- package/examples/full-example-json/docs/cli.md +236 -18
- package/examples/full-example-json/docs/http.md +1 -0
- package/examples/full-example-json/docs/mcp.md +18 -0
- package/examples/full-example-json/docs/openapi.json +155 -0
- package/examples/full-example-json/justfile +5 -3
- package/examples/full-example-json/src/commands/shape-area/__generated__/ShapeAreaInputSchema.json +59 -0
- package/examples/full-example-json/src/commands/shape-area/__generated__/index.ts +5 -0
- package/examples/full-example-json/src/commands/shape-area/command.test.ts +34 -0
- package/examples/full-example-json/src/commands/shape-area/command.ts +31 -0
- package/examples/full-example-json/src/commands/shape-area/types.ts +28 -0
- package/examples/full-example-json/src/program.ts +2 -1
- package/examples/mcp-plugin/.claude-plugin/plugin.json +7 -1
- package/examples/mcp-plugin/.cursor-plugin/plugin.json +7 -1
- package/examples/mcp-plugin/AGENTS.md +12 -1
- package/examples/mcp-plugin/README.md +18 -9
- package/examples/mcp-plugin/bun.lock +83 -1
- package/examples/mcp-plugin/dist/mcp-plugin.mjs +385 -0
- package/examples/mcp-plugin/justfile +17 -12
- package/examples/mcp-plugin/package.json +2 -5
- package/examples/mcp-plugin/scripts/release.ts +17 -30
- package/examples/mcp-plugin/src/index.ts +0 -1
- package/package.json +1 -1
- package/src/cli-tool/create.test.ts +14 -0
- package/src/cli-tool/create.ts +9 -0
- package/src/cli-tool/main.ts +1 -1
- package/src/cli-tool/schemagen/run.ts +41 -2
- package/src/cli-tool/schemagen/schemagen.test.ts +87 -2
- package/src/config/validate.ts +13 -31
- package/src/core/json-pointer.ts +46 -0
- package/src/core/validate.ts +64 -1
- package/src/headless/tool-call.test.ts +74 -2
- package/src/headless/tool-call.ts +44 -22
- package/src/http/schema-deref.ts +1 -23
- package/src/mcp/tools.test.ts +137 -3
- package/src/mcp/tools.ts +50 -5
- package/examples/mcp-plugin/.mcp.json +0 -6
- package/examples/mcp-plugin/bunfig.toml +0 -3
- package/examples/mcp-plugin/mcp.json +0 -8
- package/examples/mcp-plugin/scripts/mcp.mjs +0 -11106
- package/examples/mcp-plugin/src/commands/render-json/__generated__/RenderJsonInputSchema.json +0 -15
- package/examples/mcp-plugin/src/commands/render-json/__generated__/index.ts +0 -5
- package/examples/mcp-plugin/src/commands/status/__generated__/StatusJsonOutputSchema.json +0 -15
- package/examples/mcp-plugin/src/commands/status/__generated__/index.ts +0 -5
- package/examples/mcp-plugin/src/commands/workspaces/__generated__/WorkspaceNameInputSchema.json +0 -15
- package/examples/mcp-plugin/src/commands/workspaces/__generated__/index.ts +0 -5
|
@@ -7,12 +7,12 @@ export PATH := justfile_directory() + "/node_modules/.bin:" + env_var("PATH")
|
|
|
7
7
|
_:
|
|
8
8
|
@just --list
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
build
|
|
12
|
-
bun build ./src/index.ts --target=node --outfile=./scripts/mcp.mjs
|
|
10
|
+
# Run schemagen, format, typecheck, and rebuild the committed Node bundle
|
|
11
|
+
check: schemagen format typecheck build
|
|
13
12
|
|
|
14
|
-
#
|
|
15
|
-
|
|
13
|
+
# Bundle the Node MCP server that plugins run (commit dist/mcp-plugin.mjs)
|
|
14
|
+
build: schemagen
|
|
15
|
+
bun build ./src/index.ts --target=node --minify-whitespace --minify-syntax --outfile=./dist/mcp-plugin.mjs
|
|
16
16
|
|
|
17
17
|
# demo the HTTP API
|
|
18
18
|
demo-http:
|
|
@@ -52,16 +52,21 @@ format:
|
|
|
52
52
|
http:
|
|
53
53
|
@just run http
|
|
54
54
|
|
|
55
|
+
# Lint sources without writing
|
|
56
|
+
lint:
|
|
57
|
+
bun run biome check ./src ./scripts
|
|
58
|
+
|
|
59
|
+
# Install the Claude Code plugin from this checkout (restart Claude Code afterwards)
|
|
60
|
+
plugin-claude-install: build
|
|
61
|
+
claude plugin marketplace add "$(pwd)"
|
|
62
|
+
claude plugin install mcp-plugin@mcp-plugin
|
|
63
|
+
|
|
55
64
|
# Link plugin into ~/.cursor/plugins/local/mcp-plugin for local testing
|
|
56
|
-
|
|
65
|
+
plugin-cursor-upsert: build
|
|
57
66
|
mkdir -p ~/.cursor/plugins/local
|
|
58
67
|
ln -sfn '{{justfile_directory()}}' ~/.cursor/plugins/local/mcp-plugin
|
|
59
68
|
@echo "Linked MCP plugin to ~/.cursor/plugins/local/mcp-plugin"
|
|
60
69
|
|
|
61
|
-
# Lint sources without writing
|
|
62
|
-
lint:
|
|
63
|
-
bun run biome check ./src ./scripts
|
|
64
|
-
|
|
65
70
|
# Run the CLI from source once
|
|
66
71
|
run *ARGS:
|
|
67
72
|
bun ./src/index.ts {{ARGS}}
|
|
@@ -73,14 +78,14 @@ schemagen:
|
|
|
73
78
|
# Install bun/npm dependencies and generate schemas
|
|
74
79
|
setup:
|
|
75
80
|
bun install
|
|
76
|
-
test -f node_modules/argsbarg/bin/argsbarg && ln -sf ../argsbarg/bin/argsbarg node_modules/.bin/argsbarg
|
|
81
|
+
test -f node_modules/argsbarg/bin/argsbarg && ln -sf ../argsbarg/bin/argsbarg node_modules/.bin/argsbarg # argsbarg-dev-only: fix link for file:../.. installs
|
|
77
82
|
just schemagen
|
|
78
83
|
|
|
79
84
|
# Run unit tests (after check)
|
|
80
85
|
test: check
|
|
81
86
|
bun test .
|
|
82
87
|
|
|
83
|
-
# Bump version,
|
|
88
|
+
# Bump version, rebuild the bundle, and publish a release
|
|
84
89
|
release *ARGS:
|
|
85
90
|
bun scripts/release.ts {{ARGS}}
|
|
86
91
|
|
|
@@ -6,11 +6,8 @@
|
|
|
6
6
|
"module": "src/index.ts",
|
|
7
7
|
"description": "Argsbarg MCP plugin template for Cursor and Claude Code marketplaces.",
|
|
8
8
|
"engines": {
|
|
9
|
-
"bun": ">=1.3"
|
|
10
|
-
|
|
11
|
-
"scripts": {
|
|
12
|
-
"biome": "biome",
|
|
13
|
-
"start": "bun run src/index.ts"
|
|
9
|
+
"bun": ">=1.3",
|
|
10
|
+
"node": ">=20"
|
|
14
11
|
},
|
|
15
12
|
"dependencies": {
|
|
16
13
|
"argsbarg": "file:../.."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/*
|
|
3
|
-
Bump version,
|
|
3
|
+
Bump version, rebuild the committed Node bundle, and publish a plugin release tag.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import * as fs from "node:fs";
|
|
@@ -39,10 +39,7 @@ async function main(): Promise<void> {
|
|
|
39
39
|
|
|
40
40
|
/** Prints usage and exits. */
|
|
41
41
|
function usage(): never {
|
|
42
|
-
process.stderr.write(
|
|
43
|
-
"Usage:\n" +
|
|
44
|
-
" bun scripts/release.ts <major|minor|patch> [--yes] [--dry-run]\n",
|
|
45
|
-
);
|
|
42
|
+
process.stderr.write("Usage:\n" + " bun scripts/release.ts <major|minor|patch> [--yes] [--dry-run]\n");
|
|
46
43
|
process.exit(1);
|
|
47
44
|
}
|
|
48
45
|
|
|
@@ -76,26 +73,28 @@ async function runRelease(
|
|
|
76
73
|
/** Parsed CLI options. */
|
|
77
74
|
options: ReleaseOptions,
|
|
78
75
|
): Promise<void> {
|
|
76
|
+
const currentVersion = readCurrentVersion();
|
|
77
|
+
const newVersion = applyBump(currentVersion, bump);
|
|
78
|
+
if (options.dryRun) {
|
|
79
|
+
console.log(
|
|
80
|
+
`[dry-run] Would run checks, bump ${currentVersion} to ${newVersion}, update the changelog, regenerate docs, rebuild the bundle, commit, tag v${newVersion}, push, and create a GitHub release.`,
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
79
85
|
const testResult = await $`just test`.nothrow();
|
|
80
86
|
if (testResult.exitCode !== 0) process.exit(testResult.exitCode);
|
|
81
87
|
|
|
82
|
-
const currentVersion = readCurrentVersion();
|
|
83
|
-
const newVersion = applyBump(currentVersion, bump);
|
|
84
88
|
console.log(`Releasing ${currentVersion} → ${newVersion}`);
|
|
85
89
|
|
|
86
90
|
updateVersion(newVersion);
|
|
87
91
|
updateChangelog(newVersion);
|
|
88
92
|
|
|
89
|
-
const buildResult = await $`just build`.nothrow();
|
|
90
|
-
if (buildResult.exitCode !== 0) process.exit(buildResult.exitCode);
|
|
91
|
-
|
|
92
93
|
const docgenResult = await $`just docgen`.nothrow();
|
|
93
94
|
if (docgenResult.exitCode !== 0) process.exit(docgenResult.exitCode);
|
|
94
95
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
96
|
+
const buildResult = await $`just build`.nothrow();
|
|
97
|
+
if (buildResult.exitCode !== 0) process.exit(buildResult.exitCode);
|
|
99
98
|
|
|
100
99
|
await commitAndTag(newVersion);
|
|
101
100
|
await createGithubRelease(`v${newVersion}`);
|
|
@@ -154,31 +153,19 @@ function updateVersion(
|
|
|
154
153
|
): void {
|
|
155
154
|
const pkgPath = "package.json";
|
|
156
155
|
const pkgContent = fs.readFileSync(pkgPath, "utf-8");
|
|
157
|
-
fs.writeFileSync(
|
|
158
|
-
pkgPath,
|
|
159
|
-
pkgContent.replace(/"version":\s*"[^"]+"/, `"version": "${newVersion}"`),
|
|
160
|
-
);
|
|
156
|
+
fs.writeFileSync(pkgPath, pkgContent.replace(/"version":\s*"[^"]+"/, `"version": "${newVersion}"`));
|
|
161
157
|
|
|
162
158
|
const progContent = fs.readFileSync(programPath, "utf-8");
|
|
163
|
-
fs.writeFileSync(
|
|
164
|
-
programPath,
|
|
165
|
-
progContent.replace(/version:\s*"[^"]+"/, `version: "${newVersion}"`),
|
|
166
|
-
);
|
|
159
|
+
fs.writeFileSync(programPath, progContent.replace(/version:\s*"[^"]+"/, `version: "${newVersion}"`));
|
|
167
160
|
|
|
168
161
|
if (fs.existsSync(cursorManifestPath)) {
|
|
169
162
|
const cursorContent = fs.readFileSync(cursorManifestPath, "utf-8");
|
|
170
|
-
fs.writeFileSync(
|
|
171
|
-
cursorManifestPath,
|
|
172
|
-
cursorContent.replace(/"version":\s*"[^"]+"/, `"version": "${newVersion}"`),
|
|
173
|
-
);
|
|
163
|
+
fs.writeFileSync(cursorManifestPath, cursorContent.replace(/"version":\s*"[^"]+"/, `"version": "${newVersion}"`));
|
|
174
164
|
}
|
|
175
165
|
|
|
176
166
|
if (fs.existsSync(claudeManifestPath)) {
|
|
177
167
|
const claudeContent = fs.readFileSync(claudeManifestPath, "utf-8");
|
|
178
|
-
fs.writeFileSync(
|
|
179
|
-
claudeManifestPath,
|
|
180
|
-
claudeContent.replace(/"version":\s*"[^"]+"/, `"version": "${newVersion}"`),
|
|
181
|
-
);
|
|
168
|
+
fs.writeFileSync(claudeManifestPath, claudeContent.replace(/"version":\s*"[^"]+"/, `"version": "${newVersion}"`));
|
|
182
169
|
}
|
|
183
170
|
}
|
|
184
171
|
|
package/package.json
CHANGED
|
@@ -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}",
|
package/src/cli-tool/create.ts
CHANGED
|
@@ -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
|
}
|
package/src/cli-tool/main.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
/** Argsbarg package CLI (`
|
|
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
|
-
|
|
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([
|
|
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:
|
|
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
|
});
|
package/src/config/validate.ts
CHANGED
|
@@ -5,6 +5,7 @@ CLI value coercion for configure set remains here (comma-separated arrays, boole
|
|
|
5
5
|
|
|
6
6
|
import { format as jsonSchemaFormats, type Schema, type SchemaDraft, Validator } from "@cfworker/json-schema";
|
|
7
7
|
import { parseCommaList, parseDate, parseDateTime, validateCommaList } from "../core/formats.ts";
|
|
8
|
+
import { decodeJsonPointerSegment, resolveJsonPointer } from "../core/json-pointer.ts";
|
|
8
9
|
import { isFrameworkConfigKey } from "./bindings.ts";
|
|
9
10
|
|
|
10
11
|
type JsonSchema = Record<string, unknown>;
|
|
@@ -72,10 +73,6 @@ function formatInstancePath(instanceLocation: string): string {
|
|
|
72
73
|
return instanceLocation;
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
function decodeJsonPointerSegment(segment: string): string {
|
|
76
|
-
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
77
|
-
}
|
|
78
|
-
|
|
79
76
|
/** cfworker keywords that only wrap a deeper, more specific failure — dropped when one survives underneath. */
|
|
80
77
|
const WRAPPER_KEYWORDS = new Set([
|
|
81
78
|
"$ref",
|
|
@@ -235,8 +232,12 @@ function unionDiscriminator(branches: unknown[], root: JsonSchema): UnionDiscrim
|
|
|
235
232
|
if (eligible.length === 0) {
|
|
236
233
|
return undefined;
|
|
237
234
|
}
|
|
238
|
-
const prop = eligible.includes("kind") ? "kind" : eligible.includes("type") ? "type" : [...eligible].sort()[0]
|
|
239
|
-
|
|
235
|
+
const prop = eligible.includes("kind") ? "kind" : eligible.includes("type") ? "type" : [...eligible].sort()[0];
|
|
236
|
+
const valuesByBranch = prop === undefined ? undefined : branchValuesFor(prop);
|
|
237
|
+
if (prop === undefined || valuesByBranch === undefined) {
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
return { prop, valuesByBranch };
|
|
240
241
|
}
|
|
241
242
|
|
|
242
243
|
/** Sorted, comma-joined, unquoted list of values for error messages. */
|
|
@@ -248,7 +249,7 @@ function joinSorted(values: Iterable<string>): string {
|
|
|
248
249
|
function rewriteErrorMessage(err: RawError, root: JsonSchema): string {
|
|
249
250
|
const additionalPropsMatch = /^Property "(.+)" does not match additional properties schema\.$/.exec(err.error);
|
|
250
251
|
if (additionalPropsMatch) {
|
|
251
|
-
const name = additionalPropsMatch[1]
|
|
252
|
+
const name = additionalPropsMatch[1] ?? "";
|
|
252
253
|
const parentLoc = parentPointer(err.keywordLocation);
|
|
253
254
|
const parentSchema = parentLoc ? schemaAtPointer(root, parentLoc) : undefined;
|
|
254
255
|
const props =
|
|
@@ -268,7 +269,7 @@ function rewriteErrorMessage(err: RawError, root: JsonSchema): string {
|
|
|
268
269
|
const enumMatch = /^Instance does not match any of (\[.*\])\.$/.exec(err.error);
|
|
269
270
|
if (enumMatch) {
|
|
270
271
|
try {
|
|
271
|
-
const values = JSON.parse(enumMatch[1]
|
|
272
|
+
const values = JSON.parse(enumMatch[1] ?? "") as unknown[];
|
|
272
273
|
return `must be one of: ${values.map((v) => String(v)).join(", ")}`;
|
|
273
274
|
} catch {
|
|
274
275
|
// fall through to the raw message
|
|
@@ -394,16 +395,16 @@ function narrowUnionErrors(errors: RawError[], root: JsonSchema, data: unknown):
|
|
|
394
395
|
for (const err of errors) {
|
|
395
396
|
if (dropped.has(err)) continue;
|
|
396
397
|
if (err.keyword === "additionalProperties") {
|
|
397
|
-
const
|
|
398
|
+
const name = /^Property "(.+)" does not match additional properties schema\.$/.exec(err.error)?.[1];
|
|
398
399
|
const parentLoc = parentPointer(err.keywordLocation);
|
|
399
400
|
const parentSchema = parentLoc ? schemaAtPointer(root, parentLoc) : undefined;
|
|
400
401
|
const props =
|
|
401
|
-
|
|
402
|
+
name !== undefined && parentSchema && typeof parentSchema === "object" && !Array.isArray(parentSchema)
|
|
402
403
|
? (parentSchema as JsonSchema).properties
|
|
403
404
|
: undefined;
|
|
404
405
|
const declared =
|
|
405
|
-
|
|
406
|
-
? Object.hasOwn(props as JsonSchema,
|
|
406
|
+
name !== undefined && props && typeof props === "object" && !Array.isArray(props)
|
|
407
|
+
? Object.hasOwn(props as JsonSchema, name)
|
|
407
408
|
: false;
|
|
408
409
|
if (declared) dropped.add(err);
|
|
409
410
|
continue;
|
|
@@ -450,25 +451,6 @@ export function resolveSchemaDraft(schema: JsonSchema): SchemaDraft {
|
|
|
450
451
|
return "7";
|
|
451
452
|
}
|
|
452
453
|
|
|
453
|
-
function resolveJsonPointer(root: JsonSchema, ref: string): unknown {
|
|
454
|
-
if (!ref.startsWith("#/")) {
|
|
455
|
-
return undefined;
|
|
456
|
-
}
|
|
457
|
-
const segments = ref
|
|
458
|
-
.slice(2)
|
|
459
|
-
.split("/")
|
|
460
|
-
.filter((segment) => segment.length > 0)
|
|
461
|
-
.map(decodeJsonPointerSegment);
|
|
462
|
-
let current: unknown = root;
|
|
463
|
-
for (const segment of segments) {
|
|
464
|
-
if (typeof current !== "object" || current === null || Array.isArray(current)) {
|
|
465
|
-
return undefined;
|
|
466
|
-
}
|
|
467
|
-
current = (current as Record<string, unknown>)[segment];
|
|
468
|
-
}
|
|
469
|
-
return current;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
454
|
function attachRootCompanionSchemas(validator: Validator, root: JsonSchema, active: JsonSchema): void {
|
|
473
455
|
if (active === root) {
|
|
474
456
|
return;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Same-document JSON Pointer resolution for JSON Schema `$ref` values (`#/definitions/Foo`).
|
|
3
|
+
Shared by schemagen root hoisting, MCP tool schema checks, config validation, and OpenAPI dereferencing.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Decodes one JSON Pointer segment: URI percent-escapes first (ts-json-schema-generator writes
|
|
8
|
+
* `#/definitions/Box%3Cstring%3E`), then the `~1` → `/` and `~0` → `~` pointer escapes.
|
|
9
|
+
*/
|
|
10
|
+
export function decodeJsonPointerSegment(
|
|
11
|
+
/** Raw segment between `/` separators. */
|
|
12
|
+
segment: string,
|
|
13
|
+
): string {
|
|
14
|
+
let decoded = segment;
|
|
15
|
+
try {
|
|
16
|
+
decoded = decodeURIComponent(segment);
|
|
17
|
+
} catch {
|
|
18
|
+
// Malformed percent-escape: fall back to the raw segment.
|
|
19
|
+
}
|
|
20
|
+
return decoded.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Resolves a same-document JSON Pointer (`#/definitions/Foo`) against `root`; `undefined` when it does not resolve. */
|
|
24
|
+
export function resolveJsonPointer(
|
|
25
|
+
/** Document the pointer is resolved against (the schema root). */
|
|
26
|
+
root: unknown,
|
|
27
|
+
/** `$ref` value; only `#/…` pointers are resolved. */
|
|
28
|
+
ref: string,
|
|
29
|
+
): unknown {
|
|
30
|
+
if (!ref.startsWith("#/")) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
const segments = ref
|
|
34
|
+
.slice(2)
|
|
35
|
+
.split("/")
|
|
36
|
+
.filter((segment) => segment.length > 0)
|
|
37
|
+
.map(decodeJsonPointerSegment);
|
|
38
|
+
let current: unknown = root;
|
|
39
|
+
for (const segment of segments) {
|
|
40
|
+
if (typeof current !== "object" || current === null || Array.isArray(current)) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
current = (current as Record<string, unknown>)[segment];
|
|
44
|
+
}
|
|
45
|
+
return current;
|
|
46
|
+
}
|
package/src/core/validate.ts
CHANGED
|
@@ -5,9 +5,10 @@ This module validates CLI schemas before execution.
|
|
|
5
5
|
import { reservedDocsTopicResourceUris } from "../docs/mcp-resources.ts";
|
|
6
6
|
import { DOCS_BUILTIN_TOPIC_KEYS, docsEnabled } from "../docs/resolve.ts";
|
|
7
7
|
import { HTTP_RESERVED_TOP_LEVEL_SEGMENTS } from "../http/paths.ts";
|
|
8
|
-
import { resolveMcpSchemaUri } from "../mcp/tools.ts";
|
|
8
|
+
import { collectMcpTools, resolveMcpSchemaUri } from "../mcp/tools.ts";
|
|
9
9
|
import { reservedCommandNames, resolveCapabilities } from "../runtime/capabilities.ts";
|
|
10
10
|
import { validateFormatValue } from "./formats.ts";
|
|
11
|
+
import { resolveJsonPointer } from "./json-pointer.ts";
|
|
11
12
|
import {
|
|
12
13
|
type CliLeaf,
|
|
13
14
|
type CliNode,
|
|
@@ -196,6 +197,68 @@ export function cliValidateProgram(program: CliProgram): void {
|
|
|
196
197
|
}
|
|
197
198
|
|
|
198
199
|
walkNode(program, program, true);
|
|
200
|
+
|
|
201
|
+
if (caps.mcp) {
|
|
202
|
+
validateMcpToolSchemas(program);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Keywords whose values are instance data, not subschemas; a `$ref` string inside them is not a reference. */
|
|
207
|
+
const SCHEMA_DATA_KEYWORDS = new Set(["const", "default", "enum", "examples"]);
|
|
208
|
+
|
|
209
|
+
/** Collects every `$ref` string in a schema (skipping instance-data keywords). */
|
|
210
|
+
function collectSchemaRefs(
|
|
211
|
+
/** Schema fragment to walk. */
|
|
212
|
+
node: unknown,
|
|
213
|
+
/** Accumulator for found `$ref` values. */
|
|
214
|
+
out: string[],
|
|
215
|
+
): string[] {
|
|
216
|
+
if (Array.isArray(node)) {
|
|
217
|
+
for (const item of node) {
|
|
218
|
+
collectSchemaRefs(item, out);
|
|
219
|
+
}
|
|
220
|
+
} else if (typeof node === "object" && node !== null) {
|
|
221
|
+
for (const [key, value] of Object.entries(node)) {
|
|
222
|
+
if (key === "$ref" && typeof value === "string") {
|
|
223
|
+
out.push(value);
|
|
224
|
+
} else if (!SCHEMA_DATA_KEYWORDS.has(key)) {
|
|
225
|
+
collectSchemaRefs(value, out);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Checks the schemas MCP clients will see (after object-root wrapping in `collectMcpTools`):
|
|
234
|
+
* every local `$ref` must resolve, and wrapped schemas cannot use `$ref: "#"` (it would point at the wrapper).
|
|
235
|
+
*/
|
|
236
|
+
function validateMcpToolSchemas(
|
|
237
|
+
/** Program with `mcpServer.enabled`. */
|
|
238
|
+
program: CliProgram,
|
|
239
|
+
): void {
|
|
240
|
+
for (const tool of collectMcpTools(program)) {
|
|
241
|
+
const schemas = [
|
|
242
|
+
{ label: "inputSchema", schema: tool.inputSchema, wrapped: tool.inputWrapped },
|
|
243
|
+
{ label: "outputSchema", schema: tool.outputSchema, wrapped: tool.outputWrapped },
|
|
244
|
+
];
|
|
245
|
+
for (const { label, schema, wrapped } of schemas) {
|
|
246
|
+
if (schema === undefined) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
for (const ref of collectSchemaRefs(schema, [])) {
|
|
250
|
+
if (ref === "#" && wrapped) {
|
|
251
|
+
throw new CliSchemaValidationError(
|
|
252
|
+
`MCP tool "${tool.name}" ${label} uses $ref "#" but its root is not type "object", so MCP wraps it; ` +
|
|
253
|
+
"reference a named definition instead",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (ref.startsWith("#/") && resolveJsonPointer(schema, ref) === undefined) {
|
|
257
|
+
throw new CliSchemaValidationError(`MCP tool "${tool.name}" ${label} has an unresolved $ref: ${ref}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
199
262
|
}
|
|
200
263
|
|
|
201
264
|
const PARAM_ROUTER_KEY = /^:[a-zA-Z][a-zA-Z0-9_]*$/;
|