@wacht/bench 0.1.1 → 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
@@ -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 { envPull } from './env-pull.js';
19
20
  import { apiCommand, listProjects } from './machine-api.js';
20
21
  import { openApiCall, openApiDescribe, openApiList, openApiRefresh } from './openapi.js';
21
22
  import { installMcp, listMcp, printMcpConfig, uninstallMcp } from './mcp.js';
@@ -168,10 +169,22 @@ export async function runCli(args) {
168
169
  const skills = program.command('skills').description('install Wacht agent skills');
169
170
  skills
170
171
  .command('install')
171
- .description('install the Wacht skills pack')
172
+ .description('install the Wacht skills pack into one or more AI agents')
172
173
  .option('--skill <name>', 'install one skill from the pack')
174
+ .option('--agent <ids>', 'comma-separated agent ids (e.g. claude-code,cursor,codex); skips the agent picker', (value) => value.split(',').map((s) => s.trim()).filter(Boolean))
175
+ .option('--all-agents', "install into every supported agent (passes -a '*')")
176
+ .option('--global', 'install at user scope instead of project scope')
177
+ .option('--yes', 'do not prompt for confirmation')
178
+ .option('--copy', 'copy skill files instead of symlinking')
173
179
  .action(async (options) => {
174
- await installSkills(options.skill);
180
+ await installSkills({
181
+ skill: options.skill,
182
+ agents: options.agent,
183
+ allAgents: options.allAgents,
184
+ global: options.global,
185
+ yes: options.yes,
186
+ copy: options.copy,
187
+ });
175
188
  });
176
189
  const mcp = program.command('mcp').description('configure Wacht Docs MCP across AI clients');
177
190
  mcp
@@ -206,6 +219,15 @@ export async function runCli(args) {
206
219
  .action((options) => {
207
220
  printMcpConfig(options.client);
208
221
  });
222
+ const env = program.command('env').description('manage deployment credentials and environment files');
223
+ env
224
+ .command('pull')
225
+ .description('mint a fresh backend API key for the active deployment and write keys to .env.local')
226
+ .option('--file <path>', 'env file path; defaults to .env.local in the current directory')
227
+ .option('--print', 'print credentials to stdout instead of writing the env file')
228
+ .action(async (options) => {
229
+ await envPull(context(program), options);
230
+ });
209
231
  const config = program.command('config').description('manage Wacht deployment settings as code');
210
232
  config
211
233
  .command('pull')
@@ -285,14 +307,18 @@ export async function runCli(args) {
285
307
  .argument('<operation>', 'OpenAPI operation id')
286
308
  .option('--deployment <id>', 'deployment id override; defaults to active deployment')
287
309
  .option('--param <key=value>', 'path or query parameter; repeatable', collect, [])
288
- .option('--body <json>', 'JSON request body')
310
+ .option('--body <json>', 'JSON request body; pass @path/to/file.json to read from disk')
289
311
  .option('--field <key=value>', 'URL-encoded form field; repeatable', collect, [])
290
312
  .option('--form <key=value>', 'multipart form field; value @path is treated as a file; repeatable', collect, [])
291
313
  .option('--file <key=path>', 'multipart file field; key=path or key=@path; repeatable', collect, [])
292
314
  .option('--header <key=value>', 'request header; repeatable', collect, [])
293
315
  .option('--refresh', 'refresh the cached OpenAPI schema first')
294
- .action(async (operation, options) => {
295
- 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());
296
322
  });
297
323
  const schema = api.command('schema').description('manage cached OpenAPI schema');
298
324
  schema
@@ -0,0 +1,105 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { machineRequest } from './machine-api.js';
4
+ import { detectProject } from './project-detect.js';
5
+ import { field, log, printBannerFor, printJson, section } from './ui.js';
6
+ function isCredentialsResponse(value) {
7
+ if (typeof value !== 'object' || value === null)
8
+ return false;
9
+ const v = value;
10
+ if (typeof v.publishable_key !== 'string')
11
+ return false;
12
+ if (typeof v.frontend_host !== 'string')
13
+ return false;
14
+ if (typeof v.backend_host !== 'string')
15
+ return false;
16
+ if (typeof v.api_key !== 'object' || v.api_key === null)
17
+ return false;
18
+ const k = v.api_key;
19
+ return typeof k.secret === 'string';
20
+ }
21
+ function publishableKeyVar(frameworks) {
22
+ if (frameworks.has('Next.js'))
23
+ return 'NEXT_PUBLIC_WACHT_PUBLISHABLE_KEY';
24
+ if (frameworks.has('React Router') || frameworks.has('TanStack Router')) {
25
+ return 'VITE_WACHT_PUBLISHABLE_KEY';
26
+ }
27
+ return 'NEXT_PUBLIC_WACHT_PUBLISHABLE_KEY';
28
+ }
29
+ function upsertEnvLines(existing, updates) {
30
+ const lines = existing.length === 0 ? [] : existing.split(/\r?\n/);
31
+ const matched = new Set();
32
+ const next = lines.map((line) => {
33
+ const eqIdx = line.indexOf('=');
34
+ if (eqIdx <= 0)
35
+ return line;
36
+ const key = line.slice(0, eqIdx).trim();
37
+ if (!(key in updates))
38
+ return line;
39
+ matched.add(key);
40
+ return `${key}=${updates[key]}`;
41
+ });
42
+ const missing = Object.entries(updates).filter(([key]) => !matched.has(key));
43
+ if (missing.length) {
44
+ if (next.length && next[next.length - 1].trim() !== '')
45
+ next.push('');
46
+ for (const [key, value] of missing)
47
+ next.push(`${key}=${value}`);
48
+ }
49
+ let out = next.join('\n');
50
+ if (!out.endsWith('\n'))
51
+ out += '\n';
52
+ return out;
53
+ }
54
+ export async function envPull(ctx, options = {}) {
55
+ const data = await machineRequest('/credentials', { method: 'POST' });
56
+ if (!isCredentialsResponse(data)) {
57
+ throw new Error('Unexpected response shape from /credentials.');
58
+ }
59
+ if (options.print || ctx.json) {
60
+ if (ctx.json) {
61
+ printJson({ ok: true, data });
62
+ }
63
+ else {
64
+ printBannerFor(ctx);
65
+ log(ctx, section('Deployment Credentials'));
66
+ log(ctx, field('Publishable key', data.publishable_key));
67
+ log(ctx, field('API key', data.api_key.secret));
68
+ log(ctx, field('API key suffix', `${data.api_key.prefix}…${data.api_key.suffix}`));
69
+ log(ctx, field('Frontend host', data.frontend_host));
70
+ log(ctx, field('Backend host', data.backend_host));
71
+ }
72
+ return;
73
+ }
74
+ const root = process.cwd();
75
+ const profile = await detectProject(root);
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';
82
+ const filePath = options.file
83
+ ? path.resolve(root, options.file)
84
+ : path.join(root, defaultFile);
85
+ const existing = await readFile(filePath, 'utf8').catch((err) => {
86
+ if (err.code === 'ENOENT')
87
+ return '';
88
+ throw err;
89
+ });
90
+ const updated = upsertEnvLines(existing, {
91
+ [pubVar]: data.publishable_key,
92
+ WACHT_API_KEY: data.api_key.secret,
93
+ });
94
+ await mkdir(path.dirname(filePath), { recursive: true });
95
+ await writeFile(filePath, updated, 'utf8');
96
+ printBannerFor(ctx);
97
+ log(ctx, section('Wrote credentials'));
98
+ log(ctx, field('File', path.relative(root, filePath) || filePath));
99
+ log(ctx, field(pubVar, `${data.publishable_key.slice(0, 12)}…`));
100
+ log(ctx, field('WACHT_API_KEY', `${data.api_key.prefix}…${data.api_key.suffix}`));
101
+ log(ctx, field('Frontend host', data.frontend_host));
102
+ log(ctx, field('Backend host', data.backend_host));
103
+ log(ctx, '');
104
+ log(ctx, 'Note: the API key secret is only shown once. Subsequent calls mint a new key.');
105
+ }
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));
@@ -140,11 +150,7 @@ export async function initProject(args, ctx) {
140
150
  if (options.installSkills) {
141
151
  log(ctx, '');
142
152
  log(ctx, section('Install Skills'));
143
- await installSkills();
144
- }
145
- else {
146
- log(ctx, '');
147
- log(ctx, field('Skills', `run ${command('wacht skills add')} when you want to install/update the pack`));
153
+ await installSkills({ yes: true });
148
154
  }
149
155
  if (ctx.json) {
150
156
  printJson({
@@ -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 }));
@@ -1,12 +1,40 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { MACHINE_API_URL } from './config.js';
4
+ import { readBenchContext } from './context-store.js';
4
5
  import { getValidAuth } from './oauth.js';
5
6
  import { promptChoice, promptOptionalList, promptText } from './prompts.js';
6
7
  import { field, log, printBannerFor, printJson, section } from './ui.js';
8
+ function isProjectScopedPath(p) {
9
+ if (p === '/projects' || p === '/project')
10
+ return true;
11
+ if (p.startsWith('/projects/') || p.startsWith('/project/'))
12
+ return true;
13
+ return false;
14
+ }
15
+ function isAlreadyDeploymentScoped(p) {
16
+ return p === '/deployments' || p.startsWith('/deployments/');
17
+ }
18
+ async function applyDeploymentPrefix(pathname) {
19
+ const normalized = pathname.startsWith('/') ? pathname : `/${pathname}`;
20
+ if (isProjectScopedPath(normalized) || isAlreadyDeploymentScoped(normalized)) {
21
+ return normalized;
22
+ }
23
+ const context = await readBenchContext();
24
+ if (!context?.deployment_id) {
25
+ throw new Error('No active deployment selected. Run `wacht deployments select`.');
26
+ }
27
+ const splitIdx = normalized.search(/[?#]/);
28
+ const prefix = `/deployments/${context.deployment_id}`;
29
+ const pathPart = splitIdx === -1 ? normalized : normalized.slice(0, splitIdx);
30
+ const suffix = splitIdx === -1 ? '' : normalized.slice(splitIdx);
31
+ const joinedPath = pathPart === '/' ? prefix : `${prefix}${pathPart}`;
32
+ return `${joinedPath}${suffix}`;
33
+ }
7
34
  export async function machineRequest(pathname, options = {}) {
8
35
  const auth = await getValidAuth();
9
- const url = new URL(pathname, auth.machine_api_url || MACHINE_API_URL);
36
+ const resolvedPath = await applyDeploymentPrefix(pathname);
37
+ const url = new URL(resolvedPath, auth.machine_api_url || MACHINE_API_URL);
10
38
  const headers = new Headers(options.headers);
11
39
  headers.set('authorization', `Bearer ${auth.access_token}`);
12
40
  headers.set('accept', 'application/json');
@@ -211,9 +239,16 @@ export async function requestBody(options) {
211
239
  headers.set(key, value);
212
240
  }
213
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
+ }
214
249
  headers.set('content-type', 'application/json');
215
250
  return {
216
- body: JSON.stringify(JSON.parse(options.body)),
251
+ body: JSON.stringify(JSON.parse(source)),
217
252
  headers,
218
253
  };
219
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,13 +409,46 @@ 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
- const machinePath = pathWithParams.startsWith('/project') || pathWithParams === '/projects'
223
- ? pathWithParams
224
- : `/deployments/${deploymentId ?? ''}${pathWithParams}`;
225
- if (machinePath.includes('/deployments//')) {
440
+ const isProjectScoped = pathWithParams.startsWith('/project') || pathWithParams === '/projects';
441
+ let machinePath;
442
+ if (isProjectScoped) {
443
+ machinePath = pathWithParams;
444
+ }
445
+ else if (!deploymentId) {
226
446
  throw new Error('Select an active deployment first, or pass raw paths with `wacht api METHOD /path`.');
227
447
  }
448
+ else {
449
+ const base = `/deployments/${deploymentId}`;
450
+ machinePath = pathWithParams === '/' ? base : `${base}${pathWithParams}`;
451
+ }
228
452
  const { body, headers } = await requestBody(apiOptions);
229
453
  const data = await machineRequest(machinePath, {
230
454
  method: operation.method,
package/dist/skills.js CHANGED
@@ -1,10 +1,22 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { SKILLS_SOURCE } from './config.js';
3
3
  import { valueAfter } from './util.js';
4
- export function installSkills(skill) {
4
+ export function installSkills(options = {}) {
5
5
  const installArgs = ['skills', 'add', SKILLS_SOURCE];
6
- if (skill)
7
- installArgs.push('--skill', skill);
6
+ if (options.allAgents) {
7
+ installArgs.push('-a', '*');
8
+ }
9
+ else if (options.agents && options.agents.length > 0) {
10
+ installArgs.push('-a', ...options.agents);
11
+ }
12
+ if (options.skill)
13
+ installArgs.push('-s', options.skill);
14
+ if (options.global)
15
+ installArgs.push('-g');
16
+ if (options.yes)
17
+ installArgs.push('-y');
18
+ if (options.copy)
19
+ installArgs.push('--copy');
8
20
  return new Promise((resolve, reject) => {
9
21
  const child = spawn('npx', installArgs, {
10
22
  stdio: 'inherit',
@@ -25,5 +37,5 @@ export function installSkills(skill) {
25
37
  });
26
38
  }
27
39
  export async function skillsInstall(args) {
28
- await installSkills(valueAfter(args, '--skill'));
40
+ await installSkills({ skill: valueAfter(args, '--skill') });
29
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wacht/bench",
3
- "version": "0.1.1",
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": {