diff --git a/openspec/changes/add-codex-slash-command-support/proposal.md b/openspec/changes/add-codex-slash-command-support/proposal.md index 7c439d96..8e2a1169 100644 --- a/openspec/changes/add-codex-slash-command-support/proposal.md +++ b/openspec/changes/add-codex-slash-command-support/proposal.md @@ -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 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. +- 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. - 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 adapting to Codex's numbered argument placeholders (e.g., `$1` for the change identifier or follow-up question text). +- 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). - 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. +- 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. - 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 diff --git a/src/core/configurators/slash/codex.ts b/src/core/configurators/slash/codex.ts index b1c82f32..cf523601 100644 --- a/src/core/configurators/slash/codex.ts +++ b/src/core/configurators/slash/codex.ts @@ -20,14 +20,29 @@ export class CodexSlashCommandConfigurator extends SlashCommandConfigurator { } protected getFrontmatter(id: SlashCommandId): string | undefined { - // Codex does not use YAML front matter. Provide a heading-style - // preface that captures the first numbered placeholder `$1`. - const headers: Record = { - proposal: "Request: $1", - apply: "Change ID: $1", - archive: "Change ID: $1", + // Codex supports YAML frontmatter with description and argument-hint fields, + // plus $ARGUMENTS to capture all arguments as a single string. + const frontmatter: Record = { + 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`, }; - return headers[id]; + return frontmatter[id]; } private getGlobalPromptsDir(): string { @@ -49,11 +64,11 @@ export class CodexSlashCommandConfigurator extends SlashCommandConfigurator { await FileSystemUtils.createDirectory(path.dirname(filePath)); if (await FileSystemUtils.fileExists(filePath)) { - await this.updateBody(filePath, body); + await this.updateFullFile(filePath, target.id, body); } else { - const header = this.getFrontmatter(target.id); + const frontmatter = this.getFrontmatter(target.id); const sections: string[] = []; - if (header) sections.push(header.trim()); + 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"); } @@ -70,13 +85,31 @@ export class CodexSlashCommandConfigurator extends SlashCommandConfigurator { const filePath = path.join(promptsDir, path.basename(target.path)); if (await FileSystemUtils.fileExists(filePath)) { const body = TemplateManager.getSlashCommandBody(target.id).trim(); - await this.updateBody(filePath, body); + await this.updateFullFile(filePath, target.id, 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 { + 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(); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 27580841..52f5a8c7 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -339,16 +339,22 @@ describe('InitCommand', () => { expect(await fileExists(archivePath)).toBe(true); const proposalContent = await fs.readFile(proposalPath, 'utf-8'); - expect(proposalContent).toContain('Request: $1'); + 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(''); expect(proposalContent).toContain('**Guardrails**'); const applyContent = await fs.readFile(applyPath, 'utf-8'); - expect(applyContent).toContain('Change ID: $1'); + 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('Work through tasks sequentially'); const archiveContent = await fs.readFile(archivePath, 'utf-8'); - expect(archiveContent).toContain('Change ID: $1'); + 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('openspec archive --yes'); }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 3ba69b93..d5ddf4e5 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -262,7 +262,7 @@ Old body '.codex/prompts/openspec-apply.md' ); await fs.mkdir(path.dirname(codexPath), { recursive: true }); - const initialContent = `Change ID: $1\n\nOld body\n`; + const initialContent = `---\ndescription: Old description\nargument-hint: old-hint\n---\n\n$ARGUMENTS\n\nOld body\n`; await fs.writeFile(codexPath, initialContent); const consoleSpy = vi.spyOn(console, 'log'); @@ -270,9 +270,12 @@ Old body await updateCommand.execute(testDir); const updated = await fs.readFile(codexPath, 'utf-8'); - expect(updated).toContain('Change ID: $1'); + 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('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( @@ -292,7 +295,7 @@ Old body await fs.mkdir(path.dirname(codexApply), { recursive: true }); await fs.writeFile( codexApply, - 'Change ID: $1\n\nOld\n' + '---\ndescription: Old\nargument-hint: old\n---\n\n$ARGUMENTS\n\nOld\n' ); await updateCommand.execute(testDir);