toolcraft-openapi 0.0.38 → 0.0.40

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 (62) hide show
  1. package/README.md +67 -0
  2. package/dist/bin/generate.d.ts +9 -0
  3. package/dist/bin/generate.js +122 -6
  4. package/dist/config.d.ts +53 -0
  5. package/dist/config.js +266 -0
  6. package/dist/define-client.js +4 -0
  7. package/dist/diagnose.d.ts +4 -0
  8. package/dist/diagnose.js +93 -0
  9. package/dist/diagnostics.d.ts +19 -0
  10. package/dist/diagnostics.js +19 -0
  11. package/dist/generate.d.ts +13 -1
  12. package/dist/generate.js +165 -12
  13. package/dist/http.d.ts +30 -25
  14. package/dist/http.js +184 -42
  15. package/dist/index.d.ts +7 -2
  16. package/dist/index.js +5 -2
  17. package/dist/runtime.d.ts +7 -0
  18. package/dist/runtime.js +30 -5
  19. package/node_modules/@poe-code/frontmatter/README.md +35 -0
  20. package/node_modules/@poe-code/frontmatter/dist/fences.d.ts +8 -0
  21. package/node_modules/@poe-code/frontmatter/dist/fences.js +72 -0
  22. package/node_modules/@poe-code/frontmatter/dist/index.d.ts +3 -0
  23. package/node_modules/@poe-code/frontmatter/dist/index.js +3 -0
  24. package/node_modules/@poe-code/frontmatter/dist/parse.d.ts +20 -0
  25. package/node_modules/@poe-code/frontmatter/dist/parse.js +137 -0
  26. package/node_modules/@poe-code/frontmatter/dist/stringify.d.ts +1 -0
  27. package/node_modules/@poe-code/frontmatter/dist/stringify.js +35 -0
  28. package/node_modules/@poe-code/frontmatter/package.json +25 -0
  29. package/node_modules/toolcraft-design/README.md +12 -0
  30. package/node_modules/toolcraft-design/dist/prompts/index.d.ts +39 -14
  31. package/node_modules/toolcraft-design/dist/prompts/index.js +10 -6
  32. package/node_modules/toolcraft-design/dist/prompts/interactive/cancel-symbol.d.ts +2 -0
  33. package/node_modules/toolcraft-design/dist/prompts/interactive/cancel-symbol.js +4 -0
  34. package/node_modules/toolcraft-design/dist/prompts/interactive/confirm.d.ts +9 -0
  35. package/node_modules/toolcraft-design/dist/prompts/interactive/confirm.js +47 -0
  36. package/node_modules/toolcraft-design/dist/prompts/interactive/core.d.ts +55 -0
  37. package/node_modules/toolcraft-design/dist/prompts/interactive/core.js +274 -0
  38. package/node_modules/toolcraft-design/dist/prompts/interactive/glyphs.d.ts +20 -0
  39. package/node_modules/toolcraft-design/dist/prompts/interactive/glyphs.js +53 -0
  40. package/node_modules/toolcraft-design/dist/prompts/interactive/index.d.ts +6 -0
  41. package/node_modules/toolcraft-design/dist/prompts/interactive/index.js +6 -0
  42. package/node_modules/toolcraft-design/dist/prompts/interactive/keys.d.ts +2 -0
  43. package/node_modules/toolcraft-design/dist/prompts/interactive/keys.js +28 -0
  44. package/node_modules/toolcraft-design/dist/prompts/interactive/multiselect.d.ts +13 -0
  45. package/node_modules/toolcraft-design/dist/prompts/interactive/multiselect.js +131 -0
  46. package/node_modules/toolcraft-design/dist/prompts/interactive/pagination.d.ts +10 -0
  47. package/node_modules/toolcraft-design/dist/prompts/interactive/pagination.js +52 -0
  48. package/node_modules/toolcraft-design/dist/prompts/interactive/password.d.ts +10 -0
  49. package/node_modules/toolcraft-design/dist/prompts/interactive/password.js +55 -0
  50. package/node_modules/toolcraft-design/dist/prompts/interactive/select.d.ts +18 -0
  51. package/node_modules/toolcraft-design/dist/prompts/interactive/select.js +89 -0
  52. package/node_modules/toolcraft-design/dist/prompts/interactive/test-helpers.d.ts +21 -0
  53. package/node_modules/toolcraft-design/dist/prompts/interactive/test-helpers.js +32 -0
  54. package/node_modules/toolcraft-design/dist/prompts/interactive/text.d.ts +12 -0
  55. package/node_modules/toolcraft-design/dist/prompts/interactive/text.js +60 -0
  56. package/node_modules/toolcraft-design/dist/prompts/interactive/wrap.d.ts +4 -0
  57. package/node_modules/toolcraft-design/dist/prompts/interactive/wrap.js +18 -0
  58. package/node_modules/toolcraft-design/dist/prompts/primitives/cancel.d.ts +1 -1
  59. package/node_modules/toolcraft-design/dist/prompts/primitives/cancel.js +1 -1
  60. package/node_modules/toolcraft-design/dist/terminal-markdown/parser/frontmatter.js +20 -453
  61. package/node_modules/toolcraft-design/package.json +6 -3
  62. package/package.json +7 -7
package/README.md CHANGED
@@ -24,6 +24,10 @@ command files.
24
24
  - `--input <path-or-url>` — OpenAPI document to read. Defaults to `openapi.json`.
25
25
  - `--output <dir>` — directory for generated files. Defaults to `src/generated`.
26
26
  - `--check` — exits non-zero when generated files would change.
27
+ - `--diff` — prints the generated file changes without writing them.
28
+
29
+ When `toolcraft.yml` is present next to the input file, the generator validates it, prints
30
+ diagnostics, and uses it to shape generated command names for mapped resources.
27
31
 
28
32
  ### CI drift check
29
33
 
@@ -36,6 +40,14 @@ toolcraft-openapi-generate --check
36
40
  - `bearerTokenAuth(opts)`
37
41
  - `requestJson(options)`
38
42
  - `HttpError`
43
+ - `readToolcraftConfig(path)`
44
+ - `validateToolcraftConfig(value)`
45
+ - `mergeToolcraftConfig(base, override)`
46
+ - `diagnose(config, document)`
47
+ - `formatDiagnostic(diagnostic)`
48
+ - `formatDiagnostics(diagnostics)`
49
+ - `resolveOpenApiBaseUrl(options)`
50
+ - `DIAGNOSTIC_CODES`
39
51
  - `TokenSource`
40
52
  - `CommandContributor`
41
53
  - `AuthProvider`
@@ -44,9 +56,64 @@ toolcraft-openapi-generate --check
44
56
 
45
57
  - `<envVar passed to bearerTokenAuth>` — bearer token override; wins over stored credentials.
46
58
  - `AUTH_BACKEND` — forwarded to `auth-store` to select `file` or `keychain` storage.
59
+ - `TOOLCRAFT_OPENAPI_ENV` — selects a configured OpenAPI environment when using
60
+ `resolveOpenApiBaseUrl`.
47
61
 
48
62
  ## Configuration
49
63
 
64
+ ### `toolcraft.yml`
65
+
66
+ `toolcraft.yml` lives next to `openapi.json`. Without it, OpenAPI-only generation remains the
67
+ default.
68
+
69
+ ```yaml
70
+ edition: 2026-05-16
71
+
72
+ client_settings:
73
+ idempotency_header: Idempotency-Key
74
+
75
+ pagination:
76
+ cursor:
77
+ request: { cursor: cursor, limit: limit }
78
+ response: { items: data, next_cursor: meta.next_cursor }
79
+
80
+ resources:
81
+ messages:
82
+ methods:
83
+ list: get /messages { pagination: cursor }
84
+ create: post /messages { idempotent: true }
85
+
86
+ unspecified_endpoints:
87
+ - get /internal/health
88
+ ```
89
+
90
+ Supported keys:
91
+
92
+ - `edition` — required when `toolcraft.yml` exists. Currently `2026-05-16`.
93
+ - `environments` — named base URLs for `resolveOpenApiBaseUrl`.
94
+ - `client_settings.idempotency_header` — header used for generated idempotency keys.
95
+ - `client_settings.auth.*.env` — declarative auth environment metadata.
96
+ - `pagination` — named request/response field mappings. Schemes are validated today; iterator
97
+ generation is not implemented yet.
98
+ - `resources` — resource/method mapping. Mapped methods shape generated names and file paths.
99
+ - `readme.examples` — examples emitted into generated command help and MCP descriptions.
100
+ - `unspecified_endpoints` — endpoints intentionally omitted from `resources`.
101
+
102
+ Mapped idempotent methods with `client_settings.idempotency_header` get an optional
103
+ `idempotencyKey` param across CLI, MCP, and SDK surfaces. Mapped methods also get a CLI/SDK
104
+ `rawResponse` param that returns `{ data, response }`; the CLI accepts both `--raw-response` and
105
+ `--raw`.
106
+
107
+ Diagnostics use stable codes:
108
+
109
+ - `TOOLCRAFT_OPENAPI_001` — endpoint is not mapped or listed in `unspecified_endpoints`.
110
+ - `TOOLCRAFT_OPENAPI_002` — duplicate configured method path.
111
+ - `TOOLCRAFT_OPENAPI_003` — unknown pagination scheme.
112
+ - `TOOLCRAFT_OPENAPI_004` — reserved for spec drift.
113
+ - `TOOLCRAFT_OPENAPI_005` — reserved method name.
114
+ - `TOOLCRAFT_OPENAPI_006` — missing or unsupported edition.
115
+ - `TOOLCRAFT_OPENAPI_007` — invalid config shape.
116
+
50
117
  ### `bearerTokenAuth(opts)`
51
118
 
52
119
  - `serviceName: string` — auth-store service name and file-store key.
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { type OutputFormat } from "toolcraft-design";
3
+ import { type Diagnostic } from "../diagnostics.js";
4
+ import { type GeneratedFile } from "../generate.js";
3
5
  interface GenerateCliFileSystem {
4
6
  lstat(targetPath: string): Promise<{
5
7
  isDirectory(): boolean;
@@ -33,6 +35,7 @@ interface GenerateCliServices {
33
35
  }
34
36
  interface GenerateCliOptions {
35
37
  check: boolean;
38
+ diff: boolean;
36
39
  input: string;
37
40
  inspect: boolean;
38
41
  outputFormat: OutputFormat;
@@ -40,10 +43,16 @@ interface GenerateCliOptions {
40
43
  }
41
44
  interface SyncGeneratedClientResult {
42
45
  deletedFileCount: number;
46
+ deletedFiles: string[];
47
+ diagnostics: Diagnostic[];
43
48
  drifted: boolean;
44
49
  specSha: string;
50
+ updatedFiles: UpdatedGeneratedFile[];
45
51
  updatedFileCount: number;
46
52
  }
53
+ interface UpdatedGeneratedFile extends GeneratedFile {
54
+ previousContents?: string;
55
+ }
47
56
  export declare function runGenerateCli(argv?: string[], services?: GenerateCliServices): Promise<number>;
48
57
  export declare function syncGeneratedClient(options: GenerateCliOptions, services: Pick<GenerateCliServices, "cwd" | "fetch" | "fs">): Promise<SyncGeneratedClientResult>;
49
58
  export {};
@@ -7,12 +7,16 @@ import { fileURLToPath } from "node:url";
7
7
  import { UserError } from "toolcraft";
8
8
  import { hasOwnErrorCode } from "../error-codes.js";
9
9
  import { withOutputFormat } from "toolcraft-design";
10
+ import { readToolcraftConfig } from "../config.js";
11
+ import { diagnose } from "../diagnose.js";
12
+ import { formatDiagnostics } from "../diagnostics.js";
10
13
  import { generate } from "../generate.js";
11
14
  import { inspectOpenApiSource } from "../inspect-source.js";
12
15
  import { renderOpenApiInspection } from "../render-inspection.js";
13
16
  import { parseOpenApiDocument, readOpenApiSourceText } from "../spec-source.js";
14
17
  const DEFAULT_OPTIONS = {
15
18
  check: false,
19
+ diff: false,
16
20
  input: "openapi.json",
17
21
  inspect: false,
18
22
  outputFormat: "terminal",
@@ -24,6 +28,7 @@ Options:
24
28
  --input <path-or-url> OpenAPI document to read (default: openapi.json)
25
29
  --output <dir> Directory for generated command files (default: src/generated)
26
30
  --check Exit non-zero if generated output would change
31
+ --diff Print a diff of generated changes without writing files
27
32
  --inspect Inspect route compatibility without writing files
28
33
  --output-format <fmt> Inspection output: terminal, markdown, or json (default: terminal)
29
34
  -h, --help Show this help text
@@ -47,11 +52,26 @@ export async function runGenerateCli(argv = process.argv, services = {
47
52
  return 0;
48
53
  }
49
54
  const result = await syncGeneratedClient(parsed, services);
55
+ if (result.diagnostics.length > 0) {
56
+ services.stderr.write(formatDiagnostics(result.diagnostics));
57
+ }
58
+ if (parsed.diff) {
59
+ if (!result.drifted) {
60
+ services.stdout.write(`OpenAPI output is up to date (${result.specSha}).\n`);
61
+ return 0;
62
+ }
63
+ services.stdout.write(renderGeneratedDiff(result, services.cwd));
64
+ return 1;
65
+ }
50
66
  if (parsed.check) {
51
67
  if (result.drifted) {
52
68
  services.stderr.write(`OpenAPI output is out of date for ${parsed.outputDir}. Run the generator without --check to update it.\n`);
53
69
  return 1;
54
70
  }
71
+ if (hasErrorDiagnostics(result.diagnostics)) {
72
+ services.stderr.write("OpenAPI diagnostics failed for toolcraft.yml.\n");
73
+ return 1;
74
+ }
55
75
  services.stdout.write(`OpenAPI output is up to date (${result.specSha}).\n`);
56
76
  return 0;
57
77
  }
@@ -81,7 +101,12 @@ export async function syncGeneratedClient(options, services) {
81
101
  const sourceText = await readOpenApiSourceText(options.input, services);
82
102
  const specSha = createSpecSha(sourceText);
83
103
  const document = parseOpenApiDocument(sourceText, options.input);
84
- const generatedFiles = generate(document, { specSha });
104
+ const configResult = await readAdjacentToolcraftConfig(options.input, services);
105
+ const diagnostics = configResult.config === undefined
106
+ ? configResult.diagnostics
107
+ : [...configResult.diagnostics, ...diagnose(configResult.config, document)];
108
+ const effectiveConfig = hasErrorDiagnostics(diagnostics) ? undefined : configResult.config;
109
+ const generatedFiles = generate(document, { specSha, config: effectiveConfig });
85
110
  const outputDir = path.resolve(services.cwd, options.outputDir);
86
111
  const currentFiles = await readGeneratedFiles(services.fs, outputDir);
87
112
  const desiredFiles = new Map([
@@ -91,7 +116,7 @@ export async function syncGeneratedClient(options, services) {
91
116
  const updatedFiles = collectUpdatedFiles(currentFiles, desiredFiles);
92
117
  const deletedFiles = collectDeletedFiles(currentFiles, desiredFiles);
93
118
  const drifted = updatedFiles.length > 0 || deletedFiles.length > 0;
94
- if (!options.check && drifted) {
119
+ if (!options.check && !options.diff && drifted) {
95
120
  try {
96
121
  await writeGeneratedFiles(services.fs, outputDir, updatedFiles);
97
122
  await deleteGeneratedFiles(services.fs, outputDir, deletedFiles);
@@ -102,12 +127,98 @@ export async function syncGeneratedClient(options, services) {
102
127
  }
103
128
  }
104
129
  return {
130
+ deletedFiles,
131
+ deletedFileCount: deletedFiles.length,
132
+ diagnostics,
105
133
  drifted,
106
134
  specSha,
107
- updatedFileCount: updatedFiles.length,
108
- deletedFileCount: deletedFiles.length
135
+ updatedFiles,
136
+ updatedFileCount: updatedFiles.length
109
137
  };
110
138
  }
139
+ async function readAdjacentToolcraftConfig(input, services) {
140
+ const configPath = resolveAdjacentConfigPath(input, services.cwd);
141
+ if (configPath === undefined) {
142
+ return { diagnostics: [] };
143
+ }
144
+ try {
145
+ return await readToolcraftConfig(configPath, { fs: services.fs });
146
+ }
147
+ catch (error) {
148
+ if (isNotFoundError(error)) {
149
+ return { diagnostics: [] };
150
+ }
151
+ throw error;
152
+ }
153
+ }
154
+ function resolveAdjacentConfigPath(input, cwd) {
155
+ const inputUrl = tryParseUrl(input);
156
+ if (inputUrl !== null && (inputUrl.protocol === "http:" || inputUrl.protocol === "https:")) {
157
+ return undefined;
158
+ }
159
+ return path.resolve(cwd, path.dirname(input), "toolcraft.yml");
160
+ }
161
+ function hasErrorDiagnostics(diagnostics) {
162
+ return diagnostics.some((diagnostic) => diagnostic.severity === "error");
163
+ }
164
+ function renderGeneratedDiff(result, outputDir) {
165
+ const sections = [];
166
+ for (const file of result.updatedFiles) {
167
+ const relativePath = path.relative(outputDir, file.path);
168
+ sections.push(renderFileDiff(relativePath, file.previousContents ?? "", file.contents));
169
+ }
170
+ for (const filePath of result.deletedFiles) {
171
+ const relativePath = path.relative(outputDir, filePath);
172
+ sections.push([`--- ${relativePath}`, `+++ /dev/null`, "-<deleted>"].join("\n"));
173
+ }
174
+ return `${sections.join("\n")}\n`;
175
+ }
176
+ function renderFileDiff(relativePath, previousContents, nextContents) {
177
+ const previousLines = previousContents.split("\n");
178
+ const nextLines = nextContents.split("\n");
179
+ return [
180
+ `--- ${relativePath}`,
181
+ `+++ ${relativePath}`,
182
+ ...collectChangedDiffLines(previousLines, nextLines)
183
+ ].join("\n");
184
+ }
185
+ function collectChangedDiffLines(previousLines, nextLines) {
186
+ const lengths = Array.from({ length: previousLines.length + 1 }, () => Array(nextLines.length + 1).fill(0));
187
+ for (let leftIndex = previousLines.length - 1; leftIndex >= 0; leftIndex -= 1) {
188
+ for (let rightIndex = nextLines.length - 1; rightIndex >= 0; rightIndex -= 1) {
189
+ lengths[leftIndex][rightIndex] =
190
+ previousLines[leftIndex] === nextLines[rightIndex]
191
+ ? lengths[leftIndex + 1][rightIndex + 1] + 1
192
+ : Math.max(lengths[leftIndex + 1][rightIndex], lengths[leftIndex][rightIndex + 1]);
193
+ }
194
+ }
195
+ const lines = [];
196
+ let leftIndex = 0;
197
+ let rightIndex = 0;
198
+ while (leftIndex < previousLines.length && rightIndex < nextLines.length) {
199
+ if (previousLines[leftIndex] === nextLines[rightIndex]) {
200
+ leftIndex += 1;
201
+ rightIndex += 1;
202
+ continue;
203
+ }
204
+ if (lengths[leftIndex + 1][rightIndex] >= lengths[leftIndex][rightIndex + 1]) {
205
+ lines.push(`-${previousLines[leftIndex]}`);
206
+ leftIndex += 1;
207
+ continue;
208
+ }
209
+ lines.push(`+${nextLines[rightIndex]}`);
210
+ rightIndex += 1;
211
+ }
212
+ while (leftIndex < previousLines.length) {
213
+ lines.push(`-${previousLines[leftIndex]}`);
214
+ leftIndex += 1;
215
+ }
216
+ while (rightIndex < nextLines.length) {
217
+ lines.push(`+${nextLines[rightIndex]}`);
218
+ rightIndex += 1;
219
+ }
220
+ return lines;
221
+ }
111
222
  function parseGenerateCliArgs(argv) {
112
223
  const options = { ...DEFAULT_OPTIONS };
113
224
  for (let index = 0; index < argv.length; index += 1) {
@@ -119,6 +230,10 @@ function parseGenerateCliArgs(argv) {
119
230
  options.check = true;
120
231
  continue;
121
232
  }
233
+ if (argument === "--diff") {
234
+ options.diff = true;
235
+ continue;
236
+ }
122
237
  if (argument === "--inspect") {
123
238
  options.inspect = true;
124
239
  continue;
@@ -228,10 +343,11 @@ async function readGeneratedFiles(fs, directoryPath) {
228
343
  function collectUpdatedFiles(currentFiles, desiredFiles) {
229
344
  const updatedFiles = [];
230
345
  for (const [filePath, contents] of desiredFiles) {
231
- if (currentFiles.get(filePath) === contents) {
346
+ const previousContents = currentFiles.get(filePath);
347
+ if (previousContents === contents) {
232
348
  continue;
233
349
  }
234
- updatedFiles.push({ path: filePath, contents });
350
+ updatedFiles.push({ path: filePath, contents, previousContents });
235
351
  }
236
352
  return updatedFiles;
237
353
  }
@@ -0,0 +1,53 @@
1
+ import type { Diagnostic } from "./diagnostics.js";
2
+ export declare const SUPPORTED_TOOLCRAFT_EDITION = "2026-05-16";
3
+ export type ToolcraftHttpMethod = "get" | "post" | "put" | "patch" | "delete" | "head" | "options";
4
+ export interface ToolcraftMethodConfig {
5
+ method: ToolcraftHttpMethod;
6
+ path: string;
7
+ pagination?: string;
8
+ idempotent?: boolean;
9
+ }
10
+ export interface ToolcraftResourceConfig {
11
+ methods?: Record<string, ToolcraftMethodConfig>;
12
+ subresources?: Record<string, ToolcraftResourceConfig>;
13
+ }
14
+ export interface ToolcraftConfig {
15
+ edition: string;
16
+ environments?: Record<string, string>;
17
+ client_settings?: {
18
+ idempotency_header?: string;
19
+ auth?: Record<string, {
20
+ env?: string;
21
+ }>;
22
+ };
23
+ pagination?: Record<string, {
24
+ request: Record<string, string>;
25
+ response: Record<string, string>;
26
+ }>;
27
+ retries?: {
28
+ max?: number;
29
+ backoff?: "exponential";
30
+ retry_on?: number[];
31
+ };
32
+ resources?: Record<string, ToolcraftResourceConfig>;
33
+ readme?: {
34
+ examples?: Record<string, Array<{
35
+ title: string;
36
+ params: Record<string, unknown>;
37
+ }>>;
38
+ };
39
+ unspecified_endpoints?: string[];
40
+ }
41
+ export interface ReadToolcraftConfigOptions {
42
+ cwd?: string;
43
+ fs?: {
44
+ readFile(filePath: string, encoding: BufferEncoding): Promise<string>;
45
+ };
46
+ }
47
+ export interface ToolcraftConfigResult {
48
+ config?: ToolcraftConfig;
49
+ diagnostics: Diagnostic[];
50
+ }
51
+ export declare function readToolcraftConfig(filePath: string, options?: ReadToolcraftConfigOptions): Promise<ToolcraftConfigResult>;
52
+ export declare function validateToolcraftConfig(value: unknown): ToolcraftConfigResult;
53
+ export declare function mergeToolcraftConfig(base: Partial<ToolcraftConfig>, override: Partial<ToolcraftConfig>): Partial<ToolcraftConfig>;
package/dist/config.js ADDED
@@ -0,0 +1,266 @@
1
+ import path from "node:path";
2
+ import { parse } from "yaml";
3
+ import { S, validate } from "toolcraft-schema";
4
+ import { DIAGNOSTIC_CODES, createDiagnostic } from "./diagnostics.js";
5
+ export const SUPPORTED_TOOLCRAFT_EDITION = "2026-05-16";
6
+ const configSchema = S.Object({
7
+ edition: S.Optional(S.String()),
8
+ environments: S.Optional(S.Record(S.String())),
9
+ client_settings: S.Optional(S.Object({
10
+ idempotency_header: S.Optional(S.String()),
11
+ auth: S.Optional(S.Record(S.Object({
12
+ env: S.Optional(S.String())
13
+ }, { additionalProperties: false })))
14
+ }, { additionalProperties: false })),
15
+ pagination: S.Optional(S.Record(S.Object({
16
+ request: S.Record(S.String()),
17
+ response: S.Record(S.String())
18
+ }, { additionalProperties: false }))),
19
+ retries: S.Optional(S.Object({
20
+ max: S.Optional(S.Number({ jsonType: "integer", minimum: 0 })),
21
+ backoff: S.Optional(S.Enum(["exponential"])),
22
+ retry_on: S.Optional(S.Array(S.Number({ jsonType: "integer", minimum: 100, maximum: 599 })))
23
+ }, { additionalProperties: false })),
24
+ resources: S.Optional(S.Json()),
25
+ readme: S.Optional(S.Json()),
26
+ unspecified_endpoints: S.Optional(S.Array(S.String()))
27
+ }, { additionalProperties: false });
28
+ export async function readToolcraftConfig(filePath, options = {}) {
29
+ const fs = options.fs ?? (await import("node:fs/promises"));
30
+ const resolvedPath = path.resolve(options.cwd ?? process.cwd(), filePath);
31
+ const source = await fs.readFile(resolvedPath, "utf8");
32
+ const parsed = parseToolcraftYaml(source);
33
+ return validateToolcraftConfig(parsed);
34
+ }
35
+ function parseToolcraftYaml(source) {
36
+ try {
37
+ return parse(source);
38
+ }
39
+ catch {
40
+ return parse(quoteMethodShorthandValues(source));
41
+ }
42
+ }
43
+ function quoteMethodShorthandValues(source) {
44
+ let methodsIndent;
45
+ return source
46
+ .split("\n")
47
+ .map((line) => {
48
+ const trimmed = line.trim();
49
+ if (trimmed.length === 0) {
50
+ return line;
51
+ }
52
+ const indent = countLeadingSpaces(line);
53
+ if (methodsIndent !== undefined && indent <= methodsIndent) {
54
+ methodsIndent = undefined;
55
+ }
56
+ if (trimmed === "methods:") {
57
+ methodsIndent = indent;
58
+ return line;
59
+ }
60
+ if (methodsIndent === undefined) {
61
+ return line;
62
+ }
63
+ const separatorIndex = line.indexOf(":");
64
+ if (separatorIndex < 0) {
65
+ return line;
66
+ }
67
+ const value = line.slice(separatorIndex + 1).trim();
68
+ if (!value.includes("{") ||
69
+ value.startsWith("{") ||
70
+ value.startsWith("\"") ||
71
+ value.startsWith("'")) {
72
+ return line;
73
+ }
74
+ return `${line.slice(0, separatorIndex + 1)} ${JSON.stringify(value)}`;
75
+ })
76
+ .join("\n");
77
+ }
78
+ function countLeadingSpaces(value) {
79
+ let count = 0;
80
+ for (const character of value) {
81
+ if (character !== " ") {
82
+ break;
83
+ }
84
+ count += 1;
85
+ }
86
+ return count;
87
+ }
88
+ export function validateToolcraftConfig(value) {
89
+ const result = validate(configSchema, value);
90
+ const diagnostics = [];
91
+ if (!isPlainObject(value) || typeof value.edition !== "string") {
92
+ diagnostics.push(createDiagnostic({
93
+ code: DIAGNOSTIC_CODES.invalidEdition,
94
+ severity: "error",
95
+ location: "edition",
96
+ message: `toolcraft.yml must set edition: ${SUPPORTED_TOOLCRAFT_EDITION}.`
97
+ }));
98
+ }
99
+ else if (value.edition !== SUPPORTED_TOOLCRAFT_EDITION) {
100
+ diagnostics.push(createDiagnostic({
101
+ code: DIAGNOSTIC_CODES.invalidEdition,
102
+ severity: "error",
103
+ location: "edition",
104
+ message: `Unsupported edition ${JSON.stringify(value.edition)}. Expected ${SUPPORTED_TOOLCRAFT_EDITION}.`
105
+ }));
106
+ }
107
+ if (!result.ok) {
108
+ diagnostics.push(...result.issues.map((issue) => createDiagnostic({
109
+ code: DIAGNOSTIC_CODES.invalidConfig,
110
+ severity: "error",
111
+ location: issue.path.join("."),
112
+ message: issue.message
113
+ })));
114
+ }
115
+ if (diagnostics.length > 0 || !result.ok) {
116
+ return { diagnostics };
117
+ }
118
+ return {
119
+ config: normalizeToolcraftConfig(result.value),
120
+ diagnostics
121
+ };
122
+ }
123
+ export function mergeToolcraftConfig(base, override) {
124
+ return deepMerge(base, override);
125
+ }
126
+ function normalizeToolcraftConfig(value) {
127
+ return {
128
+ ...value,
129
+ resources: normalizeResources(value.resources),
130
+ readme: normalizeReadme(value.readme)
131
+ };
132
+ }
133
+ function normalizeResources(value) {
134
+ if (!isPlainObject(value)) {
135
+ return undefined;
136
+ }
137
+ return Object.fromEntries(Object.entries(value).map(([name, resource]) => [name, normalizeResource(resource)]));
138
+ }
139
+ function normalizeResource(value) {
140
+ if (!isPlainObject(value)) {
141
+ return {};
142
+ }
143
+ return {
144
+ methods: normalizeMethods(value.methods),
145
+ subresources: normalizeResources(value.subresources)
146
+ };
147
+ }
148
+ function normalizeMethods(value) {
149
+ if (!isPlainObject(value)) {
150
+ return undefined;
151
+ }
152
+ return Object.fromEntries(Object.entries(value)
153
+ .map(([name, method]) => [name, normalizeMethod(method)])
154
+ .filter((entry) => entry[1] !== undefined));
155
+ }
156
+ function normalizeMethod(value) {
157
+ if (typeof value === "string") {
158
+ return parseMethodShorthand(value);
159
+ }
160
+ if (!isPlainObject(value)) {
161
+ return undefined;
162
+ }
163
+ const method = value.method;
164
+ const requestPath = value.path;
165
+ if (!isHttpMethod(method) || typeof requestPath !== "string") {
166
+ return undefined;
167
+ }
168
+ return {
169
+ method,
170
+ path: requestPath,
171
+ ...(typeof value.pagination === "string" ? { pagination: value.pagination } : {}),
172
+ ...(typeof value.idempotent === "boolean" ? { idempotent: value.idempotent } : {})
173
+ };
174
+ }
175
+ function parseMethodShorthand(value) {
176
+ const parts = value.trim().split(" ").filter((part) => part.length > 0);
177
+ const method = parts[0];
178
+ const requestPath = parts[1];
179
+ if (!isHttpMethod(method) || requestPath === undefined) {
180
+ return undefined;
181
+ }
182
+ const optionsText = parts.slice(2).join(" ");
183
+ const options = parseInlineOptions(optionsText);
184
+ return {
185
+ method,
186
+ path: requestPath,
187
+ ...(typeof options.pagination === "string" ? { pagination: options.pagination } : {}),
188
+ ...(typeof options.idempotent === "boolean" ? { idempotent: options.idempotent } : {})
189
+ };
190
+ }
191
+ function parseInlineOptions(value) {
192
+ const trimmed = value.trim();
193
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
194
+ return {};
195
+ }
196
+ const body = trimmed.slice(1, -1).trim();
197
+ if (body.length === 0) {
198
+ return {};
199
+ }
200
+ const entries = [];
201
+ for (const pair of body.split(",")) {
202
+ const separatorIndex = pair.indexOf(":");
203
+ if (separatorIndex < 0) {
204
+ continue;
205
+ }
206
+ const key = pair.slice(0, separatorIndex).trim();
207
+ const rawValue = pair.slice(separatorIndex + 1).trim();
208
+ entries.push([key, parseInlineScalar(rawValue)]);
209
+ }
210
+ return Object.fromEntries(entries);
211
+ }
212
+ function parseInlineScalar(value) {
213
+ if (value === "true")
214
+ return true;
215
+ if (value === "false")
216
+ return false;
217
+ return value;
218
+ }
219
+ function normalizeReadme(value) {
220
+ if (!isPlainObject(value) || !isPlainObject(value.examples)) {
221
+ return undefined;
222
+ }
223
+ const examples = Object.fromEntries(Object.entries(value.examples)
224
+ .filter((entry) => Array.isArray(entry[1]))
225
+ .map(([key, entries]) => [
226
+ key,
227
+ entries
228
+ .filter(isPlainObject)
229
+ .map((entry) => ({
230
+ title: typeof entry.title === "string" ? entry.title : "Example",
231
+ params: isPlainObject(entry.params) ? entry.params : {}
232
+ }))
233
+ ]));
234
+ return { examples };
235
+ }
236
+ function isHttpMethod(value) {
237
+ return (value === "get" ||
238
+ value === "post" ||
239
+ value === "put" ||
240
+ value === "patch" ||
241
+ value === "delete" ||
242
+ value === "head" ||
243
+ value === "options");
244
+ }
245
+ function deepMerge(left, right) {
246
+ if (!isPlainObject(left) || !isPlainObject(right)) {
247
+ return cloneMergeValue(right);
248
+ }
249
+ const merged = { ...left };
250
+ for (const [key, value] of Object.entries(right)) {
251
+ merged[key] = key in merged ? deepMerge(merged[key], value) : cloneMergeValue(value);
252
+ }
253
+ return merged;
254
+ }
255
+ function cloneMergeValue(value) {
256
+ if (Array.isArray(value)) {
257
+ return [...value];
258
+ }
259
+ if (isPlainObject(value)) {
260
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, cloneMergeValue(entry)]));
261
+ }
262
+ return value;
263
+ }
264
+ function isPlainObject(value) {
265
+ return typeof value === "object" && value !== null && !Array.isArray(value);
266
+ }
@@ -78,6 +78,10 @@ function cloneCommand(command, scopeOverride) {
78
78
  return defineCommand({
79
79
  name: command.name,
80
80
  description: command.description,
81
+ examples: command.examples.map((example) => ({
82
+ title: example.title,
83
+ params: { ...example.params }
84
+ })),
81
85
  aliases: [...command.aliases],
82
86
  positional: [...command.positional],
83
87
  params: command.params,
@@ -0,0 +1,4 @@
1
+ import { type Diagnostic } from "./diagnostics.js";
2
+ import type { ToolcraftConfig } from "./config.js";
3
+ import type { OpenApiDocument } from "./generate.js";
4
+ export declare function diagnose(config: ToolcraftConfig, document: OpenApiDocument): Diagnostic[];