@wacht/bench 0.1.2 → 0.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/dist/commands.js CHANGED
@@ -16,6 +16,7 @@ import { completionScript } from './completion.js';
16
16
  import { configApply, configDiff, configPull, configSchemaCommand, printConfigTemplate, } from './config-workflow.js';
17
17
  import { clearDeployment, createDeploymentCommand, createProjectCommand, currentDeployment, selectDeployment, } from './deployment-context.js';
18
18
  import { initProject, initStarter } from './init.js';
19
+ import { docsSearch } from './docs-search.js';
19
20
  import { envPull } from './env-pull.js';
20
21
  import { apiCommand, listProjects } from './machine-api.js';
21
22
  import { openApiCall, openApiDescribe, openApiList, openApiRefresh } from './openapi.js';
@@ -219,6 +220,21 @@ export async function runCli(args) {
219
220
  .action((options) => {
220
221
  printMcpConfig(options.client);
221
222
  });
223
+ const docs = program.command('docs').description('search and explore Wacht docs');
224
+ docs
225
+ .command('search <query...>')
226
+ .description('full-text search Wacht docs and print matching pages')
227
+ .option('--limit <n>', 'max number of pages to print', (v) => Number.parseInt(v, 10))
228
+ .option('--base-url <url>', 'docs base URL (default: https://wacht.dev/docs, or $WACHT_DOCS_URL)')
229
+ .option('--json', 'emit JSON instead of human-readable output')
230
+ .action(async (queryParts, options) => {
231
+ await docsSearch(context(program), {
232
+ query: queryParts.join(' '),
233
+ limit: options.limit,
234
+ baseUrl: options.baseUrl,
235
+ json: options.json,
236
+ });
237
+ });
222
238
  const env = program.command('env').description('manage deployment credentials and environment files');
223
239
  env
224
240
  .command('pull')
@@ -307,14 +323,18 @@ export async function runCli(args) {
307
323
  .argument('<operation>', 'OpenAPI operation id')
308
324
  .option('--deployment <id>', 'deployment id override; defaults to active deployment')
309
325
  .option('--param <key=value>', 'path or query parameter; repeatable', collect, [])
310
- .option('--body <json>', 'JSON request body')
326
+ .option('--body <json>', 'JSON request body; pass @path/to/file.json to read from disk')
311
327
  .option('--field <key=value>', 'URL-encoded form field; repeatable', collect, [])
312
328
  .option('--form <key=value>', 'multipart form field; value @path is treated as a file; repeatable', collect, [])
313
329
  .option('--file <key=path>', 'multipart file field; key=path or key=@path; repeatable', collect, [])
314
330
  .option('--header <key=value>', 'request header; repeatable', collect, [])
315
331
  .option('--refresh', 'refresh the cached OpenAPI schema first')
316
- .action(async (operation, options) => {
317
- await openApiCall(context(program), operation, options);
332
+ .option('--no-validate', 'skip local JSON Schema validation of the request body')
333
+ .action(async (operation, _options, cmd) => {
334
+ // The parent `api` command also declares --body / --field / --form / --file / --header
335
+ // so `optsWithGlobals()` is what actually surfaces them through this subcommand.
336
+ // Commander does not merge clashing options automatically.
337
+ await openApiCall(context(program), operation, cmd.optsWithGlobals());
318
338
  });
319
339
  const schema = api.command('schema').description('manage cached OpenAPI schema');
320
340
  schema
@@ -0,0 +1,81 @@
1
+ import { field, log, printBannerFor, printError, printJson, section } from './ui.js';
2
+ const DEFAULT_DOCS_URL = 'https://wacht.dev/docs';
3
+ export async function docsSearch(ctx, options) {
4
+ const baseUrl = (options.baseUrl ?? process.env.WACHT_DOCS_URL ?? DEFAULT_DOCS_URL).replace(/\/+$/, '');
5
+ const endpoint = `${baseUrl}/api/search?query=${encodeURIComponent(options.query)}`;
6
+ let response;
7
+ try {
8
+ response = await fetch(endpoint, { headers: { Accept: 'application/json' } });
9
+ }
10
+ catch (error) {
11
+ printError(error);
12
+ process.exitCode = 1;
13
+ return;
14
+ }
15
+ if (!response.ok) {
16
+ printError(new Error(`docs search failed: ${response.status} ${response.statusText} (${endpoint})`));
17
+ process.exitCode = 1;
18
+ return;
19
+ }
20
+ const raw = (await response.json());
21
+ if (!Array.isArray(raw)) {
22
+ printError(new Error('unexpected docs search response shape'));
23
+ process.exitCode = 1;
24
+ return;
25
+ }
26
+ const pages = new Map();
27
+ const snippets = new Map();
28
+ for (const hit of raw) {
29
+ const pageUrl = hit.url.split('#')[0];
30
+ if (hit.type === 'page') {
31
+ pages.set(pageUrl, hit);
32
+ }
33
+ else {
34
+ if (!snippets.has(pageUrl))
35
+ snippets.set(pageUrl, []);
36
+ snippets.get(pageUrl).push(hit);
37
+ }
38
+ }
39
+ const orderedPages = Array.from(pages.values());
40
+ const sliced = options.limit ? orderedPages.slice(0, options.limit) : orderedPages;
41
+ if (options.json) {
42
+ printJson({
43
+ ok: true,
44
+ query: options.query,
45
+ baseUrl,
46
+ results: sliced.map((page) => ({
47
+ title: page.content,
48
+ url: `${baseUrl}${page.url}`,
49
+ breadcrumbs: page.breadcrumbs ?? [],
50
+ snippets: (snippets.get(page.url) ?? []).map((s) => ({
51
+ text: s.content,
52
+ url: `${baseUrl}${s.url}`,
53
+ })),
54
+ })),
55
+ });
56
+ return;
57
+ }
58
+ printBannerFor(ctx);
59
+ log(ctx, section('Docs Search'));
60
+ log(ctx, field('Query', options.query));
61
+ log(ctx, field('Source', baseUrl));
62
+ log(ctx, field('Results', String(sliced.length)));
63
+ log(ctx, '');
64
+ if (sliced.length === 0) {
65
+ log(ctx, 'No matches.');
66
+ return;
67
+ }
68
+ for (const page of sliced) {
69
+ const breadcrumbs = page.breadcrumbs?.length ? page.breadcrumbs.join(' › ') : '';
70
+ log(ctx, `• ${page.content}`);
71
+ if (breadcrumbs)
72
+ log(ctx, ` ${breadcrumbs}`);
73
+ log(ctx, ` ${baseUrl}${page.url}`);
74
+ const pageSnippets = snippets.get(page.url) ?? [];
75
+ for (const s of pageSnippets.slice(0, 3)) {
76
+ const trimmed = s.content.length > 140 ? `${s.content.slice(0, 137).trimEnd()}…` : s.content;
77
+ log(ctx, ` – ${trimmed}`);
78
+ }
79
+ log(ctx, '');
80
+ }
81
+ }
package/dist/env-pull.js CHANGED
@@ -73,10 +73,15 @@ export async function envPull(ctx, options = {}) {
73
73
  }
74
74
  const root = process.cwd();
75
75
  const profile = await detectProject(root);
76
- const pubVar = publishableKeyVar(new Set(profile.frameworks));
76
+ const frameworks = new Set(profile.frameworks);
77
+ const pubVar = publishableKeyVar(frameworks);
78
+ // Vite-based frameworks read `.env`; Next.js reads `.env.local`.
79
+ const defaultFile = frameworks.has('React Router') || frameworks.has('TanStack Router')
80
+ ? '.env'
81
+ : '.env.local';
77
82
  const filePath = options.file
78
83
  ? path.resolve(root, options.file)
79
- : path.join(root, '.env.local');
84
+ : path.join(root, defaultFile);
80
85
  const existing = await readFile(filePath, 'utf8').catch((err) => {
81
86
  if (err.code === 'ENOENT')
82
87
  return '';
package/dist/init.js CHANGED
@@ -43,7 +43,7 @@ This project is configured for AI-assisted Wacht development.
43
43
  - Detected project shape: \`${frameworks}\`.
44
44
  - Suggested Wacht skills for this project: \`${skills}\`.
45
45
  - Active skill router: \`wacht\` (always start there). For CLI work specifically, use the \`wacht-bench-cli\` skill.
46
- - Install or update skills with \`wacht skills add\`.
46
+ - Install or update skills with \`wacht skills install\`.
47
47
 
48
48
  ### Where to look (single source of truth)
49
49
 
@@ -96,6 +96,13 @@ async function upsertAgentsBlock(root, profile) {
96
96
  return agentsPath;
97
97
  }
98
98
  async function writeEnvTemplate(root, profile) {
99
+ // If the project already ships a `.env.local.example` / `.env.example` (typical for
100
+ // scaffolded starters), don't drop a second redundant file alongside it.
101
+ for (const existing of ['.env.local.example', '.env.example']) {
102
+ if (await pathExists(path.join(root, existing))) {
103
+ return null;
104
+ }
105
+ }
99
106
  const envPath = path.join(root, '.env.wacht.example');
100
107
  const frameworks = new Set(profile.frameworks);
101
108
  const isVite = frameworks.has('React Router') || frameworks.has('TanStack Router');
@@ -105,7 +112,8 @@ async function writeEnvTemplate(root, profile) {
105
112
  ? 'VITE_WACHT_PUBLISHABLE_KEY'
106
113
  : 'NEXT_PUBLIC_WACHT_PUBLISHABLE_KEY';
107
114
  const lines = [
108
- '# Wacht SDK environment. Fill these from your Wacht deployment.',
115
+ '# Wacht SDK environment. Run `wacht env pull` to populate these automatically,',
116
+ '# or copy values from https://console.wacht.dev.',
109
117
  '',
110
118
  '# Client-safe publishable key. Encodes deployment + frontend host.',
111
119
  `${publishableKeyVar}=`,
@@ -129,7 +137,9 @@ export async function initProject(args, ctx) {
129
137
  log(ctx, field('Suggested skills', profile.suggestedSkills.join(', ')));
130
138
  log(ctx, '');
131
139
  if (!options.skipEnv) {
132
- written.push(await writeEnvTemplate(root, profile));
140
+ const envPath = await writeEnvTemplate(root, profile);
141
+ if (envPath)
142
+ written.push(envPath);
133
143
  }
134
144
  if (!options.skipAgents) {
135
145
  written.push(await upsertAgentsBlock(root, profile));
@@ -142,10 +152,6 @@ export async function initProject(args, ctx) {
142
152
  log(ctx, section('Install Skills'));
143
153
  await installSkills({ yes: true });
144
154
  }
145
- else {
146
- log(ctx, '');
147
- log(ctx, field('Skills', `run ${command('wacht skills install')} when you want to install/update the pack`));
148
- }
149
155
  if (ctx.json) {
150
156
  printJson({
151
157
  ok: true,
@@ -162,6 +168,22 @@ export async function initProject(args, ctx) {
162
168
  log(ctx, '');
163
169
  log(ctx, success('Wacht Bench project bootstrap complete.'));
164
170
  }
171
+ function printAgentBootstrapSteps(ctx, opts) {
172
+ log(ctx, '');
173
+ log(ctx, section('Next'));
174
+ const cd = opts.starterDir ? `cd ${opts.starterDir} && ` : '';
175
+ if (!opts.installedSkills) {
176
+ log(ctx, ` ${command('wacht skills install --agent claude-code --global --yes')}`);
177
+ }
178
+ log(ctx, ` ${command('wacht mcp install --client claude-code-user --yes')}`);
179
+ log(ctx, ` ${command('wacht login && wacht deployments select')}`);
180
+ log(ctx, ` ${command(`${cd}wacht env pull`)}`);
181
+ if (opts.starterDir) {
182
+ log(ctx, ` ${command(`${cd}pnpm install && pnpm dev`)}`);
183
+ }
184
+ log(ctx, '');
185
+ log(ctx, '(Restart your AI client after installing skills/MCP so they load.)');
186
+ }
165
187
  // ─── Starter mode ───────────────────────────────────────────────────
166
188
  const STARTERS = {
167
189
  nextjs: {
@@ -232,7 +254,10 @@ export async function initStarter(options, ctx) {
232
254
  }
233
255
  log(ctx, '');
234
256
  log(ctx, success(`Starter ready at ${path.relative(process.cwd(), absoluteTarget) || '.'}`));
235
- log(ctx, `Next: ${command(`cd ${path.relative(process.cwd(), absoluteTarget) || '.'} && pnpm install && pnpm dev`)}`);
257
+ printAgentBootstrapSteps(ctx, {
258
+ starterDir: path.relative(process.cwd(), absoluteTarget) || '.',
259
+ installedSkills: options.install,
260
+ });
236
261
  }
237
262
  export function listStarters() {
238
263
  return Object.entries(STARTERS).map(([framework, info]) => ({ framework, description: info.description, repo: info.repo }));
@@ -239,9 +239,16 @@ export async function requestBody(options) {
239
239
  headers.set(key, value);
240
240
  }
241
241
  if (options.body) {
242
+ // `--body @path/to/file.json` reads the JSON from disk so prompt files and
243
+ // larger configuration blobs don't have to be inlined in the shell.
244
+ let source = options.body;
245
+ if (source.startsWith('@')) {
246
+ const filePath = path.resolve(source.slice(1));
247
+ source = await readFile(filePath, 'utf8');
248
+ }
242
249
  headers.set('content-type', 'application/json');
243
250
  return {
244
- body: JSON.stringify(JSON.parse(options.body)),
251
+ body: JSON.stringify(JSON.parse(source)),
245
252
  headers,
246
253
  };
247
254
  }
@@ -0,0 +1,172 @@
1
+ import { Ajv } from 'ajv';
2
+ /**
3
+ * Validate a JSON request body against an operation's `requestBody` schema using
4
+ * the cached OpenAPI spec. Returns `null` on success; an array of human-readable
5
+ * error strings on failure.
6
+ *
7
+ * The validator handles OpenAPI 3.0 `nullable: true` (rewritten to JSON-Schema
8
+ * `type: [..., "null"]`), `$ref` resolution against `components.schemas`, and
9
+ * discriminated unions via `oneOf` + `discriminator`.
10
+ */
11
+ export function validateBody(spec, schema, body) {
12
+ if (!isRecord(schema))
13
+ return null;
14
+ const componentSchemas = readComponentSchemas(spec);
15
+ const ajv = buildAjv(componentSchemas);
16
+ const compiled = compileSafely(ajv, schema);
17
+ if (!compiled)
18
+ return null;
19
+ if (compiled(body))
20
+ return null;
21
+ return summariseErrors(compiled.errors ?? [], body);
22
+ }
23
+ // Collapse `oneOf` failures into one line per union and drop the noisy umbrella
24
+ // + `type: null` errors our `nullable` rewrite introduces.
25
+ function summariseErrors(rawErrors, body) {
26
+ // Discover oneOf failure points by looking for the `oneOf` umbrella error.
27
+ const oneOfPaths = new Set();
28
+ for (const e of rawErrors) {
29
+ if (e.keyword === 'oneOf')
30
+ oneOfPaths.add(e.instancePath);
31
+ }
32
+ const collapsed = [];
33
+ const skip = new Set();
34
+ for (const path of oneOfPaths) {
35
+ const value = readJsonPointer(body, path);
36
+ // The variant tag is conventionally `type`; if absent, just describe the path.
37
+ const tag = isRecord(value) && typeof value.type === 'string'
38
+ ? `, got type "${value.type}"`
39
+ : '';
40
+ collapsed.push(`${path || '(body root)'}: doesn't match any variant of the discriminated union${tag}. ` +
41
+ `Run \`wacht api describe <operation>\` to see each variant's required fields.`);
42
+ // Drop every error at the union path; the user fixes the variant first.
43
+ for (const e of rawErrors) {
44
+ if (e.instancePath === path || e.instancePath.startsWith(path + '/'))
45
+ skip.add(e);
46
+ }
47
+ }
48
+ const out = [...collapsed];
49
+ for (const e of rawErrors) {
50
+ if (skip.has(e))
51
+ continue;
52
+ if (e.keyword === 'anyOf' || e.keyword === 'oneOf')
53
+ continue;
54
+ if (e.keyword === 'type' && e.params?.type === 'null')
55
+ continue;
56
+ out.push(formatError(e));
57
+ }
58
+ // Dedupe identical lines (e.g. two variants both miss the same field).
59
+ return [...new Set(out)];
60
+ }
61
+ function readJsonPointer(value, pointer) {
62
+ if (!pointer || pointer === '/')
63
+ return value;
64
+ const segments = pointer.split('/').slice(1).map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~'));
65
+ let cur = value;
66
+ for (const seg of segments) {
67
+ if (Array.isArray(cur)) {
68
+ const idx = Number.parseInt(seg, 10);
69
+ if (Number.isNaN(idx))
70
+ return undefined;
71
+ cur = cur[idx];
72
+ }
73
+ else if (isRecord(cur)) {
74
+ cur = cur[seg];
75
+ }
76
+ else {
77
+ return undefined;
78
+ }
79
+ }
80
+ return cur;
81
+ }
82
+ function readComponentSchemas(spec) {
83
+ if (!isRecord(spec))
84
+ return {};
85
+ const components = isRecord(spec.components) ? spec.components : undefined;
86
+ const schemas = components && isRecord(components.schemas) ? components.schemas : undefined;
87
+ return schemas ?? {};
88
+ }
89
+ function buildAjv(componentSchemas) {
90
+ // OpenAPI 3.1 schemas are JSON Schema draft 2020-12, but the spec we generate
91
+ // also leans on draft-7 `nullable: true` shorthand. Rewrite to draft-7 unions
92
+ // and run Ajv permissively — we're validating user payloads, not enforcing
93
+ // the spec itself.
94
+ const ajv = new Ajv({
95
+ strict: false,
96
+ allErrors: true,
97
+ coerceTypes: false,
98
+ allowUnionTypes: true,
99
+ validateFormats: false,
100
+ });
101
+ // Make every component schema addressable by its $ref so nested references resolve.
102
+ for (const [name, raw] of Object.entries(componentSchemas)) {
103
+ const rewritten = rewriteNullable(raw);
104
+ try {
105
+ ajv.addSchema(rewritten, `#/components/schemas/${name}`);
106
+ }
107
+ catch {
108
+ // Bad schemas shouldn't break the CLI — skip and keep going.
109
+ }
110
+ }
111
+ return ajv;
112
+ }
113
+ function compileSafely(ajv, schema) {
114
+ try {
115
+ return ajv.compile(rewriteNullable(schema));
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ /** Walk a schema and rewrite `{type: X, nullable: true}` → `{type: [X, "null"]}`. */
122
+ function rewriteNullable(value) {
123
+ if (Array.isArray(value))
124
+ return value.map(rewriteNullable);
125
+ if (!isRecord(value))
126
+ return value;
127
+ const out = {};
128
+ let nullable = false;
129
+ for (const [key, v] of Object.entries(value)) {
130
+ if (key === 'nullable' && v === true) {
131
+ nullable = true;
132
+ continue;
133
+ }
134
+ out[key] = rewriteNullable(v);
135
+ }
136
+ if (nullable && typeof out.type === 'string') {
137
+ out.type = [out.type, 'null'];
138
+ }
139
+ else if (nullable && Array.isArray(out.type)) {
140
+ if (!out.type.includes('null'))
141
+ out.type = [...out.type, 'null'];
142
+ }
143
+ else if (nullable) {
144
+ // No `type`; allow null alongside whatever shape the schema describes.
145
+ out.anyOf = [{ ...out }, { type: 'null' }];
146
+ for (const k of Object.keys(out))
147
+ if (k !== 'anyOf')
148
+ delete out[k];
149
+ }
150
+ return out;
151
+ }
152
+ function formatError(err) {
153
+ const path = err.instancePath || '(body root)';
154
+ const reason = err.message ?? 'invalid';
155
+ const extras = err.params ? formatParams(err.params) : '';
156
+ return `${path}: ${reason}${extras}`;
157
+ }
158
+ function formatParams(params) {
159
+ // Skim the most useful bits — missingProperty, allowedValues — without
160
+ // dumping every Ajv internal.
161
+ const bits = [];
162
+ if (typeof params.missingProperty === 'string')
163
+ bits.push(`missing "${params.missingProperty}"`);
164
+ if (Array.isArray(params.allowedValues))
165
+ bits.push(`allowed: ${params.allowedValues.map((v) => JSON.stringify(v)).join(', ')}`);
166
+ if (typeof params.additionalProperty === 'string')
167
+ bits.push(`unknown property "${params.additionalProperty}"`);
168
+ return bits.length ? ` (${bits.join('; ')})` : '';
169
+ }
170
+ function isRecord(value) {
171
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
172
+ }
package/dist/openapi.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
2
3
  import { AUTH_DIR, OPENAPI_CACHE_FILE, PLATFORM_OPENAPI_URL } from './config.js';
3
4
  import { readBenchContext } from './context-store.js';
4
5
  import { entries, requestBody, machineRequest } from './machine-api.js';
6
+ import { validateBody } from './openapi-validate.js';
5
7
  import { field, log, printBannerFor, printJson, section, warning } from './ui.js';
6
8
  const OPENAPI_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
7
9
  const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
@@ -74,6 +76,7 @@ function operations(spec) {
74
76
  tags: typed.tags ?? [],
75
77
  parameters: typed.parameters ?? [],
76
78
  requestBody: typed.requestBody,
79
+ responses: typed.responses,
77
80
  });
78
81
  }
79
82
  }
@@ -95,14 +98,172 @@ function findOperation(spec, target, maybePath) {
95
98
  function schemaType(schema) {
96
99
  if (!schema)
97
100
  return 'unknown';
98
- if (typeof schema.type === 'string')
101
+ if (typeof schema.type === 'string') {
102
+ if (schema.type === 'array' && isRecord(schema.items)) {
103
+ return `array<${schemaType(schema.items)}>`;
104
+ }
105
+ if (Array.isArray(schema.enum) && schema.enum.length) {
106
+ return `${schema.type}<${schema.enum.map((v) => JSON.stringify(v)).join(' | ')}>`;
107
+ }
108
+ if (typeof schema.const === 'string')
109
+ return `const "${schema.const}"`;
110
+ // Surface common formats like `binary` (file uploads) so multipart fields
111
+ // are recognisable at a glance vs plain strings.
112
+ if (typeof schema.format === 'string')
113
+ return `${schema.type} (${schema.format})`;
99
114
  return schema.type;
115
+ }
100
116
  if (typeof schema.$ref === 'string')
101
117
  return schema.$ref.split('/').pop() ?? schema.$ref;
102
118
  if (Array.isArray(schema.anyOf))
103
119
  return schema.anyOf.map((item) => isRecord(item) ? schemaType(item) : 'unknown').join(' | ');
120
+ if (Array.isArray(schema.oneOf))
121
+ return schema.oneOf.map((item) => isRecord(item) ? schemaType(item) : 'unknown').join(' | ');
122
+ if (schema.const !== undefined)
123
+ return `const ${JSON.stringify(schema.const)}`;
104
124
  return 'object';
105
125
  }
126
+ const REF_PREFIX = '#/components/schemas/';
127
+ function resolveSchemaRef(spec, schema, seen = new Set()) {
128
+ if (!schema)
129
+ return undefined;
130
+ if (typeof schema.$ref !== 'string')
131
+ return schema;
132
+ const name = schema.$ref.startsWith(REF_PREFIX) ? schema.$ref.slice(REF_PREFIX.length) : '';
133
+ if (!name || seen.has(name))
134
+ return schema;
135
+ const schemas = isRecord(spec.components) && isRecord(spec.components.schemas) ? spec.components.schemas : undefined;
136
+ const target = schemas?.[name];
137
+ if (!isRecord(target))
138
+ return schema;
139
+ seen.add(name);
140
+ return resolveSchemaRef(spec, target, seen);
141
+ }
142
+ /**
143
+ * Merge an `allOf` chain into a single object schema. Used for our generator's
144
+ * internally-tagged enum encoding: `allOf: [{$ref: Payload}, {properties: {<tag>: const}}]`.
145
+ */
146
+ function flattenAllOf(spec, schema) {
147
+ if (!Array.isArray(schema.allOf))
148
+ return schema;
149
+ const props = {};
150
+ const required = new Set();
151
+ for (const part of schema.allOf) {
152
+ if (!isRecord(part))
153
+ continue;
154
+ const resolved = resolveSchemaRef(spec, part) ?? part;
155
+ if (isRecord(resolved.properties)) {
156
+ for (const [key, value] of Object.entries(resolved.properties))
157
+ props[key] = value;
158
+ }
159
+ if (Array.isArray(resolved.required)) {
160
+ for (const key of resolved.required) {
161
+ if (typeof key === 'string')
162
+ required.add(key);
163
+ }
164
+ }
165
+ }
166
+ return { type: 'object', properties: props, required: Array.from(required) };
167
+ }
168
+ /** How many levels of nested object schemas to inline before falling back to the type name. */
169
+ const MAX_BODY_DEPTH = 3;
170
+ /** Pull the schema-component name out of a `$ref`, if any. */
171
+ function refName(schema) {
172
+ if (!schema || typeof schema.$ref !== 'string')
173
+ return undefined;
174
+ return schema.$ref.startsWith(REF_PREFIX) ? schema.$ref.slice(REF_PREFIX.length) : undefined;
175
+ }
176
+ /** True when a resolved schema carries structure worth expanding inline. */
177
+ function isStructural(schema) {
178
+ if (Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.allOf))
179
+ return true;
180
+ if (isRecord(schema.properties) && Object.keys(schema.properties).length > 0)
181
+ return true;
182
+ return false;
183
+ }
184
+ function printSchema(pctx, raw, indent, depth) {
185
+ // Stop recursing if we're already too deep — caller has shown the type name.
186
+ if (depth > MAX_BODY_DEPTH)
187
+ return;
188
+ const name = refName(raw);
189
+ if (name && pctx.seen.has(name)) {
190
+ log(pctx.ctx, `${indent}(circular reference to ${name})`);
191
+ return;
192
+ }
193
+ const resolved = resolveSchemaRef(pctx.spec, raw) ?? raw;
194
+ const flat = Array.isArray(resolved.allOf) ? flattenAllOf(pctx.spec, resolved) : resolved;
195
+ if (Array.isArray(flat.oneOf)) {
196
+ if (depth >= MAX_BODY_DEPTH) {
197
+ log(pctx.ctx, `${indent}(oneOf — drill further with \`wacht api describe ${name ?? '<schema>'}\`)`);
198
+ return;
199
+ }
200
+ if (depth === 0)
201
+ log(pctx.ctx, `${indent}(oneOf — pick exactly one variant)`);
202
+ if (name)
203
+ pctx.seen.add(name);
204
+ for (const variant of flat.oneOf) {
205
+ if (!isRecord(variant))
206
+ continue;
207
+ const variantInner = resolveSchemaRef(pctx.spec, variant) ?? variant;
208
+ const variantFlat = Array.isArray(variantInner.allOf) ? flattenAllOf(pctx.spec, variantInner) : variantInner;
209
+ const variantProps = isRecord(variantFlat.properties) ? variantFlat.properties : {};
210
+ const tagEntry = Object.entries(variantProps).find(([, val]) => isRecord(val) && typeof val.const === 'string');
211
+ const label = tagEntry
212
+ ? `variant "${tagEntry[1].const}"`
213
+ : 'variant';
214
+ log(pctx.ctx, `${indent} ${label}:`);
215
+ printObjectFields(pctx, variantFlat, `${indent} `, depth + 1);
216
+ }
217
+ if (name)
218
+ pctx.seen.delete(name);
219
+ return;
220
+ }
221
+ printObjectFields(pctx, flat, indent, depth);
222
+ }
223
+ function printObjectFields(pctx, schema, indent, depth) {
224
+ const expanded = Array.isArray(schema.allOf) ? flattenAllOf(pctx.spec, schema) : schema;
225
+ const props = isRecord(expanded.properties) ? expanded.properties : {};
226
+ const required = new Set(Array.isArray(expanded.required)
227
+ ? expanded.required.filter((k) => typeof k === 'string')
228
+ : []);
229
+ if (Object.keys(props).length === 0) {
230
+ log(pctx.ctx, `${indent}(no fields)`);
231
+ return;
232
+ }
233
+ for (const [key, raw] of Object.entries(props)) {
234
+ if (!isRecord(raw))
235
+ continue;
236
+ const tag = required.has(key) ? 'required' : 'optional';
237
+ const inlineType = schemaType(raw);
238
+ log(pctx.ctx, `${indent}${key} (${tag}, ${inlineType})`);
239
+ // Drill into nested structures one indent deeper, guarded by depth + cycle.
240
+ if (depth >= MAX_BODY_DEPTH)
241
+ continue;
242
+ const target = refName(raw) ?? refName(isRecord(raw.items) ? raw.items : undefined);
243
+ const nested = resolveSchemaRef(pctx.spec, raw) ?? raw;
244
+ // Array element refs: descend into the items' resolved schema.
245
+ const arrayItem = nested.type === 'array' && isRecord(nested.items)
246
+ ? (resolveSchemaRef(pctx.spec, nested.items) ?? nested.items)
247
+ : undefined;
248
+ const next = arrayItem ?? nested;
249
+ if (!isStructural(next))
250
+ continue;
251
+ if (target && pctx.seen.has(target)) {
252
+ log(pctx.ctx, `${indent} (circular reference to ${target})`);
253
+ continue;
254
+ }
255
+ if (target)
256
+ pctx.seen.add(target);
257
+ printSchema(pctx, next, `${indent} `, depth + 1);
258
+ if (target)
259
+ pctx.seen.delete(target);
260
+ }
261
+ }
262
+ function printBodySchema(ctx, spec, schema) {
263
+ if (!schema)
264
+ return;
265
+ printSchema({ ctx, spec, seen: new Set() }, schema, '', 0);
266
+ }
106
267
  function requestContent(operation) {
107
268
  return Object.keys(operation.requestBody?.content ?? {});
108
269
  }
@@ -177,8 +338,40 @@ export async function openApiDescribe(ctx, target, maybePath, options) {
177
338
  if (contentTypes.length) {
178
339
  log(ctx, '');
179
340
  log(ctx, section('Request Body'));
180
- for (const contentType of contentTypes)
181
- log(ctx, contentType);
341
+ const required = operation.requestBody?.required ? ' (required)' : ' (optional)';
342
+ for (const contentType of contentTypes) {
343
+ log(ctx, `${contentType}${required}`);
344
+ const bodySchema = operation.requestBody?.content?.[contentType]?.schema;
345
+ if (!isRecord(bodySchema))
346
+ continue;
347
+ // JSON, multipart, and url-encoded bodies all surface as object schemas
348
+ // with `properties` once form annotations are in place; render uniformly.
349
+ const isStructured = contentType === 'application/json'
350
+ || contentType === 'multipart/form-data'
351
+ || contentType === 'application/x-www-form-urlencoded';
352
+ if (isStructured) {
353
+ printBodySchema(ctx, loaded.spec, bodySchema);
354
+ }
355
+ }
356
+ }
357
+ if (operation.responses && Object.keys(operation.responses).length) {
358
+ log(ctx, '');
359
+ log(ctx, section('Responses'));
360
+ const statuses = Object.keys(operation.responses).sort();
361
+ for (const status of statuses) {
362
+ const response = operation.responses[status];
363
+ const description = response?.description ? ` — ${response.description}` : '';
364
+ log(ctx, `${status}${description}`);
365
+ // Only drill the 2xx success body; error responses share a common
366
+ // `{errors: [{message, code}]}` envelope and just clutter the output.
367
+ const isSuccess = status.startsWith('2');
368
+ if (!isSuccess)
369
+ continue;
370
+ const successJson = response?.content?.['application/json']?.schema;
371
+ if (isRecord(successJson)) {
372
+ printBodySchema(ctx, loaded.spec, successJson);
373
+ }
374
+ }
182
375
  }
183
376
  }
184
377
  function applyPathParams(path, params) {
@@ -218,6 +411,33 @@ export async function openApiCall(ctx, target, options) {
218
411
  file: options.file,
219
412
  header: options.header,
220
413
  };
414
+ // Validate the JSON body against the operation's request schema first —
415
+ // local check, useful regardless of deployment state. Skips multipart
416
+ // (JSON Schema can't validate FormData) and anything not parseable as JSON.
417
+ if (apiOptions.body && options.validate !== false) {
418
+ const sourceBody = apiOptions.body.startsWith('@')
419
+ ? await readFile(path.resolve(apiOptions.body.slice(1)), 'utf8')
420
+ : apiOptions.body;
421
+ let parsed;
422
+ try {
423
+ parsed = JSON.parse(sourceBody);
424
+ }
425
+ catch {
426
+ parsed = undefined;
427
+ }
428
+ const bodySchema = operation.requestBody?.content?.['application/json']?.schema;
429
+ if (parsed !== undefined && bodySchema) {
430
+ const errors = validateBody(loaded.spec, bodySchema, parsed);
431
+ if (errors && errors.length) {
432
+ log(ctx, warning(`Request body did not match schema for ${operation.operationId}:`));
433
+ for (const e of errors)
434
+ log(ctx, ` - ${e}`);
435
+ log(ctx, '');
436
+ log(ctx, `Pass --no-validate to skip local validation, or \`wacht api describe ${operation.operationId}\` to see the schema.`);
437
+ throw new Error('Validation failed.');
438
+ }
439
+ }
440
+ }
221
441
  const pathWithParams = appendQueryParams(applyPathParams(operation.path, params), params, operation);
222
442
  const isProjectScoped = pathWithParams.startsWith('/project') || pathWithParams === '/projects';
223
443
  let machinePath;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wacht/bench",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "CLI for Wacht Bench, the AI development workbench for Wacht.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "node": ">=20"
22
22
  },
23
23
  "dependencies": {
24
+ "ajv": "^8.20.0",
24
25
  "commander": "^12.1.0"
25
26
  },
26
27
  "devDependencies": {