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
7 changes: 7 additions & 0 deletions .changeset/add-trae-command-adapter.md
Original file line number Diff line number Diff line change
@@ -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-<id>.md` files for custom slash commands
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,6 @@ opencode.json

# Bob
.bob/

# Trae
.trae/
2 changes: 1 addition & 1 deletion docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<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
53 changes: 53 additions & 0 deletions src/core/command-generation/adapters/trae.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Trae adapter for command generation.
* File path: .trae/commands/opsx-<id>.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}
`;
},
};
2 changes: 2 additions & 0 deletions src/core/command-generation/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -66,6 +67,7 @@ export class CommandAdapterRegistry {
CommandAdapterRegistry.register(lingmaAdapter);
CommandAdapterRegistry.register(qwenAdapter);
CommandAdapterRegistry.register(roocodeAdapter);
CommandAdapterRegistry.register(traeAdapter);
CommandAdapterRegistry.register(windsurfAdapter);
}

Expand Down
74 changes: 73 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,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
Expand All @@ -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');
Expand Down
23 changes: 23 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down