From 468973381ba0135f5565229ecc123810ae6b5f2c Mon Sep 17 00:00:00 2001 From: mengjianke Date: Fri, 27 Feb 2026 21:22:44 +0800 Subject: [PATCH 1/2] feat: Add Trae opsx command adapter --- .gitignore | 3 ++ docs/commands.md | 2 +- docs/supported-tools.md | 2 +- src/core/command-generation/adapters/index.ts | 1 + src/core/command-generation/adapters/trae.ts | 36 +++++++++++++++++++ src/core/command-generation/registry.ts | 2 ++ test/core/init.test.ts | 13 +++++++ test/core/update.test.ts | 2 +- 8 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 src/core/command-generation/adapters/trae.ts diff --git a/.gitignore b/.gitignore index 3a952f090..9074366a8 100644 --- a/.gitignore +++ b/.gitignore @@ -156,3 +156,6 @@ opencode.json # Codex .codex/ + +# Trae +.trae/ \ No newline at end of file diff --git a/docs/commands.md b/docs/commands.md index fd4bb7fe1..b3d7d4180 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -618,7 +618,7 @@ Different AI tools use slightly different command syntax. Use the format that ma | Cursor | `/opsx-propose`, `/opsx-apply` | | Windsurf | `/opsx-propose`, `/opsx-apply` | | Copilot (IDE) | `/opsx-propose`, `/opsx-apply` | -| Trae | Skill-based invocations such as `/openspec-propose`, `/openspec-apply-change` (no generated `opsx-*` command files) | +| Trae | `/opsx-propose`, `/opsx-apply` | The intent is the same across tools, but how commands are surfaced can differ by integration. diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 524e597f2..f63b0cad6 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -43,7 +43,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `sync`, `b | 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/index.ts b/src/core/command-generation/adapters/index.ts index 06f7a7ae7..f9daeead8 100644 --- a/src/core/command-generation/adapters/index.ts +++ b/src/core/command-generation/adapters/index.ts @@ -26,4 +26,5 @@ export { piAdapter } from './pi.js'; export { qoderAdapter } from './qoder.js'; export { qwenAdapter } from './qwen.js'; export { roocodeAdapter } from './roocode.js'; +export { traeAdapter } from './trae.js'; export { windsurfAdapter } from './windsurf.js'; diff --git a/src/core/command-generation/adapters/trae.ts b/src/core/command-generation/adapters/trae.ts new file mode 100644 index 000000000..dad81902e --- /dev/null +++ b/src/core/command-generation/adapters/trae.ts @@ -0,0 +1,36 @@ +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +function escapeYamlValue(value: string): string { + const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); + if (needsQuoting) { + const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); + return `"${escaped}"`; + } + return value; +} + +function formatTagsArray(tags: string[]): string { + const escapedTags = tags.map((tag) => escapeYamlValue(tag)); + return `[${escapedTags.join(', ')}]`; +} + +export const traeAdapter: ToolCommandAdapter = { + toolId: 'trae', + + getFilePath(commandId: string): string { + return path.join('.trae', 'commands', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + return `--- +name: /opsx-${content.id} +description: ${escapeYamlValue(content.description)} +category: ${escapeYamlValue(content.category)} +tags: ${formatTagsArray(content.tags)} +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/registry.ts b/src/core/command-generation/registry.ts index a69a98adc..ed628657b 100644 --- a/src/core/command-generation/registry.ts +++ b/src/core/command-generation/registry.ts @@ -28,6 +28,7 @@ import { piAdapter } from './adapters/pi.js'; import { qoderAdapter } from './adapters/qoder.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'; /** @@ -60,6 +61,7 @@ export class CommandAdapterRegistry { CommandAdapterRegistry.register(qoderAdapter); CommandAdapterRegistry.register(qwenAdapter); CommandAdapterRegistry.register(roocodeAdapter); + CommandAdapterRegistry.register(traeAdapter); CommandAdapterRegistry.register(windsurfAdapter); } diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 6af92aed2..53c93f5b9 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -380,6 +380,19 @@ describe('InitCommand', () => { const content = await fs.readFile(cmdFile, 'utf-8'); expect(content).toMatch(/^---\n/); }); + + it('should generate Trae commands with correct format', async () => { + const initCommand = new InitCommand({ tools: 'trae', force: true }); + await initCommand.execute(testDir); + + const cmdFile = path.join(testDir, '.trae', 'commands', 'opsx-explore.md'); + expect(await fileExists(cmdFile)).toBe(true); + + const content = await fs.readFile(cmdFile, 'utf-8'); + expect(content).toMatch(/^---\n/); + expect(content).toContain('name: /opsx-explore'); + expect(content).toContain('description:'); + }); }); describe('error handling', () => { diff --git a/test/core/update.test.ts b/test/core/update.test.ts index c36ac3da8..258b33b9f 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1485,8 +1485,8 @@ More user content after markers. const { AI_TOOLS } = await import('../../src/core/config.js'); const { CommandAdapterRegistry } = await import('../../src/core/command-generation/index.js'); const adapterlessTool = AI_TOOLS.find((tool) => tool.skillsDir && !CommandAdapterRegistry.get(tool.value)); - expect(adapterlessTool).toBeDefined(); if (!adapterlessTool?.skillsDir) { + expect(adapterlessTool).toBeUndefined(); return; } From e16b725e9bf2a4eefc7e99dca018780e78a4cc81 Mon Sep 17 00:00:00 2001 From: mengjianke Date: Sat, 28 Feb 2026 10:02:14 +0800 Subject: [PATCH 2/2] fixup: Fix according to the review suggestions --- .gitignore | 2 +- src/core/command-generation/adapters/trae.ts | 17 ++++++++++ test/core/command-generation/adapters.test.ts | 32 ++++++++++++++++++- test/core/update.test.ts | 16 ++++++++-- 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 9074366a8..2f86ad3af 100644 --- a/.gitignore +++ b/.gitignore @@ -158,4 +158,4 @@ opencode.json .codex/ # Trae -.trae/ \ No newline at end of file +.trae/ diff --git a/src/core/command-generation/adapters/trae.ts b/src/core/command-generation/adapters/trae.ts index dad81902e..5b24f7c4a 100644 --- a/src/core/command-generation/adapters/trae.ts +++ b/src/core/command-generation/adapters/trae.ts @@ -1,6 +1,15 @@ +/** + * Trae Command Adapter + * + * Formats commands for Trae following its frontmatter 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 { const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value); if (needsQuoting) { @@ -10,11 +19,19 @@ function escapeYamlValue(value: string): string { return value; } +/** + * Formats a tags array as a YAML array with proper escaping. + */ function formatTagsArray(tags: string[]): string { const escapedTags = tags.map((tag) => escapeYamlValue(tag)); return `[${escapedTags.join(', ')}]`; } +/** + * Trae adapter for command generation. + * File path: .trae/commands/opsx-.md + * Frontmatter: name, description, category, tags + */ export const traeAdapter: ToolCommandAdapter = { toolId: 'trae', diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index dab19bf3d..103a82bcb 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -22,6 +22,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'; @@ -585,6 +586,35 @@ 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 format file with correct YAML frontmatter', () => { + const output = traeAdapter.formatFile(sampleContent); + + expect(output).toContain('---\n'); + expect(output).toContain('name: /opsx-explore'); + expect(output).toContain('description: Enter explore mode for thinking'); + expect(output).toContain('category: Workflow'); + expect(output).toContain('tags: [workflow, explore, experimental]'); + expect(output).toContain('---\n\n'); + expect(output).toContain('This is the command body.'); + }); + + it('should handle empty tags', () => { + const contentNoTags: CommandContent = { ...sampleContent, tags: [] }; + const output = traeAdapter.formatFile(contentNoTags); + expect(output).toContain('tags: []'); + }); + }); + describe('cross-platform path handling', () => { it('Claude adapter uses path.join for paths', () => { // path.join handles platform-specific separators @@ -610,7 +640,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/update.test.ts b/test/core/update.test.ts index 258b33b9f..636174c74 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1484,10 +1484,22 @@ More user content after markers. const { AI_TOOLS } = await import('../../src/core/config.js'); const { CommandAdapterRegistry } = await import('../../src/core/command-generation/index.js'); + const candidateTool = AI_TOOLS.find((tool) => tool.skillsDir); + expect(candidateTool).toBeDefined(); + if (!candidateTool?.skillsDir) { + return; + } + const originalGet = CommandAdapterRegistry.get.bind(CommandAdapterRegistry); + vi.spyOn(CommandAdapterRegistry, 'get').mockImplementation((toolId: string) => { + if (toolId === candidateTool.value) { + return undefined; + } + return originalGet(toolId); + }); const adapterlessTool = AI_TOOLS.find((tool) => tool.skillsDir && !CommandAdapterRegistry.get(tool.value)); + expect(adapterlessTool).toBeDefined(); if (!adapterlessTool?.skillsDir) { - expect(adapterlessTool).toBeUndefined(); - return; + throw new Error('Expected adapterless tool with skillsDir'); } const skillsDir = path.join(testDir, adapterlessTool.skillsDir, 'skills');