diff --git a/.changeset/add-trae-command-adapter.md b/.changeset/add-trae-command-adapter.md new file mode 100644 index 000000000..2566daaf3 --- /dev/null +++ b/.changeset/add-trae-command-adapter.md @@ -0,0 +1,7 @@ +--- +"@fission-ai/openspec": minor +--- + +### New Features + +- **TRAE command adapter** — Added command adapter for Trae IDE, enabling generation of `.trae/commands/opsx-.md` files for custom slash commands diff --git a/.gitignore b/.gitignore index 3ed26016a..6f334c8c4 100644 --- a/.gitignore +++ b/.gitignore @@ -159,3 +159,6 @@ opencode.json # Bob .bob/ + +# Trae +.trae/ diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 85d8e63d8..23554ee62 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -49,7 +49,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/.md` | | Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-.toml` | | RooCode (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-.md` | -| Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | +| Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | `.trae/commands/opsx-.md` | | Windsurf (`windsurf`) | `.windsurf/skills/openspec-*/SKILL.md` | `.windsurf/workflows/opsx-.md` | \* Codex commands are installed in the global Codex home (`$CODEX_HOME/prompts/` if set, otherwise `~/.codex/prompts/`), not your project directory. diff --git a/src/core/command-generation/adapters/trae.ts b/src/core/command-generation/adapters/trae.ts new file mode 100644 index 000000000..6db48e608 --- /dev/null +++ b/src/core/command-generation/adapters/trae.ts @@ -0,0 +1,53 @@ +/** + * Trae Command Adapter + * + * Formats commands for Trae IDE following its command specification. + */ + +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 { + if (value === '') { + return '""'; + } + // 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, backslashes, and newlines + const escaped = value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r'); + return `"${escaped}"`; + } + return value; +} + +/** + * Trae adapter for command generation. + * File path: .trae/commands/opsx-.md + * Frontmatter: name, description + */ +export const traeAdapter: ToolCommandAdapter = { + toolId: 'trae', + + getFilePath(commandId: string): string { + return path.join('.trae', 'commands', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/registry.ts b/src/core/command-generation/registry.ts index 3b726d707..bd4124529 100644 --- a/src/core/command-generation/registry.ts +++ b/src/core/command-generation/registry.ts @@ -31,6 +31,7 @@ import { qoderAdapter } from './adapters/qoder.js'; import { lingmaAdapter } from './adapters/lingma.js'; import { qwenAdapter } from './adapters/qwen.js'; import { roocodeAdapter } from './adapters/roocode.js'; +import { traeAdapter } from './adapters/trae.js'; import { windsurfAdapter } from './adapters/windsurf.js'; /** @@ -66,6 +67,7 @@ export class CommandAdapterRegistry { CommandAdapterRegistry.register(lingmaAdapter); CommandAdapterRegistry.register(qwenAdapter); CommandAdapterRegistry.register(roocodeAdapter); + CommandAdapterRegistry.register(traeAdapter); CommandAdapterRegistry.register(windsurfAdapter); } diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index b91dc024f..96c13aa40 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -23,6 +23,7 @@ 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'; +import { traeAdapter } from '../../../src/core/command-generation/adapters/trae.js'; import { windsurfAdapter } from '../../../src/core/command-generation/adapters/windsurf.js'; import type { CommandContent } from '../../../src/core/command-generation/types.js'; @@ -673,6 +674,77 @@ describe('command-generation/adapters', () => { }); }); + describe('traeAdapter', () => { + it('should have correct toolId', () => { + expect(traeAdapter.toolId).toBe('trae'); + }); + + it('should generate correct file path', () => { + const filePath = traeAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.trae', 'commands', 'opsx-explore.md')); + }); + + it('should generate correct file paths for different commands', () => { + expect(traeAdapter.getFilePath('new')).toBe(path.join('.trae', 'commands', 'opsx-new.md')); + expect(traeAdapter.getFilePath('bulk-archive')).toBe(path.join('.trae', 'commands', 'opsx-bulk-archive.md')); + }); + + it('should format file with name and description frontmatter', () => { + const output = traeAdapter.formatFile(sampleContent); + + expect(output).toContain('---\n'); + expect(output).toContain('name: OpenSpec Explore'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.\n\nWith multiple lines.'); + }); + + it('should escape YAML special characters in name', () => { + const contentWithSpecialChars: CommandContent = { + ...sampleContent, + name: 'Test: Command', + }; + const output = traeAdapter.formatFile(contentWithSpecialChars); + expect(output).toContain('name: "Test: Command"'); + }); + + it('should escape YAML special characters in description', () => { + const contentWithSpecialChars: CommandContent = { + ...sampleContent, + description: 'Fix: regression in "auth" feature', + }; + const output = traeAdapter.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 = traeAdapter.formatFile(contentWithNewline); + expect(output).toContain('description: "Line 1\\nLine 2"'); + }); + + it('should handle empty description', () => { + const contentEmptyDesc: CommandContent = { + ...sampleContent, + description: '', + }; + const output = traeAdapter.formatFile(contentEmptyDesc); + expect(output).toContain('description: ""'); + }); + + it('should escape carriage returns in description', () => { + const contentWithCR: CommandContent = { + ...sampleContent, + description: 'Line 1\r\nLine 2', + }; + const output = traeAdapter.formatFile(contentWithCR); + expect(output).toContain('description: "Line 1\\r\\nLine 2"'); + }); + }); + describe('cross-platform path handling', () => { it('Claude adapter uses path.join for paths', () => { // path.join handles platform-specific separators @@ -698,7 +770,7 @@ describe('command-generation/adapters', () => { codexAdapter, codebuddyAdapter, continueAdapter, costrictAdapter, crushAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter, iflowAdapter, kilocodeAdapter, opencodeAdapter, piAdapter, qoderAdapter, - qwenAdapter, roocodeAdapter + qwenAdapter, roocodeAdapter, traeAdapter ]; for (const adapter of adapters) { const filePath = adapter.getFilePath('test'); diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 6a436eaed..ee616029a 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -192,6 +192,29 @@ describe('InitCommand', () => { ).toBe(true); }); + it('should create both skills and commands for Trae with adapter', async () => { + saveGlobalConfig({ + configuredTools: [], + delivery: 'both', + }); + + const initCommand = new InitCommand({ tools: 'trae', force: true }); + await initCommand.execute(testDir); + + // Skills should be created + const skillFile = path.join(testDir, '.trae', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + // Commands should also be created (Trae has an adapter) + const commandFile = path.join(testDir, '.trae', 'commands', 'opsx-explore.md'); + expect(await fileExists(commandFile)).toBe(true); + + const commandContent = await fs.readFile(commandFile, 'utf-8'); + expect(commandContent).toContain('---'); + expect(commandContent).toContain('name:'); + expect(commandContent).toContain('description:'); + }); + it('should create skills for multiple tools at once', async () => { const initCommand = new InitCommand({ tools: 'claude,cursor', force: true });