diff --git a/packages/cli/snap-tests-global/command-config-no-agent-writes/CLAUDE.md b/packages/cli/snap-tests-global/command-config-no-agent-writes/CLAUDE.md new file mode 100644 index 0000000000..b1a9d30042 --- /dev/null +++ b/packages/cli/snap-tests-global/command-config-no-agent-writes/CLAUDE.md @@ -0,0 +1,3 @@ +# My Custom Claude Instructions + +Do not modify this file. diff --git a/packages/cli/snap-tests-global/command-config-no-agent-writes/package.json b/packages/cli/snap-tests-global/command-config-no-agent-writes/package.json new file mode 100644 index 0000000000..cc7a274434 --- /dev/null +++ b/packages/cli/snap-tests-global/command-config-no-agent-writes/package.json @@ -0,0 +1,3 @@ +{ + "name": "command-config-no-agent-writes" +} diff --git a/packages/cli/snap-tests-global/command-config-no-agent-writes/snap.txt b/packages/cli/snap-tests-global/command-config-no-agent-writes/snap.txt new file mode 100644 index 0000000000..dd0a235fcc --- /dev/null +++ b/packages/cli/snap-tests-global/command-config-no-agent-writes/snap.txt @@ -0,0 +1,9 @@ +> git init +> vp config +> cat CLAUDE.md # should be unchanged +# My Custom Claude Instructions + +Do not modify this file. + +> test -f AGENTS.md && echo 'AGENTS.md exists' || echo 'AGENTS.md not created' # should not exist +AGENTS.md not created diff --git a/packages/cli/snap-tests-global/command-config-no-agent-writes/steps.json b/packages/cli/snap-tests-global/command-config-no-agent-writes/steps.json new file mode 100644 index 0000000000..6f105a3096 --- /dev/null +++ b/packages/cli/snap-tests-global/command-config-no-agent-writes/steps.json @@ -0,0 +1,8 @@ +{ + "commands": [ + { "command": "git init", "ignoreOutput": true }, + "vp config", + "cat CLAUDE.md # should be unchanged", + "test -f AGENTS.md && echo 'AGENTS.md exists' || echo 'AGENTS.md not created' # should not exist" + ] +} diff --git a/packages/cli/snap-tests-global/command-config-update-agents/AGENTS.md b/packages/cli/snap-tests-global/command-config-update-agents/AGENTS.md new file mode 100644 index 0000000000..a67ca8979b --- /dev/null +++ b/packages/cli/snap-tests-global/command-config-update-agents/AGENTS.md @@ -0,0 +1,11 @@ +# My Project + +Custom instructions here. + + + +OUTDATED CONTENT THAT SHOULD BE REPLACED + + + +More custom content below. diff --git a/packages/cli/snap-tests-global/command-config-update-agents/package.json b/packages/cli/snap-tests-global/command-config-update-agents/package.json new file mode 100644 index 0000000000..12f5eb64f8 --- /dev/null +++ b/packages/cli/snap-tests-global/command-config-update-agents/package.json @@ -0,0 +1,3 @@ +{ + "name": "command-config-update-agents" +} diff --git a/packages/cli/snap-tests-global/command-config-update-agents/snap.txt b/packages/cli/snap-tests-global/command-config-update-agents/snap.txt new file mode 100644 index 0000000000..9522ea963e --- /dev/null +++ b/packages/cli/snap-tests-global/command-config-update-agents/snap.txt @@ -0,0 +1,16 @@ +> git init +> vp config # should auto-update agent instructions +> head -5 AGENTS.md # verify user content preserved +# My Project + +Custom instructions here. + + + +> tail -3 AGENTS.md # verify user content preserved + + +More custom content below. + +> grep -q 'OUTDATED CONTENT' AGENTS.md && echo 'ERROR: outdated content still present' || echo 'outdated content replaced' # verify old content gone +outdated content replaced diff --git a/packages/cli/snap-tests-global/command-config-update-agents/steps.json b/packages/cli/snap-tests-global/command-config-update-agents/steps.json new file mode 100644 index 0000000000..5b56f9daa0 --- /dev/null +++ b/packages/cli/snap-tests-global/command-config-update-agents/steps.json @@ -0,0 +1,9 @@ +{ + "commands": [ + { "command": "git init", "ignoreOutput": true }, + "vp config # should auto-update agent instructions", + "head -5 AGENTS.md # verify user content preserved", + "tail -3 AGENTS.md # verify user content preserved", + "grep -q 'OUTDATED CONTENT' AGENTS.md && echo 'ERROR: outdated content still present' || echo 'outdated content replaced' # verify old content gone" + ] +} diff --git a/packages/cli/snap-tests/command-run-with-vp-config/snap.txt b/packages/cli/snap-tests/command-run-with-vp-config/snap.txt index a7d8c33a45..f696f350b1 100644 --- a/packages/cli/snap-tests/command-run-with-vp-config/snap.txt +++ b/packages/cli/snap-tests/command-run-with-vp-config/snap.txt @@ -2,21 +2,6 @@ $ vp config ⊘ cache disabled .git can't be found -Created AGENTS.md with Vite+ instructions -◇ Add this MCP server config to your agent ─╮ - - { - "vite-plus": { - "command": "npx", - "args": [ - "vp", - "mcp" - ] - } - } - -╰────────────────────────────────────────────╯ - [2]> vp run bar # should throw error $ vp not-exist-command ⊘ cache disabled diff --git a/packages/cli/src/config/__tests__/agent.spec.ts b/packages/cli/src/config/__tests__/agent.spec.ts deleted file mode 100644 index 4d2e94e88c..0000000000 --- a/packages/cli/src/config/__tests__/agent.spec.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { join } from 'node:path'; - -import * as prompts from '@voidzero-dev/vite-plus-prompts'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { pkgRoot } from '../../utils/path.js'; - -const AGENT_TEMPLATE = ['', 'template block', ''].join( - '\n', -); - -const { files, fsMock } = vi.hoisted(() => { - const files = new Map(); - const fsMock = { - existsSync: (p: string) => files.has(p), - readFileSync: (p: string) => { - const content = files.get(p); - if (content === undefined) { - throw new Error(`ENOENT: no such file "${p}"`); - } - return content; - }, - writeFileSync: (p: string, data: string) => { - files.set(p, data); - }, - }; - return { files, fsMock }; -}); - -vi.mock('node:fs', () => ({ - ...fsMock, - default: fsMock, -})); - -import { injectAgentBlock } from '../agent.js'; - -beforeEach(() => { - files.clear(); - files.set(join(pkgRoot, 'AGENTS.md'), AGENT_TEMPLATE); - vi.spyOn(prompts.log, 'info').mockImplementation(() => {}); - vi.spyOn(prompts.log, 'success').mockImplementation(() => {}); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe('injectAgentBlock', () => { - it('creates file with template when file does not exist', () => { - injectAgentBlock('/project', 'AGENTS.md'); - expect(files.get(join('/project', 'AGENTS.md'))).toBe(AGENT_TEMPLATE); - }); - - it('updates marked section when file has markers', () => { - const existing = [ - '# Header', - '', - 'old content', - '', - '# Footer', - ].join('\n'); - files.set(join('/project', 'CLAUDE.md'), existing); - - injectAgentBlock('/project', 'CLAUDE.md'); - - expect(files.get(join('/project', 'CLAUDE.md'))).toBe( - [ - '# Header', - '', - 'template block', - '', - '# Footer', - ].join('\n'), - ); - }); - - it('does not write when content is already up-to-date', () => { - files.set(join('/project', 'AGENTS.md'), AGENT_TEMPLATE); - const infoSpy = vi.spyOn(prompts.log, 'info'); - - injectAgentBlock('/project', 'AGENTS.md'); - - expect(infoSpy).toHaveBeenCalledWith('AGENTS.md already has up-to-date Vite+ instructions'); - }); - - it('appends template when file exists without markers', () => { - files.set(join('/project', 'AGENTS.md'), '# Existing content\n'); - - injectAgentBlock('/project', 'AGENTS.md'); - - expect(files.get(join('/project', 'AGENTS.md'))).toBe( - `# Existing content\n\n${AGENT_TEMPLATE}`, - ); - }); -}); diff --git a/packages/cli/src/config/agent.ts b/packages/cli/src/config/agent.ts deleted file mode 100644 index d14c46510b..0000000000 --- a/packages/cli/src/config/agent.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; - -import * as prompts from '@voidzero-dev/vite-plus-prompts'; - -import { - detectAgents, - getAgentById, - hasExistingAgentInstructions, - replaceMarkedAgentInstructionsSection, - type AgentConfig, - type McpConfigTarget, -} from '../utils/agent.js'; -import { writeJsonFile, readJsonFile } from '../utils/json.js'; -import { pkgRoot } from '../utils/path.js'; - -export interface AgentSetupSelection { - instructionFilePath: 'CLAUDE.md' | 'AGENTS.md'; - agents: AgentConfig[]; -} - -function detectInstructionFilePath( - root: string, - agentConfigs: AgentConfig[], -): 'CLAUDE.md' | 'AGENTS.md' { - if (agentConfigs.some((a) => a.skillsDir === '.claude/skills')) { - return 'CLAUDE.md'; - } - if (existsSync(join(root, 'CLAUDE.md'))) { - return 'CLAUDE.md'; - } - return 'AGENTS.md'; -} - -async function pickAgentWhenUndetected(): Promise { - const choice = await prompts.select({ - message: 'Could not detect your coding agent. Which one are you using?', - options: [ - { value: 'claude-code', label: 'Claude Code' }, - { value: 'cursor', label: 'Cursor' }, - { value: 'codex', label: 'Codex' }, - { value: 'gemini-cli', label: 'Gemini CLI' }, - { value: 'generic', label: 'Generic' }, - ], - }); - if (prompts.isCancel(choice)) { - prompts.cancel('Setup cancelled.'); - process.exit(0); - } - - if (choice === 'generic') { - return { - instructionFilePath: 'AGENTS.md', - agents: [], - }; - } - - const selected = getAgentById(choice); - if (!selected) { - return { - instructionFilePath: 'AGENTS.md', - agents: [], - }; - } - - return { - instructionFilePath: choice === 'claude-code' ? 'CLAUDE.md' : 'AGENTS.md', - agents: [selected], - }; -} - -export async function resolveAgentSetup( - root: string, - interactive: boolean, -): Promise { - const detected = detectAgents(root); - if (detected.length > 0 || !interactive) { - return { - instructionFilePath: detectInstructionFilePath(root, detected), - agents: detected, - }; - } - return pickAgentWhenUndetected(); -} - -// --- Template reading --- - -function readAgentPrompt(): string { - return readFileSync(join(pkgRoot, 'AGENTS.md'), 'utf-8'); -} - -// --- Agent instructions injection --- - -export { hasExistingAgentInstructions }; - -export function injectAgentBlock(root: string, filePath: string): void { - const fullPath = join(root, filePath); - const template = readAgentPrompt(); - - if (existsSync(fullPath)) { - const existing = readFileSync(fullPath, 'utf-8'); - const updated = replaceMarkedAgentInstructionsSection(existing, template); - if (updated !== undefined) { - if (updated !== existing) { - writeFileSync(fullPath, updated); - prompts.log.success(`Updated Vite+ instructions in ${filePath}`); - } else { - prompts.log.info(`${filePath} already has up-to-date Vite+ instructions`); - } - } else { - // No markers found — append template - const separator = existing.endsWith('\n') ? '\n' : '\n\n'; - writeFileSync(fullPath, existing + separator + template); - prompts.log.success(`Added Vite+ instructions to ${filePath}`); - } - } else { - writeFileSync(fullPath, template); - prompts.log.success(`Created ${filePath} with Vite+ instructions`); - } -} - -// --- MCP config --- - -function writeMcpConfigForTarget(root: string, target: McpConfigTarget): void { - const fullPath = join(root, target.filePath); - let existing: Record = {}; - if (existsSync(fullPath)) { - try { - existing = readJsonFile(fullPath); - } catch { - prompts.log.warn( - `Could not parse ${target.filePath} — skipping MCP config. Please add the config manually.`, - ); - return; - } - } - - if (!existing[target.rootKey]) { - existing[target.rootKey] = {}; - } - - if (existing[target.rootKey]['vite-plus']) { - prompts.log.info(`${target.filePath} already has vite-plus MCP config`); - return; - } - - existing[target.rootKey]['vite-plus'] = { - command: 'npx', - args: ['vp', 'mcp'], - ...target.extraFields, - }; - - mkdirSync(dirname(fullPath), { recursive: true }); - writeJsonFile(fullPath, existing); - prompts.log.success(`Added vite-plus MCP server to ${target.filePath}`); -} - -function pickMcpTarget(root: string, targets: McpConfigTarget[]): McpConfigTarget { - if (targets.length === 1) { - return targets[0]; - } - return targets.find((t) => existsSync(join(root, t.filePath))) ?? targets[0]; -} - -export function setupMcpConfig(root: string, selectedAgents: AgentConfig[]): void { - if (selectedAgents.length === 0) { - prompts.note( - JSON.stringify( - { - 'vite-plus': { - command: 'npx', - args: ['vp', 'mcp'], - }, - }, - null, - 2, - ), - 'Add this MCP server config to your agent', - ); - return; - } - - const mcpAgents: { agent: AgentConfig; targets: McpConfigTarget[] }[] = []; - const hintAgents: { agent: AgentConfig; hint: string }[] = []; - - for (const agent of selectedAgents) { - if (agent.mcpConfig) { - mcpAgents.push({ agent, targets: agent.mcpConfig }); - } else if (agent.mcpHint) { - hintAgents.push({ agent, hint: agent.mcpHint }); - } - } - - // Print hints for agents without project-level config - for (const { agent, hint } of hintAgents) { - prompts.log.info(`${agent.displayName}: ${hint}`); - } - - // Write config for agents with project-level support - for (const { agent, targets } of mcpAgents) { - const target = pickMcpTarget(root, targets); - prompts.log.info(`${agent.displayName} MCP target: ${target.filePath}`); - writeMcpConfigForTarget(root, target); - } -} diff --git a/packages/cli/src/config/bin.ts b/packages/cli/src/config/bin.ts index 717cdd0619..4bef8b9f4f 100644 --- a/packages/cli/src/config/bin.ts +++ b/packages/cli/src/config/bin.ts @@ -1,8 +1,8 @@ -// Unified `vp config` command — merges the old `vp prepare` (hooks setup) and -// `vp init` (agent integration) into a single entry point. +// Unified `vp config` command — hooks setup + agent instruction updates. // -// Interactive mode (TTY, no CI): prompts on first run, updates silently after. -// Non-interactive mode (scripts.prepare, CI, piped): runs everything by default. +// Hooks: interactive mode prompts on first run; non-interactive installs by default. +// Agent instructions: silently updates existing files with Vite+ markers. +// Never creates new agent files. Same behavior for prepare and manual runs. import { existsSync } from 'node:fs'; import { join } from 'node:path'; @@ -10,16 +10,10 @@ import { join } from 'node:path'; import mri from 'mri'; import { vitePlusHeader } from '../../binding/index.js'; +import { updateExistingAgentInstructions } from '../utils/agent.js'; import { renderCliDoc } from '../utils/help.js'; import { defaultInteractive, promptGitHooks } from '../utils/prompts.js'; -import { linkSkillsForSpecificAgents } from '../utils/skills.js'; import { log } from '../utils/terminal.js'; -import { - resolveAgentSetup, - hasExistingAgentInstructions, - injectAgentBlock, - setupMcpConfig, -} from './agent.js'; import { install } from './hooks.js'; async function main() { @@ -81,16 +75,9 @@ async function main() { } } - // --- Step 2: Agent setup (skipped with --hooks-only or during prepare lifecycle) --- - if (!hooksOnly && process.env.npm_lifecycle_event !== 'prepare') { - const isFirstAgentRun = !hasExistingAgentInstructions(root); - const agentSetup = await resolveAgentSetup(root, interactive && isFirstAgentRun); - - injectAgentBlock(root, agentSetup.instructionFilePath); - setupMcpConfig(root, agentSetup.agents); - if (agentSetup.agents.length > 0) { - linkSkillsForSpecificAgents(root, agentSetup.agents); - } + // --- Step 2: Update agent instructions if Vite+ header exists and is outdated --- + if (!hooksOnly) { + updateExistingAgentInstructions(root); } } diff --git a/packages/cli/src/utils/agent.ts b/packages/cli/src/utils/agent.ts index a4a38c1b36..5f323c70b8 100644 --- a/packages/cli/src/utils/agent.ts +++ b/packages/cli/src/utils/agent.ts @@ -280,6 +280,40 @@ export function hasExistingAgentInstructions(projectRoot: string): boolean { return false; } +/** + * Silently update agent instruction files that contain Vite+ markers. + * - No agent files → no writes + * - No Vite+ markers → no writes + * - Markers present, content up to date → no writes + * - Markers present, content outdated → update marked section + */ +export function updateExistingAgentInstructions(projectRoot: string): void { + const targetPaths = detectExistingAgentTargetPaths(projectRoot); + if (!targetPaths) { + return; + } + + const templatePath = path.join(pkgRoot, 'AGENTS.md'); + if (!fs.existsSync(templatePath)) { + return; + } + + const templateContent = fs.readFileSync(templatePath, 'utf-8'); + + for (const targetPath of targetPaths) { + try { + const fullPath = path.join(projectRoot, targetPath); + const existing = fs.readFileSync(fullPath, 'utf-8'); + const updated = replaceMarkedAgentInstructionsSection(existing, templateContent); + if (updated !== undefined && updated !== existing) { + fs.writeFileSync(fullPath, updated); + } + } catch { + // Best-effort: skip files that can't be read or written + } + } +} + export function resolveAgentTargetPaths(agent?: string | string[]) { const agentNames = parseAgentNames(agent); const resolvedAgentNames = agentNames.length > 0 ? agentNames : ['other']; diff --git a/rfcs/config-and-staged-commands.md b/rfcs/config-and-staged-commands.md index a776183837..7a1f71edc8 100644 --- a/rfcs/config-and-staged-commands.md +++ b/rfcs/config-and-staged-commands.md @@ -100,14 +100,14 @@ Behavior: 1. Built-in husky-compatible install logic (reimplementation of husky v9, not a bundled dependency) 2. Sets `core.hooksPath` to `/_` (default: `.vite-hooks/_`) 3. Creates hook scripts in `/_/` that source the user-defined hooks in `/` -4. Agent integration: injects agent instructions and MCP config (skipped during `prepare` lifecycle — see point 9) +4. Agent instructions: silently updates existing files that contain Vite+ markers (``) when content is outdated. Never creates new agent files. Skipped with `--hooks-only`. 5. Safe to run multiple times (idempotent) 6. Exits 0 and skips hooks if `VITE_GIT_HOOKS=0` or `HUSKY=0` environment variable is set (backwards compatible) 7. Exits 0 and skips hooks if `.git` directory doesn't exist (safe during `npm install` in consumer projects) 8. Exits 1 on real errors (git command not found, `git config` failed) -9. `prepare` lifecycle detection: when `npm_lifecycle_event=prepare`, agent setup is skipped. This ensures `"prepare": "vp config"` only installs hooks during install — agent setup is handled by `vp create`/`vp migrate` -10. Interactive mode: prompts on first run for hooks and agent setup; updates silently on subsequent runs -11. Non-interactive mode: runs everything by default +9. Agent update runs uniformly in all modes (`prepare`, interactive, non-interactive). New agent file creation is handled by `vp create`/`vp migrate`. +10. Interactive mode: prompts on first run for hooks setup +11. Non-interactive mode: sets up hooks by default ### `vp staged`