Compare commits

..
Author SHA1 Message Date
Tabish Bidiwale 91941243a3 docs: remove GitHub Copilot from tools list 2025-10-09 02:18:27 +11:00
Tabish Bidiwale 8822557bcd feat: add GitHub Copilot slash command support
Add GitHub Copilot as a natively supported AI tool with custom slash
commands for OpenSpec workflow operations. This enables teams using
GitHub Copilot to access /openspec-proposal, /openspec-apply, and
/openspec-archive directly from Copilot's chat interface.

Implementation:
- Create GitHubCopilotSlashCommandConfigurator that writes prompts to
  .github/prompts/ directory with YAML frontmatter and $ARGUMENTS
  placeholder following GitHub Copilot's prompt format
- Register GitHub Copilot in AI_TOOLS configuration and slash
  command registry for automatic init/update integration
- Add comprehensive test coverage for prompt generation, updates,
  and extend mode detection
- Update documentation (README and CHANGELOG) to include GitHub
  Copilot in the slash-command support table

The implementation follows the existing SlashCommandConfigurator
pattern and integrates seamlessly with openspec init and openspec
update commands.
2025-10-09 02:15:14 +11:00
10 changed files with 33 additions and 168 deletions
-12
View File
@@ -1,17 +1,5 @@
# @fission-ai/openspec
## 0.9.1
### Patch Changes
- 8210970: Fix OpenSpec not working on Windows when Codex integration is selected. This release includes fixes for cross-platform path handling and normalization to ensure OpenSpec works correctly on Windows systems.
## 0.9.0
### Minor Changes
- efbbf3b: Add support for Codex and GitHub Copilot slash commands with YAML frontmatter and $ARGUMENTS
## Unreleased
### Minor Changes
@@ -1,13 +1,13 @@
## Why
- Codex (the VS Code extension formerly known as Codeium Chat) exposes "slash commands" by reading Markdown prompt files from `~/.codex/prompts/`. Each file name becomes the `/command` users can run, with YAML frontmatter for metadata (`description`, `argument-hint`) and `$ARGUMENTS` to capture user input. The workflow screenshot shared by Kevin Kern ("Codex problem analyzer") shows the format OpenSpec should target so teams can invoke curated workflows straight from the chat palette.
- Codex (the VS Code extension formerly known as Codeium Chat) exposes "slash commands" by reading Markdown prompt files from `~/.codex/prompts/`. Each file name becomes the `/command` users can run, with numbered placeholders (`$1`, `$2`, …) bound to the arguments they supply. The workflow screenshot shared by Kevin Kern ("Codex problem analyzer") shows the format OpenSpec should target so teams can invoke curated workflows straight from the chat palette.
- Teams already rely on OpenSpec to manage the slash-command surface area for Claude, Cursor, OpenCode, Kilo Code, and Windsurf. Leaving Codex out forces them to manually copy/paste OpenSpec guardrails into `~/.codex/prompts/*.md`, which drifts quickly and undermines the "single source of truth" promise of the CLI.
- Codex commands live outside the repository (under the user's home directory), so shipping an automated configurator that both scaffolds the prompts and keeps them refreshed via `openspec update` eliminates error-prone manual steps and keeps OpenSpec instructions synchronized across assistants.
## What Changes
- Add Codex to the `openspec init` tool picker with the same "already configured" detection we use for other editors, wiring an implementation that writes managed Markdown prompts directly to Codex's global directory (`~/.codex/prompts` or `$CODEX_HOME/prompts`) with OpenSpec marker blocks.
- Produce three Codex prompt files—`openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`—whose content mirrors the shared slash-command templates while using YAML frontmatter (`description` and `argument-hint` fields) and `$ARGUMENTS` to capture all arguments as a single string (matching the GitHub Copilot pattern and official Codex specification).
- Produce three Codex prompt files—`openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md`—whose content mirrors the shared slash-command templates while adapting to Codex's numbered argument placeholders (e.g., `$1` for the change identifier or follow-up question text).
- Document Codex's global-only discovery and that OpenSpec writes prompts directly to `~/.codex/prompts` (or `$CODEX_HOME/prompts`).
- Teach `openspec update` to refresh existing Codex prompts in-place (and only when they already exist) in the global directory, updating both frontmatter and body.
- Teach `openspec update` to refresh existing Codex prompts in-place (and only when they already exist) in the global directory.
- Document Codex support alongside other slash-command integrations and add regression coverage that exercises init/update behaviour against a temporary global prompts directory via `CODEX_HOME`.
## Impact
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@fission-ai/openspec",
"version": "0.9.1",
"version": "0.8.1",
"description": "AI-native system for spec-driven development",
"keywords": [
"openspec",
+4 -3
View File
@@ -1,3 +1,4 @@
import path from 'path';
import { FileSystemUtils } from '../../../utils/file-system.js';
import { TemplateManager, SlashCommandId } from '../../templates/index.js';
import { OPENSPEC_MARKERS } from '../../config.js';
@@ -27,7 +28,7 @@ export abstract class SlashCommandConfigurator {
for (const target of this.getTargets()) {
const body = TemplateManager.getSlashCommandBody(target.id).trim();
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
const filePath = path.join(projectPath, target.path);
if (await FileSystemUtils.fileExists(filePath)) {
await this.updateBody(filePath, body);
@@ -52,7 +53,7 @@ export abstract class SlashCommandConfigurator {
const updated: string[] = [];
for (const target of this.getTargets()) {
const filePath = FileSystemUtils.joinPath(projectPath, target.path);
const filePath = path.join(projectPath, target.path);
if (await FileSystemUtils.fileExists(filePath)) {
const body = TemplateManager.getSlashCommandBody(target.id).trim();
await this.updateBody(filePath, body);
@@ -70,7 +71,7 @@ export abstract class SlashCommandConfigurator {
// to redirect to tool-specific locations (e.g., global directories).
resolveAbsolutePath(projectPath: string, id: SlashCommandId): string {
const rel = this.getRelativePath(id);
return FileSystemUtils.joinPath(projectPath, rel);
return path.join(projectPath, rel);
}
protected async updateBody(filePath: string, body: string): Promise<void> {
+16 -56
View File
@@ -5,7 +5,6 @@ import { SlashCommandId, TemplateManager } from "../../templates/index.js";
import { FileSystemUtils } from "../../../utils/file-system.js";
import { OPENSPEC_MARKERS } from "../../config.js";
// Use POSIX-style paths for consistent logging across platforms.
const FILE_PATHS: Record<SlashCommandId, string> = {
proposal: ".codex/prompts/openspec-proposal.md",
apply: ".codex/prompts/openspec-apply.md",
@@ -21,36 +20,21 @@ export class CodexSlashCommandConfigurator extends SlashCommandConfigurator {
}
protected getFrontmatter(id: SlashCommandId): string | undefined {
// Codex supports YAML frontmatter with description and argument-hint fields,
// plus $ARGUMENTS to capture all arguments as a single string.
const frontmatter: Record<SlashCommandId, string> = {
proposal: `---
description: Scaffold a new OpenSpec change and validate strictly.
argument-hint: request or feature description
---
$ARGUMENTS`,
apply: `---
description: Implement an approved OpenSpec change and keep tasks in sync.
argument-hint: change-id
---
$ARGUMENTS`,
archive: `---
description: Archive a deployed OpenSpec change and update specs.
argument-hint: change-id
---
$ARGUMENTS`,
// Codex does not use YAML front matter. Provide a heading-style
// preface that captures the first numbered placeholder `$1`.
const headers: Record<SlashCommandId, string> = {
proposal: "Request: $1",
apply: "Change ID: $1",
archive: "Change ID: $1",
};
return frontmatter[id];
return headers[id];
}
private getGlobalPromptsDir(): string {
const home = (process.env.CODEX_HOME && process.env.CODEX_HOME.trim())
? process.env.CODEX_HOME.trim()
: FileSystemUtils.joinPath(os.homedir(), ".codex");
return FileSystemUtils.joinPath(home, "prompts");
: path.join(os.homedir(), ".codex");
return path.join(home, "prompts");
}
// Codex discovers prompts globally. Generate directly in the global directory
@@ -60,19 +44,16 @@ $ARGUMENTS`,
for (const target of this.getTargets()) {
const body = TemplateManager.getSlashCommandBody(target.id).trim();
const promptsDir = this.getGlobalPromptsDir();
const filePath = FileSystemUtils.joinPath(
promptsDir,
path.basename(target.path)
);
const filePath = path.join(promptsDir, path.basename(target.path));
await FileSystemUtils.createDirectory(path.dirname(filePath));
if (await FileSystemUtils.fileExists(filePath)) {
await this.updateFullFile(filePath, target.id, body);
await this.updateBody(filePath, body);
} else {
const frontmatter = this.getFrontmatter(target.id);
const header = this.getFrontmatter(target.id);
const sections: string[] = [];
if (frontmatter) sections.push(frontmatter.trim());
if (header) sections.push(header.trim());
sections.push(`${OPENSPEC_MARKERS.start}\n${body}\n${OPENSPEC_MARKERS.end}`);
await FileSystemUtils.writeFile(filePath, sections.join("\n") + "\n");
}
@@ -86,41 +67,20 @@ $ARGUMENTS`,
const updated: string[] = [];
for (const target of this.getTargets()) {
const promptsDir = this.getGlobalPromptsDir();
const filePath = FileSystemUtils.joinPath(
promptsDir,
path.basename(target.path)
);
const filePath = path.join(promptsDir, path.basename(target.path));
if (await FileSystemUtils.fileExists(filePath)) {
const body = TemplateManager.getSlashCommandBody(target.id).trim();
await this.updateFullFile(filePath, target.id, body);
await this.updateBody(filePath, body);
updated.push(target.path);
}
}
return updated;
}
// Update both frontmatter and body in an existing file
private async updateFullFile(filePath: string, id: SlashCommandId, body: string): Promise<void> {
const content = await FileSystemUtils.readFile(filePath);
const startIndex = content.indexOf(OPENSPEC_MARKERS.start);
if (startIndex === -1) {
throw new Error(`Missing OpenSpec start marker in ${filePath}`);
}
// Replace everything before the start marker with the new frontmatter
const frontmatter = this.getFrontmatter(id);
const sections: string[] = [];
if (frontmatter) sections.push(frontmatter.trim());
sections.push(`${OPENSPEC_MARKERS.start}\n${body}\n${OPENSPEC_MARKERS.end}`);
await FileSystemUtils.writeFile(filePath, sections.join("\n") + "\n");
}
// Resolve to the global prompts location for configuration detection
resolveAbsolutePath(_projectPath: string, id: SlashCommandId): string {
const promptsDir = this.getGlobalPromptsDir();
const fileName = path.basename(FILE_PATHS[id]);
return FileSystemUtils.joinPath(promptsDir, fileName);
return path.join(promptsDir, fileName);
}
}
+3 -3
View File
@@ -106,9 +106,9 @@ export class UpdateCommand {
}
if (updatedSlashFiles.length > 0) {
// Normalize to forward slashes for cross-platform log consistency
const normalized = updatedSlashFiles.map((p) => p.replace(/\\/g, '/'));
summaryParts.push(`Updated slash commands: ${normalized.join(', ')}`);
summaryParts.push(
`Updated slash commands: ${updatedSlashFiles.join(', ')}`
);
}
const failedItems = [
-27
View File
@@ -42,33 +42,6 @@ function findMarkerIndex(
}
export class FileSystemUtils {
private static isWindowsBasePath(basePath: string): boolean {
return /^[A-Za-z]:[\\/]/.test(basePath) || basePath.startsWith('\\');
}
private static normalizeSegments(segments: string[]): string[] {
return segments
.flatMap((segment) => segment.split(/[\\/]+/u))
.filter((part) => part.length > 0);
}
static joinPath(basePath: string, ...segments: string[]): string {
const normalizedSegments = this.normalizeSegments(segments);
if (this.isWindowsBasePath(basePath)) {
const normalizedBasePath = path.win32.normalize(basePath);
return normalizedSegments.length
? path.win32.join(normalizedBasePath, ...normalizedSegments)
: normalizedBasePath;
}
const posixBasePath = basePath.replace(/\\/g, '/');
return normalizedSegments.length
? path.posix.join(posixBasePath, ...normalizedSegments)
: path.posix.normalize(posixBasePath);
}
static async createDirectory(dirPath: string): Promise<void> {
await fs.mkdir(dirPath, { recursive: true });
}
+3 -9
View File
@@ -339,22 +339,16 @@ describe('InitCommand', () => {
expect(await fileExists(archivePath)).toBe(true);
const proposalContent = await fs.readFile(proposalPath, 'utf-8');
expect(proposalContent).toContain('description: Scaffold a new OpenSpec change and validate strictly.');
expect(proposalContent).toContain('argument-hint: request or feature description');
expect(proposalContent).toContain('$ARGUMENTS');
expect(proposalContent).toContain('Request: $1');
expect(proposalContent).toContain('<!-- OPENSPEC:START -->');
expect(proposalContent).toContain('**Guardrails**');
const applyContent = await fs.readFile(applyPath, 'utf-8');
expect(applyContent).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
expect(applyContent).toContain('argument-hint: change-id');
expect(applyContent).toContain('$ARGUMENTS');
expect(applyContent).toContain('Change ID: $1');
expect(applyContent).toContain('Work through tasks sequentially');
const archiveContent = await fs.readFile(archivePath, 'utf-8');
expect(archiveContent).toContain('description: Archive a deployed OpenSpec change and update specs.');
expect(archiveContent).toContain('argument-hint: change-id');
expect(archiveContent).toContain('$ARGUMENTS');
expect(archiveContent).toContain('Change ID: $1');
expect(archiveContent).toContain('openspec archive <id> --yes');
});
+3 -6
View File
@@ -262,7 +262,7 @@ Old body
'.codex/prompts/openspec-apply.md'
);
await fs.mkdir(path.dirname(codexPath), { recursive: true });
const initialContent = `---\ndescription: Old description\nargument-hint: old-hint\n---\n\n$ARGUMENTS\n<!-- OPENSPEC:START -->\nOld body\n<!-- OPENSPEC:END -->`;
const initialContent = `Change ID: $1\n<!-- OPENSPEC:START -->\nOld body\n<!-- OPENSPEC:END -->`;
await fs.writeFile(codexPath, initialContent);
const consoleSpy = vi.spyOn(console, 'log');
@@ -270,12 +270,9 @@ Old body
await updateCommand.execute(testDir);
const updated = await fs.readFile(codexPath, 'utf-8');
expect(updated).toContain('description: Implement an approved OpenSpec change and keep tasks in sync.');
expect(updated).toContain('argument-hint: change-id');
expect(updated).toContain('$ARGUMENTS');
expect(updated).toContain('Change ID: $1');
expect(updated).toContain('Work through tasks sequentially');
expect(updated).not.toContain('Old body');
expect(updated).not.toContain('Old description');
const [logMessage] = consoleSpy.mock.calls[0];
expect(logMessage).toContain(
@@ -295,7 +292,7 @@ Old body
await fs.mkdir(path.dirname(codexApply), { recursive: true });
await fs.writeFile(
codexApply,
'---\ndescription: Old\nargument-hint: old\n---\n\n$ARGUMENTS\n<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
'Change ID: $1\n<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
);
await updateCommand.execute(testDir);
-48
View File
@@ -160,52 +160,4 @@ describe('FileSystemUtils', () => {
expect(hasPermission).toBe(true);
});
});
describe('joinPath', () => {
it('should join POSIX-style paths', () => {
const result = FileSystemUtils.joinPath(
'/tmp/project',
'.claude/commands/openspec/proposal.md'
);
expect(result).toBe('/tmp/project/.claude/commands/openspec/proposal.md');
});
it('should join Linux home directory paths', () => {
const result = FileSystemUtils.joinPath(
'/home/dev/workspace/openspec',
'.cursor/commands/install.md'
);
expect(result).toBe('/home/dev/workspace/openspec/.cursor/commands/install.md');
});
it('should join Windows drive-letter paths with backslashes', () => {
const result = FileSystemUtils.joinPath(
'C:\\Users\\dev\\project',
'.claude/commands/openspec/proposal.md'
);
expect(result).toBe(
'C:\\Users\\dev\\project\\.claude\\commands\\openspec\\proposal.md'
);
});
it('should join Windows paths that use forward slashes', () => {
const result = FileSystemUtils.joinPath(
'D:/workspace/app',
'.cursor/commands/openspec-apply.md'
);
expect(result).toBe(
'D:\\workspace\\app\\.cursor\\commands\\openspec-apply.md'
);
});
it('should join UNC-style Windows paths', () => {
const result = FileSystemUtils.joinPath(
'\\server\\share\\repo',
'.windsurf/workflows/openspec-archive.md'
);
expect(result).toBe(
'\\server\\share\\repo\\.windsurf\\workflows\\openspec-archive.md'
);
});
});
});