diff --git a/CHANGELOG.md b/CHANGELOG.md index e1eec63d..15dd0c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # @fission-ai/openspec +## Unreleased + +### Minor Changes + +- Add GitHub Copilot slash command support. OpenSpec now writes prompts to `.github/prompts/openspec-{proposal,apply,archive}.prompt.md` with YAML frontmatter and `$ARGUMENTS` placeholder, and refreshes them on `openspec update`. + ## 0.8.1 ### Patch Changes diff --git a/README.md b/README.md index caf603e1..e9267813 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ These tools have built-in OpenSpec commands. Select the OpenSpec integration whe | **Kilo Code** | `/openspec-proposal.md`, `/openspec-apply.md`, `/openspec-archive.md` (`.kilocode/workflows/`) | | **Windsurf** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.windsurf/workflows/`) | | **Codex** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (global: `~/.codex/prompts`, auto-installed) | +| **GitHub Copilot** | `/openspec-proposal`, `/openspec-apply`, `/openspec-archive` (`.github/prompts/`) | Kilo Code discovers team workflows automatically. Save the generated files under `.kilocode/workflows/` and trigger them from the command palette with `/openspec-proposal.md`, `/openspec-apply.md`, or `/openspec-archive.md`. @@ -104,7 +105,7 @@ These tools automatically read workflow instructions from `openspec/AGENTS.md`. | Tools | |-------| -| Amp • Jules • Gemini CLI • GitHub Copilot • Others | +| Amp • Jules • Gemini CLI • Others | ### Install & Initialize diff --git a/openspec/changes/add-github-copilot-prompts/proposal.md b/openspec/changes/add-github-copilot-prompts/proposal.md new file mode 100644 index 00000000..02322a73 --- /dev/null +++ b/openspec/changes/add-github-copilot-prompts/proposal.md @@ -0,0 +1,25 @@ +## Why +- GitHub Copilot supports custom slash commands through markdown files in `.github/prompts/.prompt.md`. Each file includes YAML frontmatter with a `description` label and uses `$ARGUMENTS` to capture user input. This format allows teams to expose curated workflows directly in Copilot's chat interface. +- Teams already rely on OpenSpec to manage slash-command configurations for Claude Code, Cursor, OpenCode, Codex, Kilo Code, and Windsurf. Excluding GitHub Copilot forces developers to manually maintain OpenSpec prompts in `.github/prompts/`, which leads to drift and undermines OpenSpec's "single source of truth" promise. +- GitHub Copilot discovers prompts from the repository's `.github/prompts/` directory, making it straightforward to version control and share across the team. Adding automated generation and refresh through `openspec init` and `openspec update` eliminates manual synchronization and keeps OpenSpec instructions consistent across all AI assistants. + +## What Changes +- Add GitHub Copilot to the `openspec init` tool picker with "already configured" detection similar to other editors, wiring an implementation that writes managed Markdown prompt files to `.github/prompts/` with OpenSpec marker blocks. +- Generate three GitHub Copilot prompt files—`openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md`—whose content mirrors shared slash-command templates while conforming to Copilot's frontmatter and `$ARGUMENTS` placeholder convention. +- Document GitHub Copilot's repository-based discovery and that OpenSpec writes prompts to `.github/prompts/` with managed blocks. +- Teach `openspec update` to refresh existing GitHub Copilot prompts in-place (only when they already exist) in the repository's `.github/prompts/` directory. +- Document GitHub Copilot support alongside other slash-command integrations and add test coverage that exercises init/update behavior for `.github/prompts/` files. + +## Impact +- Specs: `cli-init`, `cli-update` +- Code: `src/core/configurators/slash/github-copilot.ts` (new), `src/core/configurators/slash/registry.ts`, `src/core/templates/slash-command-templates.ts`, CLI tool summaries, docs +- Tests: integration coverage for GitHub Copilot prompt scaffolding and refresh logic +- Docs: README and CHANGELOG entries announcing GitHub Copilot slash-command support + +## Current Spec Reference +- `specs/cli-init/spec.md` + - Requirements cover init UX, directory scaffolding, AI tool configuration, and existing slash-command support for Claude Code, Cursor, OpenCode, Codex, Kilo Code, and Windsurf. + - Our `## MODIFIED` delta in `changes/.../specs/cli-init/spec.md` will copy the full "Slash Command Configuration" requirement (header, description, and all scenarios) before appending the new GitHub Copilot scenario so archiving retains every prior scenario. +- `specs/cli-update/spec.md` + - Requirements define update preconditions, template refresh behavior, and slash-command refresh logic for existing tools. + - The corresponding delta preserves the entire "Slash Command Updates" requirement while adding the GitHub Copilot refresh scenario, ensuring the archive workflow replaces the block without losing existing scenarios or the "Missing slash command file" guardrail. diff --git a/openspec/changes/add-github-copilot-prompts/specs/cli-init/spec.md b/openspec/changes/add-github-copilot-prompts/specs/cli-init/spec.md new file mode 100644 index 00000000..ef259296 --- /dev/null +++ b/openspec/changes/add-github-copilot-prompts/specs/cli-init/spec.md @@ -0,0 +1,30 @@ +## MODIFIED Requirements + +### Requirement: Slash Command Configuration +The init command SHALL generate slash command files for supported editors using shared templates. + +#### Scenario: Generating slash commands for Claude Code +- **WHEN** the user selects Claude Code during initialization +- **THEN** create `.claude/commands/openspec/proposal.md`, `.claude/commands/openspec/apply.md`, and `.claude/commands/openspec/archive.md` +- **AND** populate each file from shared templates so command text matches other tools +- **AND** each template includes instructions for the relevant OpenSpec workflow stage + +#### Scenario: Generating slash commands for Cursor +- **WHEN** the user selects Cursor during initialization +- **THEN** create `.cursor/commands/openspec-proposal.md`, `.cursor/commands/openspec-apply.md`, and `.cursor/commands/openspec-archive.md` +- **AND** populate each file from shared templates so command text matches other tools +- **AND** each template includes instructions for the relevant OpenSpec workflow stage + +#### Scenario: Generating slash commands for OpenCode +- **WHEN** the user selects OpenCode during initialization +- **THEN** create `.opencode/commands/openspec-proposal.md`, `.opencode/commands/openspec-apply.md`, and `.opencode/commands/openspec-archive.md` +- **AND** populate each file from shared templates so command text matches other tools +- **AND** each template includes instructions for the relevant OpenSpec workflow stage + +#### Scenario: Generating slash commands for GitHub Copilot +- **WHEN** the user selects GitHub Copilot during initialization +- **THEN** create `.github/prompts/openspec-proposal.prompt.md`, `.github/prompts/openspec-apply.prompt.md`, and `.github/prompts/openspec-archive.prompt.md` +- **AND** populate each file with YAML frontmatter containing a `description` field that summarizes the workflow stage +- **AND** include `$ARGUMENTS` placeholder to capture user input +- **AND** wrap the shared template body with OpenSpec markers so `openspec update` can refresh the content +- **AND** each template includes instructions for the relevant OpenSpec workflow stage diff --git a/openspec/changes/add-github-copilot-prompts/specs/cli-update/spec.md b/openspec/changes/add-github-copilot-prompts/specs/cli-update/spec.md new file mode 100644 index 00000000..3ada0c31 --- /dev/null +++ b/openspec/changes/add-github-copilot-prompts/specs/cli-update/spec.md @@ -0,0 +1,29 @@ +## MODIFIED Requirements + +### Requirement: Slash Command Updates +The update command SHALL refresh existing slash command files for configured tools without creating new ones. + +#### Scenario: Updating slash commands for Claude Code +- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Cursor +- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for OpenCode +- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for GitHub Copilot +- **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md` +- **THEN** refresh each file using shared templates while preserving the YAML frontmatter +- **AND** update only the OpenSpec-managed block between markers +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Missing slash command file +- **WHEN** a tool lacks a slash command file +- **THEN** do not create a new file during update diff --git a/openspec/changes/add-github-copilot-prompts/tasks.md b/openspec/changes/add-github-copilot-prompts/tasks.md new file mode 100644 index 00000000..553bc374 --- /dev/null +++ b/openspec/changes/add-github-copilot-prompts/tasks.md @@ -0,0 +1,30 @@ +## Implementation Tasks + +- [x] Create `src/core/configurators/slash/github-copilot.ts` implementing `SlashCommandConfigurator` base class + - Implement `getRelativePath()` to return `.github/prompts/openspec-{proposal,apply,archive}.prompt.md` + - Implement `getFrontmatter()` to generate YAML frontmatter with `description` field and include `$ARGUMENTS` placeholder + - Implement `generateAll()` to create `.github/prompts/` directory and write three prompt files with frontmatter, markers, and shared template bodies + - Implement `updateExisting()` to refresh only the managed block between markers while preserving frontmatter + - Set `toolId = "github-copilot"` and `isAvailable = true` + +- [x] Register GitHub Copilot configurator in `src/core/configurators/slash/registry.ts` + - Import `GitHubCopilotSlashCommandConfigurator` + - Add to `SLASH_COMMAND_CONFIGURATORS` array + - Update tool picker display name to "GitHub Copilot" + +- [x] Update `src/core/init.ts` to include GitHub Copilot in the AI tool selection prompt + - Add GitHub Copilot to the available tools list with detection for existing `.github/prompts/openspec-*.prompt.md` files + - Display "(already configured)" when prompt files exist + +- [x] Update `src/core/update.ts` to refresh GitHub Copilot prompts when they exist + - Call `updateExisting()` for GitHub Copilot configurator when `.github/prompts/` contains OpenSpec prompt files + +- [x] Add integration tests for GitHub Copilot slash command generation + - Test `generateAll()` creates three prompt files with correct structure (frontmatter + markers + body) + - Test `updateExisting()` preserves frontmatter and only updates managed blocks + - Test that missing prompt files are not created during update + +- [x] Update documentation + - Add GitHub Copilot to README slash-command support table + - Document `.github/prompts/` as the discovery location + - Add CHANGELOG entry for GitHub Copilot support diff --git a/src/core/config.ts b/src/core/config.ts index 4547deb0..0c46a432 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -23,5 +23,6 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code' }, { name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf' }, { name: 'Codex', value: 'codex', available: true, successLabel: 'Codex' }, - { name: 'AGENTS.md (works with Amp, VS Code, GitHub Copilot, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' } + { name: 'GitHub Copilot', value: 'github-copilot', available: true, successLabel: 'GitHub Copilot' }, + { name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' } ]; diff --git a/src/core/configurators/slash/github-copilot.ts b/src/core/configurators/slash/github-copilot.ts new file mode 100644 index 00000000..d7792643 --- /dev/null +++ b/src/core/configurators/slash/github-copilot.ts @@ -0,0 +1,39 @@ +import { SlashCommandConfigurator } from './base.js'; +import { SlashCommandId } from '../../templates/index.js'; + +const FILE_PATHS: Record = { + proposal: '.github/prompts/openspec-proposal.prompt.md', + apply: '.github/prompts/openspec-apply.prompt.md', + archive: '.github/prompts/openspec-archive.prompt.md' +}; + +const FRONTMATTER: Record = { + proposal: `--- +description: Scaffold a new OpenSpec change and validate strictly. +--- + +$ARGUMENTS`, + apply: `--- +description: Implement an approved OpenSpec change and keep tasks in sync. +--- + +$ARGUMENTS`, + archive: `--- +description: Archive a deployed OpenSpec change and update specs. +--- + +$ARGUMENTS` +}; + +export class GitHubCopilotSlashCommandConfigurator extends SlashCommandConfigurator { + readonly toolId = 'github-copilot'; + readonly isAvailable = true; + + protected getRelativePath(id: SlashCommandId): string { + return FILE_PATHS[id]; + } + + protected getFrontmatter(id: SlashCommandId): string { + return FRONTMATTER[id]; + } +} diff --git a/src/core/configurators/slash/registry.ts b/src/core/configurators/slash/registry.ts index c9072e71..3d305654 100644 --- a/src/core/configurators/slash/registry.ts +++ b/src/core/configurators/slash/registry.ts @@ -5,6 +5,7 @@ import { WindsurfSlashCommandConfigurator } from './windsurf.js'; import { KiloCodeSlashCommandConfigurator } from './kilocode.js'; import { OpenCodeSlashCommandConfigurator } from './opencode.js'; import { CodexSlashCommandConfigurator } from './codex.js'; +import { GitHubCopilotSlashCommandConfigurator } from './github-copilot.js'; export class SlashCommandRegistry { private static configurators: Map = new Map(); @@ -16,6 +17,7 @@ export class SlashCommandRegistry { const kilocode = new KiloCodeSlashCommandConfigurator(); const opencode = new OpenCodeSlashCommandConfigurator(); const codex = new CodexSlashCommandConfigurator(); + const githubCopilot = new GitHubCopilotSlashCommandConfigurator(); this.configurators.set(claude.toolId, claude); this.configurators.set(cursor.toolId, cursor); @@ -23,6 +25,7 @@ export class SlashCommandRegistry { this.configurators.set(kilocode.toolId, kilocode); this.configurators.set(opencode.toolId, opencode); this.configurators.set(codex.toolId, codex); + this.configurators.set(githubCopilot.toolId, githubCopilot); } static register(configurator: SlashCommandConfigurator): void { diff --git a/test/core/init.test.ts b/test/core/init.test.ts index ace4eba2..27580841 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -388,6 +388,48 @@ describe('InitCommand', () => { expect(archiveContent).not.toContain('---\n'); }); + it('should create GitHub Copilot prompt files with templates', async () => { + queueSelections('github-copilot', DONE); + + await initCommand.execute(testDir); + + const proposalPath = path.join( + testDir, + '.github/prompts/openspec-proposal.prompt.md' + ); + const applyPath = path.join( + testDir, + '.github/prompts/openspec-apply.prompt.md' + ); + const archivePath = path.join( + testDir, + '.github/prompts/openspec-archive.prompt.md' + ); + + expect(await fileExists(proposalPath)).toBe(true); + expect(await fileExists(applyPath)).toBe(true); + expect(await fileExists(archivePath)).toBe(true); + + const proposalContent = await fs.readFile(proposalPath, 'utf-8'); + expect(proposalContent).toContain('---'); + expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.'); + expect(proposalContent).toContain('$ARGUMENTS'); + expect(proposalContent).toContain(''); + expect(proposalContent).toContain('**Guardrails**'); + + const applyContent = await fs.readFile(applyPath, 'utf-8'); + expect(applyContent).toContain('---'); + expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.'); + expect(applyContent).toContain('$ARGUMENTS'); + expect(applyContent).toContain('Work through tasks sequentially'); + + const archiveContent = await fs.readFile(archivePath, 'utf-8'); + expect(archiveContent).toContain('---'); + expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.'); + expect(archiveContent).toContain('$ARGUMENTS'); + expect(archiveContent).toContain('openspec archive --yes'); + }); + it('should add new tool when OpenSpec already exists', async () => { queueSelections('claude', DONE, 'cursor', DONE); await initCommand.execute(testDir); @@ -510,6 +552,18 @@ describe('InitCommand', () => { ); expect(codexChoice.configured).toBe(true); }); + + it('should mark GitHub Copilot as already configured during extend mode', async () => { + queueSelections('github-copilot', DONE, 'github-copilot', DONE); + await initCommand.execute(testDir); + await initCommand.execute(testDir); + + const secondRunArgs = mockPrompt.mock.calls[1][0]; + const githubCopilotChoice = secondRunArgs.choices.find( + (choice: any) => choice.value === 'github-copilot' + ); + expect(githubCopilotChoice.configured).toBe(true); + }); }); describe('error handling', () => { diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 5bcf72ec..3ba69b93 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -311,6 +311,69 @@ Old body await expect(FileSystemUtils.fileExists(codexArchive)).resolves.toBe(false); }); + it('should refresh existing GitHub Copilot prompts', async () => { + const ghPath = path.join( + testDir, + '.github/prompts/openspec-apply.prompt.md' + ); + await fs.mkdir(path.dirname(ghPath), { recursive: true }); + const initialContent = `--- +description: Implement an approved OpenSpec change and keep tasks in sync. +--- + +$ARGUMENTS + +Old body +`; + await fs.writeFile(ghPath, initialContent); + + const consoleSpy = vi.spyOn(console, 'log'); + + await updateCommand.execute(testDir); + + const updated = await fs.readFile(ghPath, 'utf-8'); + expect(updated).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.'); + expect(updated).toContain('$ARGUMENTS'); + expect(updated).toContain('Work through tasks sequentially'); + expect(updated).not.toContain('Old body'); + + const [logMessage] = consoleSpy.mock.calls[0]; + expect(logMessage).toContain( + 'Updated slash commands: .github/prompts/openspec-apply.prompt.md' + ); + + consoleSpy.mockRestore(); + }); + + it('should not create missing GitHub Copilot prompts on update', async () => { + const ghApply = path.join( + testDir, + '.github/prompts/openspec-apply.prompt.md' + ); + + // Only create apply; leave proposal and archive missing + await fs.mkdir(path.dirname(ghApply), { recursive: true }); + await fs.writeFile( + ghApply, + '---\ndescription: Old\n---\n\n$ARGUMENTS\n\nOld\n' + ); + + await updateCommand.execute(testDir); + + const ghProposal = path.join( + testDir, + '.github/prompts/openspec-proposal.prompt.md' + ); + const ghArchive = path.join( + testDir, + '.github/prompts/openspec-archive.prompt.md' + ); + + // Confirm they weren't created by update + await expect(FileSystemUtils.fileExists(ghProposal)).resolves.toBe(false); + await expect(FileSystemUtils.fileExists(ghArchive)).resolves.toBe(false); + }); + it('should preserve Windsurf content outside markers during update', async () => { const wsPath = path.join( testDir,