Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 7 additions & 2 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 { transformToHyphenCommands, transformToSkillReferences } from '../utils/command-references.js';
import {
AI_TOOLS,
OPENSPEC_DIR_NAME,
Expand Down Expand Up @@ -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
Expand Down
20 changes: 15 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 { transformToHyphenCommands, transformToSkillReferences } from '../utils/command-references.js';
import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
import {
generateCommands,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down
38 changes: 38 additions & 0 deletions src/utils/command-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@
* Utilities for transforming command references to tool-specific formats.
*/

/**
* Explicit mapping from `/opsx:<workflow>` to `/openspec-<skill-name>`.
* Mirrors WORKFLOW_TO_SKILL_DIR from profile-sync-drift.ts.
* Defined inline to avoid circular dependency (utils → core → utils).
*/
const OPSX_TO_SKILL: Record<string, string> = {
'/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.
Expand All @@ -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;
}
2 changes: 1 addition & 1 deletion src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,4 @@ export {
export { FileSystemUtils, removeMarkerBlock } from './file-system.js';

// Command reference utilities
export { transformToHyphenCommands } from './command-references.js';
export { transformToHyphenCommands, transformToSkillReferences } from './command-references.js';
77 changes: 76 additions & 1 deletion test/utils/command-references.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
}
});
});
});