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
4 changes: 3 additions & 1 deletion docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/` |
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions openspec/changes/multi-provider-skill-generation/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -41,7 +41,7 @@ interface AIToolOption {
**Rationale**:
- Skills follow Agent Skills spec: `<toolDir>/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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions src/core/command-generation/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>.md
* File path: <CODEX_HOME>/prompts/opsx-<id>.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 {
Expand Down
7 changes: 4 additions & 3 deletions src/core/command-generation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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);
}
}
Expand Down
39 changes: 37 additions & 2 deletions test/core/command-generation/adapters.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading