Compare commits

...
Author SHA1 Message Date
TabishB 34f3e57cc8 test: relax legacy-cleanup registry coverage invariant 2026-02-21 15:27:27 -08:00
Carlos Alberto Pereira Gomes 35ce49ff1c fix: remove Pi from LEGACY_SLASH_COMMAND_PATHS
Pi was never supported in pre-1.0 versions, so no legacy cleanup is needed.
Per reviewer feedback: this is only for tools from pre-1.0 OpenSpec.
2026-02-21 13:02:12 -03:00
Tabish Bidiwale b770fdc7e0 Merge branch 'main' into main 2026-02-21 05:49:46 -08:00
Carlos Alberto Pereira Gomes 5c2e8f4465 fix: add YAML escaping for Pi adapter to handle special characters in descriptions
- Add escapeYamlValue() function to properly escape YAML special characters
- Apply escaping to description field in frontmatter
- Add tests for YAML special character escaping (colons, quotes, newlines)

This follows the same pattern used by cursor, claude, and windsurf adapters.
2026-02-21 10:03:35 -03:00
Carlos Alberto Pereira Gomes 18df13b9d1 fix: correct legacy cleanup pattern for Pi (opsx-*.md not openspec-*.md) 2026-02-21 09:59:34 -03:00
Carlos Alberto Pereira Gomes fe99418783 style: add trailing newline to pi.ts 2026-02-21 09:56:06 -03:00
Carlos Alberto Pereira Gomes 8f379e4ef8 fix: add Pi to LEGACY_SLASH_COMMAND_PATHS for test compliance 2026-02-21 09:47:34 -03:00
Carlos Alberto Pereira Gomes dbd11f9a6e feat: add support for Pi (pi.dev) coding agent
Add Pi as a supported tool in OpenSpec with full adapter implementation.

Changes:
- Create pi.ts adapter for command generation
- Register adapter in registry and export from index
- Add Pi to AI_TOOLS config with .pi skills directory
- Add tests for piAdapter following existing patterns
- Update supported-tools.md documentation

Pi uses:
- Skills: .pi/skills/ (Agent Skills standard)
- Prompts: .pi/prompts/*.md (with description frontmatter)

Closes #732
2026-02-21 09:26:40 -03:00
8 changed files with 5070 additions and 9 deletions
+2 -1
View File
@@ -31,6 +31,7 @@ For each tool you select, OpenSpec installs:
| Kilo Code | `.kilocode/skills/` | `.kilocode/workflows/` |
| Kiro | `.kiro/skills/` | `.kiro/prompts/` |
| OpenCode | `.opencode/skills/` | `.opencode/command/` |
| Pi | `.pi/skills/` | `.pi/prompts/` |
| Qoder | `.qoder/skills/` | `.qoder/commands/opsx/` |
| Qwen Code | `.qwen/skills/` | `.qwen/commands/` |
| RooCode | `.roo/skills/` | `.roo/commands/` |
@@ -56,7 +57,7 @@ openspec init --tools all
openspec init --tools none
```
**Available tool IDs:** `amazon-q`, `antigravity`, `auggie`, `claude`, `cline`, `codebuddy`, `codex`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `kilocode`, `kiro`, `opencode`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`
**Available tool IDs:** `amazon-q`, `antigravity`, `auggie`, `claude`, `cline`, `codebuddy`, `codex`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `kilocode`, `kiro`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `windsurf`
## What Gets Installed
+4967
View File
File diff suppressed because it is too large Load Diff
@@ -22,6 +22,7 @@ export { iflowAdapter } from './iflow.js';
export { kilocodeAdapter } from './kilocode.js';
export { kiroAdapter } from './kiro.js';
export { opencodeAdapter } from './opencode.js';
export { piAdapter } from './pi.js';
export { qoderAdapter } from './qoder.js';
export { qwenAdapter } from './qwen.js';
export { roocodeAdapter } from './roocode.js';
@@ -0,0 +1,46 @@
/**
* Pi Command Adapter
*
* Formats commands for Pi (pi.dev) following its prompt template specification.
* Pi prompt templates live in .pi/prompts/*.md with description frontmatter.
*/
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
/**
* Escapes a string value for safe YAML output.
* Quotes the string if it contains special YAML characters.
*/
function escapeYamlValue(value: string): string {
// Check if value needs quoting (contains special YAML characters or starts/ends with whitespace)
const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value);
if (needsQuoting) {
// Use double quotes and escape internal double quotes and backslashes
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
return `"${escaped}"`;
}
return value;
}
/**
* Pi adapter for prompt template generation.
* File path: .pi/prompts/opsx-<id>.md
* Frontmatter: description
*/
export const piAdapter: ToolCommandAdapter = {
toolId: 'pi',
getFilePath(commandId: string): string {
return path.join('.pi', 'prompts', `opsx-${commandId}.md`);
},
formatFile(content: CommandContent): string {
return `---
description: ${escapeYamlValue(content.description)}
---
${content.body}
`;
},
};
+2
View File
@@ -24,6 +24,7 @@ import { iflowAdapter } from './adapters/iflow.js';
import { kilocodeAdapter } from './adapters/kilocode.js';
import { kiroAdapter } from './adapters/kiro.js';
import { opencodeAdapter } from './adapters/opencode.js';
import { piAdapter } from './adapters/pi.js';
import { qoderAdapter } from './adapters/qoder.js';
import { qwenAdapter } from './adapters/qwen.js';
import { roocodeAdapter } from './adapters/roocode.js';
@@ -55,6 +56,7 @@ export class CommandAdapterRegistry {
CommandAdapterRegistry.register(kilocodeAdapter);
CommandAdapterRegistry.register(kiroAdapter);
CommandAdapterRegistry.register(opencodeAdapter);
CommandAdapterRegistry.register(piAdapter);
CommandAdapterRegistry.register(qoderAdapter);
CommandAdapterRegistry.register(qwenAdapter);
CommandAdapterRegistry.register(roocodeAdapter);
+1
View File
@@ -36,6 +36,7 @@ export const AI_TOOLS: AIToolOption[] = [
{ name: 'Kilo Code', value: 'kilocode', available: true, successLabel: 'Kilo Code', skillsDir: '.kilocode' },
{ name: 'Kiro', value: 'kiro', available: true, successLabel: 'Kiro', skillsDir: '.kiro' },
{ 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' },
{ name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' },
{ name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode', skillsDir: '.roo' },
+44 -1
View File
@@ -18,6 +18,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 { 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';
import { roocodeAdapter } from '../../../src/core/command-generation/adapters/roocode.js';
@@ -523,6 +524,48 @@ describe('command-generation/adapters', () => {
});
});
describe('piAdapter', () => {
it('should have correct toolId', () => {
expect(piAdapter.toolId).toBe('pi');
});
it('should generate correct file path', () => {
const filePath = piAdapter.getFilePath('explore');
expect(filePath).toBe(path.join('.pi', 'prompts', 'opsx-explore.md'));
});
it('should generate correct file paths for different commands', () => {
expect(piAdapter.getFilePath('new')).toBe(path.join('.pi', 'prompts', 'opsx-new.md'));
expect(piAdapter.getFilePath('bulk-archive')).toBe(path.join('.pi', 'prompts', 'opsx-bulk-archive.md'));
});
it('should format file with description frontmatter', () => {
const output = piAdapter.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 escape YAML special characters in description', () => {
const contentWithSpecialChars: CommandContent = {
...sampleContent,
description: 'Fix: regression in "auth" feature',
};
const output = piAdapter.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 = piAdapter.formatFile(contentWithNewline);
expect(output).toContain('description: "Line 1\\nLine 2"');
});
});
describe('roocodeAdapter', () => {
it('should have correct toolId', () => {
expect(roocodeAdapter.toolId).toBe('roocode');
@@ -566,7 +609,7 @@ describe('command-generation/adapters', () => {
amazonQAdapter, antigravityAdapter, auggieAdapter, clineAdapter,
codexAdapter, codebuddyAdapter, continueAdapter, costrictAdapter,
crushAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter,
iflowAdapter, kilocodeAdapter, opencodeAdapter, qoderAdapter,
iflowAdapter, kilocodeAdapter, opencodeAdapter, piAdapter, qoderAdapter,
qwenAdapter, roocodeAdapter
];
for (const adapter of adapters) {
+7 -7
View File
@@ -902,16 +902,16 @@ ${OPENSPEC_MARKERS.end}`);
});
});
it('should cover all tools from the CommandAdapterRegistry', () => {
const expectedTools = CommandAdapterRegistry.getAll().map(adapter => adapter.toolId);
it('should only include legacy tool IDs that are present in the CommandAdapterRegistry', () => {
const registeredTools = new Set(CommandAdapterRegistry.getAll().map(adapter => adapter.toolId));
// Verify all adapters have legacy paths
for (const tool of expectedTools) {
expect(LEGACY_SLASH_COMMAND_PATHS).toHaveProperty(tool);
// Verify all legacy map entries correspond to known adapters
for (const tool of Object.keys(LEGACY_SLASH_COMMAND_PATHS)) {
expect(registeredTools.has(tool)).toBe(true);
}
// Verify counts match
expect(expectedTools.length).toBe(Object.keys(LEGACY_SLASH_COMMAND_PATHS).length);
// Pi was never a pre-1.0 legacy tool
expect(LEGACY_SLASH_COMMAND_PATHS).not.toHaveProperty('pi');
});
});