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
Original file line number Diff line number Diff line change
@@ -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
Expand Down
55 changes: 44 additions & 11 deletions src/core/configurators/slash/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SlashCommandId, string> = {
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<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`,
};
return headers[id];
return frontmatter[id];
}

private getGlobalPromptsDir(): string {
Expand All @@ -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");
}
Expand All @@ -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<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();
Expand Down
12 changes: 9 additions & 3 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<!-- OPENSPEC:START -->');
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 <id> --yes');
});

Expand Down
9 changes: 6 additions & 3 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,17 +262,20 @@ Old body
'.codex/prompts/openspec-apply.md'
);
await fs.mkdir(path.dirname(codexPath), { recursive: true });
const initialContent = `Change ID: $1\n<!-- OPENSPEC:START -->\nOld body\n<!-- OPENSPEC:END -->`;
const initialContent = `---\ndescription: Old description\nargument-hint: old-hint\n---\n\n$ARGUMENTS\n<!-- OPENSPEC:START -->\nOld body\n<!-- OPENSPEC:END -->`;
await fs.writeFile(codexPath, initialContent);

const consoleSpy = vi.spyOn(console, 'log');

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(
Expand All @@ -292,7 +295,7 @@ Old body
await fs.mkdir(path.dirname(codexApply), { recursive: true });
await fs.writeFile(
codexApply,
'Change ID: $1\n<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
'---\ndescription: Old\nargument-hint: old\n---\n\n$ARGUMENTS\n<!-- OPENSPEC:START -->\nOld\n<!-- OPENSPEC:END -->'
);

await updateCommand.execute(testDir);
Expand Down
Loading