Skip to content
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.
13 changes: 10 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 @@ -521,6 +521,14 @@ export class InitCommand {
// SKILL & COMMAND GENERATION
// ═══════════════════════════════════════════════════════════

/**
* Generates skill files and slash commands for each selected tool,
* honoring the configured delivery mode (skills, commands, or both).
*
* @param projectPath - Absolute path to the project root
* @param tools - Selected tools with their skill directory metadata
* @returns Created, refreshed, and failed tools plus removed artifact counts
*/
private async generateSkillsAndCommands(
projectPath: string,
tools: Array<{ value: string; name: string; skillsDir: string; wasConfigured: boolean }>
Expand Down Expand Up @@ -567,8 +575,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 (oh-my-pi, opencode, pi)
const transformer = (tool.value === 'opencode' || tool.value === 'pi' || tool.value === 'oh-my-pi') ? transformToHyphenCommands : undefined;
const transformer = getTransformerForTool(tool.value, delivery);
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);

// Write the skill file
Expand Down
14 changes: 9 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 @@ -80,6 +80,12 @@ export class UpdateCommand {
this.force = options.force ?? false;
}

/**
* Refreshes OpenSpec skills and commands for all configured tools,
* regenerating artifacts according to the effective profile and delivery mode.
*
* @param projectPath - Path to the project root containing the openspec directory
*/
async execute(projectPath: string): Promise<void> {
const resolvedProjectPath = path.resolve(projectPath);
const openspecPath = path.join(resolvedProjectPath, OPENSPEC_DIR_NAME);
Expand Down Expand Up @@ -196,8 +202,7 @@ export class UpdateCommand {
const skillDir = path.join(skillsDir, dirName);
const skillFile = path.join(skillDir, 'SKILL.md');

// Use hyphen-based command references for tools where filename === command name (oh-my-pi, opencode, pi)
const transformer = (tool.value === 'opencode' || tool.value === 'pi' || tool.value === 'oh-my-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 +695,7 @@ export class UpdateCommand {
const skillDir = path.join(skillsDir, dirName);
const skillFile = path.join(skillDir, 'SKILL.md');

// Use hyphen-based command references for tools where filename === command name (oh-my-pi, opencode, pi)
const transformer = (tool.value === 'opencode' || tool.value === 'pi' || tool.value === 'oh-my-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 (oh-my-pi, 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' || toolId === 'oh-my-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';
26 changes: 26 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,32 @@ 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).not.toContain('/opsx-');
expect(skillContent).toContain('/openspec-');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
9 changes: 9 additions & 0 deletions test/core/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1486,6 +1486,15 @@ 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).not.toContain('/opsx-');
expect(skillContent).toContain('/openspec-');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

it('should respect commands-only delivery setting', async () => {
Expand Down
110 changes: 109 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 @@ -82,3 +86,107 @@ 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);
// hyphen-command tools must not fall back to hyphen commands when no commands are generated
expect(getTransformerForTool('opencode', 'skills')).toBe(transformToSkillReferences);
expect(getTransformerForTool('pi', 'skills')).toBe(transformToSkillReferences);
expect(getTransformerForTool('oh-my-pi', 'skills')).toBe(transformToSkillReferences);
});

it('selects hyphen commands for opencode, pi, and oh-my-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);
expect(getTransformerForTool('oh-my-pi', 'both')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('oh-my-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();
});
});