Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.md` |
| Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-<id>.toml` |
| RooCode (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-<id>.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/<id>.md` |
| Windsurf (`windsurf`) | `.windsurf/skills/openspec-*/SKILL.md` | `.windsurf/workflows/opsx-<id>.md` |

\* Codex commands are installed in the global Codex home (`$CODEX_HOME/prompts/` if set, otherwise `~/.codex/prompts/`), not your project directory.
Expand Down
1 change: 1 addition & 0 deletions src/core/command-generation/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
47 changes: 47 additions & 0 deletions src/core/command-generation/adapters/trae.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* 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';

/**
* 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/<id>.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: ${escapeYamlValue(content.id)}
description: ${escapeYamlValue(content.description)}
---

${content.body}
`;
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
2 changes: 2 additions & 0 deletions src/core/command-generation/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
67 changes: 66 additions & 1 deletion test/core/command-generation/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand All @@ -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');
Expand Down
8 changes: 8 additions & 0 deletions test/core/command-generation/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -52,6 +58,7 @@ describe('command-generation/registry', () => {
expect(toolIds).toContain('claude');
expect(toolIds).toContain('cursor');
expect(toolIds).toContain('windsurf');
expect(toolIds).toContain('trae');
});
});

Expand All @@ -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', () => {
Expand Down