argsbarg 6.0.0 → 6.0.2
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 +27 -1
- package/README.md +4 -4
- package/docs/api-server.md +1 -1
- package/docs/config-schema.md +27 -15
- package/docs/output-schema.md +107 -75
- package/examples/full-example/README.md +8 -7
- package/examples/full-example/bun.lock +0 -29
- package/examples/full-example/justfile +6 -3
- package/examples/full-example/package.json +0 -2
- package/examples/full-example/src/commands/status/__generated__/index.ts +5 -0
- package/examples/full-example/{schemas/generated/status.json → src/commands/status/__generated__/outputSchema.json} +1 -1
- package/examples/full-example/src/commands/status/command.ts +2 -2
- package/examples/full-example/src/commands/status/schema.ts +14 -0
- package/examples/full-example/src/commands/status/types.ts +1 -11
- package/examples/full-example/{schemas/generated/app-config.json → src/config/__generated__/configSchema.json} +1 -1
- package/examples/full-example/src/config/__generated__/index.ts +5 -0
- package/examples/full-example/src/{types.ts → config/schema.ts} +3 -2
- package/examples/full-example/src/program.ts +4 -4
- package/package.json +24 -17
- package/src/api/openapi.ts +4 -2
- package/src/api/result.ts +23 -1
- package/src/api/schema-deref.test.ts +99 -0
- package/src/api/schema-deref.ts +76 -0
- package/src/api.integration.test.ts +85 -2
- package/src/cli-errors.ts +3 -0
- package/src/cli-tool/full-example-capabilities.test.ts +2 -1
- package/src/cli-tool/post-create.ts +3 -3
- package/src/cli-tool/program.ts +35 -0
- package/src/cli-tool/run-schemagen.ts +23 -0
- package/src/cli-tool/schemagen/cleanup.ts +60 -0
- package/src/cli-tool/schemagen/discover-schema-roots.ts +103 -0
- package/src/cli-tool/schemagen/index.ts +3 -0
- package/src/cli-tool/schemagen/names.ts +22 -0
- package/src/cli-tool/schemagen/run.ts +108 -0
- package/src/cli-tool/schemagen/schemagen.test.ts +125 -0
- package/src/docs/mcp-resources.ts +5 -2
- package/src/headless/tool-call.ts +16 -6
- package/examples/full-example/schemas/configSchemas.ts +0 -6
- package/examples/full-example/schemas/outputSchemas.ts +0 -6
- package/examples/full-example/scripts/schemagen/discover-schema-roots.test.ts +0 -25
- package/examples/full-example/scripts/schemagen/discover-schema-roots.ts +0 -93
- package/examples/full-example/scripts/schemagen/naming.ts +0 -82
- package/examples/full-example/scripts/schemagen.ts +0 -80
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Remove stale __generated__/ directories and files after schemagen runs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
6
|
+
import { dirname, join, relative } from "node:path";
|
|
7
|
+
import type { SchemaRoot } from "./discover-schema-roots.ts";
|
|
8
|
+
import { GENERATED_DIR, SCHEMA_FILE, schemaJsonBasename } from "./names.ts";
|
|
9
|
+
|
|
10
|
+
function listGeneratedDirs(srcDir: string, projectRoot: string, out: string[]): void {
|
|
11
|
+
for (const ent of readdirSync(srcDir)) {
|
|
12
|
+
const full = join(srcDir, ent);
|
|
13
|
+
const st = statSync(full);
|
|
14
|
+
if (!st.isDirectory()) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (ent === GENERATED_DIR) {
|
|
18
|
+
out.push(relative(projectRoot, full));
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
listGeneratedDirs(full, projectRoot, out);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Drop orphan `__generated__/` trees and JSON files for removed schema kinds. */
|
|
26
|
+
export function cleanStaleGenerated(
|
|
27
|
+
projectRoot: string,
|
|
28
|
+
srcDir: string,
|
|
29
|
+
activeBySchemaFile: Map<string, SchemaRoot[]>,
|
|
30
|
+
): void {
|
|
31
|
+
const srcPath = join(projectRoot, srcDir);
|
|
32
|
+
if (!existsSync(srcPath)) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const generatedDirs: string[] = [];
|
|
37
|
+
listGeneratedDirs(srcPath, projectRoot, generatedDirs);
|
|
38
|
+
|
|
39
|
+
for (const relGeneratedDir of generatedDirs) {
|
|
40
|
+
const generatedDir = join(projectRoot, relGeneratedDir);
|
|
41
|
+
const relSchemaPath = relative(projectRoot, join(dirname(generatedDir), SCHEMA_FILE));
|
|
42
|
+
const roots = existsSync(join(projectRoot, relSchemaPath)) ? (activeBySchemaFile.get(relSchemaPath) ?? []) : [];
|
|
43
|
+
|
|
44
|
+
if (roots.length === 0) {
|
|
45
|
+
rmSync(generatedDir, { recursive: true, force: true });
|
|
46
|
+
console.log(`removed ${relGeneratedDir}`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const keep = new Set(["index.ts", ...roots.map((root) => schemaJsonBasename(root.kind))]);
|
|
51
|
+
for (const ent of readdirSync(generatedDir)) {
|
|
52
|
+
if (keep.has(ent)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const stalePath = join(generatedDir, ent);
|
|
56
|
+
rmSync(stalePath, { recursive: true, force: true });
|
|
57
|
+
console.log(`removed ${relative(projectRoot, stalePath)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Discovers schema roots in schema.ts files via configType / inputType / outputType exports.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { join, relative } from "node:path";
|
|
7
|
+
import { SCHEMA_FILE } from "./names.ts";
|
|
8
|
+
|
|
9
|
+
export type SchemaRootKind = "config" | "input" | "output";
|
|
10
|
+
|
|
11
|
+
export type SchemaRole = "configType" | "inputType" | "outputType";
|
|
12
|
+
|
|
13
|
+
export interface SchemaRoot {
|
|
14
|
+
kind: SchemaRootKind;
|
|
15
|
+
typeName: string;
|
|
16
|
+
/** Path relative to project root (e.g. src/config/schema.ts). */
|
|
17
|
+
path: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ROLE_EXPORT_RE = /export\s+type\s+(configType|inputType|outputType)\s*=\s*(\w+)/g;
|
|
21
|
+
|
|
22
|
+
const ROLE_TO_KIND: Record<SchemaRole, SchemaRootKind> = {
|
|
23
|
+
configType: "config",
|
|
24
|
+
inputType: "input",
|
|
25
|
+
outputType: "output",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function listSchemaFiles(srcDir: string, baseDir: string, out: string[]): void {
|
|
29
|
+
for (const ent of readdirSync(srcDir)) {
|
|
30
|
+
const full = join(srcDir, ent);
|
|
31
|
+
const st = statSync(full);
|
|
32
|
+
if (st.isDirectory()) {
|
|
33
|
+
listSchemaFiles(full, baseDir, out);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (ent === SCHEMA_FILE) {
|
|
37
|
+
out.push(relative(baseDir, full));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when `typeName` is declared in this file (not a re-export alias to another module). */
|
|
43
|
+
function isTypeDefinedInFile(text: string, typeName: string): boolean {
|
|
44
|
+
if (new RegExp(`export\\s+interface\\s+${typeName}\\b`).test(text)) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
if (new RegExp(`export\\s+type\\s+${typeName}\\s*=`).test(text)) {
|
|
48
|
+
return !["configType", "inputType", "outputType"].includes(typeName);
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function discoverFromFile(path: string, text: string): SchemaRoot[] {
|
|
54
|
+
const rolesSeen = new Set<SchemaRole>();
|
|
55
|
+
const roots: SchemaRoot[] = [];
|
|
56
|
+
|
|
57
|
+
for (const match of text.matchAll(ROLE_EXPORT_RE)) {
|
|
58
|
+
const role = match[1] as SchemaRole | undefined;
|
|
59
|
+
const typeName = match[2];
|
|
60
|
+
if (!role || !typeName) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (rolesSeen.has(role)) {
|
|
64
|
+
throw new Error(`${path}: duplicate export type ${role}`);
|
|
65
|
+
}
|
|
66
|
+
rolesSeen.add(role);
|
|
67
|
+
if (!isTypeDefinedInFile(text, typeName)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
roots.push({ kind: ROLE_TO_KIND[role], typeName, path });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return roots;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Find all schema roots under `srcDir` in files named `schema.ts`. */
|
|
77
|
+
export function discoverSchemaRoots(projectRoot: string, srcDir = "src"): SchemaRoot[] {
|
|
78
|
+
const srcPath = join(projectRoot, srcDir);
|
|
79
|
+
const files: string[] = [];
|
|
80
|
+
listSchemaFiles(srcPath, projectRoot, files);
|
|
81
|
+
|
|
82
|
+
const roots: SchemaRoot[] = [];
|
|
83
|
+
const typeOwners = new Map<string, string>();
|
|
84
|
+
|
|
85
|
+
for (const relPath of files.sort()) {
|
|
86
|
+
const text = readFileSync(join(projectRoot, relPath), "utf8");
|
|
87
|
+
for (const root of discoverFromFile(relPath, text)) {
|
|
88
|
+
const prev = typeOwners.get(root.typeName);
|
|
89
|
+
if (prev) {
|
|
90
|
+
throw new Error(`${relPath}: duplicate schema root type ${root.typeName} (already declared in ${prev})`);
|
|
91
|
+
}
|
|
92
|
+
typeOwners.set(root.typeName, relPath);
|
|
93
|
+
roots.push(root);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const configRoots = roots.filter((r) => r.kind === "config");
|
|
98
|
+
if (configRoots.length > 1) {
|
|
99
|
+
throw new Error(`multiple config schema roots: ${configRoots.map((r) => `${r.typeName} (${r.path})`).join(", ")}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return roots;
|
|
103
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { discoverSchemaRoots, type SchemaRoot, type SchemaRootKind } from "./discover-schema-roots.ts";
|
|
2
|
+
export { GENERATED_DIR, SCHEMA_FILE, schemaExportName, schemaJsonBasename } from "./names.ts";
|
|
3
|
+
export { type RunSchemagenOptions, type RunSchemagenResult, runSchemagen } from "./run.ts";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SchemaRootKind } from "./discover-schema-roots.ts";
|
|
2
|
+
|
|
3
|
+
/** Directory name for generated schema artifacts (next to `schema.ts`). */
|
|
4
|
+
export const GENERATED_DIR = "__generated__";
|
|
5
|
+
|
|
6
|
+
/** TypeScript schema root filename under `src/`. */
|
|
7
|
+
export const SCHEMA_FILE = "schema.ts";
|
|
8
|
+
|
|
9
|
+
/** JSON basename for a schema root kind (`outputSchema.json`, etc.). */
|
|
10
|
+
export function schemaJsonBasename(kind: SchemaRootKind): string {
|
|
11
|
+
return `${kind}Schema.json`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Export const name wired on leaves or `program.appConfig`. */
|
|
15
|
+
export function schemaExportName(kind: SchemaRootKind): string {
|
|
16
|
+
return `${kind}Schema`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Safe import binding for a schema JSON basename. */
|
|
20
|
+
export function schemaJsonImportVar(kind: SchemaRootKind): string {
|
|
21
|
+
return `${kind}SchemaJson`;
|
|
22
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Generate JSON Schema artifacts under __generated__/ and write index.ts re-exports.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join, relative } from "node:path";
|
|
7
|
+
import { createGenerator } from "ts-json-schema-generator";
|
|
8
|
+
import { cleanStaleGenerated } from "./cleanup.ts";
|
|
9
|
+
import { discoverSchemaRoots, type SchemaRoot, type SchemaRootKind } from "./discover-schema-roots.ts";
|
|
10
|
+
import { GENERATED_DIR, schemaExportName, schemaJsonBasename, schemaJsonImportVar } from "./names.ts";
|
|
11
|
+
|
|
12
|
+
const KIND_ORDER: SchemaRootKind[] = ["config", "input", "output"];
|
|
13
|
+
|
|
14
|
+
export interface RunSchemagenOptions {
|
|
15
|
+
/** Project root (default: `process.cwd()`). */
|
|
16
|
+
projectRoot?: string;
|
|
17
|
+
/** Source tree directory relative to project root (default: `src`). */
|
|
18
|
+
srcDir?: string;
|
|
19
|
+
/** Path to tsconfig relative to project root (default: `tsconfig.json`). */
|
|
20
|
+
tsconfig?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RunSchemagenResult {
|
|
24
|
+
configRoots: number;
|
|
25
|
+
inputRoots: number;
|
|
26
|
+
outputRoots: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveTsconfig(projectRoot: string, tsconfig: string): string {
|
|
30
|
+
const path = join(projectRoot, tsconfig);
|
|
31
|
+
try {
|
|
32
|
+
return path;
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error(`tsconfig not found: ${path}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function generateJson(projectRoot: string, tsconfigPath: string, root: SchemaRoot): Record<string, unknown> {
|
|
39
|
+
const generator = createGenerator({
|
|
40
|
+
path: join(projectRoot, root.path),
|
|
41
|
+
type: root.typeName,
|
|
42
|
+
tsconfig: tsconfigPath,
|
|
43
|
+
topRef: false,
|
|
44
|
+
skipTypeCheck: false,
|
|
45
|
+
jsDoc: "extended",
|
|
46
|
+
additionalProperties: root.kind === "config" ? false : undefined,
|
|
47
|
+
});
|
|
48
|
+
const schema = generator.createSchema(root.typeName) as Record<string, unknown>;
|
|
49
|
+
if (root.kind === "config") {
|
|
50
|
+
schema.additionalProperties = false;
|
|
51
|
+
}
|
|
52
|
+
return schema;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeGeneratedIndex(generatedDir: string, roots: SchemaRoot[]): void {
|
|
56
|
+
const sorted = [...roots].sort((left, right) => KIND_ORDER.indexOf(left.kind) - KIND_ORDER.indexOf(right.kind));
|
|
57
|
+
const lines = ["// Auto-generated by argsbarg schemagen — do not edit by hand.", ""];
|
|
58
|
+
for (const root of sorted) {
|
|
59
|
+
lines.push(`import ${schemaJsonImportVar(root.kind)} from "./${schemaJsonBasename(root.kind)}";`);
|
|
60
|
+
}
|
|
61
|
+
if (sorted.length > 0) {
|
|
62
|
+
lines.push("");
|
|
63
|
+
}
|
|
64
|
+
for (const root of sorted) {
|
|
65
|
+
lines.push(
|
|
66
|
+
`export const ${schemaExportName(root.kind)} = ${schemaJsonImportVar(root.kind)} as Record<string, unknown>;`,
|
|
67
|
+
);
|
|
68
|
+
lines.push("");
|
|
69
|
+
}
|
|
70
|
+
writeFileSync(join(generatedDir, "index.ts"), `${lines.join("\n")}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Generate colocated `__generated__` JSON schemas and `index.ts` barrels. */
|
|
74
|
+
export function runSchemagen(options: RunSchemagenOptions = {}): RunSchemagenResult {
|
|
75
|
+
const projectRoot = options.projectRoot ?? process.cwd();
|
|
76
|
+
const srcDir = options.srcDir ?? "src";
|
|
77
|
+
const tsconfigPath = resolveTsconfig(projectRoot, options.tsconfig ?? "tsconfig.json");
|
|
78
|
+
|
|
79
|
+
const roots = discoverSchemaRoots(projectRoot, srcDir);
|
|
80
|
+
const bySchemaFile = new Map<string, SchemaRoot[]>();
|
|
81
|
+
|
|
82
|
+
for (const root of roots) {
|
|
83
|
+
const schema = generateJson(projectRoot, tsconfigPath, root);
|
|
84
|
+
const generatedDir = join(dirname(join(projectRoot, root.path)), GENERATED_DIR);
|
|
85
|
+
mkdirSync(generatedDir, { recursive: true });
|
|
86
|
+
const jsonPath = join(generatedDir, schemaJsonBasename(root.kind));
|
|
87
|
+
writeFileSync(jsonPath, `${JSON.stringify(schema, null, 2)}\n`);
|
|
88
|
+
console.log(`wrote ${relative(projectRoot, jsonPath)} (${root.typeName})`);
|
|
89
|
+
|
|
90
|
+
const list = bySchemaFile.get(root.path) ?? [];
|
|
91
|
+
list.push(root);
|
|
92
|
+
bySchemaFile.set(root.path, list);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
for (const [relPath, fileRoots] of bySchemaFile) {
|
|
96
|
+
const generatedDir = join(dirname(join(projectRoot, relPath)), GENERATED_DIR);
|
|
97
|
+
writeGeneratedIndex(generatedDir, fileRoots);
|
|
98
|
+
console.log(`wrote ${relative(projectRoot, join(generatedDir, "index.ts"))}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
cleanStaleGenerated(projectRoot, srcDir, bySchemaFile);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
configRoots: roots.filter((r) => r.kind === "config").length,
|
|
105
|
+
inputRoots: roots.filter((r) => r.kind === "input").length,
|
|
106
|
+
outputRoots: roots.filter((r) => r.kind === "output").length,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { discoverSchemaRoots } from "./discover-schema-roots.ts";
|
|
6
|
+
import { schemaExportName, schemaJsonBasename } from "./names.ts";
|
|
7
|
+
import { runSchemagen } from "./run.ts";
|
|
8
|
+
|
|
9
|
+
const exampleRoot = join(import.meta.dir, "../../../examples/full-example");
|
|
10
|
+
|
|
11
|
+
const tempRoots: string[] = [];
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
for (const root of tempRoots.splice(0)) {
|
|
15
|
+
rmSync(root, { recursive: true, force: true });
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function makeTempProject(): string {
|
|
20
|
+
const root = mkdtempSync(join(tmpdir(), "argsbarg-schemagen-"));
|
|
21
|
+
tempRoots.push(root);
|
|
22
|
+
writeFileSync(
|
|
23
|
+
join(root, "tsconfig.json"),
|
|
24
|
+
JSON.stringify(
|
|
25
|
+
{
|
|
26
|
+
compilerOptions: {
|
|
27
|
+
strict: true,
|
|
28
|
+
target: "ESNext",
|
|
29
|
+
module: "ESNext",
|
|
30
|
+
moduleResolution: "bundler",
|
|
31
|
+
resolveJsonModule: true,
|
|
32
|
+
},
|
|
33
|
+
include: ["src/**/*"],
|
|
34
|
+
},
|
|
35
|
+
null,
|
|
36
|
+
2,
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
return root;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeSchema(root: string, relDir: string, body: string): void {
|
|
43
|
+
const dir = join(root, "src", relDir);
|
|
44
|
+
mkdirSync(dir, { recursive: true });
|
|
45
|
+
writeFileSync(join(dir, "schema.ts"), body);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("schemagen", () => {
|
|
49
|
+
test("discovers AppConfig and StatusJsonOutput in full-example", () => {
|
|
50
|
+
const roots = discoverSchemaRoots(exampleRoot);
|
|
51
|
+
expect(roots.map((r) => `${r.kind}:${r.typeName}`).sort()).toEqual(["config:AppConfig", "output:StatusJsonOutput"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("maps schema kinds to __generated__ filenames and export names", () => {
|
|
55
|
+
expect(schemaJsonBasename("output")).toBe("outputSchema.json");
|
|
56
|
+
expect(schemaJsonBasename("config")).toBe("configSchema.json");
|
|
57
|
+
expect(schemaExportName("output")).toBe("outputSchema");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("runSchemagen writes __generated__ artifacts in full-example", () => {
|
|
61
|
+
const counts = runSchemagen({ projectRoot: exampleRoot });
|
|
62
|
+
expect(counts).toEqual({ configRoots: 1, inputRoots: 0, outputRoots: 1 });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("removes stale JSON files when a schema kind is dropped", () => {
|
|
66
|
+
const root = makeTempProject();
|
|
67
|
+
writeSchema(
|
|
68
|
+
root,
|
|
69
|
+
"commands/demo",
|
|
70
|
+
`/** Tool input. */
|
|
71
|
+
export interface DemoInput { id: string; }
|
|
72
|
+
/** Tool output. */
|
|
73
|
+
export interface DemoOutput { ok: boolean; }
|
|
74
|
+
export type inputType = DemoInput;
|
|
75
|
+
export type outputType = DemoOutput;
|
|
76
|
+
`,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
runSchemagen({ projectRoot: root });
|
|
80
|
+
const generatedDir = join(root, "src/commands/demo/__generated__");
|
|
81
|
+
expect(existsSync(join(generatedDir, "inputSchema.json"))).toBe(true);
|
|
82
|
+
expect(existsSync(join(generatedDir, "outputSchema.json"))).toBe(true);
|
|
83
|
+
|
|
84
|
+
writeSchema(
|
|
85
|
+
root,
|
|
86
|
+
"commands/demo",
|
|
87
|
+
`/** Tool output. */
|
|
88
|
+
export interface DemoOutput { ok: boolean; }
|
|
89
|
+
export type outputType = DemoOutput;
|
|
90
|
+
`,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
runSchemagen({ projectRoot: root });
|
|
94
|
+
expect(existsSync(join(generatedDir, "inputSchema.json"))).toBe(false);
|
|
95
|
+
expect(existsSync(join(generatedDir, "outputSchema.json"))).toBe(true);
|
|
96
|
+
expect(existsSync(join(generatedDir, "index.ts"))).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("removes orphan __generated__ when schema.ts is gone", () => {
|
|
100
|
+
const root = makeTempProject();
|
|
101
|
+
const generatedDir = join(root, "src/orphan/__generated__");
|
|
102
|
+
mkdirSync(generatedDir, { recursive: true });
|
|
103
|
+
writeFileSync(join(generatedDir, "outputSchema.json"), "{}\n");
|
|
104
|
+
writeFileSync(join(generatedDir, "index.ts"), "export const outputSchema = {};\n");
|
|
105
|
+
|
|
106
|
+
runSchemagen({ projectRoot: root });
|
|
107
|
+
expect(existsSync(generatedDir)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("removes __generated__ when schema.ts has no discoverable roots", () => {
|
|
111
|
+
const root = makeTempProject();
|
|
112
|
+
writeSchema(
|
|
113
|
+
root,
|
|
114
|
+
"commands/empty",
|
|
115
|
+
`export type outputType = never;
|
|
116
|
+
`,
|
|
117
|
+
);
|
|
118
|
+
const generatedDir = join(root, "src/commands/empty/__generated__");
|
|
119
|
+
mkdirSync(generatedDir, { recursive: true });
|
|
120
|
+
writeFileSync(join(generatedDir, "outputSchema.json"), "{}\n");
|
|
121
|
+
|
|
122
|
+
runSchemagen({ projectRoot: root });
|
|
123
|
+
expect(existsSync(generatedDir)).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -3,7 +3,7 @@ Auto MCP resources for user docs.topics when docs and MCP are both enabled.
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { CliProgram } from "../types.ts";
|
|
6
|
-
import { docsEnabled,
|
|
6
|
+
import { docsEnabled, docsTopicDescription, docsTopicText, docsUserTopicKeys } from "./resolve.ts";
|
|
7
7
|
|
|
8
8
|
/** Default URI pattern for a docs topic MCP resource (`<mcpId>://docs/<topicKey>`). */
|
|
9
9
|
export function defaultDocsTopicResourceUri(mcpId: string, topicKey: string): string {
|
|
@@ -45,7 +45,10 @@ export function docsMcpResources(program: CliProgram): {
|
|
|
45
45
|
name: key,
|
|
46
46
|
description: docsTopicDescription(key, topic.description),
|
|
47
47
|
mimeType: "text/markdown",
|
|
48
|
-
load: () =>
|
|
48
|
+
load: () => {
|
|
49
|
+
const text = docsTopicText(program, key);
|
|
50
|
+
return text.endsWith("\n") ? text : `${text}\n`;
|
|
51
|
+
},
|
|
49
52
|
};
|
|
50
53
|
});
|
|
51
54
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Shared headless tool dispatch for MCP and HTTP: config bootstrap, argv conversion, and invoke.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { apiErrorResponse, apiSuccessResponse } from "../api/result.ts";
|
|
5
|
+
import { apiErrorResponse, apiSuccessResponse, firstErrorLine } from "../api/result.ts";
|
|
6
6
|
import type { Cli, CliInvokeResult } from "../cli.ts";
|
|
7
7
|
import { bootstrapAppConfig } from "../config/bootstrap.ts";
|
|
8
8
|
import { formatMcpMissingConfigMessage, missingRequiredConfig } from "../config/resolve.ts";
|
|
@@ -137,11 +137,21 @@ export function headlessSuccessToHttpResponse(
|
|
|
137
137
|
|
|
138
138
|
/** Maps a headless failure result to a JSON HTTP error Response. */
|
|
139
139
|
export function headlessFailureToHttpResponse(result: HeadlessToolCallFailure): Response {
|
|
140
|
-
const status = result
|
|
140
|
+
const status = resolveHttpErrorStatus(result);
|
|
141
141
|
return apiErrorResponse(status, {
|
|
142
|
-
error: result.message,
|
|
143
|
-
exitCode: result.exitCode,
|
|
144
|
-
stdout: result.stdout,
|
|
145
|
-
stderr: result.stderr,
|
|
142
|
+
error: firstErrorLine(result.message),
|
|
146
143
|
});
|
|
147
144
|
}
|
|
145
|
+
|
|
146
|
+
function resolveHttpErrorStatus(result: HeadlessToolCallFailure): number {
|
|
147
|
+
if (result.kind === "argv" || result.kind === "help") {
|
|
148
|
+
return 400;
|
|
149
|
+
}
|
|
150
|
+
if (result.kind === "invoke" && result.message.includes("ctx.respond()")) {
|
|
151
|
+
return 500;
|
|
152
|
+
}
|
|
153
|
+
if (result.exitCode === 1) {
|
|
154
|
+
return 400;
|
|
155
|
+
}
|
|
156
|
+
return 500;
|
|
157
|
+
}
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
// Auto-generated by scripts/schemagen.ts — do not edit by hand.
|
|
2
|
-
|
|
3
|
-
import app_config from "./generated/app-config.json";
|
|
4
|
-
|
|
5
|
-
/** JSON Schema for program.appConfig.jsonSchema from `AppConfig`. */
|
|
6
|
-
export const APP_CONFIG_JSON_SCHEMA = app_config as Record<string, unknown>;
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { discoverSchemaRoots } from "./discover-schema-roots.ts";
|
|
4
|
-
|
|
5
|
-
const projectRoot = join(import.meta.dir, "../..");
|
|
6
|
-
|
|
7
|
-
describe("discover-schema-roots", () => {
|
|
8
|
-
test("finds AppConfig and StatusJsonOutput in src types.ts files", () => {
|
|
9
|
-
const roots = discoverSchemaRoots(projectRoot);
|
|
10
|
-
const config = roots.find((r) => r.kind === "config");
|
|
11
|
-
const output = roots.find((r) => r.kind === "output");
|
|
12
|
-
expect(config).toMatchObject({
|
|
13
|
-
typeName: "AppConfig",
|
|
14
|
-
relFile: "src/types.ts",
|
|
15
|
-
outfile: "app-config.json",
|
|
16
|
-
exportName: "APP_CONFIG_JSON_SCHEMA",
|
|
17
|
-
});
|
|
18
|
-
expect(output).toMatchObject({
|
|
19
|
-
typeName: "StatusJsonOutput",
|
|
20
|
-
relFile: "src/commands/status/types.ts",
|
|
21
|
-
outfile: "status.json",
|
|
22
|
-
exportName: "STATUS_JSON_OUTPUT_SCHEMA",
|
|
23
|
-
});
|
|
24
|
-
});
|
|
25
|
-
});
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Discovers schema roots in src (recursive) types.ts files by JSDoc markers.
|
|
3
|
-
Copy per consumer repo — see docs/output-schema.md and docs/config-schema.md.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
7
|
-
import { join, relative } from "node:path";
|
|
8
|
-
import {
|
|
9
|
-
configSchemaExportName,
|
|
10
|
-
outfileForConfigType,
|
|
11
|
-
outfileForOutputType,
|
|
12
|
-
outputSchemaExportName,
|
|
13
|
-
} from "./naming.ts";
|
|
14
|
-
|
|
15
|
-
export type SchemaRootKind = "config" | "output";
|
|
16
|
-
|
|
17
|
-
export interface SchemaRoot {
|
|
18
|
-
kind: SchemaRootKind;
|
|
19
|
-
typeName: string;
|
|
20
|
-
/** Path relative to project root (e.g. src/types.ts). */
|
|
21
|
-
relFile: string;
|
|
22
|
-
outfile: string;
|
|
23
|
-
exportName: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const CONFIG_MARKER = "Config schema";
|
|
27
|
-
const OUTPUT_MARKER = "JSON payload";
|
|
28
|
-
|
|
29
|
-
const INTERFACE_RE = /\/\*\*([\s\S]*?)\*\/\s*export\s+interface\s+(\w+)/g;
|
|
30
|
-
|
|
31
|
-
function listTypesTsFiles(srcDir: string, baseDir: string, out: string[]): void {
|
|
32
|
-
for (const ent of readdirSync(srcDir)) {
|
|
33
|
-
const full = join(srcDir, ent);
|
|
34
|
-
const st = statSync(full);
|
|
35
|
-
if (st.isDirectory()) {
|
|
36
|
-
listTypesTsFiles(full, baseDir, out);
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
if (ent === "types.ts") {
|
|
40
|
-
out.push(relative(baseDir, full));
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function classifyRoot(jsDoc: string, typeName: string, relFile: string): SchemaRoot | undefined {
|
|
46
|
-
const hasConfig = jsDoc.includes(CONFIG_MARKER);
|
|
47
|
-
const hasOutput = jsDoc.includes(OUTPUT_MARKER);
|
|
48
|
-
if (hasConfig && hasOutput) {
|
|
49
|
-
throw new Error(`${relFile}: ${typeName} has both Config schema and JSON payload markers`);
|
|
50
|
-
}
|
|
51
|
-
if (!hasConfig && !hasOutput) {
|
|
52
|
-
return undefined;
|
|
53
|
-
}
|
|
54
|
-
if (hasConfig) {
|
|
55
|
-
return {
|
|
56
|
-
kind: "config",
|
|
57
|
-
typeName,
|
|
58
|
-
relFile,
|
|
59
|
-
outfile: outfileForConfigType(typeName),
|
|
60
|
-
exportName: configSchemaExportName(typeName),
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
return {
|
|
64
|
-
kind: "output",
|
|
65
|
-
typeName,
|
|
66
|
-
relFile,
|
|
67
|
-
outfile: outfileForOutputType(typeName),
|
|
68
|
-
exportName: outputSchemaExportName(typeName),
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Find all schema roots under `src/` in files named types.ts. */
|
|
73
|
-
export function discoverSchemaRoots(projectRoot: string): SchemaRoot[] {
|
|
74
|
-
const srcDir = join(projectRoot, "src");
|
|
75
|
-
const files: string[] = [];
|
|
76
|
-
listTypesTsFiles(srcDir, projectRoot, files);
|
|
77
|
-
const roots: SchemaRoot[] = [];
|
|
78
|
-
for (const relFile of files.sort()) {
|
|
79
|
-
const text = readFileSync(join(projectRoot, relFile), "utf8");
|
|
80
|
-
for (const match of text.matchAll(INTERFACE_RE)) {
|
|
81
|
-
const jsDoc = match[1] ?? "";
|
|
82
|
-
const typeName = match[2];
|
|
83
|
-
if (!typeName) {
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
const root = classifyRoot(jsDoc, typeName, relFile);
|
|
87
|
-
if (root) {
|
|
88
|
-
roots.push(root);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
return roots;
|
|
93
|
-
}
|