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
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);
}
});
});
});
Loading