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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,6 @@ opencode.json

# Codex
.codex/

# Trae
.trae/
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<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,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';
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 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) {
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
return `"${escaped}"`;
Comment on lines +14 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Escape \r as well when quoting YAML values.

Line 5 treats carriage returns as special, but Line 7 only escapes \n. Escaping \r too avoids malformed/fragile frontmatter on CRLF-origin text.

💡 Proposed fix
-    const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
+    const escaped = value
+      .replace(/\\/g, '\\\\')
+      .replace(/"/g, '\\"')
+      .replace(/\r/g, '\\r')
+      .replace(/\n/g, '\\n');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value);
if (needsQuoting) {
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
return `"${escaped}"`;
const needsQuoting = /[:\n\r#{}[\],&*!|>'"%@`]|^\s|\s$/.test(value);
if (needsQuoting) {
const escaped = value
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n');
return `"${escaped}"`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/command-generation/adapters/trae.ts` around lines 5 - 8, The YAML
quoting logic detects carriage returns (needsQuoting uses /\r/ in the regex) but
only escapes newlines; update the escaping in the block that builds escaped (the
variable escaped derived from value) to also replace '\r' with '\\r' (i.e., add
.replace(/\r/g, '\\r') in the chain) so CRLF-origin text doesn't produce
malformed frontmatter; keep the existing backslash, double-quote and newline
replacements and return the quoted string as before.

}
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-<id>.md
* Frontmatter: name, description, category, tags
*/
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}
`;
},
};
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 { 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';

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

Expand Down
32 changes: 31 additions & 1 deletion test/core/command-generation/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand All @@ -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');
Expand Down
13 changes: 13 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
14 changes: 13 additions & 1 deletion test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
return;
throw new Error('Expected adapterless tool with skillsDir');
}

const skillsDir = path.join(testDir, adapterlessTool.skillsDir, 'skills');
Expand Down