From 8453821d5104e0e4c9e0b4bddad3246242c8ec85 Mon Sep 17 00:00:00 2001 From: devinyzeng Date: Wed, 10 Jun 2026 10:17:41 +0800 Subject: [PATCH 1/2] fix: add Trae command adapter for slash commands support Adds a command adapter for Trae so that 'openspec init --tools trae' generates commands in .trae/commands/opsx/ directory. Per Trae's convention, the command name uses the bare ID (e.g., 'apply') instead of the prefixed format used by Claude (e.g., 'OPSX: Apply'). Fixes missing Trae adapter registration in CommandAdapterRegistry.init(). --- src/core/command-generation/adapters/index.ts | 1 + src/core/command-generation/adapters/trae.ts | 32 +++++++++++++++++++ src/core/command-generation/registry.ts | 2 ++ 3 files changed, 35 insertions(+) create mode 100644 src/core/command-generation/adapters/trae.ts diff --git a/src/core/command-generation/adapters/index.ts b/src/core/command-generation/adapters/index.ts index 00fc75d5d..a6d68237c 100644 --- a/src/core/command-generation/adapters/index.ts +++ b/src/core/command-generation/adapters/index.ts @@ -26,6 +26,7 @@ export { kiroAdapter } from './kiro.js'; export { opencodeAdapter } from './opencode.js'; export { piAdapter } from './pi.js'; export { qoderAdapter } from './qoder.js'; +export { traeAdapter } from './trae.js'; export { lingmaAdapter } from './lingma.js'; export { qwenAdapter } from './qwen.js'; export { roocodeAdapter } from './roocode.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..133d563c6 --- /dev/null +++ b/src/core/command-generation/adapters/trae.ts @@ -0,0 +1,32 @@ +/** + * Trae Command Adapter + * + * Formats commands for Trae following its command specification. + * Similar to Claude but uses bare command ID as the name. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * Trae adapter for command generation. + * File path: .trae/commands/opsx/.md + * Frontmatter: name (bare id), 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: ${content.id} +description: "${content.description}" +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/registry.ts b/src/core/command-generation/registry.ts index 3b726d707..457efd43e 100644 --- a/src/core/command-generation/registry.ts +++ b/src/core/command-generation/registry.ts @@ -28,6 +28,7 @@ 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 { traeAdapter } from './adapters/trae.js'; import { lingmaAdapter } from './adapters/lingma.js'; import { qwenAdapter } from './adapters/qwen.js'; import { roocodeAdapter } from './adapters/roocode.js'; @@ -63,6 +64,7 @@ export class CommandAdapterRegistry { CommandAdapterRegistry.register(opencodeAdapter); CommandAdapterRegistry.register(piAdapter); CommandAdapterRegistry.register(qoderAdapter); + CommandAdapterRegistry.register(traeAdapter); CommandAdapterRegistry.register(lingmaAdapter); CommandAdapterRegistry.register(qwenAdapter); CommandAdapterRegistry.register(roocodeAdapter); From 7b5b11476f1f271286146a17ed44f0875e6f67c8 Mon Sep 17 00:00:00 2001 From: devinyzeng Date: Fri, 12 Jun 2026 10:35:56 +0800 Subject: [PATCH 2/2] feat(trae): add YAML escaping, tests, and update supported-tools docs - Add escapeYamlValue to trae adapter for safe YAML frontmatter output - Add traeAdapter unit tests covering toolId, getFilePath, formatFile, and YAML escaping - Add trae registry tests for get/has/getAll - Update supported-tools.md: Trae command path from 'Not generated' to '.trae/commands/opsx/.md' --- docs/supported-tools.md | 2 +- src/core/command-generation/adapters/trae.ts | 19 +++++- test/core/command-generation/adapters.test.ts | 67 ++++++++++++++++++- test/core/command-generation/registry.test.ts | 8 +++ 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/docs/supported-tools.md b/docs/supported-tools.md index b2ee30fb4..f1b327b89 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -50,7 +50,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 index 133d563c6..4b11a040a 100644 --- a/src/core/command-generation/adapters/trae.ts +++ b/src/core/command-generation/adapters/trae.ts @@ -8,6 +8,21 @@ 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; +} + /** * Trae adapter for command generation. * File path: .trae/commands/opsx/.md @@ -22,8 +37,8 @@ export const traeAdapter: ToolCommandAdapter = { formatFile(content: CommandContent): string { return `--- -name: ${content.id} -description: "${content.description}" +name: ${escapeYamlValue(content.id)} +description: ${escapeYamlValue(content.description)} --- ${content.body} diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index b91dc024f..7769dd0c8 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,70 @@ describe('command-generation/adapters', () => { }); }); + describe('traeAdapter', () => { + it('should have correct toolId', () => { + expect(traeAdapter.toolId).toBe('trae'); + }); + + it('should generate correct file path with nested opsx folder', () => { + 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: 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 not include category or tags in Trae format', () => { + const output = traeAdapter.formatFile(sampleContent); + expect(output).not.toContain('category:'); + expect(output).not.toContain('tags:'); + }); + + 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 escape YAML special characters in name', () => { + const contentWithSpecialName: CommandContent = { + ...sampleContent, + id: 'fix:auth', + }; + const output = traeAdapter.formatFile(contentWithSpecialName); + expect(output).toContain('name: "fix:auth"'); + }); + + it('should not quote simple values that do not need escaping', () => { + const output = traeAdapter.formatFile(sampleContent); + expect(output).toContain('name: explore\n'); + expect(output).toContain('description: Enter explore mode for thinking\n'); + }); + }); + describe('cross-platform path handling', () => { it('Claude adapter uses path.join for paths', () => { // path.join handles platform-specific separators @@ -698,7 +763,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/command-generation/registry.test.ts b/test/core/command-generation/registry.test.ts index 14165ff51..a1b14c9ce 100644 --- a/test/core/command-generation/registry.test.ts +++ b/test/core/command-generation/registry.test.ts @@ -27,6 +27,12 @@ describe('command-generation/registry', () => { expect(adapter?.toolId).toBe('junie'); }); + it('should return Trae adapter for "trae"', () => { + const adapter = CommandAdapterRegistry.get('trae'); + expect(adapter).toBeDefined(); + expect(adapter?.toolId).toBe('trae'); + }); + it('should return undefined for unregistered tool', () => { const adapter = CommandAdapterRegistry.get('unknown-tool'); expect(adapter).toBeUndefined(); @@ -52,6 +58,7 @@ describe('command-generation/registry', () => { expect(toolIds).toContain('claude'); expect(toolIds).toContain('cursor'); expect(toolIds).toContain('windsurf'); + expect(toolIds).toContain('trae'); }); }); @@ -61,6 +68,7 @@ describe('command-generation/registry', () => { expect(CommandAdapterRegistry.has('cursor')).toBe(true); expect(CommandAdapterRegistry.has('windsurf')).toBe(true); expect(CommandAdapterRegistry.has('junie')).toBe(true); + expect(CommandAdapterRegistry.has('trae')).toBe(true); }); it('should return false for unregistered tools', () => {