From c98ed031c267f173841a8ff786d2717d907f6bc8 Mon Sep 17 00:00:00 2001 From: Matt Coles Date: Mon, 6 Jul 2026 16:28:59 +1000 Subject: [PATCH] feat: add Oh My Pi (omp) tool support Oh My Pi (omp) is a distinct agent from the existing `pi` (pi.dev). It discovers Agent Skills from `.omp/skills` (invoked as `/skill:`) and file-based slash commands from `.omp/commands` (invoked as `/opsx-`). Register `omp` with `skillsDir: '.omp'` plus an `omp` command adapter (mirrors the Pi adapter, writing `.omp/commands/opsx-.md`), so it ships a full skills + slash-command integration. Adds docs, a tool-detection test, and adapter coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/add-oh-my-pi-tool.md | 8 ++ docs/supported-tools.md | 3 +- src/core/command-generation/adapters/index.ts | 1 + src/core/command-generation/adapters/omp.ts | 54 ++++++++++++++ src/core/command-generation/registry.ts | 2 + src/core/config.ts | 1 + test/core/available-tools.test.ts | 15 ++++ test/core/command-generation/adapters.test.ts | 74 +++++++++++++++++++ 8 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 .changeset/add-oh-my-pi-tool.md create mode 100644 src/core/command-generation/adapters/omp.ts diff --git a/.changeset/add-oh-my-pi-tool.md b/.changeset/add-oh-my-pi-tool.md new file mode 100644 index 0000000000..640f362a9d --- /dev/null +++ b/.changeset/add-oh-my-pi-tool.md @@ -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:`) and file-based slash commands from `.omp/commands` +(`/opsx-`), so it registers with `skillsDir: '.omp'` plus a command adapter +that writes `.omp/commands/opsx-.md`. diff --git a/docs/supported-tools.md b/docs/supported-tools.md index b2ee30fb42..7c03384f68 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -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-.prompt.md` | | Lingma (`lingma`) | `.lingma/skills/openspec-*/SKILL.md` | `.lingma/commands/opsx/.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-.md` | | OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-.md` | | Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-.md` | | Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/.md` | @@ -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 diff --git a/src/core/command-generation/adapters/index.ts b/src/core/command-generation/adapters/index.ts index 00fc75d5d6..e2cca720ac 100644 --- a/src/core/command-generation/adapters/index.ts +++ b/src/core/command-generation/adapters/index.ts @@ -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'; diff --git a/src/core/command-generation/adapters/omp.ts b/src/core/command-generation/adapters/omp.ts new file mode 100644 index 0000000000..6978a88075 --- /dev/null +++ b/src/core/command-generation/adapters/omp.ts @@ -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-.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)} +`; + }, +}; diff --git a/src/core/command-generation/registry.ts b/src/core/command-generation/registry.ts index 3b726d707d..24142f86b4 100644 --- a/src/core/command-generation/registry.ts +++ b/src/core/command-generation/registry.ts @@ -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'; @@ -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); diff --git a/src/core/config.ts b/src/core/config.ts index 3be428b26d..d40a50cb10 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -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' }, diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts index 50d7580702..d4633a2cb2 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -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'); + }); }); }); diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index b91dc024fb..498c13ab11 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -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'; @@ -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');