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 @@ -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
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
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
Loading