Skip to content

Commit f27e5e8

Browse files
authored
feat: support global paths for Codex command generation (Fission-AI#622)
* feat: support global paths for Codex command generation Codex custom prompts live in ~/.codex/prompts/ (global, not per-project). Update the Codex adapter to return absolute paths via os.homedir(), handle absolute paths in init/update writers, and update docs and specs to reflect the change. * fix: address review feedback on Codex global paths - Guard against empty CODEX_HOME resolving to CWD by trimming the env var - Loosen test regex to not depend on .codex prefix (resilient to custom CODEX_HOME) - Clarify non-goal wording in design.md to avoid contradictory phrasing
1 parent 6b545f6 commit f27e5e8

8 files changed

Lines changed: 65 additions & 14 deletions

File tree

docs/supported-tools.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ For each tool you select, OpenSpec installs:
1919
| Claude Code | `.claude/skills/` | `.claude/commands/opsx/` |
2020
| Cline | `.cline/skills/` | `.clinerules/workflows/` |
2121
| CodeBuddy | `.codebuddy/skills/` | `.codebuddy/commands/opsx/` |
22-
| Codex | `.codex/skills/` | `.codex/prompts/` |
22+
| Codex | `.codex/skills/` | `~/.codex/prompts/`* |
2323
| Continue | `.continue/skills/` | `.continue/prompts/` |
2424
| CoStrict | `.cospec/skills/` | `.cospec/openspec/commands/` |
2525
| Crush | `.crush/skills/` | `.crush/commands/opsx/` |
@@ -36,6 +36,8 @@ For each tool you select, OpenSpec installs:
3636
| Trae | `.trae/skills/` | `.trae/skills/` (via `/openspec-*`) |
3737
| Windsurf | `.windsurf/skills/` | `.windsurf/workflows/` |
3838

39+
\* Codex commands are installed to the global home directory (`~/.codex/prompts/` or `$CODEX_HOME/prompts/`), not the project directory.
40+
3941
## Non-Interactive Setup
4042

4143
For CI/CD or scripted setup, use the `--tools` flag:

openspec/changes/multi-provider-skill-generation/design.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Each AI tool has:
1818
- Create a generic, extensible command generation system
1919

2020
**Non-Goals:**
21-
- Global path installation (deferred to future work)
21+
- Global path installation for tools other than Codex (Codex uses absolute adapter paths today)
2222
- Multi-tool generation in single command (future enhancement)
2323
- Unifying with existing SlashCommandConfigurator (separate systems for now)
2424

@@ -41,7 +41,7 @@ interface AIToolOption {
4141
**Rationale**:
4242
- Skills follow Agent Skills spec: `<toolDir>/skills/` - suffix is standard
4343
- Commands need per-tool formatting, handled by adapters (not a simple path)
44-
- Global paths deferred - can extend interface later
44+
- Global paths supported — Codex adapter returns absolute paths via os.homedir()
4545

4646
### 2. Strategy/Adapter pattern for command generation
4747

openspec/changes/multi-provider-skill-generation/specs/command-generation/spec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting
3030
- **WHEN** implementing a tool adapter
3131
- **THEN** `ToolCommandAdapter` SHALL require:
3232
- `toolId`: string identifier matching `AIToolOption.value`
33-
- `getFilePath(commandId: string)`: returns relative file path for command
33+
- `getFilePath(commandId: string)`: returns file path for command (relative from project root, or absolute for global-scoped tools like Codex)
3434
- `formatFile(content: CommandContent)`: returns complete file content with frontmatter
3535

3636
#### Scenario: Claude adapter formatting

src/core/command-generation/adapters/codex.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,34 @@
22
* Codex Command Adapter
33
*
44
* Formats commands for Codex following its frontmatter specification.
5+
* Codex custom prompts live in the global home directory (~/.codex/prompts/)
6+
* and are not shared through the repository. The CODEX_HOME env var can
7+
* override the default ~/.codex location.
58
*/
69

10+
import os from 'os';
711
import path from 'path';
812
import type { CommandContent, ToolCommandAdapter } from '../types.js';
913

14+
/**
15+
* Returns the Codex home directory.
16+
* Respects the CODEX_HOME env var, defaulting to ~/.codex.
17+
*/
18+
function getCodexHome(): string {
19+
const envHome = process.env.CODEX_HOME?.trim();
20+
return path.resolve(envHome ? envHome : path.join(os.homedir(), '.codex'));
21+
}
22+
1023
/**
1124
* Codex adapter for command generation.
12-
* File path: .codex/prompts/opsx-<id>.md
25+
* File path: <CODEX_HOME>/prompts/opsx-<id>.md (absolute, global)
1326
* Frontmatter: description, argument-hint
1427
*/
1528
export const codexAdapter: ToolCommandAdapter = {
1629
toolId: 'codex',
1730

1831
getFilePath(commandId: string): string {
19-
return path.join('.codex', 'prompts', `opsx-${commandId}.md`);
32+
return path.join(getCodexHome(), 'prompts', `opsx-${commandId}.md`);
2033
},
2134

2235
formatFile(content: CommandContent): string {

src/core/command-generation/types.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,10 @@ export interface ToolCommandAdapter {
3333
/** Tool identifier matching AIToolOption.value (e.g., 'claude', 'cursor') */
3434
toolId: string;
3535
/**
36-
* Returns the relative file path for a command.
36+
* Returns the file path for a command.
3737
* @param commandId - The command identifier (e.g., 'explore')
38-
* @returns Relative path from project root (e.g., '.claude/commands/opsx/explore.md')
38+
* @returns Path from project root (e.g., '.claude/commands/opsx/explore.md').
39+
* May be absolute for tools with global-scoped prompts (e.g., Codex).
3940
*/
4041
getFilePath(commandId: string): string;
4142
/**
@@ -50,7 +51,7 @@ export interface ToolCommandAdapter {
5051
* Result of generating a command file.
5152
*/
5253
export interface GeneratedCommand {
53-
/** Relative file path from project root */
54+
/** File path from project root, or absolute for global-scoped tools */
5455
path: string;
5556
/** Complete file content (frontmatter + body) */
5657
fileContent: string;

src/core/init.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ export class InitCommand {
452452
const generatedCommands = generateCommands(commandContents, adapter);
453453

454454
for (const cmd of generatedCommands) {
455-
const commandFile = path.join(projectPath, cmd.path);
455+
const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);
456456
await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
457457
}
458458
} else {

src/core/update.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ export class UpdateCommand {
127127
const generatedCommands = generateCommands(commandContents, adapter);
128128

129129
for (const cmd of generatedCommands) {
130-
const commandFile = path.join(resolvedProjectPath, cmd.path);
130+
const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path);
131131
await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
132132
}
133133
}
@@ -376,7 +376,7 @@ export class UpdateCommand {
376376
const generatedCommands = generateCommands(commandContents, adapter);
377377

378378
for (const cmd of generatedCommands) {
379-
const commandFile = path.join(projectPath, cmd.path);
379+
const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path);
380380
await FileSystemUtils.writeFile(commandFile, cmd.fileContent);
381381
}
382382
}

test/core/command-generation/adapters.test.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2+
import os from 'os';
23
import path from 'path';
34
import { amazonQAdapter } from '../../../src/core/command-generation/adapters/amazon-q.js';
45
import { antigravityAdapter } from '../../../src/core/command-generation/adapters/antigravity.js';
@@ -205,9 +206,43 @@ describe('command-generation/adapters', () => {
205206
expect(codexAdapter.toolId).toBe('codex');
206207
});
207208

208-
it('should generate correct file path', () => {
209+
it('should return an absolute path', () => {
210+
const filePath = codexAdapter.getFilePath('explore');
211+
expect(path.isAbsolute(filePath)).toBe(true);
212+
});
213+
214+
it('should generate path ending with correct structure', () => {
209215
const filePath = codexAdapter.getFilePath('explore');
210-
expect(filePath).toBe(path.join('.codex', 'prompts', 'opsx-explore.md'));
216+
expect(filePath).toMatch(/prompts[/\\]opsx-explore\.md$/);
217+
});
218+
219+
it('should default to homedir/.codex', () => {
220+
const original = process.env.CODEX_HOME;
221+
delete process.env.CODEX_HOME;
222+
try {
223+
const filePath = codexAdapter.getFilePath('explore');
224+
const expected = path.join(os.homedir(), '.codex', 'prompts', 'opsx-explore.md');
225+
expect(filePath).toBe(expected);
226+
} finally {
227+
if (original !== undefined) {
228+
process.env.CODEX_HOME = original;
229+
}
230+
}
231+
});
232+
233+
it('should respect CODEX_HOME env var', () => {
234+
const original = process.env.CODEX_HOME;
235+
process.env.CODEX_HOME = '/custom/codex-home';
236+
try {
237+
const filePath = codexAdapter.getFilePath('explore');
238+
expect(filePath).toBe(path.join('/custom/codex-home', 'prompts', 'opsx-explore.md'));
239+
} finally {
240+
if (original !== undefined) {
241+
process.env.CODEX_HOME = original;
242+
} else {
243+
delete process.env.CODEX_HOME;
244+
}
245+
}
211246
});
212247

213248
it('should format file with description and argument-hint', () => {

0 commit comments

Comments
 (0)