From 5a2f69427079316e9d31dcbd148ab0583ed9b4d4 Mon Sep 17 00:00:00 2001 From: furao Date: Fri, 17 Apr 2026 18:26:14 +0800 Subject: [PATCH] fix: add transformToSkillReferences for skills-only delivery (#881) When delivery is 'skills', generated SKILL.md files contained raw /opsx:* command references that don't exist in skills-only mode. Add a transformer that maps them to /openspec-* skill references. Closes #881 Closes #879 Co-Authored-By: Claude Opus 4.6 --- src/core/init.ts | 9 +++- src/core/update.ts | 20 +++++-- src/utils/command-references.ts | 38 +++++++++++++ src/utils/index.ts | 2 +- test/utils/command-references.test.ts | 77 ++++++++++++++++++++++++++- 5 files changed, 137 insertions(+), 9 deletions(-) diff --git a/src/core/init.ts b/src/core/init.ts index aa38408f2..9e9efb9c3 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -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 { transformToHyphenCommands, transformToSkillReferences } from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME, @@ -538,7 +538,12 @@ export class InitCommand { // 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; + // Use skill references for skills-only delivery (no commands exist) + const transformer = (tool.value === 'opencode' || tool.value === 'pi') + ? transformToHyphenCommands + : delivery === 'skills' + ? transformToSkillReferences + : undefined; const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); // Write the skill file diff --git a/src/core/update.ts b/src/core/update.ts index de922a5ff..31c3bdafe 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -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 { transformToHyphenCommands, transformToSkillReferences } from '../utils/command-references.js'; import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js'; import { generateCommands, @@ -194,8 +194,13 @@ 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; + // Use hyphen-based command references for OpenCode/pi + // Use skill references for skills-only delivery (no commands exist) + const transformer = (tool.value === 'opencode' || tool.value === 'pi') + ? transformToHyphenCommands + : delivery === 'skills' + ? transformToSkillReferences + : undefined; const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } @@ -665,8 +670,13 @@ 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; + // Use hyphen-based command references for OpenCode/pi + // Use skill references for skills-only delivery (no commands exist) + const transformer = (tool.value === 'opencode' || tool.value === 'pi') + ? transformToHyphenCommands + : delivery === 'skills' + ? transformToSkillReferences + : undefined; const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } diff --git a/src/utils/command-references.ts b/src/utils/command-references.ts index bfa49b9ff..b8d114ae1 100644 --- a/src/utils/command-references.ts +++ b/src/utils/command-references.ts @@ -4,6 +4,25 @@ * Utilities for transforming command references to tool-specific formats. */ +/** + * Explicit mapping from `/opsx:` to `/openspec-`. + * Mirrors WORKFLOW_TO_SKILL_DIR from profile-sync-drift.ts. + * Defined inline to avoid circular dependency (utils → core → utils). + */ +const OPSX_TO_SKILL: Record = { + '/opsx:explore': '/openspec-explore', + '/opsx:new': '/openspec-new-change', + '/opsx:continue': '/openspec-continue-change', + '/opsx:apply': '/openspec-apply-change', + '/opsx:ff': '/openspec-ff-change', + '/opsx:sync': '/openspec-sync-specs', + '/opsx:archive': '/openspec-archive-change', + '/opsx:bulk-archive': '/openspec-bulk-archive-change', + '/opsx:verify': '/openspec-verify-change', + '/opsx:onboard': '/openspec-onboard', + '/opsx:propose': '/openspec-propose', +}; + /** * Transforms colon-based command references to hyphen-based format. * Converts `/opsx:` patterns to `/opsx-` for tools that use hyphen syntax. @@ -18,3 +37,22 @@ export function transformToHyphenCommands(text: string): string { return text.replace(/\/opsx:/g, '/opsx-'); } + +/** + * Transforms `/opsx:*` command references to `/openspec-*` skill references. + * Used for skills-only delivery where commands don't exist. + * + * @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('/opsx:explore') // returns '/openspec-explore' + */ +export function transformToSkillReferences(text: string): string { + let result = text; + for (const [from, to] of Object.entries(OPSX_TO_SKILL)) { + result = result.replaceAll(from, to); + } + return result; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index e77ddf476..4e6569726 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -15,4 +15,4 @@ export { export { FileSystemUtils, removeMarkerBlock } from './file-system.js'; // Command reference utilities -export { transformToHyphenCommands } from './command-references.js'; \ No newline at end of file +export { transformToHyphenCommands, transformToSkillReferences } from './command-references.js'; \ No newline at end of file diff --git a/test/utils/command-references.test.ts b/test/utils/command-references.test.ts index c7ff2ed85..b9e8f8873 100644 --- a/test/utils/command-references.test.ts +++ b/test/utils/command-references.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { transformToHyphenCommands } from '../../src/utils/command-references.js'; +import { transformToHyphenCommands, transformToSkillReferences } from '../../src/utils/command-references.js'; +import { WORKFLOW_TO_SKILL_DIR } from '../../src/core/profile-sync-drift.js'; describe('transformToHyphenCommands', () => { describe('basic transformations', () => { @@ -81,3 +82,77 @@ Finally /opsx-apply to implement`; } }); }); + +describe('transformToSkillReferences', () => { + describe('all 11 workflow mappings', () => { + const mappings: [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 [workflow, skillName] of mappings) { + it(`should transform /opsx:${workflow} to /${skillName}`, () => { + expect(transformToSkillReferences(`/opsx:${workflow}`)).toBe(`/${skillName}`); + }); + } + }); + + describe('unknown patterns pass through', () => { + it('should not transform unknown /opsx: references', () => { + expect(transformToSkillReferences('/opsx:unknown')).toBe('/opsx:unknown'); + }); + + it('should not transform non-matching patterns', () => { + const input = '/ops:new opsx: /other:command'; + expect(transformToSkillReferences(input)).toBe(input); + }); + }); + + describe('multiple references', () => { + it('should transform multiple references in one text', () => { + 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 multiline content', () => { + const input = `Use /opsx:explore to investigate +Then /opsx:propose to create a change +Finally /opsx:apply to implement`; + const expected = `Use /openspec-explore to investigate +Then /openspec-propose to create a change +Finally /openspec-apply-change to implement`; + expect(transformToSkillReferences(input)).toBe(expected); + }); + }); + + describe('edge cases', () => { + it('should return empty string unchanged', () => { + expect(transformToSkillReferences('')).toBe(''); + }); + + it('should return text without commands unchanged', () => { + const input = 'This is plain text without commands'; + expect(transformToSkillReferences(input)).toBe(input); + }); + }); + + describe('mapping sync with WORKFLOW_TO_SKILL_DIR', () => { + it('should cover every workflow in WORKFLOW_TO_SKILL_DIR', () => { + for (const [workflow, skillDir] of Object.entries(WORKFLOW_TO_SKILL_DIR)) { + const input = `/opsx:${workflow}`; + const expected = `/${skillDir}`; + expect(transformToSkillReferences(input), `missing mapping for workflow "${workflow}"`).toBe(expected); + } + }); + }); +});