Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 8 additions & 8 deletions openspec/changes/add-agents-md-config/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 15 additions & 9 deletions src/core/config.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
export const OPENSPEC_DIR_NAME = 'openspec';

export interface OpenSpecConfig {
aiTools: string[];
}

export const OPENSPEC_MARKERS = {
start: '<!-- OPENSPEC:START -->',
end: '<!-- OPENSPEC: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' }
];
23 changes: 23 additions & 0 deletions src/core/configurators/agents.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const filePath = path.join(projectPath, this.configFileName);
const content = TemplateManager.getAgentsStandardTemplate();

await FileSystemUtils.updateFileWithMarkers(
filePath,
content,
OPENSPEC_MARKERS.start,
OPENSPEC_MARKERS.end
);
}
}
5 changes: 4 additions & 1 deletion src/core/configurators/registry.ts
Original file line number Diff line number Diff line change
@@ -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<string, ToolConfigurator> = 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 {
Expand All @@ -25,4 +28,4 @@ export class ToolRegistry {
static getAvailable(): ToolConfigurator[] {
return this.getAll().filter(tool => tool.isAvailable);
}
}
}
2 changes: 1 addition & 1 deletion src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('────────────────────────────────────────────────────────────');
Expand Down
4 changes: 4 additions & 0 deletions src/core/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export class TemplateManager {
return claudeTemplate;
}

static getAgentsStandardTemplate(): string {
return claudeTemplate;
}

static getSlashCommandBody(id: SlashCommandId): string {
return getSlashCommandBody(id);
}
Expand Down
18 changes: 16 additions & 2 deletions src/core/update.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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();
Expand Down Expand Up @@ -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(', ')}`);
Expand Down
27 changes: 27 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<!-- OPENSPEC:START -->');
expect(content).toContain('OpenSpec Project');
expect(content).toContain('<!-- OPENSPEC:END -->');

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');

Expand Down Expand Up @@ -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', () => {
Expand Down
80 changes: 63 additions & 17 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -85,9 +86,10 @@ Old slash content
expect(updated).toContain('Validate with `openspec validate <id> --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();
});
Expand Down Expand Up @@ -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();
});
Expand All @@ -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();
});

Expand All @@ -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();
});

Expand Down Expand Up @@ -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);
}
}
});

Expand All @@ -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('<!-- OPENSPEC:START -->');
expect(content).toContain('This project uses OpenSpec');
expect(content).toContain('<!-- OPENSPEC:END -->');
});

it('should refresh root AGENTS.md while preserving surrounding content', async () => {
const rootAgentsPath = path.join(testDir, 'AGENTS.md');
const original = `# Custom intro\n\n<!-- OPENSPEC:START -->\nOld content\n<!-- OPENSPEC:END -->\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 });
Expand Down Expand Up @@ -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);
Expand Down
Loading