diff --git a/docs/supported-tools.md b/docs/supported-tools.md index b2ee30fb4..05e24fda6 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -47,6 +47,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | Mistral Vibe (`vibe`) | `.vibe/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-.md` | | Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-.md` | +| SourceCraft Code Assistant (`codeassistant`) | `.codeassistant/skills/openspec-*/SKILL.md` | `.codeassistant/commands/opsx-.md` | | 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` | @@ -75,7 +76,7 @@ openspec init --tools none openspec init --profile core ``` -**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `trae`, `vibe`, `windsurf` +**Available tool IDs (`--tools`):** `amazon-q`, `antigravity`, `auggie`, `bob`, `claude`, `cline`, `codex`, `forgecode`, `codebuddy`, `continue`, `costrict`, `crush`, `cursor`, `factory`, `gemini`, `github-copilot`, `iflow`, `junie`, `kilocode`, `kimi`, `kiro`, `lingma`, `opencode`, `pi`, `qoder`, `qwen`, `roocode`, `codeassistant`, `trae`, `vibe`, `windsurf` ## Workflow-Dependent Installation diff --git a/src/core/command-generation/adapters/codeassistant.ts b/src/core/command-generation/adapters/codeassistant.ts new file mode 100644 index 000000000..5baf41604 --- /dev/null +++ b/src/core/command-generation/adapters/codeassistant.ts @@ -0,0 +1,48 @@ +/** + * SourceCraft Code Assistant Command Adapter + * + * Formats commands for SourceCraft Code Assistant following its frontmatter specification. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { transformToHyphenCommands } from '../../../utils/command-references.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; +} + +/** + * SourceCraft Code Assistant adapter for command generation. + * File path: .codeassistant/commands/opsx-.md + * Format: Markdown header with description + */ +export const codeassistantAdapter: ToolCommandAdapter = { + toolId: 'codeassistant', + + getFilePath(commandId: string): string { + return path.join('.codeassistant', 'commands', `opsx-${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + const transformedBody = transformToHyphenCommands(content.body); + + return `--- +description: ${escapeYamlValue(content.description)} +--- + +${transformedBody} +`; + }, +}; diff --git a/src/core/command-generation/adapters/index.ts b/src/core/command-generation/adapters/index.ts index 00fc75d5d..8c97471d6 100644 --- a/src/core/command-generation/adapters/index.ts +++ b/src/core/command-generation/adapters/index.ts @@ -25,6 +25,7 @@ export { kilocodeAdapter } from './kilocode.js'; export { kiroAdapter } from './kiro.js'; export { opencodeAdapter } from './opencode.js'; export { piAdapter } from './pi.js'; +export { codeassistantAdapter } from './codeassistant.js'; export { qoderAdapter } from './qoder.js'; export { lingmaAdapter } from './lingma.js'; export { qwenAdapter } from './qwen.js'; diff --git a/src/core/command-generation/registry.ts b/src/core/command-generation/registry.ts index 3b726d707..53d800f8f 100644 --- a/src/core/command-generation/registry.ts +++ b/src/core/command-generation/registry.ts @@ -27,6 +27,7 @@ import { kilocodeAdapter } from './adapters/kilocode.js'; import { kiroAdapter } from './adapters/kiro.js'; import { opencodeAdapter } from './adapters/opencode.js'; import { piAdapter } from './adapters/pi.js'; +import { codeassistantAdapter } from './adapters/codeassistant.js'; import { qoderAdapter } from './adapters/qoder.js'; import { lingmaAdapter } from './adapters/lingma.js'; import { qwenAdapter } from './adapters/qwen.js'; @@ -62,6 +63,7 @@ export class CommandAdapterRegistry { CommandAdapterRegistry.register(kiroAdapter); CommandAdapterRegistry.register(opencodeAdapter); CommandAdapterRegistry.register(piAdapter); + CommandAdapterRegistry.register(codeassistantAdapter); CommandAdapterRegistry.register(qoderAdapter); CommandAdapterRegistry.register(lingmaAdapter); CommandAdapterRegistry.register(qwenAdapter); diff --git a/src/core/config.ts b/src/core/config.ts index 3be428b26..d063a7940 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -44,6 +44,7 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'Mistral Vibe', value: 'vibe', available: true, successLabel: 'Mistral Vibe', skillsDir: '.vibe' }, { name: 'OpenCode', value: 'opencode', available: true, successLabel: 'OpenCode', skillsDir: '.opencode' }, { name: 'Pi', value: 'pi', available: true, successLabel: 'Pi', skillsDir: '.pi' }, + { name: 'SourceCraft Code Assistant', value: 'codeassistant', available: true, successLabel: 'SourceCraft Code Assistant', skillsDir: '.codeassistant' }, { name: 'Qoder', value: 'qoder', available: true, successLabel: 'Qoder', skillsDir: '.qoder' }, { name: 'Qwen Code', value: 'qwen', available: true, successLabel: 'Qwen Code', skillsDir: '.qwen' }, { name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode', skillsDir: '.roo' }, diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts index 50d758070..de07a335c 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -157,11 +157,24 @@ describe('available-tools', () => { const tools = getAvailableTools(testDir); const toolValues = tools.map((t) => t.value); expect(toolValues).toContain('vibe'); - + const vibeTool = tools.find((t) => t.value === 'vibe'); expect(vibeTool).toBeDefined(); expect(vibeTool?.name).toBe('Mistral Vibe'); expect(vibeTool?.skillsDir).toBe('.vibe'); }); + + it('should detect SourceCraft Code Assistant when .codeassistant directory exists', async () => { + await fs.mkdir(path.join(testDir, '.codeassistant'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const toolValues = tools.map((t) => t.value); + expect(toolValues).toContain('codeassistant'); + + const vibeTool = tools.find((t) => t.value === 'codeassistant'); + expect(vibeTool).toBeDefined(); + expect(vibeTool?.name).toBe('SourceCraft Code Assistant'); + expect(vibeTool?.skillsDir).toBe('.codeassistant'); + }); }); }); diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index b91dc024f..d4e729aa9 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -24,6 +24,7 @@ import { qoderAdapter } from '../../../src/core/command-generation/adapters/qode import { qwenAdapter } from '../../../src/core/command-generation/adapters/qwen.js'; import { roocodeAdapter } from '../../../src/core/command-generation/adapters/roocode.js'; import { windsurfAdapter } from '../../../src/core/command-generation/adapters/windsurf.js'; +import { codeassistantAdapter } from '../../../src/core/command-generation/adapters/codeassistant.js'; import type { CommandContent } from '../../../src/core/command-generation/types.js'; describe('command-generation/adapters', () => { @@ -707,4 +708,38 @@ describe('command-generation/adapters', () => { } }); }); + + describe('codeassistantAdapter', () => { + it('should have correct toolId', () => { + expect(codeassistantAdapter.toolId).toBe('codeassistant'); + }); + + it('should generate correct file path', () => { + const filePath = codeassistantAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.codeassistant', 'commands', 'opsx-explore.md')); + }); + + it('should generate correct file path for different command IDs', () => { + expect(codeassistantAdapter.getFilePath('new')).toBe(path.join('.codeassistant', 'commands', 'opsx-new.md')); + expect(codeassistantAdapter.getFilePath('bulk-archive')).toBe(path.join('.codeassistant', 'commands', 'opsx-bulk-archive.md')); + }); + + it('should format file with correct YAML frontmatter', () => { + const output = codeassistantAdapter.formatFile(sampleContent); + + expect(output).toContain('---\n'); + 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 description', () => { + const contentWithSpecialChars: CommandContent = { + ...sampleContent, + description: 'Fix: regression in "auth" feature', + }; + const output = bobAdapter.formatFile(contentWithSpecialChars); + expect(output).toContain('description: "Fix: regression in \\"auth\\" feature"'); + }); + }); });