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
47 changes: 41 additions & 6 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ 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 {
getSkillReferencePrefix,
transformToHyphenCommands,
transformToToolSkillReferences,
} from '../utils/command-references.js';
import {
AI_TOOLS,
OPENSPEC_DIR_NAME,
Expand Down Expand Up @@ -74,6 +78,29 @@ const WORKFLOW_TO_SKILL_DIR: Record<string, string> = {
'propose': 'openspec-propose',
};

function getSkillInstructionTransformer(
toolId: string,
hasCommandAdapter: boolean
): ((instructions: string) => string) | undefined {
if (toolId === 'opencode' || toolId === 'pi') {
return transformToHyphenCommands;
}
if (!hasCommandAdapter) {
return (instructions: string) => transformToToolSkillReferences(instructions, toolId);
}
return undefined;
}

function formatSkillGettingStarted(workflowId: 'propose' | 'new', tools: Array<{ value: string }>): string {
const skillName = WORKFLOW_TO_SKILL_DIR[workflowId];
const prefixedTool = tools.find((tool) => getSkillReferencePrefix(tool.value));
const prefix = prefixedTool ? getSkillReferencePrefix(prefixedTool.value) : '';
if (prefix) {
return `${prefix}${skillName} "your idea"`;
}
return `${skillName} skill for "your idea"`;
}

// -----------------------------------------------------------------------------
// Types
// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -535,10 +562,10 @@ export class InitCommand {
for (const { template, dirName } of skillTemplates) {
const skillDir = path.join(skillsDir, dirName);
const skillFile = path.join(skillDir, 'SKILL.md');
const hasCommandAdapter = CommandAdapterRegistry.has(tool.value);

// 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 = getSkillInstructionTransformer(tool.value, hasCommandAdapter);
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);

// Write the skill file
Expand Down Expand Up @@ -657,7 +684,8 @@ export class InitCommand {
const workflows = getProfileWorkflows(profile, globalConfig.workflows);
const toolDirs = [...new Set(successfulTools.map((t) => t.skillsDir))].join(', ');
const skillCount = delivery !== 'commands' ? getSkillTemplates(workflows).length : 0;
const commandCount = delivery !== 'skills' ? getCommandContents(workflows).length : 0;
const hasCommandCapableTool = successfulTools.some((tool) => CommandAdapterRegistry.has(tool.value));
const commandCount = delivery !== 'skills' && hasCommandCapableTool ? getCommandContents(workflows).length : 0;
if (skillCount > 0 && commandCount > 0) {
console.log(`${skillCount} skills and ${commandCount} commands in ${toolDirs}/`);
} else if (skillCount > 0) {
Expand Down Expand Up @@ -700,13 +728,20 @@ export class InitCommand {
const globalCfg = getGlobalConfig();
const activeProfile: Profile = (this.profileOverride as Profile) ?? globalCfg.profile ?? 'core';
const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)];
const hasCommandCapableTool = successfulTools.some((tool) => CommandAdapterRegistry.has(tool.value));
console.log();
if (activeWorkflows.includes('propose')) {
console.log(chalk.bold('Getting started:'));
console.log(' Start your first change: /opsx:propose "your idea"');
const startCommand = hasCommandCapableTool
? '/opsx:propose "your idea"'
: formatSkillGettingStarted('propose', successfulTools);
console.log(` Start your first change: ${startCommand}`);
} else if (activeWorkflows.includes('new')) {
console.log(chalk.bold('Getting started:'));
console.log(' Start your first change: /opsx:new "your idea"');
const startCommand = hasCommandCapableTool
? '/opsx:new "your idea"'
: formatSkillGettingStarted('new', successfulTools);
console.log(` Start your first change: ${startCommand}`);
} else {
console.log("Done. Run 'openspec config profile' to configure your workflows.");
}
Expand Down
26 changes: 21 additions & 5 deletions src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ 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,
transformToToolSkillReferences,
} from '../utils/command-references.js';
import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js';
import {
generateCommands,
Expand Down Expand Up @@ -52,6 +55,19 @@ const require = createRequire(import.meta.url);
const { version: OPENSPEC_VERSION } = require('../../package.json');
const OLD_CORE_WORKFLOWS = ['propose', 'explore', 'apply', 'archive'] as const;

function getSkillInstructionTransformer(
toolId: string,
hasCommandAdapter: boolean
): ((instructions: string) => string) | undefined {
if (toolId === 'opencode' || toolId === 'pi') {
return transformToHyphenCommands;
}
if (!hasCommandAdapter) {
return (instructions: string) => transformToToolSkillReferences(instructions, toolId);
}
return undefined;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/**
* Options for the update command.
*/
Expand Down Expand Up @@ -195,9 +211,9 @@ export class UpdateCommand {
for (const { template, dirName } of skillTemplates) {
const skillDir = path.join(skillsDir, dirName);
const skillFile = path.join(skillDir, 'SKILL.md');
const hasCommandAdapter = CommandAdapterRegistry.has(tool.value);

// Use hyphen-based command references for OpenCode
const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
const transformer = getSkillInstructionTransformer(tool.value, hasCommandAdapter);
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
await FileSystemUtils.writeFile(skillFile, skillContent);
}
Expand Down Expand Up @@ -689,9 +705,9 @@ export class UpdateCommand {
for (const { template, dirName } of skillTemplates) {
const skillDir = path.join(skillsDir, dirName);
const skillFile = path.join(skillDir, 'SKILL.md');
const hasCommandAdapter = CommandAdapterRegistry.has(tool.value);

// Use hyphen-based command references for OpenCode
const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined;
const transformer = getSkillInstructionTransformer(tool.value, hasCommandAdapter);
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
await FileSystemUtils.writeFile(skillFile, skillContent);
}
Expand Down
35 changes: 35 additions & 0 deletions src/utils/command-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,38 @@
export function transformToHyphenCommands(text: string): string {
return text.replace(/\/opsx:/g, '/opsx-');
}

const WORKFLOW_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',
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

/**
* Transforms generated slash-command references to Agent Skill references.
*
* Adapterless tools can receive skills without matching command files. In that
* mode, `/opsx:*` guidance points users at commands that were never generated.
*/
export function transformToSkillReferences(text: string, prefix = ''): string {
return text.replace(/\/opsx:([a-z-]+)/g, (match, workflow: string) => {
const skillReference = WORKFLOW_TO_SKILL_REFERENCE[workflow];
return skillReference ? `${prefix}${skillReference}` : match;
});
}

export function getSkillReferencePrefix(toolId: string): string {
return toolId === 'kimi' ? '/skill:' : '';
}

export function transformToToolSkillReferences(text: string, toolId: string): string {
return transformToSkillReferences(text, getSkillReferencePrefix(toolId));
}
31 changes: 31 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,18 +180,49 @@ describe('InitCommand', () => {

const skillFile = path.join(testDir, '.kimi', 'skills', 'openspec-explore', 'SKILL.md');
expect(await fileExists(skillFile)).toBe(true);
const proposeSkillFile = path.join(testDir, '.kimi', 'skills', 'openspec-propose', 'SKILL.md');
const proposeSkill = await fs.readFile(proposeSkillFile, 'utf-8');
expect(proposeSkill).not.toContain('/opsx:');
expect(proposeSkill).toContain('/skill:openspec-apply-change');

const commandsDir = path.join(testDir, '.kimi', 'commands');
expect(await directoryExists(commandsDir)).toBe(false);

const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
expect(logCalls.some((entry) => entry.includes('skills in .kimi/'))).toBe(true);
expect(logCalls.some((entry) => entry.includes('skills and') && entry.includes('commands in .kimi/'))).toBe(false);
expect(logCalls.some((entry) => entry.includes('/skill:openspec-propose "your idea"'))).toBe(true);
expect(
logCalls.some(
(entry) => entry.includes('Commands skipped for: kimi') && entry.includes('(no adapter)'),
),
).toBe(true);
});

it('should avoid slash-command references for Vibe adapterless skills', async () => {
saveGlobalConfig({
featureFlags: {},
profile: 'core',
delivery: 'both',
});

const initCommand = new InitCommand({ tools: 'vibe', force: true });
await initCommand.execute(testDir);

const proposeSkillFile = path.join(testDir, '.vibe', 'skills', 'openspec-propose', 'SKILL.md');
const proposeSkill = await fs.readFile(proposeSkillFile, 'utf-8');
expect(proposeSkill).not.toContain('/opsx:');
expect(proposeSkill).toContain('openspec-apply-change');

const commandsDir = path.join(testDir, '.vibe', 'commands');
expect(await directoryExists(commandsDir)).toBe(false);

const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
expect(logCalls.some((entry) => entry.includes('skills in .vibe/'))).toBe(true);
expect(logCalls.some((entry) => entry.includes('skills and') && entry.includes('commands in .vibe/'))).toBe(false);
expect(logCalls.some((entry) => entry.includes('openspec-propose skill for "your idea"'))).toBe(true);
});

it('should create skills for multiple tools at once', async () => {
const initCommand = new InitCommand({ tools: 'claude,cursor', force: true });

Expand Down
39 changes: 38 additions & 1 deletion test/utils/command-references.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, it, expect } from 'vitest';
import { transformToHyphenCommands } from '../../src/utils/command-references.js';
import {
getSkillReferencePrefix,
transformToHyphenCommands,
transformToSkillReferences,
transformToToolSkillReferences,
} from '../../src/utils/command-references.js';

describe('transformToHyphenCommands', () => {
describe('basic transformations', () => {
Expand Down Expand Up @@ -81,3 +86,35 @@ Finally /opsx-apply to implement`;
}
});
});

describe('transformToSkillReferences', () => {
it('should transform command references to skill names', () => {
expect(transformToSkillReferences('/opsx:apply')).toBe('openspec-apply-change');
});

it('should apply an optional skill invocation prefix', () => {
expect(transformToSkillReferences('Run /opsx:propose', '/skill:')).toBe(
'Run /skill:openspec-propose'
);
});

it('should transform multiple workflow references', () => {
const input = 'Use /opsx:new, then /opsx:continue and /opsx:archive';
const expected = 'Use openspec-new-change, then openspec-continue-change and openspec-archive-change';
expect(transformToSkillReferences(input)).toBe(expected);
});

it('should leave unknown command references unchanged', () => {
expect(transformToSkillReferences('/opsx:unknown')).toBe('/opsx:unknown');
});

it('should use Kimi skill invocation prefix for tool-specific references', () => {
expect(getSkillReferencePrefix('kimi')).toBe('/skill:');
expect(transformToToolSkillReferences('/opsx:apply', 'kimi')).toBe('/skill:openspec-apply-change');
});

it('should use plain skill names for tools without a specific prefix', () => {
expect(getSkillReferencePrefix('vibe')).toBe('');
expect(transformToToolSkillReferences('/opsx:apply', 'vibe')).toBe('openspec-apply-change');
});
});