@wacht/bench 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/commands.js CHANGED
@@ -307,14 +307,18 @@ export async function runCli(args) {
307
307
  .argument('<operation>', 'OpenAPI operation id')
308
308
  .option('--deployment <id>', 'deployment id override; defaults to active deployment')
309
309
  .option('--param <key=value>', 'path or query parameter; repeatable', collect, [])
310
- .option('--body <json>', 'JSON request body')
310
+ .option('--body <json>', 'JSON request body; pass @path/to/file.json to read from disk')
311
311
  .option('--field <key=value>', 'URL-encoded form field; repeatable', collect, [])
312
312
  .option('--form <key=value>', 'multipart form field; value @path is treated as a file; repeatable', collect, [])
313
313
  .option('--file <key=path>', 'multipart file field; key=path or key=@path; repeatable', collect, [])
314
314
  .option('--header <key=value>', 'request header; repeatable', collect, [])
315
315
  .option('--refresh', 'refresh the cached OpenAPI schema first')
316
- .action(async (operation, options) => {
317
- await openApiCall(context(program), operation, options);
316
+ .option('--no-validate', 'skip local JSON Schema validation of the request body')
317
+ .action(async (operation, _options, cmd) => {
318
+ // The parent `api` command also declares --body / --field / --form / --file / --header
319
+ // so `optsWithGlobals()` is what actually surfaces them through this subcommand.
320
+ // Commander does not merge clashing options automatically.
321
+ await openApiCall(context(program), operation, cmd.optsWithGlobals());
318
322
  });
319
323
  const schema = api.command('schema').description('manage cached OpenAPI schema');
320
324
  schema
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,193 @@
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
+ /**
24
+ * The raw ajv error stream is noisy:
25
+ * - `anyOf` / `oneOf` umbrellas restate "doesn't match" without specifics.
26
+ * - The `type: null` branches our `nullable` rewrite introduces are never what
27
+ * the user is trying to send.
28
+ * - oneOf-with-discriminator emits per-variant required errors from every
29
+ * non-matching branch, drowning out the actual problem.
30
+ *
31
+ * Strategy:
32
+ * 1. Drop nullable + anyOf/oneOf umbrellas.
33
+ * 2. When an `oneOf` umbrella was present at a path, collapse all required/const
34
+ * errors under that path into a single "doesn't match any variant" line with
35
+ * the discriminator value found in the body (if any). Tell the user to run
36
+ * `wacht api describe` for the variant payload — avoids guessing wrong.
37
+ * 3. Pass everything else through unchanged.
38
+ */
39
+ function summariseErrors(rawErrors, body) {
40
+ // Discover oneOf failure points by looking for the `oneOf` umbrella error.
41
+ const oneOfPaths = new Set();
42
+ for (const e of rawErrors) {
43
+ if (e.keyword === 'oneOf')
44
+ oneOfPaths.add(e.instancePath);
45
+ }
46
+ const collapsed = [];
47
+ const skip = new Set();
48
+ for (const path of oneOfPaths) {
49
+ const value = readJsonPointer(body, path);
50
+ // The variant tag is conventionally `type`; if absent, just describe the path.
51
+ const tag = isRecord(value) && typeof value.type === 'string'
52
+ ? `, got type "${value.type}"`
53
+ : '';
54
+ collapsed.push(`${path || '(body root)'}: doesn't match any variant of the discriminated union${tag}. ` +
55
+ `Run \`wacht api describe <operation>\` to see each variant's required fields.`);
56
+ // Drop every error at the union path; the user fixes the variant first.
57
+ for (const e of rawErrors) {
58
+ if (e.instancePath === path || e.instancePath.startsWith(path + '/'))
59
+ skip.add(e);
60
+ }
61
+ }
62
+ const out = [...collapsed];
63
+ for (const e of rawErrors) {
64
+ if (skip.has(e))
65
+ continue;
66
+ if (e.keyword === 'anyOf' || e.keyword === 'oneOf')
67
+ continue;
68
+ if (e.keyword === 'type' && e.params?.type === 'null')
69
+ continue;
70
+ out.push(formatError(e));
71
+ }
72
+ // Dedupe identical lines (e.g. two variants both miss the same field).
73
+ return [...new Set(out)];
74
+ }
75
+ /**
76
+ * When a `oneOf` discriminated union mismatches, ajv emits a "must be equal to constant"
77
+ * error for every variant whose const failed, plus the required-field errors from each
78
+ * variant's payload schema. That's overwhelming. If the input has a discriminator value
79
+ * that matches *no* variant, surface a single "X must be one of [...]"; if it matches
80
+ * exactly one, drop the per-variant required errors from the other variants.
81
+ */
82
+ function readJsonPointer(value, pointer) {
83
+ if (!pointer || pointer === '/')
84
+ return value;
85
+ const segments = pointer.split('/').slice(1).map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~'));
86
+ let cur = value;
87
+ for (const seg of segments) {
88
+ if (Array.isArray(cur)) {
89
+ const idx = Number.parseInt(seg, 10);
90
+ if (Number.isNaN(idx))
91
+ return undefined;
92
+ cur = cur[idx];
93
+ }
94
+ else if (isRecord(cur)) {
95
+ cur = cur[seg];
96
+ }
97
+ else {
98
+ return undefined;
99
+ }
100
+ }
101
+ return cur;
102
+ }
103
+ function readComponentSchemas(spec) {
104
+ if (!isRecord(spec))
105
+ return {};
106
+ const components = isRecord(spec.components) ? spec.components : undefined;
107
+ const schemas = components && isRecord(components.schemas) ? components.schemas : undefined;
108
+ return schemas ?? {};
109
+ }
110
+ function buildAjv(componentSchemas) {
111
+ // OpenAPI 3.1 schemas are JSON Schema draft 2020-12, but the spec we generate
112
+ // also leans on draft-7 `nullable: true` shorthand. Rewrite to draft-7 unions
113
+ // and run Ajv permissively — we're validating user payloads, not enforcing
114
+ // the spec itself.
115
+ const ajv = new Ajv({
116
+ strict: false,
117
+ allErrors: true,
118
+ coerceTypes: false,
119
+ allowUnionTypes: true,
120
+ validateFormats: false,
121
+ });
122
+ // Make every component schema addressable by its $ref so nested references resolve.
123
+ for (const [name, raw] of Object.entries(componentSchemas)) {
124
+ const rewritten = rewriteNullable(raw);
125
+ try {
126
+ ajv.addSchema(rewritten, `#/components/schemas/${name}`);
127
+ }
128
+ catch {
129
+ // Bad schemas shouldn't break the CLI — skip and keep going.
130
+ }
131
+ }
132
+ return ajv;
133
+ }
134
+ function compileSafely(ajv, schema) {
135
+ try {
136
+ return ajv.compile(rewriteNullable(schema));
137
+ }
138
+ catch {
139
+ return null;
140
+ }
141
+ }
142
+ /** Walk a schema and rewrite `{type: X, nullable: true}` → `{type: [X, "null"]}`. */
143
+ function rewriteNullable(value) {
144
+ if (Array.isArray(value))
145
+ return value.map(rewriteNullable);
146
+ if (!isRecord(value))
147
+ return value;
148
+ const out = {};
149
+ let nullable = false;
150
+ for (const [key, v] of Object.entries(value)) {
151
+ if (key === 'nullable' && v === true) {
152
+ nullable = true;
153
+ continue;
154
+ }
155
+ out[key] = rewriteNullable(v);
156
+ }
157
+ if (nullable && typeof out.type === 'string') {
158
+ out.type = [out.type, 'null'];
159
+ }
160
+ else if (nullable && Array.isArray(out.type)) {
161
+ if (!out.type.includes('null'))
162
+ out.type = [...out.type, 'null'];
163
+ }
164
+ else if (nullable) {
165
+ // No `type`; allow null alongside whatever shape the schema describes.
166
+ out.anyOf = [{ ...out }, { type: 'null' }];
167
+ for (const k of Object.keys(out))
168
+ if (k !== 'anyOf')
169
+ delete out[k];
170
+ }
171
+ return out;
172
+ }
173
+ function formatError(err) {
174
+ const path = err.instancePath || '(body root)';
175
+ const reason = err.message ?? 'invalid';
176
+ const extras = err.params ? formatParams(err.params) : '';
177
+ return `${path}: ${reason}${extras}`;
178
+ }
179
+ function formatParams(params) {
180
+ // Skim the most useful bits — missingProperty, allowedValues — without
181
+ // dumping every Ajv internal.
182
+ const bits = [];
183
+ if (typeof params.missingProperty === 'string')
184
+ bits.push(`missing "${params.missingProperty}"`);
185
+ if (Array.isArray(params.allowedValues))
186
+ bits.push(`allowed: ${params.allowedValues.map((v) => JSON.stringify(v)).join(', ')}`);
187
+ if (typeof params.additionalProperty === 'string')
188
+ bits.push(`unknown property "${params.additionalProperty}"`);
189
+ return bits.length ? ` (${bits.join('; ')})` : '';
190
+ }
191
+ function isRecord(value) {
192
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
193
+ }
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,170 @@ 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(' | ');
104
122
  return 'object';
105
123
  }
124
+ const REF_PREFIX = '#/components/schemas/';
125
+ function resolveSchemaRef(spec, schema, seen = new Set()) {
126
+ if (!schema)
127
+ return undefined;
128
+ if (typeof schema.$ref !== 'string')
129
+ return schema;
130
+ const name = schema.$ref.startsWith(REF_PREFIX) ? schema.$ref.slice(REF_PREFIX.length) : '';
131
+ if (!name || seen.has(name))
132
+ return schema;
133
+ const schemas = isRecord(spec.components) && isRecord(spec.components.schemas) ? spec.components.schemas : undefined;
134
+ const target = schemas?.[name];
135
+ if (!isRecord(target))
136
+ return schema;
137
+ seen.add(name);
138
+ return resolveSchemaRef(spec, target, seen);
139
+ }
140
+ /**
141
+ * Merge an `allOf` chain into a single object schema. Used for our generator's
142
+ * internally-tagged enum encoding: `allOf: [{$ref: Payload}, {properties: {<tag>: const}}]`.
143
+ */
144
+ function flattenAllOf(spec, schema) {
145
+ if (!Array.isArray(schema.allOf))
146
+ return schema;
147
+ const props = {};
148
+ const required = new Set();
149
+ for (const part of schema.allOf) {
150
+ if (!isRecord(part))
151
+ continue;
152
+ const resolved = resolveSchemaRef(spec, part) ?? part;
153
+ if (isRecord(resolved.properties)) {
154
+ for (const [key, value] of Object.entries(resolved.properties))
155
+ props[key] = value;
156
+ }
157
+ if (Array.isArray(resolved.required)) {
158
+ for (const key of resolved.required) {
159
+ if (typeof key === 'string')
160
+ required.add(key);
161
+ }
162
+ }
163
+ }
164
+ return { type: 'object', properties: props, required: Array.from(required) };
165
+ }
166
+ /** How many levels of nested object schemas to inline before falling back to the type name. */
167
+ const MAX_BODY_DEPTH = 3;
168
+ /** Pull the schema-component name out of a `$ref`, if any. */
169
+ function refName(schema) {
170
+ if (!schema || typeof schema.$ref !== 'string')
171
+ return undefined;
172
+ return schema.$ref.startsWith(REF_PREFIX) ? schema.$ref.slice(REF_PREFIX.length) : undefined;
173
+ }
174
+ /** True when a resolved schema carries structure worth expanding inline. */
175
+ function isStructural(schema) {
176
+ if (Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.allOf))
177
+ return true;
178
+ if (isRecord(schema.properties) && Object.keys(schema.properties).length > 0)
179
+ return true;
180
+ return false;
181
+ }
182
+ function printSchema(pctx, raw, indent, depth) {
183
+ // Stop recursing if we're already too deep — caller has shown the type name.
184
+ if (depth > MAX_BODY_DEPTH)
185
+ return;
186
+ const name = refName(raw);
187
+ if (name && pctx.seen.has(name)) {
188
+ log(pctx.ctx, `${indent}(circular reference to ${name})`);
189
+ return;
190
+ }
191
+ const resolved = resolveSchemaRef(pctx.spec, raw) ?? raw;
192
+ const flat = Array.isArray(resolved.allOf) ? flattenAllOf(pctx.spec, resolved) : resolved;
193
+ if (Array.isArray(flat.oneOf)) {
194
+ if (depth >= MAX_BODY_DEPTH) {
195
+ log(pctx.ctx, `${indent}(oneOf — drill further with \`wacht api describe ${name ?? '<schema>'}\`)`);
196
+ return;
197
+ }
198
+ if (depth === 0)
199
+ log(pctx.ctx, `${indent}(oneOf — pick exactly one variant)`);
200
+ if (name)
201
+ pctx.seen.add(name);
202
+ for (const variant of flat.oneOf) {
203
+ if (!isRecord(variant))
204
+ continue;
205
+ const variantInner = resolveSchemaRef(pctx.spec, variant) ?? variant;
206
+ const variantFlat = Array.isArray(variantInner.allOf) ? flattenAllOf(pctx.spec, variantInner) : variantInner;
207
+ const variantProps = isRecord(variantFlat.properties) ? variantFlat.properties : {};
208
+ const tagEntry = Object.entries(variantProps).find(([, val]) => isRecord(val) && typeof val.const === 'string');
209
+ const label = tagEntry
210
+ ? `variant "${tagEntry[1].const}"`
211
+ : 'variant';
212
+ log(pctx.ctx, `${indent} ${label}:`);
213
+ printObjectFields(pctx, variantFlat, `${indent} `, depth + 1);
214
+ }
215
+ if (name)
216
+ pctx.seen.delete(name);
217
+ return;
218
+ }
219
+ printObjectFields(pctx, flat, indent, depth);
220
+ }
221
+ function printObjectFields(pctx, schema, indent, depth) {
222
+ const expanded = Array.isArray(schema.allOf) ? flattenAllOf(pctx.spec, schema) : schema;
223
+ const props = isRecord(expanded.properties) ? expanded.properties : {};
224
+ const required = new Set(Array.isArray(expanded.required)
225
+ ? expanded.required.filter((k) => typeof k === 'string')
226
+ : []);
227
+ if (Object.keys(props).length === 0) {
228
+ log(pctx.ctx, `${indent}(no fields)`);
229
+ return;
230
+ }
231
+ for (const [key, raw] of Object.entries(props)) {
232
+ if (!isRecord(raw))
233
+ continue;
234
+ const tag = required.has(key) ? 'required' : 'optional';
235
+ const inlineType = schemaType(raw);
236
+ log(pctx.ctx, `${indent}${key} (${tag}, ${inlineType})`);
237
+ // Drill into nested structures one indent deeper, guarded by depth + cycle.
238
+ if (depth >= MAX_BODY_DEPTH)
239
+ continue;
240
+ const target = refName(raw) ?? refName(isRecord(raw.items) ? raw.items : undefined);
241
+ const nested = resolveSchemaRef(pctx.spec, raw) ?? raw;
242
+ // Array element refs: descend into the items' resolved schema.
243
+ const arrayItem = nested.type === 'array' && isRecord(nested.items)
244
+ ? (resolveSchemaRef(pctx.spec, nested.items) ?? nested.items)
245
+ : undefined;
246
+ const next = arrayItem ?? nested;
247
+ if (!isStructural(next))
248
+ continue;
249
+ if (target && pctx.seen.has(target)) {
250
+ log(pctx.ctx, `${indent} (circular reference to ${target})`);
251
+ continue;
252
+ }
253
+ if (target)
254
+ pctx.seen.add(target);
255
+ printSchema(pctx, next, `${indent} `, depth + 1);
256
+ if (target)
257
+ pctx.seen.delete(target);
258
+ }
259
+ }
260
+ function printBodySchema(ctx, spec, schema) {
261
+ if (!schema)
262
+ return;
263
+ printSchema({ ctx, spec, seen: new Set() }, schema, '', 0);
264
+ }
106
265
  function requestContent(operation) {
107
266
  return Object.keys(operation.requestBody?.content ?? {});
108
267
  }
@@ -177,8 +336,40 @@ export async function openApiDescribe(ctx, target, maybePath, options) {
177
336
  if (contentTypes.length) {
178
337
  log(ctx, '');
179
338
  log(ctx, section('Request Body'));
180
- for (const contentType of contentTypes)
181
- log(ctx, contentType);
339
+ const required = operation.requestBody?.required ? ' (required)' : ' (optional)';
340
+ for (const contentType of contentTypes) {
341
+ log(ctx, `${contentType}${required}`);
342
+ const bodySchema = operation.requestBody?.content?.[contentType]?.schema;
343
+ if (!isRecord(bodySchema))
344
+ continue;
345
+ // JSON, multipart, and url-encoded bodies all surface as object schemas
346
+ // with `properties` once form annotations are in place; render uniformly.
347
+ const isStructured = contentType === 'application/json'
348
+ || contentType === 'multipart/form-data'
349
+ || contentType === 'application/x-www-form-urlencoded';
350
+ if (isStructured) {
351
+ printBodySchema(ctx, loaded.spec, bodySchema);
352
+ }
353
+ }
354
+ }
355
+ if (operation.responses && Object.keys(operation.responses).length) {
356
+ log(ctx, '');
357
+ log(ctx, section('Responses'));
358
+ const statuses = Object.keys(operation.responses).sort();
359
+ for (const status of statuses) {
360
+ const response = operation.responses[status];
361
+ const description = response?.description ? ` — ${response.description}` : '';
362
+ log(ctx, `${status}${description}`);
363
+ // Only drill the 2xx success body; error responses share a common
364
+ // `{errors: [{message, code}]}` envelope and just clutter the output.
365
+ const isSuccess = status.startsWith('2');
366
+ if (!isSuccess)
367
+ continue;
368
+ const successJson = response?.content?.['application/json']?.schema;
369
+ if (isRecord(successJson)) {
370
+ printBodySchema(ctx, loaded.spec, successJson);
371
+ }
372
+ }
182
373
  }
183
374
  }
184
375
  function applyPathParams(path, params) {
@@ -218,6 +409,33 @@ export async function openApiCall(ctx, target, options) {
218
409
  file: options.file,
219
410
  header: options.header,
220
411
  };
412
+ // Validate the JSON body against the operation's request schema first —
413
+ // local check, useful regardless of deployment state. Skips multipart
414
+ // (JSON Schema can't validate FormData) and anything not parseable as JSON.
415
+ if (apiOptions.body && options.validate !== false) {
416
+ const sourceBody = apiOptions.body.startsWith('@')
417
+ ? await readFile(path.resolve(apiOptions.body.slice(1)), 'utf8')
418
+ : apiOptions.body;
419
+ let parsed;
420
+ try {
421
+ parsed = JSON.parse(sourceBody);
422
+ }
423
+ catch {
424
+ parsed = undefined;
425
+ }
426
+ const bodySchema = operation.requestBody?.content?.['application/json']?.schema;
427
+ if (parsed !== undefined && bodySchema) {
428
+ const errors = validateBody(loaded.spec, bodySchema, parsed);
429
+ if (errors && errors.length) {
430
+ log(ctx, warning(`Request body did not match schema for ${operation.operationId}:`));
431
+ for (const e of errors)
432
+ log(ctx, ` - ${e}`);
433
+ log(ctx, '');
434
+ log(ctx, `Pass --no-validate to skip local validation, or \`wacht api describe ${operation.operationId}\` to see the schema.`);
435
+ throw new Error('Validation failed.');
436
+ }
437
+ }
438
+ }
221
439
  const pathWithParams = appendQueryParams(applyPathParams(operation.path, params), params, operation);
222
440
  const isProjectScoped = pathWithParams.startsWith('/project') || pathWithParams === '/projects';
223
441
  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.3",
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": {