mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-30 07:53:11 +08:00
* fix(skills): use skill references in skills-only delivery mode When delivery is configured as 'skills', generated SKILL.md files contained hardcoded /opsx:* command references pointing to commands that were never generated, breaking cross-skill workflows. Add transformToSkillReferences() with the explicit command-to-skill mapping (kept in sync with WORKFLOW_TO_SKILL_DIR) and wire it in init and update wherever skill content is generated, following the approach outlined in #881. Closes #881 Closes #879 Generated with Claude (Cowork) using claude-fable-5; verified with the full vitest suite (1683 tests passing). * fix(skills): wire skill references in workspace skill generation Review follow-up for the skills-only delivery fix: workspace skill setup (src/core/workspace/skills.ts) generates SKILL.md via the same generateSkillContent path but was not wired with transformToSkillReferences, leaving dangling /opsx:* references when delivery is 'skills'. Wire both call sites, add a regression test (verified to fail against the unwired code), strengthen the update skills-only test with content assertions, and correct the COMMAND_TO_SKILL_REFERENCE comment (WORKFLOW_TO_SKILL_DIR exists in both profile-sync-drift.ts and init.ts). Generated with Claude (Cowork) using claude-fable-5; verified with eslint and targeted vitest suites (144 tests passing). * refactor(skills): extract transformer selection into getTransformerForTool Address CodeRabbit review: the tool/delivery transformer selection was duplicated at five call sites across init.ts, update.ts, and workspace/skills.ts. Extract it into a documented helper in command-references.ts with unit tests locking the selection matrix (opencode/pi precedence, skills-only delivery, default). Generated with Claude (Cowork) using claude-fable-5; verified with eslint, tsc, and targeted vitest suites (147 tests passing). * fix(skills): prioritize skill references over hyphen commands in skills-only delivery Address review: getTransformerForTool returned transformToHyphenCommands for opencode/pi before checking delivery, so skills-only delivery still emitted /opsx-* references to commands that were never generated. Check delivery === 'skills' first so skill references win for every tool, and keep the hyphen transform for opencode/pi only when commands are generated. Add unit coverage for the opencode/pi selection matrix and an opencode skills-only init integration test asserting no /opsx: or /opsx- references remain. Generated with Claude (Cowork) using claude-fable-5; verified with eslint, tsc, and targeted vitest suites (148 tests passing). * docs(skills): add docstrings to init/update generation entry points * test(skills): reject stale hyphenated references in skills-only assertions * fix(skills): add missing update entry to command-to-skill reference map COMMAND_TO_SKILL_REFERENCE was missing the update workflow, so a /opsx:update reference in any skill template would survive skills-only transformation untouched. Align the map with the canonical 12-entry WORKFLOW_TO_SKILL_DIR and assert the generated openspec-update-change skill carries no raw command references.
1035 lines
41 KiB
TypeScript
1035 lines
41 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { InitCommand } from '../../src/core/init.js';
|
|
import { saveGlobalConfig, getGlobalConfig } from '../../src/core/global-config.js';
|
|
|
|
const { confirmMock, showWelcomeScreenMock, searchableMultiSelectMock } = vi.hoisted(() => ({
|
|
confirmMock: vi.fn(),
|
|
showWelcomeScreenMock: vi.fn().mockResolvedValue(undefined),
|
|
searchableMultiSelectMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@inquirer/prompts', () => ({
|
|
confirm: confirmMock,
|
|
}));
|
|
|
|
vi.mock('../../src/ui/welcome-screen.js', () => ({
|
|
showWelcomeScreen: showWelcomeScreenMock,
|
|
}));
|
|
|
|
vi.mock('../../src/prompts/searchable-multi-select.js', () => ({
|
|
searchableMultiSelect: searchableMultiSelectMock,
|
|
}));
|
|
|
|
describe('InitCommand', () => {
|
|
let testDir: string;
|
|
let configTempDir: string;
|
|
let originalEnv: NodeJS.ProcessEnv;
|
|
|
|
beforeEach(async () => {
|
|
testDir = path.join(os.tmpdir(), `openspec-init-test-${Date.now()}`);
|
|
await fs.mkdir(testDir, { recursive: true });
|
|
originalEnv = { ...process.env };
|
|
// Use a temp dir for global config to avoid reading real config
|
|
configTempDir = path.join(os.tmpdir(), `openspec-config-init-${Date.now()}`);
|
|
await fs.mkdir(configTempDir, { recursive: true });
|
|
process.env.XDG_CONFIG_HOME = configTempDir;
|
|
process.env.CODEX_HOME = path.join(testDir, 'codex-home');
|
|
|
|
// Mock console.log to suppress output during tests
|
|
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
confirmMock.mockReset();
|
|
confirmMock.mockResolvedValue(true);
|
|
showWelcomeScreenMock.mockClear();
|
|
searchableMultiSelectMock.mockReset();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
process.env = originalEnv;
|
|
await fs.rm(testDir, { recursive: true, force: true });
|
|
await fs.rm(configTempDir, { recursive: true, force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('execute with --tools flag', () => {
|
|
it('should create OpenSpec directory structure', async () => {
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
|
|
await initCommand.execute(testDir);
|
|
|
|
const openspecPath = path.join(testDir, 'openspec');
|
|
expect(await directoryExists(openspecPath)).toBe(true);
|
|
expect(await directoryExists(path.join(openspecPath, 'specs'))).toBe(true);
|
|
expect(await directoryExists(path.join(openspecPath, 'changes'))).toBe(true);
|
|
expect(await directoryExists(path.join(openspecPath, 'changes', 'archive'))).toBe(true);
|
|
});
|
|
|
|
it('should create config.yaml with default schema', async () => {
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
|
|
await initCommand.execute(testDir);
|
|
|
|
const configPath = path.join(testDir, 'openspec', 'config.yaml');
|
|
expect(await fileExists(configPath)).toBe(true);
|
|
|
|
const content = await fs.readFile(configPath, 'utf-8');
|
|
expect(content).toContain('schema: spec-driven');
|
|
});
|
|
|
|
it('should create core profile skills for Claude Code by default', async () => {
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
|
|
await initCommand.execute(testDir);
|
|
|
|
// Core profile: propose, explore, apply, update, sync, archive
|
|
const coreSkillNames = [
|
|
'openspec-propose',
|
|
'openspec-explore',
|
|
'openspec-apply-change',
|
|
'openspec-update-change',
|
|
'openspec-sync-specs',
|
|
'openspec-archive-change',
|
|
];
|
|
|
|
for (const skillName of coreSkillNames) {
|
|
const skillFile = path.join(testDir, '.claude', 'skills', skillName, 'SKILL.md');
|
|
expect(await fileExists(skillFile)).toBe(true);
|
|
|
|
const content = await fs.readFile(skillFile, 'utf-8');
|
|
expect(content).toContain('---');
|
|
expect(content).toContain('name:');
|
|
expect(content).toContain('description:');
|
|
}
|
|
|
|
// Non-core skills should NOT be created
|
|
const nonCoreSkillNames = [
|
|
'openspec-new-change',
|
|
'openspec-continue-change',
|
|
'openspec-ff-change',
|
|
'openspec-bulk-archive-change',
|
|
'openspec-verify-change',
|
|
];
|
|
|
|
for (const skillName of nonCoreSkillNames) {
|
|
const skillFile = path.join(testDir, '.claude', 'skills', skillName, 'SKILL.md');
|
|
expect(await fileExists(skillFile)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('should create core profile commands for Claude Code by default', async () => {
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
|
|
await initCommand.execute(testDir);
|
|
|
|
// Core profile: propose, explore, apply, update, sync, archive
|
|
const coreCommandNames = [
|
|
'opsx/propose.md',
|
|
'opsx/explore.md',
|
|
'opsx/apply.md',
|
|
'opsx/update.md',
|
|
'opsx/sync.md',
|
|
'opsx/archive.md',
|
|
];
|
|
|
|
for (const cmdName of coreCommandNames) {
|
|
const cmdFile = path.join(testDir, '.claude', 'commands', cmdName);
|
|
expect(await fileExists(cmdFile)).toBe(true);
|
|
}
|
|
|
|
// Non-core commands should NOT be created
|
|
const nonCoreCommandNames = [
|
|
'opsx/new.md',
|
|
'opsx/continue.md',
|
|
'opsx/ff.md',
|
|
'opsx/bulk-archive.md',
|
|
'opsx/verify.md',
|
|
];
|
|
|
|
for (const cmdName of nonCoreCommandNames) {
|
|
const cmdFile = path.join(testDir, '.claude', 'commands', cmdName);
|
|
expect(await fileExists(cmdFile)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('should create skills in Cursor skills directory', async () => {
|
|
const initCommand = new InitCommand({ tools: 'cursor', force: true });
|
|
|
|
await initCommand.execute(testDir);
|
|
|
|
const skillFile = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md');
|
|
expect(await fileExists(skillFile)).toBe(true);
|
|
});
|
|
|
|
it('should create skills in Windsurf skills directory', async () => {
|
|
const initCommand = new InitCommand({ tools: 'windsurf', force: true });
|
|
|
|
await initCommand.execute(testDir);
|
|
|
|
const skillFile = path.join(testDir, '.windsurf', 'skills', 'openspec-explore', 'SKILL.md');
|
|
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 Code as an adapterless skills-only tool', async () => {
|
|
saveGlobalConfig({
|
|
featureFlags: {},
|
|
profile: 'core',
|
|
delivery: 'both',
|
|
});
|
|
|
|
const initCommand = new InitCommand({ tools: 'kimi', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const skillFile = path.join(testDir, '.kimi-code', 'skills', 'openspec-explore', 'SKILL.md');
|
|
expect(await fileExists(skillFile)).toBe(true);
|
|
|
|
const commandsDir = path.join(testDir, '.kimi-code', 'commands');
|
|
expect(await directoryExists(commandsDir)).toBe(false);
|
|
|
|
const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
|
|
expect(
|
|
logCalls.some(
|
|
(entry) => entry.includes('Commands skipped for: kimi') && entry.includes('(no adapter)'),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('should support CodeArts as an adapterless skills-only tool', async () => {
|
|
saveGlobalConfig({
|
|
featureFlags: {},
|
|
profile: 'core',
|
|
delivery: 'both',
|
|
});
|
|
|
|
const initCommand = new InitCommand({ tools: 'codeartsagent', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const skillFile = path.join(testDir, '.codeartsdoer', 'skills', 'openspec-explore', 'SKILL.md');
|
|
expect(await fileExists(skillFile)).toBe(true);
|
|
|
|
const commandsDir = path.join(testDir, '.codeartsdoer', 'commands');
|
|
expect(await directoryExists(commandsDir)).toBe(false);
|
|
|
|
const codeArtsLogCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
|
|
expect(codeArtsLogCalls.some((entry) => entry.includes('Created: CodeArts'))).toBe(true);
|
|
expect(
|
|
codeArtsLogCalls.some(
|
|
(entry) => entry.includes('Commands skipped for: codeartsagent') && entry.includes('(no adapter)'),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('should support Hermes Agent as an adapterless skills-only tool with a setup note', async () => {
|
|
saveGlobalConfig({
|
|
featureFlags: {},
|
|
profile: 'core',
|
|
delivery: 'both',
|
|
});
|
|
|
|
const initCommand = new InitCommand({ tools: 'hermes', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const skillFile = path.join(testDir, '.hermes', 'skills', 'openspec-explore', 'SKILL.md');
|
|
expect(await fileExists(skillFile)).toBe(true);
|
|
|
|
const commandsDir = path.join(testDir, '.hermes', 'commands');
|
|
expect(await directoryExists(commandsDir)).toBe(false);
|
|
|
|
const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
|
|
expect(
|
|
logCalls.some(
|
|
(entry) => entry.includes('Commands skipped for: hermes') && entry.includes('(no adapter)'),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
logCalls.some(
|
|
(entry) => entry.includes('Setup required for Hermes Agent') && entry.includes('skills.external_dirs'),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('should migrate OpenSpec skills from legacy .kimi to .kimi-code during init', async () => {
|
|
const legacySkillDir = path.join(testDir, '.kimi', 'skills', 'openspec-explore');
|
|
await fs.mkdir(legacySkillDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(legacySkillDir, 'SKILL.md'),
|
|
`---\nname: openspec-explore\nmetadata:\n author: openspec\n version: "0.9"\n---\n\nOld instructions content\n`
|
|
);
|
|
await fs.writeFile(path.join(testDir, '.kimi', 'config.toml'), 'user config');
|
|
|
|
const initCommand = new InitCommand({ tools: 'kimi', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
// Regenerated in the new location, legacy managed skill removed
|
|
const newSkill = path.join(testDir, '.kimi-code', 'skills', 'openspec-explore', 'SKILL.md');
|
|
expect(await fileExists(newSkill)).toBe(true);
|
|
expect(await directoryExists(legacySkillDir)).toBe(false);
|
|
|
|
// User files under .kimi are preserved
|
|
expect(await fileExists(path.join(testDir, '.kimi', 'config.toml'))).toBe(true);
|
|
});
|
|
|
|
it('should create both skills and commands for Trae with adapter', async () => {
|
|
saveGlobalConfig({
|
|
featureFlags: {},
|
|
profile: 'core',
|
|
delivery: 'both',
|
|
});
|
|
|
|
const initCommand = new InitCommand({ tools: 'trae', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
// Skills should be created
|
|
const skillFile = path.join(testDir, '.trae', 'skills', 'openspec-explore', 'SKILL.md');
|
|
expect(await fileExists(skillFile)).toBe(true);
|
|
|
|
// Commands should also be created (Trae has an adapter)
|
|
const commandFile = path.join(testDir, '.trae', 'commands', 'opsx-explore.md');
|
|
expect(await fileExists(commandFile)).toBe(true);
|
|
|
|
const commandContent = await fs.readFile(commandFile, 'utf-8');
|
|
expect(commandContent).toContain('---');
|
|
expect(commandContent).toContain('name:');
|
|
expect(commandContent).toContain('description:');
|
|
});
|
|
|
|
it.each(['both', 'skills', 'commands'] as const)(
|
|
'should create Codex skills and no global prompts when delivery=%s',
|
|
async (delivery) => {
|
|
saveGlobalConfig({
|
|
featureFlags: {},
|
|
profile: 'core',
|
|
delivery,
|
|
});
|
|
|
|
const initCommand = new InitCommand({ tools: 'codex', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const skillFile = path.join(testDir, '.codex', 'skills', 'openspec-explore', 'SKILL.md');
|
|
expect(await fileExists(skillFile)).toBe(true);
|
|
|
|
const promptFile = path.join(process.env.CODEX_HOME!, 'prompts', 'opsx-explore.md');
|
|
expect(await fileExists(promptFile)).toBe(false);
|
|
}
|
|
);
|
|
|
|
it('should create skills for multiple tools at once', async () => {
|
|
const initCommand = new InitCommand({ tools: 'claude,cursor', force: true });
|
|
|
|
await initCommand.execute(testDir);
|
|
|
|
const claudeSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
const cursorSkill = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md');
|
|
|
|
expect(await fileExists(claudeSkill)).toBe(true);
|
|
expect(await fileExists(cursorSkill)).toBe(true);
|
|
});
|
|
|
|
it('should select all tools with --tools all option', async () => {
|
|
const initCommand = new InitCommand({ tools: 'all', force: true });
|
|
|
|
await initCommand.execute(testDir);
|
|
|
|
// Check a few representative tools
|
|
const claudeSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
const codeArtsSkill = path.join(testDir, '.codeartsdoer', 'skills', 'openspec-explore', 'SKILL.md');
|
|
const cursorSkill = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md');
|
|
const windsurfSkill = path.join(testDir, '.windsurf', 'skills', 'openspec-explore', 'SKILL.md');
|
|
|
|
expect(await fileExists(claudeSkill)).toBe(true);
|
|
expect(await fileExists(codeArtsSkill)).toBe(true);
|
|
expect(await fileExists(cursorSkill)).toBe(true);
|
|
expect(await fileExists(windsurfSkill)).toBe(true);
|
|
});
|
|
|
|
it('should skip tool configuration with --tools none option', async () => {
|
|
const initCommand = new InitCommand({ tools: 'none', force: true });
|
|
|
|
await initCommand.execute(testDir);
|
|
|
|
// Should create OpenSpec structure but no skills
|
|
const openspecPath = path.join(testDir, 'openspec');
|
|
expect(await directoryExists(openspecPath)).toBe(true);
|
|
|
|
// No tool-specific directories should be created
|
|
const claudeSkillsDir = path.join(testDir, '.claude', 'skills');
|
|
expect(await directoryExists(claudeSkillsDir)).toBe(false);
|
|
});
|
|
|
|
it('should throw error for invalid tool names', async () => {
|
|
const initCommand = new InitCommand({ tools: 'invalid-tool', force: true });
|
|
|
|
await expect(initCommand.execute(testDir)).rejects.toThrow(/Invalid tool\(s\): invalid-tool/);
|
|
});
|
|
|
|
it('should handle comma-separated tool names with spaces', async () => {
|
|
const initCommand = new InitCommand({ tools: 'claude, cursor', force: true });
|
|
|
|
await initCommand.execute(testDir);
|
|
|
|
const claudeSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
const cursorSkill = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md');
|
|
|
|
expect(await fileExists(claudeSkill)).toBe(true);
|
|
expect(await fileExists(cursorSkill)).toBe(true);
|
|
});
|
|
|
|
it('should reject combining reserved keywords with explicit tool ids', async () => {
|
|
const initCommand = new InitCommand({ tools: 'all,claude', force: true });
|
|
|
|
await expect(initCommand.execute(testDir)).rejects.toThrow(
|
|
/Cannot combine reserved values "all" or "none" with specific tool IDs/
|
|
);
|
|
});
|
|
|
|
it('should not create config.yaml if it already exists', async () => {
|
|
// Pre-create config.yaml
|
|
const openspecDir = path.join(testDir, 'openspec');
|
|
await fs.mkdir(openspecDir, { recursive: true });
|
|
const configPath = path.join(openspecDir, 'config.yaml');
|
|
const existingContent = 'schema: custom-schema\n';
|
|
await fs.writeFile(configPath, existingContent);
|
|
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const content = await fs.readFile(configPath, 'utf-8');
|
|
expect(content).toBe(existingContent);
|
|
});
|
|
|
|
it('should handle non-existent target directory', async () => {
|
|
const newDir = path.join(testDir, 'new-project');
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
|
|
await initCommand.execute(newDir);
|
|
|
|
const openspecPath = path.join(newDir, 'openspec');
|
|
expect(await directoryExists(openspecPath)).toBe(true);
|
|
});
|
|
|
|
it('should work in extend mode (re-running init)', async () => {
|
|
const initCommand1 = new InitCommand({ tools: 'claude', force: true });
|
|
await initCommand1.execute(testDir);
|
|
|
|
// Run init again with a different tool
|
|
const initCommand2 = new InitCommand({ tools: 'cursor', force: true });
|
|
await initCommand2.execute(testDir);
|
|
|
|
// Both tools should have skills
|
|
const claudeSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
const cursorSkill = path.join(testDir, '.cursor', 'skills', 'openspec-explore', 'SKILL.md');
|
|
|
|
expect(await fileExists(claudeSkill)).toBe(true);
|
|
expect(await fileExists(cursorSkill)).toBe(true);
|
|
});
|
|
|
|
it('should refresh skills on re-run for the same tool', async () => {
|
|
const initCommand1 = new InitCommand({ tools: 'claude', force: true });
|
|
await initCommand1.execute(testDir);
|
|
|
|
const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
const originalContent = await fs.readFile(skillFile, 'utf-8');
|
|
|
|
// Modify the file
|
|
await fs.writeFile(skillFile, '# Modified content\n');
|
|
|
|
// Run init again
|
|
const initCommand2 = new InitCommand({ tools: 'claude', force: true });
|
|
await initCommand2.execute(testDir);
|
|
|
|
const newContent = await fs.readFile(skillFile, 'utf-8');
|
|
expect(newContent).toBe(originalContent);
|
|
});
|
|
});
|
|
|
|
describe('skill content validation', () => {
|
|
it('should generate valid SKILL.md with YAML frontmatter', async () => {
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
const content = await fs.readFile(skillFile, 'utf-8');
|
|
|
|
// Should have YAML frontmatter
|
|
expect(content).toMatch(/^---\n/);
|
|
expect(content).toContain('name: openspec-explore');
|
|
expect(content).toContain('description:');
|
|
expect(content).toContain('license:');
|
|
expect(content).toContain('compatibility:');
|
|
expect(content).toContain('metadata:');
|
|
expect(content).toMatch(/---\n\n/); // End of frontmatter
|
|
});
|
|
|
|
it('should include explore mode instructions', async () => {
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
const content = await fs.readFile(skillFile, 'utf-8');
|
|
|
|
expect(content).toContain('Enter explore mode');
|
|
expect(content).toContain('thinking partner');
|
|
});
|
|
|
|
it('should include propose skill instructions', async () => {
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-propose', 'SKILL.md');
|
|
const content = await fs.readFile(skillFile, 'utf-8');
|
|
|
|
expect(content).toContain('name: openspec-propose');
|
|
});
|
|
|
|
it('should include apply-change skill instructions', async () => {
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-apply-change', 'SKILL.md');
|
|
const content = await fs.readFile(skillFile, 'utf-8');
|
|
|
|
expect(content).toContain('name: openspec-apply-change');
|
|
});
|
|
|
|
it('should embed generatedBy version in skill files', async () => {
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
const content = await fs.readFile(skillFile, 'utf-8');
|
|
|
|
// Should contain generatedBy field with a version string
|
|
expect(content).toMatch(/generatedBy:\s*["']?\d+\.\d+\.\d+["']?/);
|
|
});
|
|
});
|
|
|
|
describe('command generation', () => {
|
|
it('should generate Claude Code commands with correct format', async () => {
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const cmdFile = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md');
|
|
const content = await fs.readFile(cmdFile, 'utf-8');
|
|
|
|
// Claude commands use YAML frontmatter
|
|
expect(content).toMatch(/^---\n/);
|
|
expect(content).toContain('name:');
|
|
expect(content).toContain('description:');
|
|
});
|
|
|
|
it('should generate Cursor commands with correct format', async () => {
|
|
const initCommand = new InitCommand({ tools: 'cursor', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const cmdFile = path.join(testDir, '.cursor', 'commands', 'opsx-explore.md');
|
|
expect(await fileExists(cmdFile)).toBe(true);
|
|
|
|
const content = await fs.readFile(cmdFile, 'utf-8');
|
|
expect(content).toMatch(/^---\n/);
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
it('should provide helpful error for insufficient permissions', async () => {
|
|
// Mock the permission check to fail
|
|
const readOnlyDir = path.join(testDir, 'readonly');
|
|
await fs.mkdir(readOnlyDir);
|
|
|
|
const originalWriteFile = fs.writeFile;
|
|
vi.spyOn(fs, 'writeFile').mockImplementation(
|
|
async (filePath: any, ...args: any[]) => {
|
|
if (
|
|
typeof filePath === 'string' &&
|
|
filePath.includes('.openspec-test-')
|
|
) {
|
|
throw new Error('EACCES: permission denied');
|
|
}
|
|
return (originalWriteFile as any)(filePath, ...args);
|
|
}
|
|
);
|
|
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
await expect(initCommand.execute(readOnlyDir)).rejects.toThrow(/Insufficient permissions/);
|
|
});
|
|
|
|
it('should throw error in non-interactive mode without --tools flag and no detected tools', async () => {
|
|
const initCommand = new InitCommand({ interactive: false });
|
|
|
|
await expect(initCommand.execute(testDir)).rejects.toThrow(/No tools detected and no --tools flag/);
|
|
});
|
|
});
|
|
|
|
describe('tool-specific adapters', () => {
|
|
it('should generate Gemini CLI commands as TOML files', async () => {
|
|
const initCommand = new InitCommand({ tools: 'gemini', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const cmdFile = path.join(testDir, '.gemini', 'commands', 'opsx', 'explore.toml');
|
|
expect(await fileExists(cmdFile)).toBe(true);
|
|
|
|
const content = await fs.readFile(cmdFile, 'utf-8');
|
|
expect(content).toContain('description =');
|
|
expect(content).toContain('prompt =');
|
|
});
|
|
|
|
it('should generate Windsurf commands', async () => {
|
|
const initCommand = new InitCommand({ tools: 'windsurf', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const cmdFile = path.join(testDir, '.windsurf', 'workflows', 'opsx-explore.md');
|
|
expect(await fileExists(cmdFile)).toBe(true);
|
|
});
|
|
|
|
it('should generate Continue prompt files', async () => {
|
|
const initCommand = new InitCommand({ tools: 'continue', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const cmdFile = path.join(testDir, '.continue', 'prompts', 'opsx-explore.prompt');
|
|
expect(await fileExists(cmdFile)).toBe(true);
|
|
|
|
const content = await fs.readFile(cmdFile, 'utf-8');
|
|
expect(content).toContain('name: opsx-explore');
|
|
expect(content).toContain('invokable: true');
|
|
});
|
|
|
|
it('should generate Cline workflow files', async () => {
|
|
const initCommand = new InitCommand({ tools: 'cline', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const cmdFile = path.join(testDir, '.clinerules', 'workflows', 'opsx-explore.md');
|
|
expect(await fileExists(cmdFile)).toBe(true);
|
|
});
|
|
|
|
it('should generate GitHub Copilot prompt files', async () => {
|
|
const initCommand = new InitCommand({ tools: 'github-copilot', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const cmdFile = path.join(testDir, '.github', 'prompts', 'opsx-explore.prompt.md');
|
|
expect(await fileExists(cmdFile)).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('InitCommand - profile and detection features', () => {
|
|
let testDir: string;
|
|
let configTempDir: string;
|
|
let originalEnv: NodeJS.ProcessEnv;
|
|
|
|
beforeEach(async () => {
|
|
testDir = path.join(os.tmpdir(), `openspec-init-profile-test-${Date.now()}`);
|
|
await fs.mkdir(testDir, { recursive: true });
|
|
originalEnv = { ...process.env };
|
|
// Use a temp dir for global config to avoid polluting real config
|
|
configTempDir = path.join(os.tmpdir(), `openspec-config-test-${Date.now()}`);
|
|
await fs.mkdir(configTempDir, { recursive: true });
|
|
process.env.XDG_CONFIG_HOME = configTempDir;
|
|
process.env.CODEX_HOME = path.join(testDir, 'codex-home');
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
confirmMock.mockReset();
|
|
confirmMock.mockResolvedValue(true);
|
|
showWelcomeScreenMock.mockClear();
|
|
searchableMultiSelectMock.mockReset();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
process.env = originalEnv;
|
|
await fs.rm(testDir, { recursive: true, force: true });
|
|
await fs.rm(configTempDir, { recursive: true, force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('should use --profile flag to override global config', async () => {
|
|
// Set global config to custom profile
|
|
saveGlobalConfig({
|
|
featureFlags: {},
|
|
profile: 'custom',
|
|
delivery: 'both',
|
|
workflows: ['explore', 'new', 'apply'],
|
|
});
|
|
|
|
// Override with --profile core
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true, profile: 'core' });
|
|
await initCommand.execute(testDir);
|
|
|
|
// Core profile skills should be created
|
|
const proposeSkill = path.join(testDir, '.claude', 'skills', 'openspec-propose', 'SKILL.md');
|
|
expect(await fileExists(proposeSkill)).toBe(true);
|
|
|
|
// Non-core skills (from the custom profile) should NOT be created
|
|
const newChangeSkill = path.join(testDir, '.claude', 'skills', 'openspec-new-change', 'SKILL.md');
|
|
expect(await fileExists(newChangeSkill)).toBe(false);
|
|
});
|
|
|
|
it('should reject invalid --profile values', async () => {
|
|
const initCommand = new InitCommand({
|
|
tools: 'claude',
|
|
force: true,
|
|
profile: 'invalid-profile',
|
|
});
|
|
|
|
await expect(initCommand.execute(testDir)).rejects.toThrow(
|
|
/Invalid profile "invalid-profile"/
|
|
);
|
|
});
|
|
|
|
it('should use detected tools in non-interactive mode when no --tools flag', async () => {
|
|
// Create a .claude directory to simulate detected tool
|
|
await fs.mkdir(path.join(testDir, '.claude'), { recursive: true });
|
|
|
|
const initCommand = new InitCommand({ interactive: false, force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
// Should have used claude (detected)
|
|
const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
expect(await fileExists(skillFile)).toBe(true);
|
|
});
|
|
|
|
it('should auto-cleanup legacy artifacts in non-interactive mode without --force', async () => {
|
|
// Create legacy OpenCode command files (singular 'command' path)
|
|
const legacyDir = path.join(testDir, '.opencode', 'command');
|
|
await fs.mkdir(legacyDir, { recursive: true });
|
|
await fs.writeFile(path.join(legacyDir, 'opsx-propose.md'), 'legacy content');
|
|
|
|
// Run init in non-interactive mode without --force
|
|
const initCommand = new InitCommand({ tools: 'opencode' });
|
|
await initCommand.execute(testDir);
|
|
|
|
// Legacy files should be cleaned up automatically
|
|
expect(await fileExists(path.join(legacyDir, 'opsx-propose.md'))).toBe(false);
|
|
|
|
// New commands should be at the correct plural path
|
|
const newCommandsDir = path.join(testDir, '.opencode', 'commands');
|
|
expect(await directoryExists(newCommandsDir)).toBe(true);
|
|
});
|
|
|
|
it('should remove managed global Codex prompts in non-interactive mode', async () => {
|
|
const promptDir = path.join(process.env.CODEX_HOME!, 'prompts');
|
|
const legacyPrompt = path.join(promptDir, 'opsx-apply.md');
|
|
await fs.mkdir(promptDir, { recursive: true });
|
|
await fs.writeFile(legacyPrompt, 'legacy apply prompt');
|
|
|
|
const initCommand = new InitCommand({ tools: 'codex' });
|
|
await initCommand.execute(testDir);
|
|
|
|
expect(await fileExists(legacyPrompt)).toBe(false);
|
|
expect(await fileExists(
|
|
path.join(testDir, '.codex', 'skills', 'openspec-apply-change', 'SKILL.md')
|
|
)).toBe(true);
|
|
});
|
|
|
|
it('should preserve legacy Codex prompts without replacement skills during non-interactive init', async () => {
|
|
const promptDir = path.join(process.env.CODEX_HOME!, 'prompts');
|
|
const legacyPrompt = path.join(promptDir, 'opsx-onboard.md');
|
|
await fs.mkdir(promptDir, { recursive: true });
|
|
await fs.writeFile(legacyPrompt, 'legacy onboard prompt');
|
|
|
|
const initCommand = new InitCommand({ tools: 'codex' });
|
|
await initCommand.execute(testDir);
|
|
|
|
expect(await fileExists(legacyPrompt)).toBe(true);
|
|
expect(await fileExists(
|
|
path.join(testDir, '.codex', 'skills', 'openspec-explore', 'SKILL.md')
|
|
)).toBe(true);
|
|
expect(await fileExists(
|
|
path.join(testDir, '.codex', 'skills', 'openspec-onboard', 'SKILL.md')
|
|
)).toBe(false);
|
|
});
|
|
|
|
it('should defer global Codex prompt removal messaging until after interactive tool selection', async () => {
|
|
const promptDir = path.join(process.env.CODEX_HOME!, 'prompts');
|
|
const legacyPrompt = path.join(promptDir, 'opsx-apply.md');
|
|
await fs.mkdir(promptDir, { recursive: true });
|
|
await fs.writeFile(legacyPrompt, 'legacy apply prompt');
|
|
|
|
searchableMultiSelectMock.mockResolvedValue(['codex']);
|
|
|
|
const initCommand = new InitCommand({ force: true });
|
|
vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true);
|
|
|
|
await initCommand.execute(testDir);
|
|
|
|
const toolSelectionOrder = searchableMultiSelectMock.mock.invocationCallOrder[0];
|
|
const consoleLogMock = console.log as ReturnType<typeof vi.fn>;
|
|
const logsBeforeSelection = consoleLogMock.mock.calls
|
|
.filter((_, index) => consoleLogMock.mock.invocationCallOrder[index] < toolSelectionOrder)
|
|
.flat()
|
|
.join('\n');
|
|
|
|
expect(logsBeforeSelection).toContain('Deferred global prompts cleanup');
|
|
expect(logsBeforeSelection).toContain('will only be removed after matching replacement skills are installed');
|
|
expect(logsBeforeSelection).toContain(`codex: ${legacyPrompt}`);
|
|
expect(await fileExists(legacyPrompt)).toBe(false);
|
|
});
|
|
|
|
it('should preselect configured tools but not directory-detected tools in extend mode', async () => {
|
|
// Simulate existing OpenSpec project (extend mode).
|
|
await fs.mkdir(path.join(testDir, 'openspec'), { recursive: true });
|
|
|
|
// Configured with OpenSpec
|
|
const claudeSkillDir = path.join(testDir, '.claude', 'skills', 'openspec-explore');
|
|
await fs.mkdir(claudeSkillDir, { recursive: true });
|
|
await fs.writeFile(path.join(claudeSkillDir, 'SKILL.md'), 'configured');
|
|
|
|
// Directory detected only (not configured with OpenSpec)
|
|
await fs.mkdir(path.join(testDir, '.github'), { recursive: true });
|
|
await fs.writeFile(path.join(testDir, '.github', 'copilot-instructions.md'), '');
|
|
|
|
searchableMultiSelectMock.mockResolvedValue(['claude']);
|
|
|
|
const initCommand = new InitCommand({ force: true });
|
|
vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true);
|
|
|
|
await initCommand.execute(testDir);
|
|
|
|
expect(searchableMultiSelectMock).toHaveBeenCalledTimes(1);
|
|
const [{ choices }] = searchableMultiSelectMock.mock.calls[0] as [{ choices: Array<{ value: string; preSelected?: boolean; detected?: boolean }> }];
|
|
|
|
const claude = choices.find((choice) => choice.value === 'claude');
|
|
const githubCopilot = choices.find((choice) => choice.value === 'github-copilot');
|
|
|
|
expect(claude?.preSelected).toBe(true);
|
|
expect(githubCopilot?.preSelected).toBe(false);
|
|
expect(githubCopilot?.detected).toBe(true);
|
|
});
|
|
|
|
it('should preselect detected tools for first-time interactive setup', async () => {
|
|
// First-time init: no openspec/ directory and no configured OpenSpec skills.
|
|
await fs.mkdir(path.join(testDir, '.github'), { recursive: true });
|
|
await fs.writeFile(path.join(testDir, '.github', 'copilot-instructions.md'), '');
|
|
|
|
searchableMultiSelectMock.mockResolvedValue(['github-copilot']);
|
|
|
|
const initCommand = new InitCommand({ force: true });
|
|
vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true);
|
|
|
|
await initCommand.execute(testDir);
|
|
|
|
expect(searchableMultiSelectMock).toHaveBeenCalledTimes(1);
|
|
const [{ choices }] = searchableMultiSelectMock.mock.calls[0] as [{ choices: Array<{ value: string; preSelected?: boolean }> }];
|
|
const githubCopilot = choices.find((choice) => choice.value === 'github-copilot');
|
|
|
|
expect(githubCopilot?.preSelected).toBe(true);
|
|
});
|
|
|
|
it('should respect custom profile from global config', async () => {
|
|
saveGlobalConfig({
|
|
featureFlags: {},
|
|
profile: 'custom',
|
|
delivery: 'both',
|
|
workflows: ['explore', 'new'],
|
|
});
|
|
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
// Custom profile skills should be created
|
|
const exploreSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
const newChangeSkill = path.join(testDir, '.claude', 'skills', 'openspec-new-change', 'SKILL.md');
|
|
expect(await fileExists(exploreSkill)).toBe(true);
|
|
expect(await fileExists(newChangeSkill)).toBe(true);
|
|
|
|
// Non-selected skills should NOT be created
|
|
const proposeSkill = path.join(testDir, '.claude', 'skills', 'openspec-propose', 'SKILL.md');
|
|
expect(await fileExists(proposeSkill)).toBe(false);
|
|
});
|
|
|
|
it('should migrate commands-only extend mode to custom profile without injecting propose', async () => {
|
|
await fs.mkdir(path.join(testDir, 'openspec'), { recursive: true });
|
|
await fs.mkdir(path.join(testDir, '.claude', 'commands', 'opsx'), { recursive: true });
|
|
await fs.writeFile(path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md'), '# explore\n');
|
|
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const config = getGlobalConfig();
|
|
expect(config.profile).toBe('custom');
|
|
expect(config.delivery).toBe('commands');
|
|
expect(config.workflows).toEqual(['explore']);
|
|
|
|
const exploreCommand = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md');
|
|
const proposeCommand = path.join(testDir, '.claude', 'commands', 'opsx', 'propose.md');
|
|
expect(await fileExists(exploreCommand)).toBe(true);
|
|
expect(await fileExists(proposeCommand)).toBe(false);
|
|
|
|
const exploreSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
const proposeSkill = path.join(testDir, '.claude', 'skills', 'openspec-propose', 'SKILL.md');
|
|
expect(await fileExists(exploreSkill)).toBe(false);
|
|
expect(await fileExists(proposeSkill)).toBe(false);
|
|
});
|
|
|
|
it('should not prompt for confirmation when applying custom profile in interactive init', async () => {
|
|
saveGlobalConfig({
|
|
featureFlags: {},
|
|
profile: 'custom',
|
|
delivery: 'both',
|
|
workflows: ['explore', 'new'],
|
|
});
|
|
|
|
const initCommand = new InitCommand({ force: true });
|
|
vi.spyOn(initCommand as any, 'canPromptInteractively').mockReturnValue(true);
|
|
vi.spyOn(initCommand as any, 'getSelectedTools').mockResolvedValue(['claude']);
|
|
|
|
await initCommand.execute(testDir);
|
|
|
|
expect(showWelcomeScreenMock).toHaveBeenCalled();
|
|
expect(confirmMock).not.toHaveBeenCalled();
|
|
|
|
const exploreSkill = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
const newChangeSkill = path.join(testDir, '.claude', 'skills', 'openspec-new-change', 'SKILL.md');
|
|
expect(await fileExists(exploreSkill)).toBe(true);
|
|
expect(await fileExists(newChangeSkill)).toBe(true);
|
|
|
|
const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
|
|
expect(logCalls.some((entry) => entry.includes('Applying custom profile'))).toBe(false);
|
|
});
|
|
|
|
it('should respect delivery=skills setting (no commands)', async () => {
|
|
saveGlobalConfig({
|
|
featureFlags: {},
|
|
profile: 'core',
|
|
delivery: 'skills',
|
|
});
|
|
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
// Skills should exist
|
|
const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
expect(await fileExists(skillFile)).toBe(true);
|
|
|
|
// Commands should NOT exist
|
|
const cmdFile = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md');
|
|
expect(await fileExists(cmdFile)).toBe(false);
|
|
|
|
// Skill content should reference skills, not commands that were never generated
|
|
const skillContent = await fs.readFile(skillFile, 'utf-8');
|
|
expect(skillContent).not.toContain('/opsx:');
|
|
expect(skillContent).not.toContain('/opsx-');
|
|
expect(skillContent).toContain('/openspec-');
|
|
|
|
// update-change references several other workflows; a command missing
|
|
// from the reference map would leave a raw /opsx: reference behind
|
|
const updateSkillContent = await fs.readFile(
|
|
path.join(testDir, '.claude', 'skills', 'openspec-update-change', 'SKILL.md'),
|
|
'utf-8'
|
|
);
|
|
expect(updateSkillContent).not.toContain('/opsx:');
|
|
expect(updateSkillContent).not.toContain('/opsx-');
|
|
expect(updateSkillContent).toContain('/openspec-');
|
|
});
|
|
|
|
it('should use skill references for opencode in skills-only delivery', async () => {
|
|
saveGlobalConfig({
|
|
featureFlags: {},
|
|
profile: 'core',
|
|
delivery: 'skills',
|
|
});
|
|
|
|
const initCommand = new InitCommand({ tools: 'opencode', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
const skillFile = path.join(testDir, '.opencode', 'skills', 'openspec-explore', 'SKILL.md');
|
|
expect(await fileExists(skillFile)).toBe(true);
|
|
|
|
// Skills-only must win over the hyphen transform: no /opsx: or /opsx- references
|
|
const skillContent = await fs.readFile(skillFile, 'utf-8');
|
|
expect(skillContent).not.toContain('/opsx:');
|
|
expect(skillContent).not.toContain('/opsx-');
|
|
expect(skillContent).toContain('/openspec-');
|
|
});
|
|
|
|
it('should respect delivery=commands setting (no skills)', async () => {
|
|
saveGlobalConfig({
|
|
featureFlags: {},
|
|
profile: 'core',
|
|
delivery: 'commands',
|
|
});
|
|
|
|
const initCommand = new InitCommand({ tools: 'claude', force: true });
|
|
await initCommand.execute(testDir);
|
|
|
|
// Skills should NOT exist
|
|
const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
expect(await fileExists(skillFile)).toBe(false);
|
|
|
|
// Commands should exist
|
|
const cmdFile = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md');
|
|
expect(await fileExists(cmdFile)).toBe(true);
|
|
});
|
|
|
|
it('should remove commands on re-init when delivery changes to skills', async () => {
|
|
saveGlobalConfig({
|
|
featureFlags: {},
|
|
profile: 'core',
|
|
delivery: 'both',
|
|
});
|
|
|
|
const initCommand1 = new InitCommand({ tools: 'claude', force: true });
|
|
await initCommand1.execute(testDir);
|
|
|
|
const cmdFile = path.join(testDir, '.claude', 'commands', 'opsx', 'explore.md');
|
|
expect(await fileExists(cmdFile)).toBe(true);
|
|
|
|
saveGlobalConfig({
|
|
featureFlags: {},
|
|
profile: 'core',
|
|
delivery: 'skills',
|
|
});
|
|
|
|
const initCommand2 = new InitCommand({ tools: 'claude', force: true });
|
|
await initCommand2.execute(testDir);
|
|
|
|
expect(await fileExists(cmdFile)).toBe(false);
|
|
|
|
const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-explore', 'SKILL.md');
|
|
expect(await fileExists(skillFile)).toBe(true);
|
|
});
|
|
});
|
|
|
|
async function fileExists(filePath: string): Promise<boolean> {
|
|
try {
|
|
await fs.access(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function directoryExists(dirPath: string): Promise<boolean> {
|
|
try {
|
|
const stats = await fs.stat(dirPath);
|
|
return stats.isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|