@wacht/bench 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/commands.js CHANGED
@@ -1,11 +1,25 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
1
4
  import { Command } from 'commander';
5
+ const PKG_VERSION = (() => {
6
+ try {
7
+ const here = path.dirname(fileURLToPath(import.meta.url));
8
+ const pkg = JSON.parse(readFileSync(path.join(here, '..', 'package.json'), 'utf8'));
9
+ return pkg.version ?? '0.0.0';
10
+ }
11
+ catch {
12
+ return '0.0.0';
13
+ }
14
+ })();
2
15
  import { completionScript } from './completion.js';
3
16
  import { configApply, configDiff, configPull, configSchemaCommand, printConfigTemplate, } from './config-workflow.js';
4
17
  import { clearDeployment, createDeploymentCommand, createProjectCommand, currentDeployment, selectDeployment, } from './deployment-context.js';
5
18
  import { initProject, initStarter } from './init.js';
19
+ import { envPull } from './env-pull.js';
6
20
  import { apiCommand, listProjects } from './machine-api.js';
7
21
  import { openApiCall, openApiDescribe, openApiList, openApiRefresh } from './openapi.js';
8
- import { printMcpConfig } from './mcp.js';
22
+ import { installMcp, listMcp, printMcpConfig, uninstallMcp } from './mcp.js';
9
23
  import { authStatus, login, logout } from './oauth.js';
10
24
  import { createOrg, createUser, createWorkspace, getOrg, getUser, getWorkspace, listOrgs, listUsers, listWorkspaces, } from './resources.js';
11
25
  import { installSkills } from './skills.js';
@@ -34,14 +48,8 @@ function initArgs(options) {
34
48
  args.push('--install-skills');
35
49
  if (options.skipAgents)
36
50
  args.push('--skip-agents');
37
- if (options.skipConfig)
38
- args.push('--skip-config');
39
51
  if (options.skipEnv)
40
52
  args.push('--skip-env');
41
- if (options.skipGuide)
42
- args.push('--skip-guide');
43
- if (options.skipTemplates)
44
- args.push('--skip-templates');
45
53
  return args;
46
54
  }
47
55
  export async function runCli(args) {
@@ -49,6 +57,7 @@ export async function runCli(args) {
49
57
  program
50
58
  .name('wacht')
51
59
  .description('AI development workbench for Wacht')
60
+ .version(PKG_VERSION, '-v, --version', 'print Wacht Bench CLI version')
52
61
  .showHelpAfterError()
53
62
  .option('--json', 'emit machine-readable JSON where supported')
54
63
  .option('--quiet', 'suppress nonessential human output')
@@ -69,10 +78,7 @@ export async function runCli(args) {
69
78
  .option('--target <dir>', 'target directory when using --starter (defaults to ./wacht-<framework>-starter)')
70
79
  .option('--install-skills', 'run npx skills add after writing config')
71
80
  .option('--skip-agents', 'do not create or update AGENTS.md')
72
- .option('--skip-config', 'do not write .wacht/bench.json')
73
81
  .option('--skip-env', 'do not write .env.wacht.example')
74
- .option('--skip-guide', 'do not write .wacht/BOOTSTRAP.md')
75
- .option('--skip-templates', 'do not write starter templates under .wacht/templates')
76
82
  .action(async (options) => {
77
83
  const ctx = context(program);
78
84
  if (options.starter) {
@@ -163,19 +169,65 @@ export async function runCli(args) {
163
169
  const skills = program.command('skills').description('install Wacht agent skills');
164
170
  skills
165
171
  .command('install')
166
- .description('install the Wacht skills pack')
172
+ .description('install the Wacht skills pack into one or more AI agents')
167
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')
168
179
  .action(async (options) => {
169
- 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
+ });
188
+ });
189
+ const mcp = program.command('mcp').description('configure Wacht Docs MCP across AI clients');
190
+ mcp
191
+ .command('list')
192
+ .alias('ls')
193
+ .description('list known MCP clients with detection + install status')
194
+ .action(async () => {
195
+ await listMcp(context(program));
196
+ });
197
+ mcp
198
+ .command('install')
199
+ .description('install Wacht Docs MCP into one or more clients (interactive by default)')
200
+ .option('--client <ids>', 'comma-separated target ids; skips the picker', (value) => value.split(',').map((s) => s.trim()).filter(Boolean))
201
+ .option('--all', 'install into every known target without prompting')
202
+ .option('--yes', 'do not ask to confirm before writing')
203
+ .action(async (options) => {
204
+ await installMcp(context(program), { clients: options.client, all: options.all, yes: options.yes });
205
+ });
206
+ mcp
207
+ .command('uninstall')
208
+ .description('remove Wacht Docs MCP from one or more clients')
209
+ .option('--client <ids>', 'comma-separated target ids; skips the picker', (value) => value.split(',').map((s) => s.trim()).filter(Boolean))
210
+ .option('--all', 'remove from every known target without prompting')
211
+ .option('--yes', 'do not ask to confirm before writing')
212
+ .action(async (options) => {
213
+ await uninstallMcp(context(program), { clients: options.client, all: options.all, yes: options.yes });
170
214
  });
171
- const mcp = program.command('mcp').description('print Wacht Docs MCP configuration');
172
215
  mcp
173
216
  .command('config')
174
- .description('print MCP config JSON for an assistant client')
175
- .option('--client <client>', 'cursor, claude, or codex', 'cursor')
217
+ .description('print raw MCP config JSON for a client (no file write)')
218
+ .option('--client <client>', 'claude-desktop, cursor, vscode, codex, windsurf, claude-code', 'cursor')
176
219
  .action((options) => {
177
220
  printMcpConfig(options.client);
178
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
+ });
179
231
  const config = program.command('config').description('manage Wacht deployment settings as code');
180
232
  config
181
233
  .command('pull')
@@ -0,0 +1,100 @@
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 pubVar = publishableKeyVar(new Set(profile.frameworks));
77
+ const filePath = options.file
78
+ ? path.resolve(root, options.file)
79
+ : path.join(root, '.env.local');
80
+ const existing = await readFile(filePath, 'utf8').catch((err) => {
81
+ if (err.code === 'ENOENT')
82
+ return '';
83
+ throw err;
84
+ });
85
+ const updated = upsertEnvLines(existing, {
86
+ [pubVar]: data.publishable_key,
87
+ WACHT_API_KEY: data.api_key.secret,
88
+ });
89
+ await mkdir(path.dirname(filePath), { recursive: true });
90
+ await writeFile(filePath, updated, 'utf8');
91
+ printBannerFor(ctx);
92
+ log(ctx, section('Wrote credentials'));
93
+ log(ctx, field('File', path.relative(root, filePath) || filePath));
94
+ log(ctx, field(pubVar, `${data.publishable_key.slice(0, 12)}…`));
95
+ log(ctx, field('WACHT_API_KEY', `${data.api_key.prefix}…${data.api_key.suffix}`));
96
+ log(ctx, field('Frontend host', data.frontend_host));
97
+ log(ctx, field('Backend host', data.backend_host));
98
+ log(ctx, '');
99
+ log(ctx, 'Note: the API key secret is only shown once. Subsequent calls mint a new key.');
100
+ }
package/dist/init.js CHANGED
@@ -1,8 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
2
+ import { readFile, stat, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { MACHINE_API_URL, MCP_URL, OAUTH_CLIENT_ID, OAUTH_ISSUER, OAUTH_SCOPES, REDIRECT_URI, SKILLS_SOURCE, PLATFORM_OPENAPI_URL, } from './config.js';
5
- import { readBenchContext } from './context-store.js';
4
+ import { MCP_URL } from './config.js';
6
5
  import { detectProject } from './project-detect.js';
7
6
  import { installSkills } from './skills.js';
8
7
  import { command, field, log, printBannerFor, printJson, section, success } from './ui.js';
@@ -20,10 +19,7 @@ export function parseInitOptions(args) {
20
19
  client: valueAfter(args, '--client') ?? 'cursor',
21
20
  installSkills: hasFlag(args, '--install-skills'),
22
21
  skipAgents: hasFlag(args, '--skip-agents'),
23
- skipConfig: hasFlag(args, '--skip-config'),
24
22
  skipEnv: hasFlag(args, '--skip-env'),
25
- skipGuide: hasFlag(args, '--skip-guide'),
26
- skipTemplates: hasFlag(args, '--skip-templates'),
27
23
  };
28
24
  }
29
25
  async function readOptional(filePath) {
@@ -47,8 +43,17 @@ This project is configured for AI-assisted Wacht development.
47
43
  - Detected project shape: \`${frameworks}\`.
48
44
  - Suggested Wacht skills for this project: \`${skills}\`.
49
45
  - Active skill router: \`wacht\` (always start there). For CLI work specifically, use the \`wacht-bench-cli\` skill.
50
- - Before coding Wacht behavior, consult Wacht Docs MCP at \`${MCP_URL}\`.
51
- - Install or update skills with \`npx skills add ${SKILLS_SOURCE}\`.
46
+ - Install or update skills with \`wacht skills add\`.
47
+
48
+ ### Where to look (single source of truth)
49
+
50
+ | Need | Source |
51
+ | --- | --- |
52
+ | Framework wiring patterns (provider, middleware, loaders) | Skills: \`${skills}\`. Always load before editing SDK code — never freelance the wiring. |
53
+ | Endpoint contracts, request/response shapes, errors | Wacht Docs MCP at \`${MCP_URL}\`. Required before calling any Machine API operation by hand. |
54
+ | Live deployment context (project id, deployment id, hosts) | \`wacht deployments current\` — re-run every time, never cache. |
55
+ | Available CLI surface | \`wacht --help\` and \`wacht <command> --help\`. |
56
+ | Any Machine API operation by name | \`wacht api ls --search <text>\` → \`wacht api describe <op>\` → \`wacht api call <op>\`. |
52
57
 
53
58
  ### Default CLI workflow
54
59
 
@@ -59,9 +64,14 @@ This project is configured for AI-assisted Wacht development.
59
64
  | Manage users | \`wacht users list\` · \`wacht users get <id>\` · \`wacht users create --field …\` |
60
65
  | Manage orgs / workspaces | \`wacht orgs list\` · \`wacht workspaces list --org <id>\` |
61
66
  | Pull / diff / apply config | \`wacht config pull\` · \`wacht config diff\` · \`wacht config apply --yes\` |
62
- | Discover any Machine API operation | \`wacht api ls --search <text>\` · \`wacht api describe <op>\` · \`wacht api call <op>\` |
67
+ | Configure Docs MCP across clients | \`wacht mcp install\` (interactive picker) · \`wacht mcp list\` |
63
68
 
64
- Always pass \`--json\` and \`--no-interactive\` when running commands inside an agent loop. Confirm the active deployment with \`wacht deployments current\` before any deployment-scoped change. Production config applies require \`--production --confirm <deployment_id> --yes\`.
69
+ ### Rules for agent loops
70
+
71
+ - Pass \`--json --no-interactive\` to every CLI invocation inside an agent loop.
72
+ - Confirm the active deployment with \`wacht deployments current\` before any deployment-scoped change.
73
+ - Production config applies require \`--production --confirm <deployment_id> --yes\` — never bypass.
74
+ - Do not write on-disk snapshots of deployment state; query it live.
65
75
 
66
76
  ${AGENTS_END}`;
67
77
  }
@@ -85,325 +95,28 @@ async function upsertAgentsBlock(root, profile) {
85
95
  await writeFile(agentsPath, `${existing.trimEnd()}\n\n${nextBlock}\n`, 'utf8');
86
96
  return agentsPath;
87
97
  }
88
- async function writeBenchConfig(root, profile, options) {
89
- const wachtDir = path.join(root, '.wacht');
90
- await mkdir(wachtDir, { recursive: true });
91
- const configPath = path.join(wachtDir, 'bench.json');
92
- const config = {
93
- version: 1,
94
- client: options.client,
95
- skillsSource: SKILLS_SOURCE,
96
- docsMcpUrl: MCP_URL,
97
- machineApiUrl: MACHINE_API_URL,
98
- openApiUrl: PLATFORM_OPENAPI_URL,
99
- oauth: {
100
- issuer: OAUTH_ISSUER,
101
- clientId: OAUTH_CLIENT_ID,
102
- redirectUri: REDIRECT_URI,
103
- scopes: OAUTH_SCOPES.split(' '),
104
- },
105
- project: {
106
- packageManager: profile.packageManager,
107
- frameworks: profile.frameworks,
108
- suggestedSkills: profile.suggestedSkills,
109
- },
110
- };
111
- await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
112
- return configPath;
113
- }
114
- function frameworkChecklist(profile) {
115
- const frameworks = new Set(profile.frameworks);
116
- if (frameworks.has('Next.js')) {
117
- return [
118
- 'Review `.wacht/templates/nextjs/wacht-provider.tsx` as a provider reference only.',
119
- 'Review `.wacht/templates/nextjs/middleware.ts` as a protected-route reference only.',
120
- 'Use `wacht-nextjs-patterns` and Wacht Docs MCP before making app edits.',
121
- 'Let the assistant adapt the template to the existing layout, route groups, and Next.js version.',
122
- 'Keep publishable/frontend values separate from server-only secrets.',
123
- ];
124
- }
125
- if (frameworks.has('React Router')) {
126
- return [
127
- 'Review `.wacht/templates/react-router/wacht-provider.tsx` as a provider reference only.',
128
- 'Review `.wacht/templates/react-router/protected-loader.ts` as a loader/action reference only.',
129
- 'Use `wacht-react-router-patterns` and Wacht Docs MCP before making app edits.',
130
- 'Let the assistant adapt the template to the existing root route and data APIs.',
131
- ];
132
- }
133
- if (frameworks.has('TanStack Router')) {
134
- return [
135
- 'Review `.wacht/templates/tanstack-router/wacht-provider.tsx` as a provider reference only.',
136
- 'Review `.wacht/templates/tanstack-router/protected-request.ts` as a request-auth reference only.',
137
- 'Use `wacht-tanstack-router-patterns` and Wacht Docs MCP before making app edits.',
138
- 'Let the assistant adapt the template to the existing router context and route tree.',
139
- ];
140
- }
141
- return [
142
- 'Review `.wacht/templates/wacht-contract.ts` for deployment/env assumptions.',
143
- 'Select an active deployment with `wacht deployments select`.',
144
- 'Use Wacht Docs MCP and the suggested skills before choosing framework-specific app edits.',
145
- ];
146
- }
147
- async function writeEnvTemplate(root) {
148
- const context = await readBenchContext();
98
+ async function writeEnvTemplate(root, profile) {
149
99
  const envPath = path.join(root, '.env.wacht.example');
100
+ const frameworks = new Set(profile.frameworks);
101
+ const isVite = frameworks.has('React Router') || frameworks.has('TanStack Router');
102
+ const publishableKeyVar = frameworks.has('Next.js')
103
+ ? 'NEXT_PUBLIC_WACHT_PUBLISHABLE_KEY'
104
+ : isVite
105
+ ? 'VITE_WACHT_PUBLISHABLE_KEY'
106
+ : 'NEXT_PUBLIC_WACHT_PUBLISHABLE_KEY';
150
107
  const lines = [
151
- '# Generated by Wacht Bench. Copy values into your local env file as needed.',
152
- `WACHT_MACHINE_API_URL=${MACHINE_API_URL}`,
153
- `WACHT_OPENAPI_URL=${PLATFORM_OPENAPI_URL}`,
154
- `WACHT_PROJECT_ID=${context?.project_id ?? ''}`,
155
- `WACHT_DEPLOYMENT_ID=${context?.deployment_id ?? ''}`,
156
- `WACHT_DEPLOYMENT_MODE=${context?.deployment_mode ?? ''}`,
157
- `WACHT_BACKEND_HOST=${context?.deployment_backend_host ?? ''}`,
158
- `WACHT_FRONTEND_HOST=${context?.deployment_frontend_host ?? ''}`,
108
+ '# Wacht SDK environment. Fill these from your Wacht deployment.',
159
109
  '',
160
- '# Framework SDK keys. Fill the publishable key from your Wacht deployment.',
161
- 'NEXT_PUBLIC_WACHT_PUBLISHABLE_KEY=',
162
- 'VITE_WACHT_PUBLISHABLE_KEY=',
110
+ '# Client-safe publishable key. Encodes deployment + frontend host.',
111
+ `${publishableKeyVar}=`,
112
+ '',
113
+ '# Server-only API key for backend SDK calls. Never expose to the client.',
163
114
  'WACHT_API_KEY=',
164
115
  '',
165
116
  ];
166
117
  await writeFile(envPath, lines.join('\n'), 'utf8');
167
118
  return envPath;
168
119
  }
169
- async function writeBootstrapGuide(root, profile) {
170
- const guidePath = path.join(root, '.wacht', 'BOOTSTRAP.md');
171
- await mkdir(path.dirname(guidePath), { recursive: true });
172
- const context = await readBenchContext();
173
- const checklist = frameworkChecklist(profile).map((item) => `- ${item}`).join('\n');
174
- const active = context
175
- ? `- Active project: ${context.project_name} (${context.project_id})
176
- - Active deployment: ${context.deployment_mode} (${context.deployment_id})
177
- - Backend host: ${context.deployment_backend_host ?? ''}
178
- - Frontend host: ${context.deployment_frontend_host ?? ''}`
179
- : '- No active deployment selected. Run `wacht deployments select`.';
180
- const content = `# Wacht Bootstrap
181
-
182
- ## Project
183
-
184
- - Detected frameworks: ${profile.frameworks.length ? profile.frameworks.join(', ') : 'unknown'}
185
- - Package manager: ${profile.packageManager}
186
- - Suggested skills: ${profile.suggestedSkills.join(', ')}
187
-
188
- ## Active Deployment
189
-
190
- ${active}
191
-
192
- ## Checklist
193
-
194
- ${checklist}
195
-
196
- ## Templates
197
-
198
- Bench writes framework starter templates under \`.wacht/templates\`. These files are not integrated into the app automatically. Use the suggested Wacht skills and Docs MCP to adapt them to the existing project structure.
199
-
200
- ## Useful Commands
201
-
202
- \`\`\`bash
203
- npx skills add ${SKILLS_SOURCE}
204
- wacht login
205
- wacht deployments select
206
- wacht deployments current
207
- wacht api ls --search users
208
- wacht api describe createUser
209
- wacht api call createUser --body '{"email_address":"person@example.com"}'
210
- \`\`\`
211
-
212
- ## API Discovery
213
-
214
- Bench reads the Wacht Platform OpenAPI schema from:
215
-
216
- \`\`\`text
217
- ${PLATFORM_OPENAPI_URL}
218
- \`\`\`
219
-
220
- Schema cache TTL is 24 hours. Refresh it with:
221
-
222
- \`\`\`bash
223
- wacht api schema refresh
224
- \`\`\`
225
- `;
226
- await writeFile(guidePath, content, 'utf8');
227
- return guidePath;
228
- }
229
- function nextProviderTemplate() {
230
- return `'use client';
231
-
232
- /**
233
- * Template only. Bench does not import this file or patch your layout.
234
- * Adapt it with the wacht-nextjs-patterns skill after checking Docs MCP.
235
- */
236
-
237
- import type { ReactNode } from 'react';
238
- import { DeploymentInitialized, DeploymentProvider } from '@wacht/nextjs';
239
-
240
- export function WachtProvider({ children }: { children: ReactNode }) {
241
- return (
242
- <DeploymentProvider publicKey={process.env.NEXT_PUBLIC_WACHT_PUBLISHABLE_KEY!}>
243
- <DeploymentInitialized>{children}</DeploymentInitialized>
244
- </DeploymentProvider>
245
- );
246
- }
247
- `;
248
- }
249
- function nextMiddlewareTemplate() {
250
- return `/**
251
- * Template only. Bench does not install this file into the app.
252
- * For Next.js 16, adapt this into proxy.ts. For older Next.js, adapt into middleware.ts.
253
- */
254
-
255
- import { NextResponse } from 'next/server';
256
- import { createRouteMatcher, wachtMiddleware } from '@wacht/nextjs/server';
257
-
258
- const isProtected = createRouteMatcher(['/account(.*)', '/dashboard(.*)']);
259
-
260
- export default wachtMiddleware(
261
- async (auth, req) => {
262
- if (!isProtected(req)) return NextResponse.next();
263
- await auth.protect();
264
- return NextResponse.next();
265
- },
266
- { apiRoutePrefixes: ['/api', '/trpc'] },
267
- );
268
-
269
- export const config = {
270
- matcher: [
271
- '/((?!_next|[^?]*\\\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
272
- '/(api|trpc)(.*)',
273
- ],
274
- };
275
- `;
276
- }
277
- function reactProviderTemplate(adapterImport) {
278
- return `/**
279
- * Template only. Bench does not import this file or patch your route root.
280
- * Adapt it with the framework skill after checking Docs MCP.
281
- */
282
-
283
- import type { ReactNode } from 'react';
284
- import { DeploymentProvider } from '${adapterImport}';
285
-
286
- export function WachtDeploymentProvider({ children }: { children: ReactNode }) {
287
- return (
288
- <DeploymentProvider publicKey={import.meta.env.VITE_WACHT_PUBLISHABLE_KEY}>
289
- {children}
290
- </DeploymentProvider>
291
- );
292
- }
293
- `;
294
- }
295
- function reactRouterMiddlewareTemplate() {
296
- return `/**
297
- * Template only. Bench does not attach this loader to any route.
298
- * Adapt it with the wacht-react-router-patterns skill after checking Docs MCP.
299
- */
300
-
301
- import { redirect, type LoaderFunctionArgs } from 'react-router';
302
- import { authenticateRequest } from '@wacht/react-router/server';
303
-
304
- export async function protectedLoader({ request }: LoaderFunctionArgs) {
305
- const result = await authenticateRequest(request);
306
-
307
- if (!result.auth.isAuthenticated) {
308
- throw redirect('/sign-in', { headers: result.headers });
309
- }
310
-
311
- return Response.json({ userId: result.auth.userId }, { headers: result.headers });
312
- }
313
- `;
314
- }
315
- function tanstackMiddlewareTemplate() {
316
- return `/**
317
- * Template only. Bench does not attach this helper to any route.
318
- * Adapt it with the wacht-tanstack-router-patterns skill after checking Docs MCP.
319
- */
320
-
321
- import { authenticateRequest } from '@wacht/tanstack-router/server';
322
-
323
- export async function getProtectedUserId(request: Request) {
324
- const result = await authenticateRequest(request);
325
-
326
- if (!result.auth.isAuthenticated) {
327
- throw new Response(null, {
328
- status: 302,
329
- headers: {
330
- ...Object.fromEntries(result.headers.entries()),
331
- Location: '/sign-in',
332
- },
333
- });
334
- }
335
-
336
- return result.auth.userId;
337
- }
338
- `;
339
- }
340
- function contractWrapperTemplate(profile) {
341
- const framework = profile.frameworks[0] ?? 'unknown';
342
- return `/**
343
- * Wacht contract wrapper template.
344
- *
345
- * This file is generated for AI-assisted development. It is not imported
346
- * anywhere by Bench. Move/adapt it into a server-only module after reading
347
- * the active Wacht skill and Wacht Docs MCP pages for this framework.
348
- */
349
-
350
- export type WachtRuntimeContract = {
351
- framework: string;
352
- deploymentId: string;
353
- deploymentMode: string;
354
- backendHost: string;
355
- frontendHost: string;
356
- openApiUrl: string;
357
- };
358
-
359
- export function readWachtRuntimeContract(env: Record<string, string | undefined> = process.env): WachtRuntimeContract {
360
- return {
361
- framework: '${framework}',
362
- deploymentId: env.WACHT_DEPLOYMENT_ID ?? '',
363
- deploymentMode: env.WACHT_DEPLOYMENT_MODE ?? '',
364
- backendHost: env.WACHT_BACKEND_HOST ?? '',
365
- frontendHost: env.WACHT_FRONTEND_HOST ?? '',
366
- openApiUrl: env.WACHT_OPENAPI_URL ?? '${PLATFORM_OPENAPI_URL}',
367
- };
368
- }
369
-
370
- export function assertWachtRuntimeContract(contract = readWachtRuntimeContract()): WachtRuntimeContract {
371
- const missing = Object.entries(contract)
372
- .filter(([key, value]) => key !== 'framework' && key !== 'openApiUrl' && !value)
373
- .map(([key]) => key);
374
-
375
- if (missing.length) {
376
- throw new Error(\`Missing Wacht runtime config: \${missing.join(', ')}\`);
377
- }
378
-
379
- return contract;
380
- }
381
- `;
382
- }
383
- async function writeTemplate(root, relativePath, content) {
384
- const filePath = path.join(root, relativePath);
385
- await mkdir(path.dirname(filePath), { recursive: true });
386
- await writeFile(filePath, content, 'utf8');
387
- return filePath;
388
- }
389
- async function writeStarterTemplates(root, profile) {
390
- const frameworks = new Set(profile.frameworks);
391
- const written = [];
392
- if (frameworks.has('Next.js')) {
393
- written.push(await writeTemplate(root, '.wacht/templates/nextjs/wacht-provider.tsx', nextProviderTemplate()));
394
- written.push(await writeTemplate(root, '.wacht/templates/nextjs/middleware.ts', nextMiddlewareTemplate()));
395
- }
396
- else if (frameworks.has('React Router')) {
397
- written.push(await writeTemplate(root, '.wacht/templates/react-router/wacht-provider.tsx', reactProviderTemplate('@wacht/react-router')));
398
- written.push(await writeTemplate(root, '.wacht/templates/react-router/protected-loader.ts', reactRouterMiddlewareTemplate()));
399
- }
400
- else if (frameworks.has('TanStack Router')) {
401
- written.push(await writeTemplate(root, '.wacht/templates/tanstack-router/wacht-provider.tsx', reactProviderTemplate('@wacht/tanstack-router')));
402
- written.push(await writeTemplate(root, '.wacht/templates/tanstack-router/protected-request.ts', tanstackMiddlewareTemplate()));
403
- }
404
- written.push(await writeTemplate(root, '.wacht/templates/wacht-contract.ts', contractWrapperTemplate(profile)));
405
- return written;
406
- }
407
120
  export async function initProject(args, ctx) {
408
121
  const options = parseInitOptions(args);
409
122
  const root = process.cwd();
@@ -415,17 +128,8 @@ export async function initProject(args, ctx) {
415
128
  log(ctx, field('Detected', profile.frameworks.length ? profile.frameworks.join(', ') : 'unknown project shape'));
416
129
  log(ctx, field('Suggested skills', profile.suggestedSkills.join(', ')));
417
130
  log(ctx, '');
418
- if (!options.skipConfig) {
419
- written.push(await writeBenchConfig(root, profile, options));
420
- }
421
131
  if (!options.skipEnv) {
422
- written.push(await writeEnvTemplate(root));
423
- }
424
- if (!options.skipGuide) {
425
- written.push(await writeBootstrapGuide(root, profile));
426
- }
427
- if (!options.skipTemplates) {
428
- written.push(...await writeStarterTemplates(root, profile));
132
+ written.push(await writeEnvTemplate(root, profile));
429
133
  }
430
134
  if (!options.skipAgents) {
431
135
  written.push(await upsertAgentsBlock(root, profile));
@@ -436,11 +140,11 @@ export async function initProject(args, ctx) {
436
140
  if (options.installSkills) {
437
141
  log(ctx, '');
438
142
  log(ctx, section('Install Skills'));
439
- await installSkills();
143
+ await installSkills({ yes: true });
440
144
  }
441
145
  else {
442
146
  log(ctx, '');
443
- log(ctx, field('Skills', `run ${command(`npx skills add ${SKILLS_SOURCE}`)} when you want to install/update the pack`));
147
+ log(ctx, field('Skills', `run ${command('wacht skills install')} when you want to install/update the pack`));
444
148
  }
445
149
  if (ctx.json) {
446
150
  printJson({
@@ -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');
@@ -0,0 +1,225 @@
1
+ import { readFile, stat, mkdir, writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { MCP_URL } from './config.js';
5
+ export const SERVER_NAME = 'wacht-docs';
6
+ const HOME = os.homedir();
7
+ const PLATFORM = process.platform;
8
+ function fileExists(p) {
9
+ return stat(p).then(() => true).catch(() => false);
10
+ }
11
+ async function looksLikeProjectRoot() {
12
+ const cwd = process.cwd();
13
+ for (const marker of ['package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml', '.git']) {
14
+ if (await fileExists(path.join(cwd, marker)))
15
+ return true;
16
+ }
17
+ return false;
18
+ }
19
+ function claudeDesktopPath() {
20
+ if (PLATFORM === 'darwin') {
21
+ return path.join(HOME, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
22
+ }
23
+ if (PLATFORM === 'win32') {
24
+ return path.join(process.env.APPDATA ?? path.join(HOME, 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json');
25
+ }
26
+ return path.join(process.env.XDG_CONFIG_HOME ?? path.join(HOME, '.config'), 'Claude', 'claude_desktop_config.json');
27
+ }
28
+ function vscodeUserDir() {
29
+ if (PLATFORM === 'darwin')
30
+ return path.join(HOME, 'Library', 'Application Support', 'Code', 'User');
31
+ if (PLATFORM === 'win32')
32
+ return path.join(process.env.APPDATA ?? path.join(HOME, 'AppData', 'Roaming'), 'Code', 'User');
33
+ return path.join(process.env.XDG_CONFIG_HOME ?? path.join(HOME, '.config'), 'Code', 'User');
34
+ }
35
+ export async function listTargets() {
36
+ const cwd = process.cwd();
37
+ const inProject = await looksLikeProjectRoot();
38
+ const targets = [
39
+ {
40
+ id: 'claude-desktop',
41
+ label: 'Claude Desktop',
42
+ scope: 'user',
43
+ client: 'claude-desktop',
44
+ configPath: claudeDesktopPath(),
45
+ format: 'json-mcpServers',
46
+ detect: async () => fileExists(path.dirname(claudeDesktopPath())),
47
+ },
48
+ {
49
+ id: 'claude-code-user',
50
+ label: 'Claude Code (user)',
51
+ scope: 'user',
52
+ client: 'claude-code',
53
+ configPath: path.join(HOME, '.claude.json'),
54
+ format: 'json-mcpServers',
55
+ detect: async () => (await fileExists(path.join(HOME, '.claude.json'))) || (await fileExists(path.join(HOME, '.claude'))),
56
+ },
57
+ {
58
+ id: 'cursor-user',
59
+ label: 'Cursor (user)',
60
+ scope: 'user',
61
+ client: 'cursor',
62
+ configPath: path.join(HOME, '.cursor', 'mcp.json'),
63
+ format: 'json-mcpServers',
64
+ detect: async () => fileExists(path.join(HOME, '.cursor')),
65
+ },
66
+ {
67
+ id: 'vscode-user',
68
+ label: 'VS Code (user)',
69
+ scope: 'user',
70
+ client: 'vscode',
71
+ configPath: path.join(vscodeUserDir(), 'mcp.json'),
72
+ format: 'json-vscode',
73
+ detect: async () => fileExists(vscodeUserDir()),
74
+ },
75
+ {
76
+ id: 'windsurf',
77
+ label: 'Windsurf',
78
+ scope: 'user',
79
+ client: 'windsurf',
80
+ configPath: path.join(HOME, '.codeium', 'windsurf', 'mcp_config.json'),
81
+ format: 'json-mcpServers',
82
+ detect: async () => fileExists(path.join(HOME, '.codeium', 'windsurf')),
83
+ },
84
+ {
85
+ id: 'codex',
86
+ label: 'Codex CLI (OpenAI)',
87
+ scope: 'user',
88
+ client: 'codex',
89
+ configPath: path.join(HOME, '.codex', 'config.toml'),
90
+ format: 'toml-codex',
91
+ detect: async () => fileExists(path.join(HOME, '.codex')),
92
+ },
93
+ ];
94
+ if (inProject) {
95
+ targets.push({
96
+ id: 'claude-code-project',
97
+ label: 'Claude Code (project)',
98
+ scope: 'project',
99
+ client: 'claude-code',
100
+ configPath: path.join(cwd, '.mcp.json'),
101
+ format: 'json-mcpServers',
102
+ detect: async () => fileExists(path.join(cwd, '.mcp.json')),
103
+ }, {
104
+ id: 'cursor-project',
105
+ label: 'Cursor (project)',
106
+ scope: 'project',
107
+ client: 'cursor',
108
+ configPath: path.join(cwd, '.cursor', 'mcp.json'),
109
+ format: 'json-mcpServers',
110
+ detect: async () => fileExists(path.join(cwd, '.cursor')),
111
+ }, {
112
+ id: 'vscode-project',
113
+ label: 'VS Code (project)',
114
+ scope: 'project',
115
+ client: 'vscode',
116
+ configPath: path.join(cwd, '.vscode', 'mcp.json'),
117
+ format: 'json-vscode',
118
+ detect: async () => fileExists(path.join(cwd, '.vscode')),
119
+ });
120
+ }
121
+ return targets;
122
+ }
123
+ export async function findTarget(id) {
124
+ const targets = await listTargets();
125
+ return targets.find((t) => t.id === id);
126
+ }
127
+ // ─── JSON helpers ─────────────────────────────────────────────────────
128
+ function stripJsonComments(text) {
129
+ // Remove /* ... */ and // line comments, plus trailing commas before } or ].
130
+ return text
131
+ .replace(/\/\*[\s\S]*?\*\//g, '')
132
+ .replace(/(^|[^:])\/\/.*$/gm, '$1')
133
+ .replace(/,(\s*[}\]])/g, '$1');
134
+ }
135
+ async function readJson(filePath) {
136
+ const raw = await readFile(filePath, 'utf8').catch(() => '');
137
+ if (!raw.trim())
138
+ return {};
139
+ try {
140
+ return JSON.parse(raw);
141
+ }
142
+ catch {
143
+ return JSON.parse(stripJsonComments(raw));
144
+ }
145
+ }
146
+ async function writeJson(filePath, value) {
147
+ await mkdir(path.dirname(filePath), { recursive: true });
148
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
149
+ }
150
+ function stdioServer() {
151
+ return { command: 'npx', args: ['-y', 'mcp-remote', MCP_URL] };
152
+ }
153
+ // ─── Per-format readers/writers ───────────────────────────────────────
154
+ async function applyMcpServersJson(target, action) {
155
+ const config = await readJson(target.configPath);
156
+ const servers = config.mcpServers ?? {};
157
+ if (action === 'install') {
158
+ // Claude Desktop is stdio-only; Claude Code/Cursor/Windsurf accept HTTP url.
159
+ servers[SERVER_NAME] = target.client === 'claude-desktop'
160
+ ? stdioServer()
161
+ : { url: MCP_URL };
162
+ }
163
+ else {
164
+ delete servers[SERVER_NAME];
165
+ }
166
+ config.mcpServers = servers;
167
+ await writeJson(target.configPath, config);
168
+ }
169
+ async function applyVscodeJson(target, action) {
170
+ const config = await readJson(target.configPath);
171
+ const servers = config.servers ?? {};
172
+ if (action === 'install') {
173
+ servers[SERVER_NAME] = { type: 'http', url: MCP_URL };
174
+ }
175
+ else {
176
+ delete servers[SERVER_NAME];
177
+ }
178
+ config.servers = servers;
179
+ await writeJson(target.configPath, config);
180
+ }
181
+ async function applyCodexToml(target, action) {
182
+ const existing = await readFile(target.configPath, 'utf8').catch(() => '');
183
+ const sectionHeader = `[mcp_servers.${SERVER_NAME}]`;
184
+ const blockRegex = new RegExp(`(^|\\n)\\[mcp_servers\\.${SERVER_NAME}\\][\\s\\S]*?(?=\\n\\[|$)`, 'm');
185
+ let next = existing.replace(blockRegex, '').replace(/\n{3,}/g, '\n\n').trimEnd();
186
+ if (action === 'install') {
187
+ const block = [
188
+ sectionHeader,
189
+ `command = "npx"`,
190
+ `args = ["-y", "mcp-remote", "${MCP_URL}"]`,
191
+ ].join('\n');
192
+ next = next ? `${next}\n\n${block}\n` : `${block}\n`;
193
+ }
194
+ else if (next) {
195
+ next = `${next}\n`;
196
+ }
197
+ await mkdir(path.dirname(target.configPath), { recursive: true });
198
+ await writeFile(target.configPath, next, 'utf8');
199
+ }
200
+ export async function applyTarget(target, action) {
201
+ switch (target.format) {
202
+ case 'json-mcpServers':
203
+ return applyMcpServersJson(target, action);
204
+ case 'json-vscode':
205
+ return applyVscodeJson(target, action);
206
+ case 'toml-codex':
207
+ return applyCodexToml(target, action);
208
+ }
209
+ }
210
+ export async function isInstalled(target) {
211
+ const exists = await fileExists(target.configPath);
212
+ if (!exists)
213
+ return false;
214
+ if (target.format === 'toml-codex') {
215
+ const raw = await readFile(target.configPath, 'utf8').catch(() => '');
216
+ return raw.includes(`[mcp_servers.${SERVER_NAME}]`);
217
+ }
218
+ const config = await readJson(target.configPath);
219
+ if (target.format === 'json-vscode') {
220
+ const servers = config.servers;
221
+ return !!servers?.[SERVER_NAME];
222
+ }
223
+ const servers = config.mcpServers;
224
+ return !!servers?.[SERVER_NAME];
225
+ }
package/dist/mcp.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { MCP_URL } from './config.js';
2
+ import { applyTarget, isInstalled, listTargets, SERVER_NAME } from './mcp-clients.js';
3
+ import { promptConfirm, promptMultiSelect } from './prompts.js';
4
+ import { field, log, printJson, section, success, warning } from './ui.js';
2
5
  export function printMcpConfig(client) {
3
- if (client === 'claude') {
6
+ if (client === 'claude' || client === 'claude-desktop') {
4
7
  console.log(JSON.stringify({
5
8
  mcpServers: {
6
- 'wacht-docs': {
9
+ [SERVER_NAME]: {
7
10
  command: 'npx',
8
11
  args: ['-y', 'mcp-remote', MCP_URL],
9
12
  },
@@ -11,11 +14,131 @@ export function printMcpConfig(client) {
11
14
  }, null, 2));
12
15
  return;
13
16
  }
17
+ if (client === 'vscode') {
18
+ console.log(JSON.stringify({
19
+ servers: { [SERVER_NAME]: { type: 'http', url: MCP_URL } },
20
+ }, null, 2));
21
+ return;
22
+ }
23
+ if (client === 'codex') {
24
+ console.log(`[mcp_servers.${SERVER_NAME}]\ncommand = "npx"\nargs = ["-y", "mcp-remote", "${MCP_URL}"]`);
25
+ return;
26
+ }
14
27
  console.log(JSON.stringify({
15
- mcpServers: {
16
- 'wacht-docs': {
17
- url: MCP_URL,
18
- },
19
- },
28
+ mcpServers: { [SERVER_NAME]: { url: MCP_URL } },
20
29
  }, null, 2));
21
30
  }
31
+ function homeRelative(filePath) {
32
+ const home = process.env.HOME ?? '';
33
+ if (home && filePath.startsWith(home))
34
+ return `~${filePath.slice(home.length)}`;
35
+ return filePath;
36
+ }
37
+ async function selectTargets(ctx, options, action) {
38
+ const all = await listTargets();
39
+ if (options.clients && options.clients.length) {
40
+ const ids = new Set(options.clients);
41
+ const matched = all.filter((t) => ids.has(t.id));
42
+ const missing = [...ids].filter((id) => !matched.find((t) => t.id === id));
43
+ if (missing.length) {
44
+ throw new Error(`Unknown client targets: ${missing.join(', ')}. Run \`wacht mcp list\` to see valid ids.`);
45
+ }
46
+ return matched;
47
+ }
48
+ if (options.all)
49
+ return all;
50
+ // Detect + multi-select.
51
+ const detection = await Promise.all(all.map(async (t) => ({ target: t, detected: await t.detect() })));
52
+ const items = detection.map(({ target, detected }) => ({
53
+ value: target.id,
54
+ label: target.label,
55
+ hint: detected ? `detected · ${homeRelative(target.configPath)}` : `not detected · ${homeRelative(target.configPath)}`,
56
+ preselected: detected,
57
+ }));
58
+ log(ctx, section(action === 'install' ? 'Install Wacht Docs MCP' : 'Remove Wacht Docs MCP'));
59
+ log(ctx, field('Server URL', MCP_URL));
60
+ log(ctx, '');
61
+ const ids = await promptMultiSelect(ctx, items, action === 'install' ? 'Install to which clients?' : 'Remove from which clients?');
62
+ return all.filter((t) => ids.includes(t.id));
63
+ }
64
+ export async function installMcp(ctx, options) {
65
+ const targets = await selectTargets(ctx, options, 'install');
66
+ if (!targets.length) {
67
+ log(ctx, warning('No MCP clients selected. Nothing to do.'));
68
+ if (ctx.json)
69
+ printJson({ ok: true, written: [] });
70
+ return;
71
+ }
72
+ if (!options.yes && !options.clients && ctx.interactive) {
73
+ log(ctx, '');
74
+ log(ctx, 'Will write to:');
75
+ for (const target of targets) {
76
+ log(ctx, ` - ${target.label} ${homeRelative(target.configPath)}`);
77
+ }
78
+ const ok = await promptConfirm(ctx, 'Proceed?', true);
79
+ if (!ok) {
80
+ log(ctx, warning('Aborted.'));
81
+ return;
82
+ }
83
+ }
84
+ const written = [];
85
+ for (const target of targets) {
86
+ await applyTarget(target, 'install');
87
+ written.push({ id: target.id, path: target.configPath });
88
+ log(ctx, field('Wrote', `${target.label} · ${homeRelative(target.configPath)}`));
89
+ }
90
+ if (ctx.json) {
91
+ printJson({ ok: true, server: SERVER_NAME, url: MCP_URL, written });
92
+ return;
93
+ }
94
+ log(ctx, '');
95
+ log(ctx, success(`Installed Wacht Docs MCP into ${written.length} client${written.length === 1 ? '' : 's'}.`));
96
+ log(ctx, 'Restart the affected clients for the change to take effect.');
97
+ }
98
+ export async function uninstallMcp(ctx, options) {
99
+ const targets = await selectTargets(ctx, options, 'remove');
100
+ if (!targets.length) {
101
+ log(ctx, warning('No MCP clients selected. Nothing to do.'));
102
+ if (ctx.json)
103
+ printJson({ ok: true, removed: [] });
104
+ return;
105
+ }
106
+ const removed = [];
107
+ for (const target of targets) {
108
+ await applyTarget(target, 'remove');
109
+ removed.push({ id: target.id, path: target.configPath });
110
+ log(ctx, field('Updated', `${target.label} · ${homeRelative(target.configPath)}`));
111
+ }
112
+ if (ctx.json) {
113
+ printJson({ ok: true, server: SERVER_NAME, removed });
114
+ return;
115
+ }
116
+ log(ctx, '');
117
+ log(ctx, success(`Removed Wacht Docs MCP from ${removed.length} client${removed.length === 1 ? '' : 's'}.`));
118
+ }
119
+ export async function listMcp(ctx) {
120
+ const all = await listTargets();
121
+ const rows = await Promise.all(all.map(async (target) => ({
122
+ id: target.id,
123
+ label: target.label,
124
+ scope: target.scope,
125
+ configPath: target.configPath,
126
+ detected: await target.detect(),
127
+ installed: await isInstalled(target),
128
+ })));
129
+ if (ctx.json) {
130
+ printJson({ server: SERVER_NAME, url: MCP_URL, targets: rows });
131
+ return;
132
+ }
133
+ log(ctx, section('MCP client targets'));
134
+ log(ctx, field('Server URL', MCP_URL));
135
+ log(ctx, '');
136
+ for (const row of rows) {
137
+ const detect = row.detected ? 'detected' : 'not detected';
138
+ const status = row.installed ? 'installed' : 'not installed';
139
+ log(ctx, ` ${row.id.padEnd(22)} ${row.label}`);
140
+ log(ctx, ` ${''.padEnd(22)} ${detect} · ${status} · ${homeRelative(row.configPath)}`);
141
+ }
142
+ log(ctx, '');
143
+ log(ctx, `Use ${'`wacht mcp install`'} to bulk-apply, ${'`wacht mcp install --client <id,...>`'} to target specific entries.`);
144
+ }
package/dist/openapi.js CHANGED
@@ -219,12 +219,18 @@ export async function openApiCall(ctx, target, options) {
219
219
  header: options.header,
220
220
  };
221
221
  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//')) {
222
+ const isProjectScoped = pathWithParams.startsWith('/project') || pathWithParams === '/projects';
223
+ let machinePath;
224
+ if (isProjectScoped) {
225
+ machinePath = pathWithParams;
226
+ }
227
+ else if (!deploymentId) {
226
228
  throw new Error('Select an active deployment first, or pass raw paths with `wacht api METHOD /path`.');
227
229
  }
230
+ else {
231
+ const base = `/deployments/${deploymentId}`;
232
+ machinePath = pathWithParams === '/' ? base : `${base}${pathWithParams}`;
233
+ }
228
234
  const { body, headers } = await requestBody(apiOptions);
229
235
  const data = await machineRequest(machinePath, {
230
236
  method: operation.method,
package/dist/prompts.js CHANGED
@@ -72,3 +72,72 @@ export async function promptOptionalList(ctx, values, question) {
72
72
  return [];
73
73
  return answer.split(',').map((item) => item.trim()).filter(Boolean);
74
74
  }
75
+ function expandRange(token, max) {
76
+ const range = token.match(/^(\d+)-(\d+)$/);
77
+ if (range) {
78
+ const start = Math.max(1, Number.parseInt(range[1], 10));
79
+ const end = Math.min(max, Number.parseInt(range[2], 10));
80
+ const result = [];
81
+ for (let i = start; i <= end; i += 1)
82
+ result.push(i - 1);
83
+ return result;
84
+ }
85
+ const single = Number.parseInt(token, 10);
86
+ if (Number.isInteger(single) && single >= 1 && single <= max)
87
+ return [single - 1];
88
+ return [];
89
+ }
90
+ export async function promptMultiSelect(ctx, items, question) {
91
+ if (!canPrompt(ctx)) {
92
+ return items.filter((item) => item.preselected).map((item) => item.value);
93
+ }
94
+ const selected = new Set();
95
+ items.forEach((item, index) => {
96
+ if (item.preselected)
97
+ selected.add(index);
98
+ });
99
+ while (true) {
100
+ console.log('');
101
+ items.forEach((item, index) => {
102
+ const mark = selected.has(index) ? '[x]' : '[ ]';
103
+ const hint = item.hint ? ` ${item.hint}` : '';
104
+ console.log(` ${mark} ${index + 1}. ${item.label}${hint}`);
105
+ });
106
+ console.log('');
107
+ console.log(' Toggle by number/range (e.g. "1,3-5"), "all", "none", or press Enter to confirm.');
108
+ const answer = (await ask(`${question} `)).trim();
109
+ if (!answer)
110
+ break;
111
+ const lower = answer.toLowerCase();
112
+ if (lower === 'all') {
113
+ items.forEach((_, index) => selected.add(index));
114
+ continue;
115
+ }
116
+ if (lower === 'none' || lower === 'clear') {
117
+ selected.clear();
118
+ continue;
119
+ }
120
+ if (lower === 'done' || lower === 'ok')
121
+ break;
122
+ const tokens = answer.split(/[\s,]+/).filter(Boolean);
123
+ for (const token of tokens) {
124
+ const indices = expandRange(token, items.length);
125
+ for (const idx of indices) {
126
+ if (selected.has(idx))
127
+ selected.delete(idx);
128
+ else
129
+ selected.add(idx);
130
+ }
131
+ }
132
+ }
133
+ return [...selected].map((idx) => items[idx].value);
134
+ }
135
+ export async function promptConfirm(ctx, question, defaultYes = true) {
136
+ if (!canPrompt(ctx))
137
+ return defaultYes;
138
+ const suffix = defaultYes ? ' [Y/n] ' : ' [y/N] ';
139
+ const answer = (await ask(`${question}${suffix}`)).trim().toLowerCase();
140
+ if (!answer)
141
+ return defaultYes;
142
+ return answer === 'y' || answer === 'yes';
143
+ }
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.0",
3
+ "version": "0.1.2",
4
4
  "description": "CLI for Wacht Bench, the AI development workbench for Wacht.",
5
5
  "type": "module",
6
6
  "bin": {