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
5 changes: 5 additions & 0 deletions .changeset/skills-only-references.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fission-ai/openspec': patch
---

Fix skills-only delivery emitting `/opsx:*` command references. SKILL.md files generated by init, update, and workspace skill setup now reference the corresponding skills (e.g. `/openspec-apply-change`) when `delivery: 'skills'` is configured, instead of commands that were never generated.
5 changes: 2 additions & 3 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { createRequire } from 'module';
import { FileSystemUtils } from '../utils/file-system.js';
import { classifyOpenSpecDir, storePointerProblem } from './project-config.js';
import { findRepoPlanningRootSync } from './planning-home.js';
import { transformToHyphenCommands } from '../utils/command-references.js';
import { getTransformerForTool } from '../utils/command-references.js';
import {
AI_TOOLS,
OPENSPEC_DIR_NAME,
Expand Down Expand Up @@ -566,8 +566,7 @@ export class InitCommand {
const skillFile = path.join(skillDir, 'SKILL.md');

// Generate SKILL.md content with YAML frontmatter including generatedBy
// Use hyphen-based command references for tools where filename = command name
const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
const transformer = getTransformerForTool(tool.value, delivery);
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);

// Write the skill file
Expand Down
8 changes: 3 additions & 5 deletions src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import ora from 'ora';
import * as fs from 'fs';
import { createRequire } from 'module';
import { FileSystemUtils } from '../utils/file-system.js';
import { transformToHyphenCommands } from '../utils/command-references.js';
import { getTransformerForTool } from '../utils/command-references.js';
import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
import {
generateCommands,
Expand Down Expand Up @@ -196,8 +196,7 @@ export class UpdateCommand {
const skillDir = path.join(skillsDir, dirName);
const skillFile = path.join(skillDir, 'SKILL.md');

// Use hyphen-based command references for OpenCode
const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
const transformer = getTransformerForTool(tool.value, delivery);
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
await FileSystemUtils.writeFile(skillFile, skillContent);
}
Expand Down Expand Up @@ -690,8 +689,7 @@ export class UpdateCommand {
const skillDir = path.join(skillsDir, dirName);
const skillFile = path.join(skillDir, 'SKILL.md');

// Use hyphen-based command references for OpenCode
const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
const transformer = getTransformerForTool(tool.value, delivery);
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
await FileSystemUtils.writeFile(skillFile, skillContent);
}
Expand Down
65 changes: 65 additions & 0 deletions src/utils/command-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,68 @@
export function transformToHyphenCommands(text: string): string {
return text.replace(/\/opsx:/g, '/opsx-');
}

/**
* Maps command short names to their skill directory references.
* Keep in sync with WORKFLOW_TO_SKILL_DIR, which exists in both
* src/core/profile-sync-drift.ts (exported) and src/core/init.ts (local copy).
*/
const COMMAND_TO_SKILL_REFERENCE: Record<string, string> = {
'explore': '/openspec-explore',
'new': '/openspec-new-change',
'continue': '/openspec-continue-change',
'apply': '/openspec-apply-change',
'ff': '/openspec-ff-change',
'sync': '/openspec-sync-specs',
'archive': '/openspec-archive-change',
'bulk-archive': '/openspec-bulk-archive-change',
'verify': '/openspec-verify-change',
'onboard': '/openspec-onboard',
'propose': '/openspec-propose',
};

/**
* Transforms command references to skill references for skills-only delivery.
* Converts `/opsx:<command>` patterns to `/openspec-<skill>` so that
* generated skills do not reference commands that were never generated.
*
* Unknown command references are left unchanged.
*
* @param text - The text containing command references
* @returns Text with command references transformed to skill references
*
* @example
* transformToSkillReferences('/opsx:apply') // returns '/openspec-apply-change'
* transformToSkillReferences('Use /opsx:archive next') // returns 'Use /openspec-archive-change next'
*/
export function transformToSkillReferences(text: string): string {
return text.replace(/\/opsx:([a-z-]+)/g, (match, commandId: string) => {
return COMMAND_TO_SKILL_REFERENCE[commandId] ?? match;
});
}

/**
* Selects the command-reference transformer for a skill generation target.
*
* Skills-only delivery always uses skill references — for every tool — so
* generated skills never point at commands that were not generated. When
* commands are generated, tools where the command filename doubles as the
* command name (opencode, pi) use hyphen-based command references. All other
* cases keep the default `/opsx:*` references.
*
* @param toolId - The AI tool identifier (e.g. 'claude', 'opencode', 'pi')
* @param delivery - The configured delivery mode
* @returns The transformer to pass to generateSkillContent, or undefined
*/
export function getTransformerForTool(
toolId: string,
delivery: 'both' | 'skills' | 'commands'
): ((text: string) => string) | undefined {
if (delivery === 'skills') {
return transformToSkillReferences;
}
if (toolId === 'opencode' || toolId === 'pi') {
return transformToHyphenCommands;
}
return undefined;
}
6 changes: 5 additions & 1 deletion src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,8 @@ export {
export { FileSystemUtils, removeMarkerBlock } from './file-system.js';

// Command reference utilities
export { transformToHyphenCommands } from './command-references.js';
export {
transformToHyphenCommands,
transformToSkillReferences,
getTransformerForTool,
} from './command-references.js';
25 changes: 25 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,31 @@ describe('InitCommand - profile and detection features', () => {
// 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).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 () => {
Expand Down
8 changes: 8 additions & 0 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1485,6 +1485,14 @@ More user content after markers.
expect(await FileSystemUtils.fileExists(
path.join(commandsDir, 'explore.md')
)).toBe(false);

// Skill content should reference skills, not commands that were never generated
const skillContent = await fs.readFile(
path.join(skillsDir, 'openspec-explore', 'SKILL.md'),
'utf-8'
);
expect(skillContent).not.toContain('/opsx:');
expect(skillContent).toContain('/openspec-');
});

it('should respect commands-only delivery setting', async () => {
Expand Down
107 changes: 106 additions & 1 deletion test/utils/command-references.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest';
import { transformToHyphenCommands } from '../../src/utils/command-references.js';
import {
getTransformerForTool,
transformToHyphenCommands,
transformToSkillReferences,
} from '../../src/utils/command-references.js';

describe('transformToHyphenCommands', () => {
describe('basic transformations', () => {
Expand Down Expand Up @@ -81,3 +85,104 @@ Finally /opsx-apply to implement`;
}
});
});

describe('transformToSkillReferences', () => {
describe('all known commands', () => {
const mappings: Array<[string, string]> = [
['explore', '/openspec-explore'],
['new', '/openspec-new-change'],
['continue', '/openspec-continue-change'],
['apply', '/openspec-apply-change'],
['ff', '/openspec-ff-change'],
['sync', '/openspec-sync-specs'],
['archive', '/openspec-archive-change'],
['bulk-archive', '/openspec-bulk-archive-change'],
['verify', '/openspec-verify-change'],
['onboard', '/openspec-onboard'],
['propose', '/openspec-propose'],
];

for (const [cmd, skillRef] of mappings) {
it(`should transform /opsx:${cmd} to ${skillRef}`, () => {
expect(transformToSkillReferences(`/opsx:${cmd}`)).toBe(skillRef);
});
}
});

describe('basic transformations', () => {
it('should transform command reference in context', () => {
const input = 'Use /opsx:apply to implement tasks';
const expected = 'Use /openspec-apply-change to implement tasks';
expect(transformToSkillReferences(input)).toBe(expected);
});

it('should transform multiple command references', () => {
const input = 'Run /opsx:apply then /opsx:archive';
const expected = 'Run /openspec-apply-change then /openspec-archive-change';
expect(transformToSkillReferences(input)).toBe(expected);
});

it('should handle backtick-quoted commands', () => {
const input = 'Run `/opsx:continue` to proceed';
const expected = 'Run `/openspec-continue-change` to proceed';
expect(transformToSkillReferences(input)).toBe(expected);
});

it('should transform references across multiple lines', () => {
const input = `Use /opsx:new to start
Then /opsx:apply to implement`;
const expected = `Use /openspec-new-change to start
Then /openspec-apply-change to implement`;
expect(transformToSkillReferences(input)).toBe(expected);
});
});

describe('edge cases', () => {
it('should return unchanged text with no command references', () => {
const input = 'This is plain text without commands';
expect(transformToSkillReferences(input)).toBe(input);
});

it('should return empty string unchanged', () => {
expect(transformToSkillReferences('')).toBe('');
});

it('should leave unknown command references unchanged', () => {
const input = 'Try /opsx:unknown-command here';
expect(transformToSkillReferences(input)).toBe(input);
});

it('should not transform similar but non-matching patterns', () => {
const input = '/ops:new opsx: /other:command';
expect(transformToSkillReferences(input)).toBe(input);
});

it('should transform longest matching command (bulk-archive vs archive)', () => {
const input = '/opsx:bulk-archive and /opsx:archive';
const expected = '/openspec-bulk-archive-change and /openspec-archive-change';
expect(transformToSkillReferences(input)).toBe(expected);
});
});
});

describe('getTransformerForTool', () => {
it('selects skill references for skills-only delivery for every tool', () => {
expect(getTransformerForTool('claude', 'skills')).toBe(transformToSkillReferences);
expect(getTransformerForTool('codex', 'skills')).toBe(transformToSkillReferences);
// opencode/pi must not fall back to hyphen commands when no commands are generated
expect(getTransformerForTool('opencode', 'skills')).toBe(transformToSkillReferences);
expect(getTransformerForTool('pi', 'skills')).toBe(transformToSkillReferences);
});

it('selects hyphen commands for opencode and pi when commands are generated', () => {
expect(getTransformerForTool('opencode', 'both')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('opencode', 'commands')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('pi', 'both')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('pi', 'commands')).toBe(transformToHyphenCommands);
});

it('selects no transformer for other tools when commands are generated', () => {
expect(getTransformerForTool('claude', 'both')).toBeUndefined();
expect(getTransformerForTool('claude', 'commands')).toBeUndefined();
});
});