Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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 @@ -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,
Expand Down Expand Up @@ -537,8 +537,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
8 changes: 3 additions & 5 deletions src/core/workspace/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as nodeFs from 'node:fs';
import { createRequire } from 'node: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, type AIToolOption } from '../config.js';
import { getGlobalConfig, type Delivery, type Profile } from '../global-config.js';
import { getProfileWorkflows } from '../profiles.js';
Expand Down Expand Up @@ -353,8 +353,7 @@ export async function generateWorkspaceAgentSkills(

try {
const skillsDir = getWorkspaceSkillDirectoryForTool(workspaceRoot, tool);
const transformer =
tool.value === 'opencode' || tool.value === 'pi' ? transformToHyphenCommands : undefined;
const transformer = getTransformerForTool(tool.value, profileContext.delivery);

for (const { template, dirName } of skillTemplates) {
const skillFile = FileSystemUtils.joinPath(skillsDir, dirName, 'SKILL.md');
Expand Down Expand Up @@ -465,8 +464,7 @@ export async function updateWorkspaceAgentSkills(

try {
const skillsDir = getWorkspaceSkillDirectoryForTool(workspaceRoot, tool);
const transformer =
tool.value === 'opencode' || tool.value === 'pi' ? transformToHyphenCommands : undefined;
const transformer = getTransformerForTool(tool.value, profileContext.delivery);

for (const { template, dirName } of skillTemplates) {
const skillFile = FileSystemUtils.joinPath(skillsDir, dirName, 'SKILL.md');
Expand Down
64 changes: 64 additions & 0 deletions src/utils/command-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,67 @@
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.
*
* Tools where the command filename doubles as the command name (opencode, pi)
* always use hyphen-based command references. Otherwise, skills-only delivery
* uses skill references so generated skills never point at commands that were
* not generated. 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 (toolId === 'opencode' || toolId === 'pi') {
return transformToHyphenCommands;
}
if (delivery === 'skills') {
return transformToSkillReferences;
}
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';
5 changes: 5 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,11 @@ 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 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
56 changes: 56 additions & 0 deletions test/core/workspace/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as path from 'node:path';
import { describe, expect, it } from 'vitest';

import {
generateWorkspaceAgentSkills,
getWorkspaceSkillDirectory,
getWorkspaceSkillToolIds,
hasWorkspaceSkillProfileDrift,
Expand All @@ -30,6 +31,37 @@ function withDefaultGlobalConfig<T>(callback: () => T): T {
}
}

/**
* Runs the callback with XDG_CONFIG_HOME pointed at a temp directory
* containing the given global config, restoring the environment afterwards.
*/
async function withGlobalConfigFile<T>(
config: Record<string, unknown>,
callback: () => Promise<T>
): Promise<T> {
const previousConfigHome = process.env.XDG_CONFIG_HOME;
const configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-workspace-skills-'));

fs.mkdirSync(path.join(configHome, 'openspec'), { recursive: true });
fs.writeFileSync(
path.join(configHome, 'openspec', 'config.json'),
JSON.stringify(config)
);

process.env.XDG_CONFIG_HOME = configHome;

try {
return await callback();
} finally {
if (previousConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME;
} else {
process.env.XDG_CONFIG_HOME = previousConfigHome;
}
fs.rmSync(configHome, { recursive: true, force: true });
}
}

describe('workspace skill helpers', () => {
it('parses workspace --tools values using the skill-capable tool set', () => {
expect(parseWorkspaceSkillToolsValue('all')).toEqual(getWorkspaceSkillToolIds());
Expand All @@ -52,6 +84,30 @@ describe('workspace skill helpers', () => {
);
});

it('uses skill references in generated skills for skills-only delivery', async () => {
await withGlobalConfigFile({ profile: 'core', delivery: 'skills' }, async () => {
const workspaceRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'openspec-workspace-root-')
);

try {
const report = await generateWorkspaceAgentSkills(workspaceRoot, ['claude']);
expect(report.failed).toEqual([]);

const skillFile = path.join(
getWorkspaceSkillDirectory(workspaceRoot, 'claude'),
'openspec-explore',
'SKILL.md'
);
const skillContent = fs.readFileSync(skillFile, 'utf-8');
expect(skillContent).not.toContain('/opsx:');
expect(skillContent).toContain('/openspec-');
} finally {
fs.rmSync(workspaceRoot, { recursive: true, force: true });
}
});
});

it('does not report profile drift when workflow IDs match in a different order', () => {
withDefaultGlobalConfig(() => {
expect(
Expand Down
104 changes: 103 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,101 @@ 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 hyphen commands for opencode and pi regardless of delivery', () => {
expect(getTransformerForTool('opencode', 'both')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('opencode', 'skills')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('pi', 'both')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('pi', 'skills')).toBe(transformToHyphenCommands);
});

it('selects skill references for skills-only delivery', () => {
expect(getTransformerForTool('claude', 'skills')).toBe(transformToSkillReferences);
expect(getTransformerForTool('codex', 'skills')).toBe(transformToSkillReferences);
});

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