@wacht/bench 0.1.0 → 0.1.1

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,24 @@
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';
6
19
  import { apiCommand, listProjects } from './machine-api.js';
7
20
  import { openApiCall, openApiDescribe, openApiList, openApiRefresh } from './openapi.js';
8
- import { printMcpConfig } from './mcp.js';
21
+ import { installMcp, listMcp, printMcpConfig, uninstallMcp } from './mcp.js';
9
22
  import { authStatus, login, logout } from './oauth.js';
10
23
  import { createOrg, createUser, createWorkspace, getOrg, getUser, getWorkspace, listOrgs, listUsers, listWorkspaces, } from './resources.js';
11
24
  import { installSkills } from './skills.js';
@@ -34,14 +47,8 @@ function initArgs(options) {
34
47
  args.push('--install-skills');
35
48
  if (options.skipAgents)
36
49
  args.push('--skip-agents');
37
- if (options.skipConfig)
38
- args.push('--skip-config');
39
50
  if (options.skipEnv)
40
51
  args.push('--skip-env');
41
- if (options.skipGuide)
42
- args.push('--skip-guide');
43
- if (options.skipTemplates)
44
- args.push('--skip-templates');
45
52
  return args;
46
53
  }
47
54
  export async function runCli(args) {
@@ -49,6 +56,7 @@ export async function runCli(args) {
49
56
  program
50
57
  .name('wacht')
51
58
  .description('AI development workbench for Wacht')
59
+ .version(PKG_VERSION, '-v, --version', 'print Wacht Bench CLI version')
52
60
  .showHelpAfterError()
53
61
  .option('--json', 'emit machine-readable JSON where supported')
54
62
  .option('--quiet', 'suppress nonessential human output')
@@ -69,10 +77,7 @@ export async function runCli(args) {
69
77
  .option('--target <dir>', 'target directory when using --starter (defaults to ./wacht-<framework>-starter)')
70
78
  .option('--install-skills', 'run npx skills add after writing config')
71
79
  .option('--skip-agents', 'do not create or update AGENTS.md')
72
- .option('--skip-config', 'do not write .wacht/bench.json')
73
80
  .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
81
  .action(async (options) => {
77
82
  const ctx = context(program);
78
83
  if (options.starter) {
@@ -168,11 +173,36 @@ export async function runCli(args) {
168
173
  .action(async (options) => {
169
174
  await installSkills(options.skill);
170
175
  });
171
- const mcp = program.command('mcp').description('print Wacht Docs MCP configuration');
176
+ const mcp = program.command('mcp').description('configure Wacht Docs MCP across AI clients');
177
+ mcp
178
+ .command('list')
179
+ .alias('ls')
180
+ .description('list known MCP clients with detection + install status')
181
+ .action(async () => {
182
+ await listMcp(context(program));
183
+ });
184
+ mcp
185
+ .command('install')
186
+ .description('install Wacht Docs MCP into one or more clients (interactive by default)')
187
+ .option('--client <ids>', 'comma-separated target ids; skips the picker', (value) => value.split(',').map((s) => s.trim()).filter(Boolean))
188
+ .option('--all', 'install into every known target without prompting')
189
+ .option('--yes', 'do not ask to confirm before writing')
190
+ .action(async (options) => {
191
+ await installMcp(context(program), { clients: options.client, all: options.all, yes: options.yes });
192
+ });
193
+ mcp
194
+ .command('uninstall')
195
+ .description('remove Wacht Docs MCP from one or more clients')
196
+ .option('--client <ids>', 'comma-separated target ids; skips the picker', (value) => value.split(',').map((s) => s.trim()).filter(Boolean))
197
+ .option('--all', 'remove from every known target without prompting')
198
+ .option('--yes', 'do not ask to confirm before writing')
199
+ .action(async (options) => {
200
+ await uninstallMcp(context(program), { clients: options.client, all: options.all, yes: options.yes });
201
+ });
172
202
  mcp
173
203
  .command('config')
174
- .description('print MCP config JSON for an assistant client')
175
- .option('--client <client>', 'cursor, claude, or codex', 'cursor')
204
+ .description('print raw MCP config JSON for a client (no file write)')
205
+ .option('--client <client>', 'claude-desktop, cursor, vscode, codex, windsurf, claude-code', 'cursor')
176
206
  .action((options) => {
177
207
  printMcpConfig(options.client);
178
208
  });
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));
@@ -440,7 +144,7 @@ export async function initProject(args, ctx) {
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 add')} when you want to install/update the pack`));
444
148
  }
445
149
  if (ctx.json) {
446
150
  printJson({
@@ -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/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wacht/bench",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "CLI for Wacht Bench, the AI development workbench for Wacht.",
5
5
  "type": "module",
6
6
  "bin": {