@webpresso/agent-kit 0.25.0 → 0.26.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.
Files changed (41) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/esm/audit/toolchain-isolation.d.ts +3 -0
  4. package/dist/esm/audit/toolchain-isolation.js +139 -0
  5. package/dist/esm/cli/cli.d.ts +1 -1
  6. package/dist/esm/cli/cli.js +6 -0
  7. package/dist/esm/cli/commands/audit-core.d.ts +1 -1
  8. package/dist/esm/cli/commands/audit.js +1 -0
  9. package/dist/esm/cli/commands/deploy.d.ts +4 -0
  10. package/dist/esm/cli/commands/deploy.js +35 -0
  11. package/dist/esm/cli/commands/init/gitignore-patcher.d.ts +17 -0
  12. package/dist/esm/cli/commands/init/gitignore-patcher.js +42 -0
  13. package/dist/esm/cli/commands/init/index.js +30 -2
  14. package/dist/esm/cli/commands/init/scaffold-base-kit.js +44 -17
  15. package/dist/esm/cli/commands/init/scaffolders/codex-cli/index.js +5 -1
  16. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.d.ts +29 -1
  17. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +72 -2
  18. package/dist/esm/deploy/index.d.ts +5 -0
  19. package/dist/esm/deploy/index.js +5 -0
  20. package/dist/esm/deploy/load-adapter.d.ts +6 -0
  21. package/dist/esm/deploy/load-adapter.js +57 -0
  22. package/dist/esm/deploy/run.d.ts +12 -0
  23. package/dist/esm/deploy/run.js +52 -0
  24. package/dist/esm/deploy/schema.d.ts +6 -0
  25. package/dist/esm/deploy/schema.js +61 -0
  26. package/dist/esm/deploy/types.d.ts +43 -0
  27. package/dist/esm/deploy/types.js +2 -0
  28. package/dist/esm/e2e/command-builder.js +37 -18
  29. package/dist/esm/e2e/config.d.ts +2 -0
  30. package/dist/esm/e2e/config.js +6 -1
  31. package/dist/esm/hooks/pretool-guard/dev-routing.d.ts +6 -0
  32. package/dist/esm/hooks/pretool-guard/dev-routing.js +3 -2
  33. package/dist/esm/hooks/pretool-guard/runner.js +11 -6
  34. package/dist/esm/mcp/tools/_shared/runner-failure.d.ts +30 -0
  35. package/dist/esm/mcp/tools/_shared/runner-failure.js +45 -0
  36. package/dist/esm/mcp/tools/typecheck.js +20 -3
  37. package/dist/esm/package.json +3 -1
  38. package/dist/esm/test/command-builder.d.ts +1 -0
  39. package/dist/esm/test/command-builder.js +9 -5
  40. package/dist/esm/tool-runtime/resolve-runner.js +38 -10
  41. package/package.json +32 -18
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Webpresso agent-kit Claude Code plugin: blueprints, skills, hooks, MCP server",
9
- "version": "0.25.0"
9
+ "version": "0.26.1"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -23,5 +23,5 @@
23
23
  ]
24
24
  }
25
25
  ],
26
- "version": "0.25.0"
26
+ "version": "0.26.1"
27
27
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webpresso",
3
- "version": "0.25.0",
3
+ "version": "0.26.1",
4
4
  "description": "Webpresso agent-kit: blueprints, skills, lore commit protocol, tech-debt lifecycle",
5
5
  "skills": "./skills",
6
6
  "commands": "./commands",
@@ -0,0 +1,3 @@
1
+ import type { RepoAuditResult } from './repo-guardrails.js';
2
+ export declare function auditToolchainIsolation(root: string): RepoAuditResult;
3
+ //# sourceMappingURL=toolchain-isolation.d.ts.map
@@ -0,0 +1,139 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ const FORBIDDEN_DEPENDENCY_PATTERNS = [
4
+ /^typescript$/u,
5
+ /^vite$/u,
6
+ /^vitest$/u,
7
+ /^@stryker-mutator\//u,
8
+ /^@playwright\/test$/u,
9
+ /^wrangler$/u,
10
+ /^oxlint$/u,
11
+ /^oxfmt$/u,
12
+ /^tsx$/u,
13
+ ];
14
+ const FORBIDDEN_SCRIPT_PATTERNS = [
15
+ /(^|\s)(tsc|vite|vitest|stryker|playwright|wrangler|oxlint|oxfmt|tsx)(\s|$)/u,
16
+ /node\s+\.\/node_modules\/(typescript|vite|vitest|wrangler|oxlint|tsx)\//u,
17
+ ];
18
+ const ALLOWED_SCRIPT_PREFIXES = ['wp ', 'vp run ', 'vp run -r '];
19
+ export function auditToolchainIsolation(root) {
20
+ const packagePaths = findPackageJsonFiles(root);
21
+ const violations = [];
22
+ for (const packagePath of packagePaths) {
23
+ const pkg = readPackageJson(packagePath);
24
+ if (!pkg) {
25
+ violations.push({ file: packagePath, message: 'package.json must be valid JSON' });
26
+ continue;
27
+ }
28
+ if (isExemptPackage(root, packagePath, pkg))
29
+ continue;
30
+ for (const field of [
31
+ 'dependencies',
32
+ 'devDependencies',
33
+ 'optionalDependencies',
34
+ 'peerDependencies',
35
+ ]) {
36
+ for (const depName of Object.keys(pkg[field] ?? {})) {
37
+ if (!isForbiddenDependency(depName))
38
+ continue;
39
+ violations.push({
40
+ file: packagePath,
41
+ message: `${field}.${depName} is toolchain-owned; route it through @webpresso/agent-kit/wp instead of declaring it directly`,
42
+ });
43
+ }
44
+ }
45
+ for (const [scriptName, scriptValue] of Object.entries(pkg.scripts ?? {})) {
46
+ if (typeof scriptValue !== 'string')
47
+ continue;
48
+ if (isAllowedScript(scriptValue))
49
+ continue;
50
+ if (!FORBIDDEN_SCRIPT_PATTERNS.some((pattern) => pattern.test(scriptValue)))
51
+ continue;
52
+ violations.push({
53
+ file: packagePath,
54
+ message: `script "${scriptName}" invokes a toolchain binary directly; use wp-managed commands instead`,
55
+ });
56
+ }
57
+ }
58
+ return {
59
+ ok: violations.length === 0,
60
+ title: 'Toolchain isolation',
61
+ checked: packagePaths.length,
62
+ violations,
63
+ };
64
+ }
65
+ function isExemptPackage(root, packagePath, pkg) {
66
+ if (pkg.name === '@webpresso/agent-kit')
67
+ return true;
68
+ const relativePath = path.relative(root, packagePath).split(path.sep).join('/');
69
+ // Catalog skill templates are sample project manifests shipped by agent-kit,
70
+ // not live consumer package manifests. They intentionally demonstrate raw
71
+ // framework CLIs in generated starter content and should not make agent-kit's
72
+ // own audit fail.
73
+ const unpackedPath = relativePath.replace(/^\.webpresso-packed-surface\//u, '');
74
+ if (unpackedPath.startsWith('catalog/') && unpackedPath.includes('/templates/'))
75
+ return true;
76
+ return false;
77
+ }
78
+ function findPackageJsonFiles(root) {
79
+ const files = [];
80
+ walk(root, files);
81
+ return files.sort();
82
+ }
83
+ function walk(dir, files) {
84
+ for (const entry of safeReadDir(dir)) {
85
+ const absolute = path.join(dir, entry.name);
86
+ if (entry.isDirectory()) {
87
+ if (!shouldSkipDirectory(entry.name))
88
+ walk(absolute, files);
89
+ continue;
90
+ }
91
+ if (entry.isFile() && entry.name === 'package.json')
92
+ files.push(absolute);
93
+ }
94
+ }
95
+ function safeReadDir(dir) {
96
+ try {
97
+ return existsSync(dir) ? readdirSync(dir, { withFileTypes: true }) : [];
98
+ }
99
+ catch {
100
+ return [];
101
+ }
102
+ }
103
+ function shouldSkipDirectory(name) {
104
+ return [
105
+ '.git',
106
+ 'node_modules',
107
+ 'dist',
108
+ 'build',
109
+ '.turbo',
110
+ '.next',
111
+ '.wrangler',
112
+ '.agent',
113
+ '.agents',
114
+ '.omx',
115
+ '.omc',
116
+ '.codex',
117
+ // Gitignored Claude Code agent surface — agent worktree scratch under
118
+ // .claude/worktrees/* carries vendored package manifests that are not the
119
+ // repo's own packages; walking it produces false positives on local dev
120
+ // machines and in consumer repos that run agent worktrees.
121
+ '.claude',
122
+ ].includes(name);
123
+ }
124
+ function readPackageJson(file) {
125
+ try {
126
+ return JSON.parse(readFileSync(file, 'utf8'));
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
132
+ function isForbiddenDependency(depName) {
133
+ return FORBIDDEN_DEPENDENCY_PATTERNS.some((pattern) => pattern.test(depName));
134
+ }
135
+ function isAllowedScript(script) {
136
+ const trimmed = script.trim();
137
+ return ALLOWED_SCRIPT_PREFIXES.some((prefix) => trimmed.startsWith(prefix));
138
+ }
139
+ //# sourceMappingURL=toolchain-isolation.js.map
@@ -5,7 +5,7 @@
5
5
  * Lazy-loads subcommand modules based on the first argv to keep startup
6
6
  * cheap. Modeled on apps/cli-wp/src/cli.ts.
7
7
  */
8
- declare const SUPPORTED_COMMANDS: readonly ["blueprint", "config", "roadmap", "sync", "audit", "compile", "rule", "skill", "skills", "docs", "setup", "init", "dev", "doctor", "err", "test", "e2e", "ci", "typecheck", "lint", "format", "tech-debt", "worktree", "mcp", "hook", "hooks", "gain", "bench", "install", "add", "remove", "update", "exec", "run"];
8
+ declare const SUPPORTED_COMMANDS: readonly ["blueprint", "config", "roadmap", "sync", "audit", "compile", "rule", "skill", "skills", "docs", "setup", "init", "dev", "deploy", "doctor", "err", "test", "e2e", "ci", "typecheck", "lint", "format", "tech-debt", "worktree", "mcp", "hook", "hooks", "gain", "bench", "install", "add", "remove", "update", "exec", "run"];
9
9
  export { SUPPORTED_COMMANDS };
10
10
  export declare function main(): Promise<number>;
11
11
  //# sourceMappingURL=cli.d.ts.map
@@ -27,6 +27,7 @@ const SUPPORTED_COMMANDS = [
27
27
  'setup',
28
28
  'init',
29
29
  'dev',
30
+ 'deploy',
30
31
  'doctor',
31
32
  'err',
32
33
  'test',
@@ -209,6 +210,11 @@ export async function main() {
209
210
  registerDevCommand(cli);
210
211
  break;
211
212
  }
213
+ case 'deploy': {
214
+ const { registerDeployCommand } = await import('./commands/deploy.js');
215
+ registerDeployCommand(cli);
216
+ break;
217
+ }
212
218
  case 'doctor': {
213
219
  const { registerDoctorCommand } = await import('./commands/doctor.js');
214
220
  registerDoctorCommand(cli);
@@ -1,5 +1,5 @@
1
1
  import type { RepoAuditResult } from '#audit/repo-guardrails';
2
- export type AuditKind = 'tph' | 'tph-e2e' | 'bundle-budget' | 'commit-message' | 'blueprint-lifecycle' | 'roadmap-links' | 'docs-frontmatter' | 'catalog-drift' | 'package-surface' | 'agents' | 'tech-debt' | 'no-relative-parent-imports' | 'no-link-protocol' | 'vision' | 'bucket-boundary' | 'skill-sizes' | 'broken-refs' | 'memory-rotation' | 'gitignore-agent-surfaces' | 'memory-unified' | 'compile-drift' | 'architecture-drift' | 'cloudflare-deploy-contract' | 'absolute-path-policy' | 'agent-cost' | 'blueprint-db-consistency' | 'blueprint-lifecycle-sql' | 'tech-debt-cadence' | 'cross-repo-correlation' | 'ai-contracts' | 'mutation' | 'quality' | 'guardrails' | 'hook-surface' | 'no-relative-package-scripts';
2
+ export type AuditKind = 'tph' | 'tph-e2e' | 'bundle-budget' | 'commit-message' | 'blueprint-lifecycle' | 'roadmap-links' | 'docs-frontmatter' | 'catalog-drift' | 'package-surface' | 'agents' | 'tech-debt' | 'no-relative-parent-imports' | 'no-link-protocol' | 'vision' | 'bucket-boundary' | 'skill-sizes' | 'broken-refs' | 'memory-rotation' | 'gitignore-agent-surfaces' | 'memory-unified' | 'compile-drift' | 'architecture-drift' | 'cloudflare-deploy-contract' | 'toolchain-isolation' | 'absolute-path-policy' | 'agent-cost' | 'blueprint-db-consistency' | 'blueprint-lifecycle-sql' | 'tech-debt-cadence' | 'cross-repo-correlation' | 'ai-contracts' | 'mutation' | 'quality' | 'guardrails' | 'hook-surface' | 'no-relative-package-scripts';
3
3
  export type AuditOutcome = {
4
4
  kind: 'invalid-usage';
5
5
  message: string;
@@ -59,6 +59,7 @@ const REPO_AUDIT_REGISTRY = {
59
59
  'no-legacy-cli-bin': async (root) => (await import('#audit/no-legacy-cli-bin')).auditNoLegacyCliBin(root),
60
60
  'architecture-drift': async (root) => (await import('#audit/architecture-drift')).auditArchitectureDrift(root),
61
61
  'cloudflare-deploy-contract': async (root) => (await import('#audit/cloudflare-deploy-contract')).auditCloudflareDeployContract(root),
62
+ 'toolchain-isolation': async (root) => (await import('#audit/toolchain-isolation')).auditToolchainIsolation(root),
62
63
  'absolute-path-policy': async (root) => (await import('#audit/absolute-path-policy')).auditAbsolutePathPolicy(root),
63
64
  'agent-cost': async (root) => (await import('#audit/agent-cost')).auditAgentCost(root),
64
65
  'blueprint-db-consistency': async (root) => (await import('#audit/blueprint-db-consistency')).auditBlueprintDbConsistency(root),
@@ -0,0 +1,4 @@
1
+ import type { CAC } from 'cac';
2
+ export declare const DEPLOY_COMMAND_HELP: string;
3
+ export declare function registerDeployCommand(cli: CAC): void;
4
+ //# sourceMappingURL=deploy.d.ts.map
@@ -0,0 +1,35 @@
1
+ import { runDeployPlan } from '#deploy/run.js';
2
+ export const DEPLOY_COMMAND_HELP = [
3
+ 'Run a consumer-owned deploy adapter through the managed wp deploy surface.',
4
+ '',
5
+ 'Examples:',
6
+ ' wp deploy --lane prd --dry-run',
7
+ ' wp deploy --lane preview_pr_123 --plan-json',
8
+ ].join('\n');
9
+ export function registerDeployCommand(cli) {
10
+ cli
11
+ .command('deploy', DEPLOY_COMMAND_HELP)
12
+ .option('--lane <lane>', 'Deploy lane: dev, preview_main, preview_pr_<n>, or prd')
13
+ .option('--dry-run', 'Ask the adapter for its dry-run deploy plan')
14
+ .option('--plan-json', 'Print the validated deploy plan JSON without executing steps')
15
+ .action(async (options) => {
16
+ const lane = typeof options.lane === 'string' ? options.lane : undefined;
17
+ if (!lane) {
18
+ console.error('Usage: wp deploy --lane <dev|preview_main|preview_pr_<n>|prd>');
19
+ return 1;
20
+ }
21
+ try {
22
+ return await runDeployPlan({
23
+ cwd: process.cwd(),
24
+ lane,
25
+ dryRun: Boolean(options.dryRun),
26
+ planJson: Boolean(options.planJson),
27
+ });
28
+ }
29
+ catch (error) {
30
+ console.error(error instanceof Error ? error.message : String(error));
31
+ return 1;
32
+ }
33
+ });
34
+ }
35
+ //# sourceMappingURL=deploy.js.map
@@ -3,7 +3,24 @@ export interface GitignoreBlock {
3
3
  id: string;
4
4
  patterns: readonly string[];
5
5
  }
6
+ export type GeneratedIndexCleanupResult = {
7
+ kind: 'skipped-dry-run';
8
+ pathspecs: readonly string[];
9
+ } | {
10
+ kind: 'skipped-not-git';
11
+ pathspecs: readonly string[];
12
+ } | {
13
+ kind: 'ok';
14
+ pathspecs: readonly string[];
15
+ removedPaths: readonly string[];
16
+ } | {
17
+ kind: 'failed';
18
+ pathspecs: readonly string[];
19
+ exitCode: number | null;
20
+ stderr: string;
21
+ };
6
22
  /** Canonical gitignore block for webpresso generated/transient paths. */
7
23
  export declare const GENERATED_PATHS_BLOCK: GitignoreBlock;
24
+ export declare function untrackGeneratedGitignoredPaths(repoRoot: string, block?: GitignoreBlock, opts?: Pick<MergeOptions, 'dryRun'>): GeneratedIndexCleanupResult;
8
25
  export declare function patchGitignore(targetPath: string, block: GitignoreBlock, opts?: MergeOptions): MergeResult;
9
26
  //# sourceMappingURL=gitignore-patcher.d.ts.map
@@ -11,6 +11,7 @@
11
11
  * drift). Other content in `.gitignore` — including unrelated managed blocks
12
12
  * from other scaffolders — is preserved verbatim.
13
13
  */
14
+ import { spawnSync } from 'node:child_process';
14
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
16
  import { dirname } from 'node:path';
16
17
  const BEGIN = (id) => `# >>> managed by webpresso (${id})`;
@@ -55,6 +56,7 @@ export const GENERATED_PATHS_BLOCK = {
55
56
  '.opencode/',
56
57
  '.codex/',
57
58
  '.claude/settings.json',
59
+ '.claude/settings.local.json',
58
60
  '.claude/agents/',
59
61
  '.claude/hooks/',
60
62
  '.claude/rules/',
@@ -77,6 +79,46 @@ export const GENERATED_PATHS_BLOCK = {
77
79
  '.agent/.tail-hint-history.jsonl',
78
80
  ],
79
81
  };
82
+ function generatedPathspecs(block) {
83
+ return block.patterns
84
+ .map((pattern) => pattern.trim())
85
+ .filter((pattern) => pattern.length > 0)
86
+ .filter((pattern) => !pattern.startsWith('#'))
87
+ .filter((pattern) => !pattern.startsWith('!'));
88
+ }
89
+ function parseGitRmStdout(stdout) {
90
+ return stdout
91
+ .split('\n')
92
+ .map((line) => line.trim())
93
+ .filter((line) => line.startsWith('rm '))
94
+ .map((line) => line.replace(/^rm ['"]?/, '').replace(/['"]?$/, ''));
95
+ }
96
+ export function untrackGeneratedGitignoredPaths(repoRoot, block = GENERATED_PATHS_BLOCK, opts = {}) {
97
+ const pathspecs = generatedPathspecs(block);
98
+ if (opts.dryRun)
99
+ return { kind: 'skipped-dry-run', pathspecs };
100
+ if (pathspecs.length === 0)
101
+ return { kind: 'ok', pathspecs, removedPaths: [] };
102
+ const gitProbe = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
103
+ cwd: repoRoot,
104
+ encoding: 'utf8',
105
+ });
106
+ if (gitProbe.status !== 0)
107
+ return { kind: 'skipped-not-git', pathspecs };
108
+ const gitRm = spawnSync('git', ['rm', '--cached', '-r', '--ignore-unmatch', '--', ...pathspecs], {
109
+ cwd: repoRoot,
110
+ encoding: 'utf8',
111
+ });
112
+ if (gitRm.status !== 0) {
113
+ return {
114
+ kind: 'failed',
115
+ pathspecs,
116
+ exitCode: gitRm.status,
117
+ stderr: String(gitRm.stderr ?? '').trim(),
118
+ };
119
+ }
120
+ return { kind: 'ok', pathspecs, removedPaths: parseGitRmStdout(String(gitRm.stdout ?? '')) };
121
+ }
80
122
  export function patchGitignore(targetPath, block, opts = {}) {
81
123
  const exists = existsSync(targetPath);
82
124
  const original = exists ? readFileSync(targetPath, 'utf8') : '';
@@ -22,7 +22,7 @@ import { scaffoldAgent, RENDERED_SKILLS, TIER1_SKILLS, TIER2_SKILLS } from './sc
22
22
  import { scaffoldAgentRules } from './scaffold-agent-rules.js';
23
23
  import { scaffoldAgentSkills } from './scaffold-agent-skills.js';
24
24
  import { scaffoldCatalogIgnore } from './scaffold-catalog-ignore.js';
25
- import { GENERATED_PATHS_BLOCK, patchGitignore } from './gitignore-patcher.js';
25
+ import { GENERATED_PATHS_BLOCK, patchGitignore, untrackGeneratedGitignoredPaths, } from './gitignore-patcher.js';
26
26
  import { scaffoldAgentsMd } from './scaffold-agents-md.js';
27
27
  import { scaffoldBlueprints } from './scaffold-blueprints.js';
28
28
  import { scaffoldDocs } from './scaffold-docs.js';
@@ -35,7 +35,7 @@ import { scaffoldAuditHooks } from './scaffolders/audit-hooks/index.js';
35
35
  import { ensureClaudeCodeUserPlugin } from './scaffolders/claude-plugin/index.js';
36
36
  import { scaffoldClaudeRules } from './scaffolders/claude-rules/index.js';
37
37
  import { ensureCodexCli } from './scaffolders/codex-cli/index.js';
38
- import { ensureCodexWebpressoMcp, ensureCodexPlaywrightMcp } from './scaffolders/codex-mcp/index.js';
38
+ import { ensureCodexWebpressoMcp, ensureCodexPlaywrightMcp, ensureClaudePlaywrightMcp, } from './scaffolders/codex-mcp/index.js';
39
39
  import { scaffoldExampleSkill } from './scaffolders/example-skill/index.js';
40
40
  import { ensureGstack } from './scaffolders/gstack/index.js';
41
41
  import { scaffoldLoreCommits } from './scaffolders/lore-commits/index.js';
@@ -244,6 +244,7 @@ export async function runInit(flags) {
244
244
  overwrite: options.overwrite,
245
245
  });
246
246
  const generatedSurfaceIgnoreResult = patchGitignore(join(consumer.repoRoot, '.gitignore'), GENERATED_PATHS_BLOCK, { dryRun: options.dryRun, overwrite: true });
247
+ const generatedIndexCleanupResult = untrackGeneratedGitignoredPaths(consumer.repoRoot, GENERATED_PATHS_BLOCK, { dryRun: options.dryRun });
247
248
  const baseKitResults = tier3Selection.includes('base-kit')
248
249
  ? scaffoldBaseKit({
249
250
  catalogDir,
@@ -436,6 +437,24 @@ export async function runInit(flags) {
436
437
  console.log(' codex playwright mcp: skipped (--dry-run)');
437
438
  break;
438
439
  }
440
+ const claudePlaywrightMcpResult = ensureClaudePlaywrightMcp({
441
+ options,
442
+ repoRoot: consumer.repoRoot,
443
+ });
444
+ switch (claudePlaywrightMcpResult.kind) {
445
+ case 'claude-playwright-mcp-written':
446
+ console.log(` claude playwright mcp: ✓ ${claudePlaywrightMcpResult.path}`);
447
+ break;
448
+ case 'claude-playwright-mcp-unchanged':
449
+ console.log(` claude playwright mcp: already configured at ${claudePlaywrightMcpResult.path}`);
450
+ break;
451
+ case 'claude-playwright-mcp-skipped-dry-run':
452
+ console.log(' claude playwright mcp: skipped (--dry-run)');
453
+ break;
454
+ case 'claude-playwright-mcp-invalid-json':
455
+ console.warn(` claude playwright mcp: ⚠ ${claudePlaywrightMcpResult.path} is not valid JSON; left unchanged`);
456
+ break;
457
+ }
439
458
  }
440
459
  if (presets.includes('omx')) {
441
460
  agentHooksResult = await scaffoldAgentHooks({
@@ -669,6 +688,15 @@ export async function runInit(flags) {
669
688
  console.log(` drifted: ${summary.drifted}`);
670
689
  if (options.dryRun)
671
690
  console.log(` would-change: ${summary['skipped-dry']}`);
691
+ if (generatedIndexCleanupResult.kind === 'ok') {
692
+ console.log(` git index cleanup: ${generatedIndexCleanupResult.removedPaths.length} untracked`);
693
+ }
694
+ else if (generatedIndexCleanupResult.kind === 'failed') {
695
+ console.warn(` git index cleanup: failed (git rm --cached exited ${generatedIndexCleanupResult.exitCode})`);
696
+ if (generatedIndexCleanupResult.stderr.length > 0) {
697
+ console.warn(` git index cleanup stderr: ${generatedIndexCleanupResult.stderr}`);
698
+ }
699
+ }
672
700
  if (tier3Selection.includes('base-kit')) {
673
701
  const qualityTargets = new Set(BASE_KIT_QUALITY_TARGETS);
674
702
  const qualityResults = baseKitResults.filter((result) => qualityTargets.has(relative(consumer.repoRoot, result.targetPath).replaceAll('\\', '/')));
@@ -138,7 +138,7 @@ function mergePackageJson(repoRoot, options, globalInstall = false) {
138
138
  const devDeps = (pkg['devDependencies'] ?? {});
139
139
  const hasAgentKitDevDep = typeof devDeps['@webpresso/agent-kit'] === 'string';
140
140
  const hasLegacyAgentKitDevDep = typeof devDeps['webpresso'] === 'string';
141
- const shouldSkipSelfInstall = packageName === '@webpresso/agent-kit' || packageName === 'webpresso';
141
+ const shouldSkipSelfInstall = isSelfPackageName(packageName);
142
142
  const shouldManageAgentKitAsGlobal = globalInstall && !shouldSkipSelfInstall;
143
143
  const requiredAuthoringDeps = {
144
144
  '@playwright/test': 'latest',
@@ -236,10 +236,35 @@ function mergePackageJson(repoRoot, options, globalInstall = false) {
236
236
  writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
237
237
  return { targetPath: pkgPath, action: 'overwritten' };
238
238
  }
239
+ /** agent-kit's own package identities: scoped canonical + legacy unscoped. */
240
+ const SELF_PACKAGE_NAMES = ['@webpresso/agent-kit', 'webpresso'];
241
+ /** True when `name` is one of agent-kit's own package identities. */
242
+ function isSelfPackageName(name) {
243
+ return name !== undefined && SELF_PACKAGE_NAMES.includes(name);
244
+ }
245
+ /**
246
+ * True when `repoRoot` is agent-kit's own source repo (by package.json name).
247
+ * agent-kit dogfoods base-kit's scripts and shared templates, but the QUALITY
248
+ * starter samples (quality-sample.ts, e2e/smoke, sample configs) are teaching
249
+ * artifacts for FRESH consumer repos — scaffolding them into agent-kit's own
250
+ * source tree is pollution. This flag skips ONLY those samples.
251
+ */
252
+ function isAgentKitSelfRepo(repoRoot) {
253
+ try {
254
+ const pkg = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'));
255
+ return isSelfPackageName(pkg.name);
256
+ }
257
+ catch {
258
+ return false;
259
+ }
260
+ }
239
261
  export function scaffoldBaseKit(input) {
240
262
  const { catalogDir, repoRoot, options, globalInstall = false } = input;
241
263
  const baseKitDir = join(catalogDir, 'base-kit');
242
264
  const results = [];
265
+ // Dogfooding boundary: agent-kit gets base-kit's scripts/templates but not the
266
+ // starter quality samples scaffolded into its own source tree.
267
+ const skipStarterSamples = isAgentKitSelfRepo(repoRoot);
243
268
  for (const [tmplRel, targetRel] of TEMPLATE_MAP) {
244
269
  const tmplPath = join(baseKitDir, tmplRel);
245
270
  if (!existsSync(tmplPath))
@@ -269,23 +294,25 @@ export function scaffoldBaseKit(input) {
269
294
  writeFileSync(targetPath, content);
270
295
  results.push({ targetPath, action: 'created' });
271
296
  }
272
- for (const [tmplRel, targetRel] of QUALITY_BOOTSTRAP_ONLY_MAP) {
273
- const tmplPath = join(baseKitDir, tmplRel);
274
- if (!existsSync(tmplPath))
275
- continue;
276
- const targetPath = join(repoRoot, targetRel);
277
- if (existsSync(targetPath)) {
278
- results.push({ targetPath, action: 'identical' });
279
- continue;
280
- }
281
- const content = readFileSync(tmplPath, 'utf8');
282
- if (options.dryRun) {
283
- results.push({ targetPath, action: 'skipped-dry' });
284
- continue;
297
+ if (!skipStarterSamples) {
298
+ for (const [tmplRel, targetRel] of QUALITY_BOOTSTRAP_ONLY_MAP) {
299
+ const tmplPath = join(baseKitDir, tmplRel);
300
+ if (!existsSync(tmplPath))
301
+ continue;
302
+ const targetPath = join(repoRoot, targetRel);
303
+ if (existsSync(targetPath)) {
304
+ results.push({ targetPath, action: 'identical' });
305
+ continue;
306
+ }
307
+ const content = readFileSync(tmplPath, 'utf8');
308
+ if (options.dryRun) {
309
+ results.push({ targetPath, action: 'skipped-dry' });
310
+ continue;
311
+ }
312
+ mkdirSync(dirname(targetPath), { recursive: true });
313
+ writeFileSync(targetPath, content);
314
+ results.push({ targetPath, action: 'created' });
285
315
  }
286
- mkdirSync(dirname(targetPath), { recursive: true });
287
- writeFileSync(targetPath, content);
288
- results.push({ targetPath, action: 'created' });
289
316
  }
290
317
  // Make husky hook files executable
291
318
  if (!options.dryRun) {
@@ -20,7 +20,11 @@ export function ensureCodexCli(input) {
20
20
  }
21
21
  }
22
22
  else if (!shouldSkipCodexRefresh()) {
23
- spawn('vp', ['update', '-g', '@openai/codex'], { stdio: 'inherit' });
23
+ // `--latest` ignores the recorded semver range so the global is pulled to
24
+ // the absolute newest published release, matching the force-to-latest
25
+ // guarantee `vp install -g <bare>` gives the agent-kit self-update. Plain
26
+ // `vp update -g` is range-bound and can strand the global on an old major.
27
+ spawn('vp', ['update', '-g', '--latest', '@openai/codex'], { stdio: 'inherit' });
24
28
  }
25
29
  return { kind: 'codex-cli-ok', installed };
26
30
  }
@@ -1,7 +1,7 @@
1
1
  import type { MergeOptions } from '#cli/commands/init/merge';
2
2
  export declare const PLAYWRIGHT_MCP_SERVER_NAME = "playwright";
3
3
  export declare const PLAYWRIGHT_MCP_HEADER = "[mcp_servers.playwright]";
4
- export declare const PLAYWRIGHT_MCP_BLOCK = "[mcp_servers.playwright]\ncommand = \"vp\"\nargs = [\"dlx\", \"@playwright/mcp@latest\", \"--caps=testing,storage,network,devtools\"]\nenabled = true\nstartup_timeout_sec = 30\n";
4
+ export declare const PLAYWRIGHT_MCP_BLOCK: string;
5
5
  export declare const WEBPRESSO_MCP_SERVER_NAME = "webpresso";
6
6
  export declare const WEBPRESSO_MCP_HEADER = "[mcp_servers.webpresso]";
7
7
  export interface EnsureCodexPlaywrightMcpInput {
@@ -21,6 +21,34 @@ export type EnsureCodexPlaywrightMcpResult = {
21
21
  };
22
22
  export declare function upsertPlaywrightMcpServer(raw: string): string;
23
23
  export declare function ensureCodexPlaywrightMcp(input: EnsureCodexPlaywrightMcpInput): EnsureCodexPlaywrightMcpResult;
24
+ /**
25
+ * Upsert the `playwright` server into a `.mcp.json` document, preserving every
26
+ * other server (e.g. `context7`, `exa`) and any non-server top-level keys.
27
+ * Output is normalized to 2-space JSON with a trailing newline so repeated runs
28
+ * converge — idempotent after the first write.
29
+ */
30
+ export declare function upsertClaudePlaywrightMcpServer(raw: string): string;
31
+ export interface EnsureClaudePlaywrightMcpInput {
32
+ options: MergeOptions;
33
+ /** Project root whose `.mcp.json` is managed. */
34
+ repoRoot: string;
35
+ /** Test seam. Defaults to `<repoRoot>/.mcp.json`. */
36
+ configPath?: string;
37
+ }
38
+ export type EnsureClaudePlaywrightMcpResult = {
39
+ kind: 'claude-playwright-mcp-written';
40
+ path: string;
41
+ } | {
42
+ kind: 'claude-playwright-mcp-unchanged';
43
+ path: string;
44
+ } | {
45
+ kind: 'claude-playwright-mcp-skipped-dry-run';
46
+ path: string;
47
+ } | {
48
+ kind: 'claude-playwright-mcp-invalid-json';
49
+ path: string;
50
+ };
51
+ export declare function ensureClaudePlaywrightMcp(input: EnsureClaudePlaywrightMcpInput): EnsureClaudePlaywrightMcpResult;
24
52
  export interface WebpressoInstallProbe {
25
53
  /** Test seam — override the candidate roots. Default: probe in fixed order. */
26
54
  candidates?: readonly string[];
@@ -22,10 +22,27 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
22
  import { homedir } from 'node:os';
23
23
  import { dirname, join } from 'node:path';
24
24
  export const PLAYWRIGHT_MCP_SERVER_NAME = 'playwright';
25
+ /**
26
+ * Single source of truth for how the Playwright MCP server is launched. Both
27
+ * the Codex TOML block and the Claude Code `.mcp.json` block render from these.
28
+ * The portable `vp dlx` facade fetches the npm-published server on demand, so
29
+ * there is no machine-specific bin path to rot — the failure mode a hand-
30
+ * authored `~/.bun/bin/playwright-mcp` entry hits the moment that global bin
31
+ * disappears (ENOENT on spawn).
32
+ */
33
+ const PLAYWRIGHT_MCP_COMMAND = 'vp';
34
+ const PLAYWRIGHT_MCP_ARGS = [
35
+ 'dlx',
36
+ '@playwright/mcp@latest',
37
+ '--caps=testing,storage,network,devtools',
38
+ ];
39
+ function tomlStringArray(values) {
40
+ return `[${values.map((value) => `"${value}"`).join(', ')}]`;
41
+ }
25
42
  export const PLAYWRIGHT_MCP_HEADER = `[mcp_servers.${PLAYWRIGHT_MCP_SERVER_NAME}]`;
26
43
  export const PLAYWRIGHT_MCP_BLOCK = `${PLAYWRIGHT_MCP_HEADER}
27
- command = "vp"
28
- args = ["dlx", "@playwright/mcp@latest", "--caps=testing,storage,network,devtools"]
44
+ command = "${PLAYWRIGHT_MCP_COMMAND}"
45
+ args = ${tomlStringArray(PLAYWRIGHT_MCP_ARGS)}
29
46
  enabled = true
30
47
  startup_timeout_sec = 30
31
48
  `;
@@ -70,6 +87,59 @@ export function ensureCodexPlaywrightMcp(input) {
70
87
  writeFileSync(configPath, next, 'utf8');
71
88
  return { kind: 'codex-playwright-mcp-written', path: configPath };
72
89
  }
90
+ function claudePlaywrightServer() {
91
+ return { command: PLAYWRIGHT_MCP_COMMAND, args: [...PLAYWRIGHT_MCP_ARGS] };
92
+ }
93
+ function isJsonRecord(value) {
94
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
95
+ }
96
+ function parseJson(raw) {
97
+ try {
98
+ return { ok: true, value: JSON.parse(raw) };
99
+ }
100
+ catch {
101
+ return { ok: false };
102
+ }
103
+ }
104
+ /**
105
+ * Upsert the `playwright` server into a `.mcp.json` document, preserving every
106
+ * other server (e.g. `context7`, `exa`) and any non-server top-level keys.
107
+ * Output is normalized to 2-space JSON with a trailing newline so repeated runs
108
+ * converge — idempotent after the first write.
109
+ */
110
+ export function upsertClaudePlaywrightMcpServer(raw) {
111
+ const parsed = raw.trim().length > 0 ? parseJson(raw) : { ok: true, value: {} };
112
+ if (!parsed.ok) {
113
+ throw new Error('cannot upsert playwright into .mcp.json: existing file is not valid JSON');
114
+ }
115
+ const root = isJsonRecord(parsed.value) ? parsed.value : {};
116
+ const servers = isJsonRecord(root.mcpServers) ? root.mcpServers : {};
117
+ const next = {
118
+ ...root,
119
+ mcpServers: {
120
+ ...servers,
121
+ [PLAYWRIGHT_MCP_SERVER_NAME]: claudePlaywrightServer(),
122
+ },
123
+ };
124
+ return `${JSON.stringify(next, null, 2)}\n`;
125
+ }
126
+ export function ensureClaudePlaywrightMcp(input) {
127
+ const configPath = input.configPath ?? join(input.repoRoot, '.mcp.json');
128
+ if (input.options.dryRun) {
129
+ return { kind: 'claude-playwright-mcp-skipped-dry-run', path: configPath };
130
+ }
131
+ const existing = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
132
+ if (existing.trim().length > 0 && !parseJson(existing).ok) {
133
+ return { kind: 'claude-playwright-mcp-invalid-json', path: configPath };
134
+ }
135
+ const next = upsertClaudePlaywrightMcpServer(existing);
136
+ if (next === existing) {
137
+ return { kind: 'claude-playwright-mcp-unchanged', path: configPath };
138
+ }
139
+ mkdirSync(dirname(configPath), { recursive: true });
140
+ writeFileSync(configPath, next, 'utf8');
141
+ return { kind: 'claude-playwright-mcp-written', path: configPath };
142
+ }
73
143
  // ────────────────────────────────────────────────────────────────────────────
74
144
  // Agent-kit MCP server registration
75
145
  // ────────────────────────────────────────────────────────────────────────────