From 3fea146f34b0816fc1c118ca10b1d369d8a682ef Mon Sep 17 00:00:00 2001 From: young Date: Sun, 14 Jun 2026 12:08:58 +0800 Subject: [PATCH 1/5] feat(zcode): add ZCode as supported tool Register ZCode in the AI tools registry and provide a command adapter so `openspec init --tools zcode` generates per-project artifacts under a single .zcode/ root (no split across .agents + .zcode): - Skills: .zcode/skills/openspec-*/SKILL.md (ZCode-native discovery path, highest priority among project-level skill roots) - Commands: .zcode/commands/opsx/.md (Claude-compatible frontmatter) Both .zcode/skills and .agents/skills are valid ZCode discovery roots (verified from ZCode source: skillRootsForBase registers them in pairs); we use .zcode to keep all artifacts under one directory. ZCode auto-detection triggers on .zcode or .agents at the project root. Verification: - pnpm build passes (TypeScript compiles clean) - pnpm lint passes (no new warnings) - pnpm test: 1661 tests pass (no regressions) - E2E: `openspec init --tools zcode --profile core` produces 5 skills + 5 commands, all under .zcode/ (no .agents created) --- docs/supported-tools.md | 3 +- src/core/command-generation/adapters/zcode.ts | 59 +++++++++++++++++++ src/core/command-generation/registry.ts | 2 + src/core/config.ts | 1 + 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/core/command-generation/adapters/zcode.ts diff --git a/docs/supported-tools.md b/docs/supported-tools.md index b2ee30fb4..38dc02105 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -52,6 +52,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `bulk-arch | RooCode (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-.md` | | Trae (`trae`) | `.trae/skills/openspec-*/SKILL.md` | Not generated (no command adapter; use skill-based `/openspec-*` invocations) | | Windsurf (`windsurf`) | `.windsurf/skills/openspec-*/SKILL.md` | `.windsurf/workflows/opsx-.md` | +| ZCode (`zcode`) | `.zcode/skills/openspec-*/SKILL.md` | `.zcode/commands/opsx/.md` | \* Codex commands are installed in the global Codex home (`$CODEX_HOME/prompts/` if set, otherwise `~/.codex/prompts/`), not your project directory. @@ -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`, `trae`, `vibe`, `windsurf`, `zcode` ## Workflow-Dependent Installation diff --git a/src/core/command-generation/adapters/zcode.ts b/src/core/command-generation/adapters/zcode.ts new file mode 100644 index 000000000..1712ba19e --- /dev/null +++ b/src/core/command-generation/adapters/zcode.ts @@ -0,0 +1,59 @@ +/** + * ZCode Command Adapter + * + * Formats commands for ZCode following its frontmatter specification. + * ZCode shares Claude Code's command format conventions. + * File path: .zcode/commands/opsx/.md + * Frontmatter: name, description, category, tags + */ + +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 { + // 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; +} + +/** + * 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(', ')}]`; +} + +/** + * 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 { + return `--- +name: ${escapeYamlValue(content.name)} +description: ${escapeYamlValue(content.description)} +category: ${escapeYamlValue(content.category)} +tags: ${formatTagsArray(content.tags)} +--- + +${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..c41849e25 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', detectionPaths: ['.zcode', '.agents'] }, { name: 'AGENTS.md (works with Amp, VS Code, …)', value: 'agents', available: false, successLabel: 'your AGENTS.md-compatible assistant' } ]; From 16ff6c3839cb8bb14596763b694dcaefb2736dab Mon Sep 17 00:00:00 2001 From: young Date: Mon, 15 Jun 2026 01:00:47 +0800 Subject: [PATCH 2/5] fix(zcode): scope auto-detection to .zcode only ZCode's detectionPaths included '.agents', a generic directory used by many agent frameworks. A bare '.agents' at the project root caused false-positive ZCode detection (mirroring the Copilot bare-.github problem the codebase already guards against). Drop the detectionPaths override so ZCode is detected solely via its strongly-identifying skillsDir '.zcode'. Add tests locking the new contract: a bare '.agents' must not trigger detection, and '.agents' co-located with '.zcode' must not suppress real detection. --- src/core/config.ts | 2 +- test/core/available-tools.test.ts | 33 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/core/config.ts b/src/core/config.ts index c41849e25..f10a59a69 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -49,6 +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', detectionPaths: ['.zcode', '.agents'] }, + { 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..8a03746ba 100644 --- a/test/core/available-tools.test.ts +++ b/test/core/available-tools.test.ts @@ -163,5 +163,38 @@ 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 zcode = tools.find((t) => t.value === 'zcode'); + expect(zcode).toBeDefined(); + expect(zcode?.name).toBe('ZCode'); + expect(zcode?.skillsDir).toBe('.zcode'); + }); + + it('should not detect ZCode from a bare .agents directory', async () => { + // .agents is a generic directory used by many agent frameworks; a bare + // .agents must not trigger ZCode detection (mirrors the Copilot bare-.github rule). + await fs.mkdir(path.join(testDir, '.agents'), { recursive: true }); + + const tools = getAvailableTools(testDir); + expect(tools.map((t) => t.value)).not.toContain('zcode'); + }); + + it('should detect ZCode from .zcode even when .agents is also present', async () => { + // A co-located .agents must not suppress real ZCode detection via .zcode + await fs.mkdir(path.join(testDir, '.zcode'), { recursive: true }); + await fs.mkdir(path.join(testDir, '.agents'), { recursive: true }); + + const zcodeTools = getAvailableTools(testDir).filter((t) => t.value === 'zcode'); + expect(zcodeTools).toHaveLength(1); + }); + + it('should not detect ZCode when .zcode is absent', async () => { + const tools = getAvailableTools(testDir); + expect(tools.map((t) => t.value)).not.toContain('zcode'); + }); }); }); From eee25f3bc1fc5ac4c433434198f06140cdf8d273 Mon Sep 17 00:00:00 2001 From: young Date: Mon, 15 Jun 2026 01:05:22 +0800 Subject: [PATCH 3/5] test(zcode): lock adapter path and frontmatter escaping contract Add focused coverage for the ZCode command adapter that the existing broad tests did not protect: - getFilePath lands under .zcode/commands/opsx/.md and never references .agents - formatFile emits name/description/category/tags frontmatter - YAML escaping across all branches: colons/quotes/newlines (quoted values), special chars in name/category, per-tag quoting, plus the previously uncovered backslash-doubling and leading/trailing whitespace branches --- test/core/command-generation/adapters.test.ts | 110 +++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index b91dc024f..8600fa86b 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', () => { @@ -673,6 +674,113 @@ describe('command-generation/adapters', () => { }); }); + describe('zcodeAdapter', () => { + it('should have correct toolId', () => { + expect(zcodeAdapter.toolId).toBe('zcode'); + }); + + it('should generate correct file path under .zcode/commands/opsx', () => { + const filePath = zcodeAdapter.getFilePath('explore'); + expect(filePath).toBe(path.join('.zcode', 'commands', 'opsx', 'explore.md')); + }); + + it('should generate correct file paths for different command IDs', () => { + expect(zcodeAdapter.getFilePath('new')).toBe(path.join('.zcode', 'commands', 'opsx', 'new.md')); + expect(zcodeAdapter.getFilePath('bulk-archive')).toBe(path.join('.zcode', 'commands', 'opsx', 'bulk-archive.md')); + }); + + it('should keep command paths under .zcode and never reference .agents', () => { + for (const id of ['explore', 'new', 'apply', 'sync', 'archive', 'bulk-archive']) { + const filePath = zcodeAdapter.getFilePath(id); + expect(filePath).toContain('.zcode'); + expect(filePath).not.toContain('.agents'); + } + }); + + it('should format file with name, description, category, and tags frontmatter', () => { + 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.\n\nWith multiple lines.'); + }); + + it('should format empty tags as an empty YAML array', () => { + const output = zcodeAdapter.formatFile({ ...sampleContent, tags: [] }); + expect(output).toContain('tags: []'); + }); + + it('should escape colons in description by quoting the YAML value', () => { + const output = zcodeAdapter.formatFile({ + ...sampleContent, + description: 'Enter: explore mode', + }); + expect(output).toContain('description: "Enter: explore mode"'); + }); + + it('should escape double quotes in description', () => { + const output = zcodeAdapter.formatFile({ + ...sampleContent, + description: 'Enter "explore" mode', + }); + expect(output).toContain('description: "Enter \\"explore\\" mode"'); + }); + + it('should escape newlines in description', () => { + const output = zcodeAdapter.formatFile({ + ...sampleContent, + description: 'Line 1\nLine 2', + }); + expect(output).toContain('description: "Line 1\\nLine 2"'); + }); + + it('should escape special characters in name', () => { + const output = zcodeAdapter.formatFile({ + ...sampleContent, + name: 'OpenSpec: Explore', + }); + expect(output).toContain('name: "OpenSpec: Explore"'); + }); + + it('should escape special characters in category', () => { + const output = zcodeAdapter.formatFile({ + ...sampleContent, + category: 'Work #flow', + }); + expect(output).toContain('category: "Work #flow"'); + }); + + it('should quote individual tags that contain special characters', () => { + const output = zcodeAdapter.formatFile({ + ...sampleContent, + tags: ['workflow', 'explore:1', 'experimental'], + }); + expect(output).toContain('tags: [workflow, "explore:1", experimental]'); + }); + + it('should escape backslashes when quoting is triggered by another special char', () => { + // Backslash alone does not trigger quoting, but once quoting is on (via ':') + // every backslash must be doubled. Locks the replace(/\\/g, '\\\\') branch. + const output = zcodeAdapter.formatFile({ + ...sampleContent, + description: 'path:C:\\foo\\bar', + }); + expect(output).toContain('description: "path:C:\\\\foo\\\\bar"'); + }); + + it('should quote values with leading or trailing whitespace', () => { + const output = zcodeAdapter.formatFile({ + ...sampleContent, + description: ' explore mode ', + }); + expect(output).toContain('description: " explore mode "'); + }); + }); + describe('cross-platform path handling', () => { it('Claude adapter uses path.join for paths', () => { // path.join handles platform-specific separators @@ -698,7 +806,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'); From 6a0672c22c8f384f05e3354bd907b676b6a0c028 Mon Sep 17 00:00:00 2001 From: young Date: Mon, 15 Jun 2026 01:05:29 +0800 Subject: [PATCH 4/5] test(zcode): lock command adapter registry presence Verify the ZCode adapter is registered in CommandAdapterRegistry so openspec init/update can resolve it via get/getAll/has. The existing registry tests only sampled a few tools, so a future refactor that drops the zcode registration would have passed silently. --- test/core/command-generation/registry.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/core/command-generation/registry.test.ts b/test/core/command-generation/registry.test.ts index 14165ff51..701739d8a 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(); @@ -53,6 +59,13 @@ describe('command-generation/registry', () => { expect(toolIds).toContain('cursor'); expect(toolIds).toContain('windsurf'); }); + + it('should include the ZCode adapter', () => { + const adapters = CommandAdapterRegistry.getAll(); + const toolIds = adapters.map((a) => a.toolId); + + expect(toolIds).toContain('zcode'); + }); }); describe('has', () => { @@ -61,6 +74,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', () => { From ea1ec4f1ab4795487a5ddd0b9a7e9eb382d293e9 Mon Sep 17 00:00:00 2001 From: young Date: Mon, 15 Jun 2026 01:05:39 +0800 Subject: [PATCH 5/5] test(zcode): lock init/update generation stays under .zcode End-to-end coverage that init and update generate ZCode skills and commands under .zcode/ and never create a .agents directory. The adapter path/detection unit tests alone cannot catch a generation-time regression that writes outside .zcode, so this asserts the contract on disk for both entry points. --- test/core/init.test.ts | 28 ++++++++++++++++++++++++++++ test/core/update.test.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/test/core/init.test.ts b/test/core/init.test.ts index 6a436eaed..b5c10eb64 100644 --- a/test/core/init.test.ts +++ b/test/core/init.test.ts @@ -168,6 +168,34 @@ describe('InitCommand', () => { expect(await fileExists(skillFile)).toBe(true); }); + it('should generate ZCode skills and commands under .zcode without creating .agents', async () => { + const initCommand = new InitCommand({ tools: 'zcode', force: true }); + + await initCommand.execute(testDir); + + // Core profile skills land under .zcode/skills + const exploreSkill = path.join(testDir, '.zcode', 'skills', 'openspec-explore', 'SKILL.md'); + const proposeSkill = path.join(testDir, '.zcode', 'skills', 'openspec-propose', 'SKILL.md'); + expect(await fileExists(exploreSkill)).toBe(true); + expect(await fileExists(proposeSkill)).toBe(true); + + // Core profile commands land under .zcode/commands/opsx + const exploreCmd = path.join(testDir, '.zcode', 'commands', 'opsx', 'explore.md'); + const proposeCmd = path.join(testDir, '.zcode', 'commands', 'opsx', 'propose.md'); + expect(await fileExists(exploreCmd)).toBe(true); + expect(await fileExists(proposeCmd)).toBe(true); + + const cmdContent = await fs.readFile(exploreCmd, 'utf-8'); + expect(cmdContent).toContain('---'); + expect(cmdContent).toContain('name:'); + expect(cmdContent).toContain('description:'); + expect(cmdContent).toContain('category:'); + expect(cmdContent).toContain('tags:'); + + // .agents is a detection-only root and must never be created during generation + expect(await directoryExists(path.join(testDir, '.agents'))).toBe(false); + }); + it('should support Kimi CLI as an adapterless skills-only tool', async () => { saveGlobalConfig({ featureFlags: {}, diff --git a/test/core/update.test.ts b/test/core/update.test.ts index ea7f66a7e..98639abc2 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -220,6 +220,39 @@ Old instructions content expect(content).toContain('tags:'); }); + it('should generate ZCode commands under .zcode without creating .agents', async () => { + // Mark ZCode as configured with an outdated generatedBy so update picks it up + const skillsDir = path.join(testDir, '.zcode', 'skills'); + await fs.mkdir(path.join(skillsDir, 'openspec-explore'), { recursive: true }); + await fs.writeFile( + path.join(skillsDir, 'openspec-explore', 'SKILL.md'), + '---\nmetadata:\n generatedBy: "0.0.1"\n---\nold content\n' + ); + + await updateCommand.execute(testDir); + + // Commands regenerated under .zcode/commands/opsx + const exploreCmd = path.join(testDir, '.zcode', 'commands', 'opsx', 'explore.md'); + expect(await FileSystemUtils.fileExists(exploreCmd)).toBe(true); + + const cmdContent = await fs.readFile(exploreCmd, 'utf-8'); + expect(cmdContent).toContain('---'); + expect(cmdContent).toContain('name:'); + expect(cmdContent).toContain('description:'); + expect(cmdContent).toContain('category:'); + expect(cmdContent).toContain('tags:'); + + // Skill refreshed under .zcode + const refreshedSkill = await fs.readFile( + path.join(skillsDir, 'openspec-explore', 'SKILL.md'), + 'utf-8' + ); + expect(refreshedSkill).not.toContain('old content'); + + // .agents must never be created during update + await expect(fs.access(path.join(testDir, '.agents'))).rejects.toThrow(); + }); + it('should update core profile opsx commands when tool is configured', async () => { // Set up a configured tool const skillsDir = path.join(testDir, '.claude', 'skills');