diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 389cfc77..2f9b6152 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -19,7 +19,7 @@ For each tool you select, OpenSpec installs: | Claude Code | `.claude/skills/` | `.claude/commands/opsx/` | | Cline | `.cline/skills/` | `.clinerules/workflows/` | | CodeBuddy | `.codebuddy/skills/` | `.codebuddy/commands/opsx/` | -| Codex | `.codex/skills/` | `.codex/prompts/` | +| Codex | `.codex/skills/` | `~/.codex/prompts/`* | | Continue | `.continue/skills/` | `.continue/prompts/` | | CoStrict | `.cospec/skills/` | `.cospec/openspec/commands/` | | Crush | `.crush/skills/` | `.crush/commands/opsx/` | @@ -36,6 +36,8 @@ For each tool you select, OpenSpec installs: | Trae | `.trae/skills/` | `.trae/skills/` (via `/openspec-*`) | | Windsurf | `.windsurf/skills/` | `.windsurf/workflows/` | +\* Codex commands are installed to the global home directory (`~/.codex/prompts/` or `$CODEX_HOME/prompts/`), not the project directory. + ## Non-Interactive Setup For CI/CD or scripted setup, use the `--tools` flag: diff --git a/openspec/changes/multi-provider-skill-generation/design.md b/openspec/changes/multi-provider-skill-generation/design.md index bdf87183..c870aeae 100644 --- a/openspec/changes/multi-provider-skill-generation/design.md +++ b/openspec/changes/multi-provider-skill-generation/design.md @@ -18,7 +18,7 @@ Each AI tool has: - Create a generic, extensible command generation system **Non-Goals:** -- Global path installation (deferred to future work) +- Global path installation for tools other than Codex (Codex uses absolute adapter paths today) - Multi-tool generation in single command (future enhancement) - Unifying with existing SlashCommandConfigurator (separate systems for now) @@ -41,7 +41,7 @@ interface AIToolOption { **Rationale**: - Skills follow Agent Skills spec: `/skills/` - suffix is standard - Commands need per-tool formatting, handled by adapters (not a simple path) -- Global paths deferred - can extend interface later +- Global paths supported — Codex adapter returns absolute paths via os.homedir() ### 2. Strategy/Adapter pattern for command generation diff --git a/openspec/changes/multi-provider-skill-generation/specs/command-generation/spec.md b/openspec/changes/multi-provider-skill-generation/specs/command-generation/spec.md index ce538282..26e8602c 100644 --- a/openspec/changes/multi-provider-skill-generation/specs/command-generation/spec.md +++ b/openspec/changes/multi-provider-skill-generation/specs/command-generation/spec.md @@ -30,7 +30,7 @@ The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting - **WHEN** implementing a tool adapter - **THEN** `ToolCommandAdapter` SHALL require: - `toolId`: string identifier matching `AIToolOption.value` - - `getFilePath(commandId: string)`: returns relative file path for command + - `getFilePath(commandId: string)`: returns file path for command (relative from project root, or absolute for global-scoped tools like Codex) - `formatFile(content: CommandContent)`: returns complete file content with frontmatter #### Scenario: Claude adapter formatting diff --git a/src/core/command-generation/adapters/codex.ts b/src/core/command-generation/adapters/codex.ts index 1593dff2..64e73550 100644 --- a/src/core/command-generation/adapters/codex.ts +++ b/src/core/command-generation/adapters/codex.ts @@ -2,21 +2,34 @@ * Codex Command Adapter * * Formats commands for Codex following its frontmatter specification. + * Codex custom prompts live in the global home directory (~/.codex/prompts/) + * and are not shared through the repository. The CODEX_HOME env var can + * override the default ~/.codex location. */ +import os from 'os'; import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +/** + * Returns the Codex home directory. + * Respects the CODEX_HOME env var, defaulting to ~/.codex. + */ +function getCodexHome(): string { + const envHome = process.env.CODEX_HOME?.trim(); + return path.resolve(envHome ? envHome : path.join(os.homedir(), '.codex')); +} + /** * Codex adapter for command generation. - * File path: .codex/prompts/opsx-.md + * File path: /prompts/opsx-.md (absolute, global) * Frontmatter: description, argument-hint */ export const codexAdapter: ToolCommandAdapter = { toolId: 'codex', getFilePath(commandId: string): string { - return path.join('.codex', 'prompts', `opsx-${commandId}.md`); + return path.join(getCodexHome(), 'prompts', `opsx-${commandId}.md`); }, formatFile(content: CommandContent): string { diff --git a/src/core/command-generation/types.ts b/src/core/command-generation/types.ts index 96a74d3b..582d8c78 100644 --- a/src/core/command-generation/types.ts +++ b/src/core/command-generation/types.ts @@ -33,9 +33,10 @@ export interface ToolCommandAdapter { /** Tool identifier matching AIToolOption.value (e.g., 'claude', 'cursor') */ toolId: string; /** - * Returns the relative file path for a command. + * Returns the file path for a command. * @param commandId - The command identifier (e.g., 'explore') - * @returns Relative path from project root (e.g., '.claude/commands/opsx/explore.md') + * @returns Path from project root (e.g., '.claude/commands/opsx/explore.md'). + * May be absolute for tools with global-scoped prompts (e.g., Codex). */ getFilePath(commandId: string): string; /** @@ -50,7 +51,7 @@ export interface ToolCommandAdapter { * Result of generating a command file. */ export interface GeneratedCommand { - /** Relative file path from project root */ + /** File path from project root, or absolute for global-scoped tools */ path: string; /** Complete file content (frontmatter + body) */ fileContent: string; diff --git a/src/core/init.ts b/src/core/init.ts index a1f9f3cc..e4df6df3 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -452,7 +452,7 @@ export class InitCommand { const generatedCommands = generateCommands(commandContents, adapter); for (const cmd of generatedCommands) { - const commandFile = path.join(projectPath, cmd.path); + const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path); await FileSystemUtils.writeFile(commandFile, cmd.fileContent); } } else { diff --git a/src/core/update.ts b/src/core/update.ts index b395409c..6538bc0a 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -127,7 +127,7 @@ export class UpdateCommand { const generatedCommands = generateCommands(commandContents, adapter); for (const cmd of generatedCommands) { - const commandFile = path.join(resolvedProjectPath, cmd.path); + const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path); await FileSystemUtils.writeFile(commandFile, cmd.fileContent); } } @@ -376,7 +376,7 @@ export class UpdateCommand { const generatedCommands = generateCommands(commandContents, adapter); for (const cmd of generatedCommands) { - const commandFile = path.join(projectPath, cmd.path); + const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path); await FileSystemUtils.writeFile(commandFile, cmd.fileContent); } } diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index 1360f61c..1b0c94d3 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import os from 'os'; import path from 'path'; import { amazonQAdapter } from '../../../src/core/command-generation/adapters/amazon-q.js'; import { antigravityAdapter } from '../../../src/core/command-generation/adapters/antigravity.js'; @@ -205,9 +206,43 @@ describe('command-generation/adapters', () => { expect(codexAdapter.toolId).toBe('codex'); }); - it('should generate correct file path', () => { + it('should return an absolute path', () => { + const filePath = codexAdapter.getFilePath('explore'); + expect(path.isAbsolute(filePath)).toBe(true); + }); + + it('should generate path ending with correct structure', () => { const filePath = codexAdapter.getFilePath('explore'); - expect(filePath).toBe(path.join('.codex', 'prompts', 'opsx-explore.md')); + expect(filePath).toMatch(/prompts[/\\]opsx-explore\.md$/); + }); + + it('should default to homedir/.codex', () => { + const original = process.env.CODEX_HOME; + delete process.env.CODEX_HOME; + try { + const filePath = codexAdapter.getFilePath('explore'); + const expected = path.join(os.homedir(), '.codex', 'prompts', 'opsx-explore.md'); + expect(filePath).toBe(expected); + } finally { + if (original !== undefined) { + process.env.CODEX_HOME = original; + } + } + }); + + it('should respect CODEX_HOME env var', () => { + const original = process.env.CODEX_HOME; + process.env.CODEX_HOME = '/custom/codex-home'; + try { + const filePath = codexAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('/custom/codex-home', 'prompts', 'opsx-explore.md')); + } finally { + if (original !== undefined) { + process.env.CODEX_HOME = original; + } else { + delete process.env.CODEX_HOME; + } + } }); it('should format file with description and argument-hint', () => {