Skip to content
Closed
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
8 changes: 8 additions & 0 deletions .changeset/add-oh-my-pi-tool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@fission-ai/openspec": patch
---

Add Oh My Pi (`omp`) as a supported tool. Oh My Pi discovers Agent Skills from
`.omp/skills` (`/skill:<name>`) and file-based slash commands from `.omp/commands`
(`/opsx-<name>`), so it registers with `skillsDir: '.omp'` plus a command adapter
that writes `.omp/commands/opsx-<id>.md`.
3 changes: 2 additions & 1 deletion docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch
| Kiro (`kiro`) | `.kiro/skills/openspec-*/SKILL.md` | `.kiro/prompts/opsx-<id>.prompt.md` |
| Lingma (`lingma`) | `.lingma/skills/openspec-*/SKILL.md` | `.lingma/commands/opsx/<id>.md` |
| Mistral Vibe (`vibe`) | `.vibe/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) |
| Oh My Pi (`omp`) | `.omp/skills/openspec-*/SKILL.md` | `.omp/commands/opsx-<id>.md` |
| OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-<id>.md` |
| Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-<id>.md` |
| Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/<id>.md` |
Expand Down Expand Up @@ -75,7 +76,7 @@ openspec init --tools none
openspec init --profile core
```

**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `vibe`, `windsurf`
**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `omp`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `vibe`, `windsurf`

## Workflow-Dependent Installation

Expand Down
1 change: 1 addition & 0 deletions src/core/command-generation/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export { iflowAdapter } from './iflow.js';
export { junieAdapter } from './junie.js';
export { kilocodeAdapter } from './kilocode.js';
export { kiroAdapter } from './kiro.js';
export { ompAdapter } from './omp.js';
export { opencodeAdapter } from './opencode.js';
export { piAdapter } from './pi.js';
export { qoderAdapter } from './qoder.js';
Expand Down
54 changes: 54 additions & 0 deletions src/core/command-generation/adapters/omp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Oh My Pi Command Adapter
*
* Formats commands for Oh My Pi (omp), which discovers file-based slash commands
* from .omp/commands/*.md. Oh My Pi uses the filename (minus .md) as the slash
* command name, so opsx-propose.md → /opsx-propose, and supports $@ / $ARGUMENTS
* for inline args — the same shape as the Pi adapter.
*/

import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { transformToHyphenCommands } from '../../../utils/command-references.js';
import { escapeYamlValue } from '../yaml.js';

const OMP_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m;

function injectOmpArgs(body: string): string {
if (body.includes('$@') || body.includes('$ARGUMENTS')) {
return body;
}

return body.replace(
OMP_INPUT_HEADING,
(heading) => `${heading}\n**Provided arguments**: $@`
);
}

/**
* Oh My Pi adapter for slash command generation.
* File path: .omp/commands/opsx-<id>.md
* Frontmatter: description
*
* Command references in the body are transformed from /opsx: to /opsx- for
* consistency with the filename-derived command names.
*/
export const ompAdapter: ToolCommandAdapter = {
toolId: 'omp',

getFilePath(commandId: string): string {
return path.join('.omp', 'commands', `opsx-${commandId}.md`);
},

formatFile(content: CommandContent): string {
// Transform /opsx: references to /opsx- and inject $@ for template args
const transformedBody = transformToHyphenCommands(content.body);

return `---
description: ${escapeYamlValue(content.description)}
---

${injectOmpArgs(transformedBody)}
`;
},
};
2 changes: 2 additions & 0 deletions src/core/command-generation/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { iflowAdapter } from './adapters/iflow.js';
import { junieAdapter } from './adapters/junie.js';
import { kilocodeAdapter } from './adapters/kilocode.js';
import { kiroAdapter } from './adapters/kiro.js';
import { ompAdapter } from './adapters/omp.js';
import { opencodeAdapter } from './adapters/opencode.js';
import { piAdapter } from './adapters/pi.js';
import { qoderAdapter } from './adapters/qoder.js';
Expand Down Expand Up @@ -60,6 +61,7 @@ export class CommandAdapterRegistry {
CommandAdapterRegistry.register(junieAdapter);
CommandAdapterRegistry.register(kilocodeAdapter);
CommandAdapterRegistry.register(kiroAdapter);
CommandAdapterRegistry.register(ompAdapter);
CommandAdapterRegistry.register(opencodeAdapter);
CommandAdapterRegistry.register(piAdapter);
CommandAdapterRegistry.register(qoderAdapter);
Expand Down
1 change: 1 addition & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' },
{ name: 'Lingma', value: 'lingma', available: true, successLabel: 'Lingma', skillsDir: '.lingma' },
{ name: 'Mistral Vibe', value: 'vibe', available: true, successLabel: 'Mistral Vibe', skillsDir: '.vibe' },
{ name: 'Oh My Pi', value: 'omp', available: true, successLabel: 'Oh My Pi', skillsDir: '.omp' },
{ name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' },
{ name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' },
{ name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' },
Expand Down
15 changes: 15 additions & 0 deletions test/core/available-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,5 +163,20 @@ describe('available-tools', () => {
expect(vibeTool?.name).toBe('Mistral Vibe');
expect(vibeTool?.skillsDir).toBe('.vibe');
});

it('should detect Oh My Pi when .omp directory exists', async () => {
// Oh My Pi uses skillsDir: '.omp' without detectionPaths
// This test ensures path semantics do not drift for omp skill detection
await fs.mkdir(path.join(testDir, '.omp'), { recursive: true });

const tools = getAvailableTools(testDir);
const toolValues = tools.map((t) => t.value);
expect(toolValues).toContain('omp');

const ompTool = tools.find((t) => t.value === 'omp');
expect(ompTool).toBeDefined();
expect(ompTool?.name).toBe('Oh My Pi');
expect(ompTool?.skillsDir).toBe('.omp');
});
});
});
74 changes: 74 additions & 0 deletions test/core/command-generation/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { githubCopilotAdapter } from '../../../src/core/command-generation/adapt
import { iflowAdapter } from '../../../src/core/command-generation/adapters/iflow.js';
import { kilocodeAdapter } from '../../../src/core/command-generation/adapters/kilocode.js';
import { opencodeAdapter } from '../../../src/core/command-generation/adapters/opencode.js';
import { ompAdapter } from '../../../src/core/command-generation/adapters/omp.js';
import { piAdapter } from '../../../src/core/command-generation/adapters/pi.js';
import { qoderAdapter } from '../../../src/core/command-generation/adapters/qoder.js';
import { qwenAdapter } from '../../../src/core/command-generation/adapters/qwen.js';
Expand Down Expand Up @@ -590,6 +591,79 @@ describe('command-generation/adapters', () => {
});
});

describe('ompAdapter', () => {
it('should have correct toolId', () => {
expect(ompAdapter.toolId).toBe('omp');
});

it('should generate correct file path', () => {
const filePath = ompAdapter.getFilePath('explore');
expect(filePath).toBe(path.join('.omp', 'commands', 'opsx-explore.md'));
});

it('should generate correct file paths for different commands', () => {
expect(ompAdapter.getFilePath('new')).toBe(path.join('.omp', 'commands', 'opsx-new.md'));
expect(ompAdapter.getFilePath('bulk-archive')).toBe(path.join('.omp', 'commands', 'opsx-bulk-archive.md'));
});

it('should format file with description frontmatter', () => {
const output = ompAdapter.formatFile(sampleContent);
expect(output).toContain('---\n');
expect(output).toContain('description: Enter explore mode for thinking');
expect(output).toContain('---\n\n');
expect(output).toContain('This is the command body.');
});

it('should transform command references from colon to hyphen format', () => {
const contentWithRefs: CommandContent = {
...sampleContent,
body: 'Run /opsx:apply to implement. Then /opsx:archive when done.',
};

const output = ompAdapter.formatFile(contentWithRefs);
expect(output).toContain('/opsx-apply');
expect(output).toContain('/opsx-archive');
expect(output).not.toContain('/opsx:apply');
});

it('should inject template arguments into the input section', () => {
const contentWithInput: CommandContent = {
...sampleContent,
body: '**Input**: The argument after `/opsx:explore` is the topic.\n\n**Steps**\n1. Think.',
};

const output = ompAdapter.formatFile(contentWithInput);
expect(output).toContain('**Provided arguments**: $@');
});

it('should escape YAML special characters in description', () => {
const contentWithSpecialChars: CommandContent = {
...sampleContent,
description: 'Fix: regression in "auth" feature',
};
const output = ompAdapter.formatFile(contentWithSpecialChars);
expect(output).toContain('description: "Fix: regression in \\"auth\\" feature"');
});

it('should escape newlines in description', () => {
const contentWithNewline: CommandContent = {
...sampleContent,
description: 'Line 1\nLine 2',
};
const output = ompAdapter.formatFile(contentWithNewline);
expect(output).toContain('description: "Line 1\\nLine 2"');
});

it('should handle empty description', () => {
const contentEmptyDesc: CommandContent = {
...sampleContent,
description: '',
};
const output = ompAdapter.formatFile(contentEmptyDesc);
expect(output).toContain('description: \n');
});
});

describe('piAdapter', () => {
it('should have correct toolId', () => {
expect(piAdapter.toolId).toBe('pi');
Expand Down
Loading