Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
Copy Markdown
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