diff --git a/README.md b/README.md index c78a76c2..3b164c54 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,12 @@ openspec init # Select your AI tool: # "Which AI tool do you use?" -# > Claude Code +# > Claude Code (✅ OpenSpec custom slash commands available) # Use /openspec:proposal, /openspec:apply, and /openspec:archive in Claude Code to run proposals, apply tasks, and archive changes. -# Cursor +# Cursor (✅ OpenSpec custom slash commands available) # Use /openspec-proposal, /openspec-apply, and /openspec-archive in Cursor for proposals, implementation, and archiving. +# AGENTS.md (works with Codex, Amp, Copilot, …) +# Creates/updates a root-level AGENTS.md block for tools that follow the AGENTS.md convention (Codex, Amp, Jules, OpenCode, Gemini CLI, GitHub Copilot, etc.) # This creates: # openspec/ @@ -287,7 +289,7 @@ Without specs, AI coding assistants generate code based on vague prompts, often - Local dependency: `pnpm add @fission-ai/openspec@latest` - Global CLI: `npm install -g @fission-ai/openspec@latest` 2. **Refresh agent instructions** - - Run `openspec update` inside each project to regenerate AI instructions and refresh slash-command bindings. + - Run `openspec update` inside each project to regenerate AI instructions, refresh the root `AGENTS.md`, and update slash-command bindings. Run the update step after every version bump (or when switching tools) so your agents always pick up the latest guidance. diff --git a/openspec/changes/add-agents-md-config/specs/cli-init/spec.md b/openspec/changes/add-agents-md-config/specs/cli-init/spec.md index cf1dc50d..fdb4417e 100644 --- a/openspec/changes/add-agents-md-config/specs/cli-init/spec.md +++ b/openspec/changes/add-agents-md-config/specs/cli-init/spec.md @@ -6,12 +6,9 @@ The command SHALL configure AI coding assistants with OpenSpec instructions base - **WHEN** run - **THEN** prompt user to select AI tools to configure: - - Claude Code - - AGENTS.md standard -- **AND** show disabled options as "coming soon" (not selectable): - - Cursor (coming soon) - - Aider (coming soon) - - Continue (coming soon) + - Claude Code (✅ OpenSpec custom slash commands available) + - Cursor (✅ OpenSpec custom slash commands available) + - AGENTS.md (works with Codex, Amp, Copilot, …) ### Requirement: AI Tool Configuration Details The command SHALL properly configure selected AI tools with OpenSpec-specific instructions using a marker system. diff --git a/openspec/changes/add-agents-md-config/tasks.md b/openspec/changes/add-agents-md-config/tasks.md index 53aeeaa0..55400e84 100644 --- a/openspec/changes/add-agents-md-config/tasks.md +++ b/openspec/changes/add-agents-md-config/tasks.md @@ -1,17 +1,17 @@ # Implementation Tasks ## 1. Extend Init Workflow -- [ ] 1.1 Add an "AGENTS.md standard" option to the `openspec init` tool-selection prompt, respecting the existing UI conventions. -- [ ] 1.2 Generate or refresh a root-level `AGENTS.md` file using the OpenSpec markers when that option is selected, sourcing content from the canonical template. +- [x] 1.1 Add an "AGENTS.md standard" option to the `openspec init` tool-selection prompt, respecting the existing UI conventions. +- [x] 1.2 Generate or refresh a root-level `AGENTS.md` file using the OpenSpec markers when that option is selected, sourcing content from the canonical template. ## 2. Enhance Update Command -- [ ] 2.1 Ensure `openspec update` writes the root `AGENTS.md` from the latest template (creating it if missing) alongside `openspec/AGENTS.md`. -- [ ] 2.2 Update success messaging and logging to reflect creation vs refresh of the AGENTS standard file. +- [x] 2.1 Ensure `openspec update` writes the root `AGENTS.md` from the latest template (creating it if missing) alongside `openspec/AGENTS.md`. +- [x] 2.2 Update success messaging and logging to reflect creation vs refresh of the AGENTS standard file. ## 3. Shared Template Handling -- [ ] 3.1 Refactor template utilities if necessary so both commands reuse the same content without duplication. -- [ ] 3.2 Add automated tests covering init/update flows for projects with and without an existing `AGENTS.md`, ensuring markers behave correctly. +- [x] 3.1 Refactor template utilities if necessary so both commands reuse the same content without duplication. +- [x] 3.2 Add automated tests covering init/update flows for projects with and without an existing `AGENTS.md`, ensuring markers behave correctly. ## 4. Documentation -- [ ] 4.1 Update CLI specs and user-facing docs to describe AGENTS standard support. -- [ ] 4.2 Run `openspec validate add-agents-md-config --strict` and document any notable behavior changes. +- [x] 4.1 Update CLI specs and user-facing docs to describe AGENTS standard support. +- [x] 4.2 Run `openspec validate add-agents-md-config --strict` and document any notable behavior changes. diff --git a/src/core/config.ts b/src/core/config.ts index 867ae9be..6d279c73 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,17 +1,23 @@ export const OPENSPEC_DIR_NAME = 'openspec'; -export interface OpenSpecConfig { - aiTools: string[]; -} - export const OPENSPEC_MARKERS = { start: '', end: '' }; -export const AI_TOOLS = [ - { name: 'Claude Code', value: 'claude', available: true }, - { name: 'Cursor', value: 'cursor', available: true }, - { name: 'Aider', value: 'aider', available: false }, - { name: 'Continue', value: 'continue', available: false } +export interface OpenSpecConfig { + aiTools: string[]; +} + +export interface AIToolOption { + name: string; + value: string; + available: boolean; + successLabel?: string; +} + +export const AI_TOOLS: AIToolOption[] = [ + { name: 'Claude Code (✅ OpenSpec custom slash commands available)', value: 'claude', available: true, successLabel: 'Claude Code' }, + { name: 'Cursor (✅ OpenSpec custom slash commands available)', value: 'cursor', available: true, successLabel: 'Cursor' }, + { name: 'AGENTS.md (works with Codex, Amp, Copilot, …)', value: 'agents', available: true, successLabel: 'your AGENTS.md-compatible assistant' } ]; diff --git a/src/core/configurators/agents.ts b/src/core/configurators/agents.ts new file mode 100644 index 00000000..720bb324 --- /dev/null +++ b/src/core/configurators/agents.ts @@ -0,0 +1,23 @@ +import path from 'path'; +import { ToolConfigurator } from './base.js'; +import { FileSystemUtils } from '../../utils/file-system.js'; +import { TemplateManager } from '../templates/index.js'; +import { OPENSPEC_MARKERS } from '../config.js'; + +export class AgentsStandardConfigurator implements ToolConfigurator { + name = 'AGENTS.md standard'; + configFileName = 'AGENTS.md'; + isAvailable = true; + + async configure(projectPath: string, _openspecDir: string): Promise { + const filePath = path.join(projectPath, this.configFileName); + const content = TemplateManager.getAgentsStandardTemplate(); + + await FileSystemUtils.updateFileWithMarkers( + filePath, + content, + OPENSPEC_MARKERS.start, + OPENSPEC_MARKERS.end + ); + } +} diff --git a/src/core/configurators/registry.ts b/src/core/configurators/registry.ts index 8887e2a9..1b238ce0 100644 --- a/src/core/configurators/registry.ts +++ b/src/core/configurators/registry.ts @@ -1,13 +1,16 @@ import { ToolConfigurator } from './base.js'; import { ClaudeConfigurator } from './claude.js'; +import { AgentsStandardConfigurator } from './agents.js'; export class ToolRegistry { private static tools: Map = new Map(); static { const claudeConfigurator = new ClaudeConfigurator(); + const agentsConfigurator = new AgentsStandardConfigurator(); // Register with the ID that matches the checkbox value this.tools.set('claude', claudeConfigurator); + this.tools.set('agents', agentsConfigurator); } static register(tool: ToolConfigurator): void { @@ -25,4 +28,4 @@ export class ToolRegistry { static getAvailable(): ToolConfigurator[] { return this.getAll().filter(tool => tool.isAvailable); } -} \ No newline at end of file +} diff --git a/src/core/init.ts b/src/core/init.ts index 82e1f2b2..b6d9071b 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -121,7 +121,7 @@ export class InitCommand { // Get the selected tool name for display const selectedToolId = config.aiTools[0]; const selectedTool = AI_TOOLS.find(t => t.value === selectedToolId); - const toolName = selectedTool ? selectedTool.name : 'your AI assistant'; + const toolName = selectedTool?.successLabel ?? selectedTool?.name ?? 'your AI assistant'; console.log(`\nNext steps - Copy these prompts to ${toolName}:\n`); console.log('────────────────────────────────────────────────────────────'); diff --git a/src/core/templates/index.ts b/src/core/templates/index.ts index f686a5f0..92dcb1f0 100644 --- a/src/core/templates/index.ts +++ b/src/core/templates/index.ts @@ -26,6 +26,10 @@ export class TemplateManager { return claudeTemplate; } + static getAgentsStandardTemplate(): string { + return claudeTemplate; + } + static getSlashCommandBody(id: SlashCommandId): string { return getSlashCommandBody(id); } diff --git a/src/core/update.ts b/src/core/update.ts index 3cebc5a6..d8d10b96 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -1,7 +1,8 @@ import path from 'path'; import { FileSystemUtils } from '../utils/file-system.js'; -import { OPENSPEC_DIR_NAME } from './config.js'; +import { OPENSPEC_DIR_NAME, OPENSPEC_MARKERS } from './config.js'; import { agentsTemplate } from './templates/agents-template.js'; +import { TemplateManager } from './templates/index.js'; import { ToolRegistry } from './configurators/registry.js'; import { SlashCommandRegistry } from './configurators/slash/registry.js'; @@ -18,7 +19,17 @@ export class UpdateCommand { // 2. Update AGENTS.md (full replacement) const agentsPath = path.join(openspecPath, 'AGENTS.md'); + const rootAgentsPath = path.join(resolvedProjectPath, 'AGENTS.md'); + const rootAgentsExisted = await FileSystemUtils.fileExists(rootAgentsPath); + await FileSystemUtils.writeFile(agentsPath, agentsTemplate); + const agentsStandardContent = TemplateManager.getAgentsStandardTemplate(); + await FileSystemUtils.updateFileWithMarkers( + rootAgentsPath, + agentsStandardContent, + OPENSPEC_MARKERS.start, + OPENSPEC_MARKERS.end + ); // 3. Update existing AI tool configuration files only const configurators = ToolRegistry.getAll(); @@ -63,7 +74,10 @@ export class UpdateCommand { } // 4. Success message (ASCII-safe) - const messages: string[] = ['Updated OpenSpec instructions (AGENTS.md)']; + const instructionUpdates = ['openspec/AGENTS.md']; + instructionUpdates.push(`AGENTS.md${rootAgentsExisted ? '' : ' (created)'}`); + + const messages: string[] = [`Updated OpenSpec instructions (${instructionUpdates.join(', ')})`]; if (updatedFiles.length > 0) { messages.push(`Updated AI tool files: ${updatedFiles.join(', ')}`); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index e4731adb..2dd34582 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -86,6 +86,23 @@ describe('InitCommand', () => { expect(updatedContent).toContain('Custom instructions here'); }); + it('should create AGENTS.md in project root when AGENTS standard is selected', async () => { + vi.mocked(prompts.select).mockResolvedValue('agents'); + + await initCommand.execute(testDir); + + const rootAgentsPath = path.join(testDir, 'AGENTS.md'); + expect(await fileExists(rootAgentsPath)).toBe(true); + + const content = await fs.readFile(rootAgentsPath, 'utf-8'); + expect(content).toContain(''); + expect(content).toContain('OpenSpec Project'); + expect(content).toContain(''); + + const claudeExists = await fileExists(path.join(testDir, 'CLAUDE.md')); + expect(claudeExists).toBe(false); + }); + it('should create Claude slash command files with templates', async () => { vi.mocked(prompts.select).mockResolvedValue('claude'); @@ -168,6 +185,16 @@ describe('InitCommand', () => { const calls = logSpy.mock.calls.flat().join('\n'); expect(calls).toContain('Copy these prompts to Claude Code'); }); + + it('should reference AGENTS compatible assistants in success message', async () => { + vi.mocked(prompts.select).mockResolvedValue('agents'); + const logSpy = vi.spyOn(console, 'log'); + + await initCommand.execute(testDir); + + const calls = logSpy.mock.calls.flat().join('\n'); + expect(calls).toContain('Copy these prompts to your AGENTS.md-compatible assistant'); + }); }); describe('AI tool selection', () => { diff --git a/test/core/update.test.ts b/test/core/update.test.ts index a91bb8f9..eb2303ac 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -55,9 +55,10 @@ More content after.`; expect(updatedContent).toContain('More content after'); // Check console output - expect(consoleSpy).toHaveBeenCalledWith( - 'Updated OpenSpec instructions (AGENTS.md)\nUpdated AI tool files: CLAUDE.md' - ); + const [logMessage] = consoleSpy.mock.calls[0]; + expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md'); + expect(logMessage).toContain('AGENTS.md (created)'); + expect(logMessage).toContain('Updated AI tool files: CLAUDE.md'); consoleSpy.mockRestore(); }); @@ -85,9 +86,10 @@ Old slash content expect(updated).toContain('Validate with `openspec validate --strict`'); expect(updated).not.toContain('Old slash content'); - expect(consoleSpy).toHaveBeenCalledWith( - 'Updated OpenSpec instructions (AGENTS.md)\nUpdated slash commands: .claude/commands/openspec/proposal.md' - ); + const [logMessage] = consoleSpy.mock.calls[0]; + expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md'); + expect(logMessage).toContain('AGENTS.md (created)'); + expect(logMessage).toContain('Updated slash commands: .claude/commands/openspec/proposal.md'); consoleSpy.mockRestore(); }); @@ -127,9 +129,10 @@ Old body expect(updated).toContain('Work through tasks sequentially'); expect(updated).not.toContain('Old body'); - expect(consoleSpy).toHaveBeenCalledWith( - 'Updated OpenSpec instructions (AGENTS.md)\nUpdated slash commands: .cursor/commands/openspec-apply.md' - ); + const [logMessage] = consoleSpy.mock.calls[0]; + expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md'); + expect(logMessage).toContain('AGENTS.md (created)'); + expect(logMessage).toContain('Updated slash commands: .cursor/commands/openspec-apply.md'); consoleSpy.mockRestore(); }); @@ -140,7 +143,9 @@ Old body await updateCommand.execute(testDir); // Should only update OpenSpec instructions - expect(consoleSpy).toHaveBeenCalledWith('Updated OpenSpec instructions (AGENTS.md)'); + const [logMessage] = consoleSpy.mock.calls[0]; + expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md'); + expect(logMessage).toContain('AGENTS.md (created)'); consoleSpy.mockRestore(); }); @@ -157,9 +162,10 @@ Old body await updateCommand.execute(testDir); // Should report updating with new format - expect(consoleSpy).toHaveBeenCalledWith( - 'Updated OpenSpec instructions (AGENTS.md)\nUpdated AI tool files: CLAUDE.md' - ); + const [logMessage] = consoleSpy.mock.calls[0]; + expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md'); + expect(logMessage).toContain('AGENTS.md (created)'); + expect(logMessage).toContain('Updated AI tool files: CLAUDE.md'); consoleSpy.mockRestore(); }); @@ -196,7 +202,11 @@ Old content for (const configurator of configurators) { const configPath = path.join(testDir, configurator.configFileName); const fileExists = await FileSystemUtils.fileExists(configPath); - expect(fileExists).toBe(false); + if (configurator.configFileName === 'AGENTS.md') { + expect(fileExists).toBe(true); + } else { + expect(fileExists).toBe(false); + } } }); @@ -213,6 +223,41 @@ Old content expect(content).toContain('# OpenSpec Instructions'); }); + it('should create root AGENTS.md with managed block when missing', async () => { + await updateCommand.execute(testDir); + + const rootAgentsPath = path.join(testDir, 'AGENTS.md'); + const exists = await FileSystemUtils.fileExists(rootAgentsPath); + expect(exists).toBe(true); + + const content = await fs.readFile(rootAgentsPath, 'utf-8'); + expect(content).toContain(''); + expect(content).toContain('This project uses OpenSpec'); + expect(content).toContain(''); + }); + + it('should refresh root AGENTS.md while preserving surrounding content', async () => { + const rootAgentsPath = path.join(testDir, 'AGENTS.md'); + const original = `# Custom intro\n\n\nOld content\n\n\n# Footnotes`; + await fs.writeFile(rootAgentsPath, original); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const updated = await fs.readFile(rootAgentsPath, 'utf-8'); + expect(updated).toContain('# Custom intro'); + expect(updated).toContain('# Footnotes'); + expect(updated).toContain('This project uses OpenSpec'); + expect(updated).not.toContain('Old content'); + + const [logMessage] = consoleSpy.mock.calls[0]; + expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md, AGENTS.md)'); + expect(logMessage).not.toContain('AGENTS.md (created)'); + + consoleSpy.mockRestore(); + }); + it('should throw error if openspec directory does not exist', async () => { // Remove openspec directory await fs.rm(path.join(testDir, 'openspec'), { recursive: true, force: true }); @@ -245,9 +290,10 @@ Old content // Should report the failure expect(errorSpy).toHaveBeenCalled(); - expect(consoleSpy).toHaveBeenCalledWith( - 'Updated OpenSpec instructions (AGENTS.md)\nFailed to update: CLAUDE.md' - ); + const [logMessage] = consoleSpy.mock.calls[0]; + expect(logMessage).toContain('Updated OpenSpec instructions (openspec/AGENTS.md'); + expect(logMessage).toContain('AGENTS.md (created)'); + expect(logMessage).toContain('Failed to update: CLAUDE.md'); // Restore permissions for cleanup await fs.chmod(claudePath, 0o644);