Skip to content
Merged
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
16 changes: 12 additions & 4 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { createRequire } from 'module';
import { FileSystemUtils } from '../utils/file-system.js';
import { classifyOpenSpecDir, storePointerProblem } from './project-config.js';
import { findRepoPlanningRootSync } from './planning-home.js';
import { getTransformerForTool } from '../utils/command-references.js';
import { getTransformerForTool, transformToSkillReferences } from '../utils/command-references.js';
import {
AI_TOOLS,
OPENSPEC_DIR_NAME,
Expand Down Expand Up @@ -689,7 +689,7 @@ export class InitCommand {
const skillFile = path.join(skillDir, 'SKILL.md');

// Generate SKILL.md content with YAML frontmatter including generatedBy
const transformer = getTransformerForTool(tool.value, delivery);
const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value));
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);

// Write the skill file
Expand Down Expand Up @@ -868,13 +868,21 @@ export class InitCommand {
const globalCfg = getGlobalConfig();
const activeProfile: Profile = (this.profileOverride as Profile) ?? globalCfg.profile ?? 'core';
const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)];
// When skills were generated but no tool got /opsx:* commands, point at
// the skill instead of a command that does not exist.
const activeDelivery: Delivery = globalCfg.delivery ?? 'both';
const skillsOnlyHint =
successfulTools.length > 0 &&
!successfulTools.some((tool) => shouldGenerateCommandsForTool(tool.value, activeDelivery)) &&
successfulTools.some((tool) => shouldGenerateSkillsForTool(tool.value, activeDelivery));
const startReference = (command: string) => (skillsOnlyHint ? transformToSkillReferences(command) : command);
console.log();
if (activeWorkflows.includes('propose')) {
console.log(chalk.bold('Getting started:'));
console.log(' Start your first change: /opsx:propose "your idea"');
console.log(` Start your first change: ${startReference('/opsx:propose')} "your idea"`);
} else if (activeWorkflows.includes('new')) {
console.log(chalk.bold('Getting started:'));
console.log(' Start your first change: /opsx:new "your idea"');
console.log(` Start your first change: ${startReference('/opsx:new')} "your idea"`);
} else {
console.log("Done. Run 'openspec config profile' to configure your workflows.");
}
Expand Down
4 changes: 2 additions & 2 deletions src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ export class UpdateCommand {
const skillDir = path.join(skillsDir, dirName);
const skillFile = path.join(skillDir, 'SKILL.md');

const transformer = getTransformerForTool(tool.value, delivery);
const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value));
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
await FileSystemUtils.writeFile(skillFile, skillContent);
}
Expand Down Expand Up @@ -855,7 +855,7 @@ export class UpdateCommand {
const skillDir = path.join(skillsDir, dirName);
const skillFile = path.join(skillDir, 'SKILL.md');

const transformer = getTransformerForTool(tool.value, delivery);
const transformer = getTransformerForTool(tool.value, delivery, resolveCommandSurfaceCapability(tool.value));
const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer);
await FileSystemUtils.writeFile(skillFile, skillContent);
}
Expand Down
19 changes: 12 additions & 7 deletions src/utils/command-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,21 +62,26 @@ export function transformToSkillReferences(text: string): string {
/**
* 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 (oh-my-pi, opencode, pi) use hyphen-based command references.
* All other cases keep the default `/opsx:*` references.
* Skill references are used whenever the tool ends up without `/opsx:*`
* commands — either because delivery is skills-only (for every tool) or
* because the tool has no command surface at all (capability 'none', e.g.
* Kimi Code or Mistral Vibe) — 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 (oh-my-pi, 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
* @param capability - The tool's command surface capability
* @returns The transformer to pass to generateSkillContent, or undefined
*/
export function getTransformerForTool(
toolId: string,
delivery: 'both' | 'skills' | 'commands'
delivery: 'both' | 'skills' | 'commands',
capability: 'adapter-backed' | 'skills-invocable' | 'none'
): ((text: string) => string) | undefined {
if (delivery === 'skills') {
if (delivery === 'skills' || capability === 'none') {
return transformToSkillReferences;
}
if (toolId === 'opencode' || toolId === 'pi' || toolId === 'oh-my-pi') {
Expand Down
34 changes: 34 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,40 @@ describe('InitCommand - profile and detection features', () => {
expect(updateSkillContent).toContain('/openspec-');
});

it('should use skill references for adapterless tools under default delivery (#1155)', async () => {
// Kimi Code has no command adapter: commands are skipped even when
// delivery is 'both', so generated skills must not reference /opsx:*
const initCommand = new InitCommand({ tools: 'kimi', force: true });
await initCommand.execute(testDir);

const skillFile = path.join(testDir, '.kimi-code', 'skills', 'openspec-apply-change', 'SKILL.md');
expect(await fileExists(skillFile)).toBe(true);

const skillContent = await fs.readFile(skillFile, 'utf-8');
expect(skillContent).not.toContain('/opsx:');
expect(skillContent).not.toContain('/opsx-');
expect(skillContent).toContain('/openspec-');

// The getting-started hint must point at the skill, not a missing command
const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
const startHint = logCalls.find((entry) => entry.includes('Start your first change'));
expect(startHint).toContain('/openspec-propose');
expect(startHint).not.toContain('/opsx:propose');
});

it('should keep /opsx: command hints for adapter-backed tools under default delivery', async () => {
const initCommand = new InitCommand({ tools: 'claude', force: true });
await initCommand.execute(testDir);

const skillFile = path.join(testDir, '.claude', 'skills', 'openspec-apply-change', 'SKILL.md');
const skillContent = await fs.readFile(skillFile, 'utf-8');
expect(skillContent).toContain('/opsx:');

const logCalls = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.flat().map(String);
const startHint = logCalls.find((entry) => entry.includes('Start your first change'));
expect(startHint).toContain('/opsx:propose');
});

it('should use skill references for opencode in skills-only delivery', async () => {
saveGlobalConfig({
featureFlags: {},
Expand Down
37 changes: 23 additions & 14 deletions test/utils/command-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,25 +169,34 @@ Then /openspec-apply-change to implement`;

describe('getTransformerForTool', () => {
it('selects skill references for skills-only delivery for every tool', () => {
expect(getTransformerForTool('claude', 'skills')).toBe(transformToSkillReferences);
expect(getTransformerForTool('codex', 'skills')).toBe(transformToSkillReferences);
expect(getTransformerForTool('claude', 'skills', 'adapter-backed')).toBe(transformToSkillReferences);
expect(getTransformerForTool('codex', 'skills', 'skills-invocable')).toBe(transformToSkillReferences);
// hyphen-command tools must not fall back to hyphen commands when no commands are generated
expect(getTransformerForTool('opencode', 'skills')).toBe(transformToSkillReferences);
expect(getTransformerForTool('pi', 'skills')).toBe(transformToSkillReferences);
expect(getTransformerForTool('oh-my-pi', 'skills')).toBe(transformToSkillReferences);
expect(getTransformerForTool('opencode', 'skills', 'adapter-backed')).toBe(transformToSkillReferences);
expect(getTransformerForTool('pi', 'skills', 'adapter-backed')).toBe(transformToSkillReferences);
expect(getTransformerForTool('oh-my-pi', 'skills', 'adapter-backed')).toBe(transformToSkillReferences);
});

it('selects skill references for tools without a command surface, regardless of delivery', () => {
// Tools like Kimi Code or Mistral Vibe have no command adapter, so their
// skills must never reference /opsx:* commands that were not generated.
expect(getTransformerForTool('kimi', 'both', 'none')).toBe(transformToSkillReferences);
expect(getTransformerForTool('vibe', 'both', 'none')).toBe(transformToSkillReferences);
expect(getTransformerForTool('kimi', 'commands', 'none')).toBe(transformToSkillReferences);
});

it('selects hyphen commands for opencode, pi, and oh-my-pi when commands are generated', () => {
expect(getTransformerForTool('opencode', 'both')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('opencode', 'commands')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('pi', 'both')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('pi', 'commands')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('oh-my-pi', 'both')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('oh-my-pi', 'commands')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('opencode', 'both', 'adapter-backed')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('opencode', 'commands', 'adapter-backed')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('pi', 'both', 'adapter-backed')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('pi', 'commands', 'adapter-backed')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('oh-my-pi', 'both', 'adapter-backed')).toBe(transformToHyphenCommands);
expect(getTransformerForTool('oh-my-pi', 'commands', 'adapter-backed')).toBe(transformToHyphenCommands);
});

it('selects no transformer for other tools when commands are generated', () => {
expect(getTransformerForTool('claude', 'both')).toBeUndefined();
expect(getTransformerForTool('claude', 'commands')).toBeUndefined();
it('selects no transformer for adapter-backed and skills-invocable tools when commands are generated', () => {
expect(getTransformerForTool('claude', 'both', 'adapter-backed')).toBeUndefined();
expect(getTransformerForTool('claude', 'commands', 'adapter-backed')).toBeUndefined();
expect(getTransformerForTool('codex', 'both', 'skills-invocable')).toBeUndefined();
});
});
Loading