diff --git a/src/core/command-generation/adapters/index.ts b/src/core/command-generation/adapters/index.ts index 00fc75d5d..d39c5637b 100644 --- a/src/core/command-generation/adapters/index.ts +++ b/src/core/command-generation/adapters/index.ts @@ -30,3 +30,4 @@ export { lingmaAdapter } from './lingma.js'; export { qwenAdapter } from './qwen.js'; export { roocodeAdapter } from './roocode.js'; export { windsurfAdapter } from './windsurf.js'; +export { zcodeAdapter } from './zcode.js'; diff --git a/src/core/command-generation/adapters/zcode.ts b/src/core/command-generation/adapters/zcode.ts new file mode 100644 index 000000000..0b50d360a --- /dev/null +++ b/src/core/command-generation/adapters/zcode.ts @@ -0,0 +1,34 @@ +/** + * ZCode Command Adapter + * + * Formats commands for ZCode under its project-local command directory. + */ + +import path from 'path'; +import type { CommandContent, ToolCommandAdapter } from '../types.js'; + +/** + * ZCode adapter for command generation. + * File path: .zcode/commands/opsx/.md + * Frontmatter: name, description, category, tags + */ +export const zcodeAdapter: ToolCommandAdapter = { + toolId: 'zcode', + + getFilePath(commandId: string): string { + return path.join('.zcode', 'commands', 'opsx', `${commandId}.md`); + }, + + formatFile(content: CommandContent): string { + const tagsStr = content.tags.join(', '); + return `--- +name: ${content.name} +description: ${content.description} +category: ${content.category} +tags: [${tagsStr}] +--- + +${content.body} +`; + }, +}; diff --git a/src/core/command-generation/registry.ts b/src/core/command-generation/registry.ts index 3b726d707..0ee2dacfe 100644 --- a/src/core/command-generation/registry.ts +++ b/src/core/command-generation/registry.ts @@ -32,6 +32,7 @@ import { lingmaAdapter } from './adapters/lingma.js'; import { qwenAdapter } from './adapters/qwen.js'; import { roocodeAdapter } from './adapters/roocode.js'; import { windsurfAdapter } from './adapters/windsurf.js'; +import { zcodeAdapter } from './adapters/zcode.js'; /** * Registry for looking up tool command adapters. @@ -67,6 +68,7 @@ export class CommandAdapterRegistry { CommandAdapterRegistry.register(qwenAdapter); CommandAdapterRegistry.register(roocodeAdapter); CommandAdapterRegistry.register(windsurfAdapter); + CommandAdapterRegistry.register(zcodeAdapter); } /** diff --git a/src/core/config.ts b/src/core/config.ts index 3be428b26..f10a59a69 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -49,5 +49,6 @@ export const AI_TOOLS: AIToolOption[] = [ { name: 'RooCode', value: 'roocode', available: true, successLabel: 'RooCode', skillsDir: '.roo' }, { name: 'Trae', value: 'trae', available: true, successLabel: 'Trae', skillsDir: '.trae' }, { name: 'Windsurf', value: 'windsurf', available: true, successLabel: 'Windsurf', skillsDir: '.windsurf' }, + { name: 'ZCode', value: 'zcode', available: true, successLabel: 'ZCode', skillsDir: '.zcode' }, { name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' } ]; diff --git a/test/core/available-tools.test.ts b/test/core/available-tools.test.ts index 50d758070..b13266340 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -163,5 +163,16 @@ describe('available-tools', () => { expect(vibeTool?.name).toBe('Mistral Vibe'); expect(vibeTool?.skillsDir).toBe('.vibe'); }); + + it('should detect ZCode when .zcode directory exists', async () => { + await fs.mkdir(path.join(testDir, '.zcode'), { recursive: true }); + + const tools = getAvailableTools(testDir); + const zcodeTool = tools.find((t) => t.value === 'zcode'); + + expect(zcodeTool).toBeDefined(); + expect(zcodeTool?.name).toBe('ZCode'); + expect(zcodeTool?.skillsDir).toBe('.zcode'); + }); }); }); diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index b91dc024f..b8fc2eb5f 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 { zcodeAdapter } from '../../../src/core/command-generation/adapters/zcode.js'; import type { CommandContent } from '../../../src/core/command-generation/types.js'; describe('command-generation/adapters', () => { @@ -126,6 +127,28 @@ describe('command-generation/adapters', () => { }); }); + describe('zcodeAdapter', () => { + it('should have correct toolId', () => { + expect(zcodeAdapter.toolId).toBe('zcode'); + }); + + it('should generate correct file path with nested opsx folder', () => { + const filePath = zcodeAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.zcode', 'commands', 'opsx', 'explore.md')); + }); + + it('should format file with name, description, category, and tags', () => { + const output = zcodeAdapter.formatFile(sampleContent); + expect(output).toContain('---\n'); + expect(output).toContain('name: OpenSpec 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.'); + }); + }); + describe('amazonQAdapter', () => { it('should have correct toolId', () => { expect(amazonQAdapter.toolId).toBe('amazon-q'); @@ -698,7 +721,7 @@ describe('command-generation/adapters', () => { codexAdapter, codebuddyAdapter, continueAdapter, costrictAdapter, crushAdapter, factoryAdapter, geminiAdapter, githubCopilotAdapter, iflowAdapter, kilocodeAdapter, opencodeAdapter, piAdapter, qoderAdapter, - qwenAdapter, roocodeAdapter + qwenAdapter, roocodeAdapter, zcodeAdapter ]; for (const adapter of adapters) { const filePath = adapter.getFilePath('test'); diff --git a/test/core/command-generation/registry.test.ts b/test/core/command-generation/registry.test.ts index 14165ff51..2f2fee3b1 100644 --- a/test/core/command-generation/registry.test.ts +++ b/test/core/command-generation/registry.test.ts @@ -27,6 +27,12 @@ describe('command-generation/registry', () => { expect(adapter?.toolId).toBe('junie'); }); + it('should return ZCode adapter for "zcode"', () => { + const adapter = CommandAdapterRegistry.get('zcode'); + expect(adapter).toBeDefined(); + expect(adapter?.toolId).toBe('zcode'); + }); + it('should return undefined for unregistered tool', () => { const adapter = CommandAdapterRegistry.get('unknown-tool'); expect(adapter).toBeUndefined(); @@ -52,6 +58,7 @@ describe('command-generation/registry', () => { expect(toolIds).toContain('claude'); expect(toolIds).toContain('cursor'); expect(toolIds).toContain('windsurf'); + expect(toolIds).toContain('zcode'); }); }); @@ -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('zcode')).toBe(true); }); it('should return false for unregistered tools', () => { @@ -78,6 +86,7 @@ describe('command-generation/registry', () => { expect(claudeAdapter?.getFilePath('test')).toContain('.claude'); expect(cursorAdapter?.getFilePath('test')).toContain('.cursor'); expect(windsurfAdapter?.getFilePath('test')).toContain('.windsurf'); + expect(CommandAdapterRegistry.get('zcode')?.getFilePath('test')).toContain('.zcode'); }); it('registered adapters should have working formatFile', () => { diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 6a436eaed..48bbf3c37 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -168,6 +168,22 @@ describe('InitCommand', () => { expect(await fileExists(skillFile)).toBe(true); }); + it('should create ZCode skills and commands', async () => { + const initCommand = new InitCommand({ tools: 'zcode', force: true }); + + await initCommand.execute(testDir); + + const skillFile = path.join(testDir, '.zcode', 'skills', 'openspec-explore', 'SKILL.md'); + expect(await fileExists(skillFile)).toBe(true); + + const commandFile = path.join(testDir, '.zcode', 'commands', 'opsx', 'explore.md'); + expect(await fileExists(commandFile)).toBe(true); + + const commandContent = await fs.readFile(commandFile, 'utf-8'); + expect(commandContent).toContain('name: OPSX: Explore'); + expect(commandContent).toContain('description: Enter explore mode'); + }); + it('should support Kimi CLI as an adapterless skills-only tool', async () => { saveGlobalConfig({ featureFlags: {},