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: 2 additions & 1 deletion docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<id>.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-<id>.md` |
| ZCode (`zcode`) | `.zcode/skills/openspec-*/SKILL.md` | `.zcode/commands/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 All @@ -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

Expand Down
59 changes: 59 additions & 0 deletions src/core/command-generation/adapters/zcode.ts
Original file line number Diff line number Diff line change
@@ -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/<id>.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/<id>.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}
`;
},
};
2 changes: 2 additions & 0 deletions src/core/command-generation/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -67,6 +68,7 @@ export class CommandAdapterRegistry {
CommandAdapterRegistry.register(qwenAdapter);
CommandAdapterRegistry.register(roocodeAdapter);
CommandAdapterRegistry.register(windsurfAdapter);
CommandAdapterRegistry.register(zcodeAdapter);
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
];
33 changes: 33 additions & 0 deletions test/core/available-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
110 changes: 109 additions & 1 deletion test/core/command-generation/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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
Expand All @@ -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');
Expand Down
14 changes: 14 additions & 0 deletions test/core/command-generation/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
28 changes: 28 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
Expand Down
33 changes: 33 additions & 0 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down