From d12997bfc4e1a0a09366dd358fbb581900d1ef1a Mon Sep 17 00:00:00 2001 From: TabishB Date: Sun, 12 Apr 2026 00:06:22 +1000 Subject: [PATCH 1/2] fix: pi.dev prompt naming and template args passing (#912) Fix two pi.dev integration bugs: - Use colon-based filenames (opsx:explore.md) so CLI commands render as /opsx:explore instead of /opsx-explore - Inject $@ into template body so user arguments are passed through Adds getLegacyFilePaths to ToolCommandAdapter for migration-safe cleanup of old hyphenated files during init/update. --- docs/supported-tools.md | 2 +- src/core/command-generation/adapters/pi.ts | 23 ++++++- src/core/command-generation/index.ts | 1 + src/core/command-generation/types.ts | 19 ++++++ src/core/init.ts | 40 ++++++++++-- src/core/profile-sync-drift.ts | 42 ++++++++---- src/core/update.ts | 64 ++++++++++++++----- test/core/command-generation/adapters.test.ts | 23 ++++++- test/core/update.test.ts | 15 +++++ 9 files changed, 187 insertions(+), 42 deletions(-) diff --git a/docs/supported-tools.md b/docs/supported-tools.md index f652ddd13..193a11607 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -41,7 +41,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `sync`, `b | Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilocode/workflows/opsx-.md` | | Kiro (`kiro`) | `.kiro/skills/openspec-*/SKILL.md` | `.kiro/prompts/opsx-.prompt.md` | | OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-.md` | -| Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-.md` | +| Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx:.md` | | Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/.md` | | Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-.toml` | | RooCode (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-.md` | diff --git a/src/core/command-generation/adapters/pi.ts b/src/core/command-generation/adapters/pi.ts index cb8d2b331..b24e754a3 100644 --- a/src/core/command-generation/adapters/pi.ts +++ b/src/core/command-generation/adapters/pi.ts @@ -8,6 +8,19 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +const PI_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m; + +function injectPiArgs(body: string): string { + if (body.includes('$@') || body.includes('$ARGUMENTS')) { + return body; + } + + return body.replace( + PI_INPUT_HEADING, + (heading) => `${heading}\n**Provided arguments**: $@` + ); +} + /** * Escapes a string value for safe YAML output. * Quotes the string if it contains special YAML characters. @@ -25,14 +38,18 @@ function escapeYamlValue(value: string): string { /** * Pi adapter for prompt template generation. - * File path: .pi/prompts/opsx-.md + * File path: .pi/prompts/opsx:.md * Frontmatter: description */ export const piAdapter: ToolCommandAdapter = { toolId: 'pi', getFilePath(commandId: string): string { - return path.join('.pi', 'prompts', `opsx-${commandId}.md`); + return path.join('.pi', 'prompts', `opsx:${commandId}.md`); + }, + + getLegacyFilePaths(commandId: string): string[] { + return [path.join('.pi', 'prompts', `opsx-${commandId}.md`)]; }, formatFile(content: CommandContent): string { @@ -40,7 +57,7 @@ export const piAdapter: ToolCommandAdapter = { description: ${escapeYamlValue(content.description)} --- -${content.body} +${injectPiArgs(content.body)} `; }, }; diff --git a/src/core/command-generation/index.ts b/src/core/command-generation/index.ts index a067f33b2..3530ca5a4 100644 --- a/src/core/command-generation/index.ts +++ b/src/core/command-generation/index.ts @@ -22,6 +22,7 @@ export type { ToolCommandAdapter, GeneratedCommand, } from './types.js'; +export { getCommandFilePaths } from './types.js'; // Registry export { CommandAdapterRegistry } from './registry.js'; diff --git a/src/core/command-generation/types.ts b/src/core/command-generation/types.ts index 582d8c784..2c48bd4cf 100644 --- a/src/core/command-generation/types.ts +++ b/src/core/command-generation/types.ts @@ -39,6 +39,12 @@ export interface ToolCommandAdapter { * May be absolute for tools with global-scoped prompts (e.g., Codex). */ getFilePath(commandId: string): string; + /** + * Returns legacy file paths that should be treated as aliases for this command. + * These are used for migration-safe detection and cleanup when a tool changes + * its on-disk command naming convention. + */ + getLegacyFilePaths?(commandId: string): string[]; /** * Formats the complete file content including frontmatter. * @param content - The tool-agnostic command content @@ -47,6 +53,19 @@ export interface ToolCommandAdapter { formatFile(content: CommandContent): string; } +/** + * Returns the canonical file path followed by any legacy aliases for a command. + */ +export function getCommandFilePaths( + adapter: ToolCommandAdapter, + commandId: string +): string[] { + return [ + adapter.getFilePath(commandId), + ...(adapter.getLegacyFilePaths?.(commandId) ?? []), + ].filter((path, index, paths) => paths.indexOf(path) === index); +} + /** * Result of generating a command file. */ diff --git a/src/core/init.ts b/src/core/init.ts index 95728dc7e..8bebff3ee 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -23,6 +23,8 @@ import { serializeConfig } from './config-prompts.js'; import { generateCommands, CommandAdapterRegistry, + getCommandFilePaths, + type ToolCommandAdapter, } from './command-generation/index.js'; import { detectLegacyArtifacts, @@ -556,9 +558,14 @@ export class InitCommand { if (adapter) { const generatedCommands = generateCommands(commandContents, adapter); - for (const cmd of generatedCommands) { + for (let index = 0; index < generatedCommands.length; index++) { + const cmd = generatedCommands[index]; const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path); await FileSystemUtils.writeFile(commandFile, cmd.fileContent); + const commandId = commandContents[index]?.id; + if (commandId) { + await this.removeLegacyCommandAliases(projectPath, adapter, commandId); + } } } else { commandsSkipped.push(tool.value); @@ -761,19 +768,40 @@ export class InitCommand { if (!adapter) return 0; for (const workflow of ALL_WORKFLOWS) { - const cmdPath = adapter.getFilePath(workflow); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + for (const cmdPath of getCommandFilePaths(adapter, workflow)) { + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + + try { + if (fs.existsSync(fullPath)) { + await fs.promises.unlink(fullPath); + removed++; + } + } catch { + // Ignore errors + } + } + } + + return removed; + } + + private async removeLegacyCommandAliases( + projectPath: string, + adapter: ToolCommandAdapter, + commandId: string + ): Promise { + const [, ...legacyPaths] = getCommandFilePaths(adapter, commandId); + + for (const legacyPath of legacyPaths) { + const fullPath = path.isAbsolute(legacyPath) ? legacyPath : path.join(projectPath, legacyPath); try { if (fs.existsSync(fullPath)) { await fs.promises.unlink(fullPath); - removed++; } } catch { // Ignore errors } } - - return removed; } } diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index 782bdcc9f..ffe1542ef 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import { AI_TOOLS } from './config.js'; import type { Delivery } from './global-config.js'; import { ALL_WORKFLOWS } from './profiles.js'; -import { CommandAdapterRegistry } from './command-generation/index.js'; +import { CommandAdapterRegistry, getCommandFilePaths } from './command-generation/index.js'; import { COMMAND_IDS, getConfiguredTools } from './shared/index.js'; type WorkflowId = (typeof ALL_WORKFLOWS)[number]; @@ -40,9 +40,11 @@ export function toolHasAnyConfiguredCommand(projectPath: string, toolId: string) if (!adapter) return false; for (const commandId of COMMAND_IDS) { - const cmdPath = adapter.getFilePath(commandId); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - if (fs.existsSync(fullPath)) { + const cmdPaths = getCommandFilePaths(adapter, commandId); + if (cmdPaths.some((cmdPath) => { + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + return fs.existsSync(fullPath); + })) { return true; } } @@ -131,27 +133,37 @@ export function hasToolProfileOrDeliveryDrift( if (shouldGenerateCommands && adapter) { for (const workflow of knownDesiredWorkflows) { - const cmdPath = adapter.getFilePath(workflow); + const [cmdPath, ...legacyPaths] = getCommandFilePaths(adapter, workflow); const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); if (!fs.existsSync(fullPath)) { return true; } + if (legacyPaths.some((legacyPath) => { + const fullLegacyPath = path.isAbsolute(legacyPath) ? legacyPath : path.join(projectPath, legacyPath); + return fs.existsSync(fullLegacyPath); + })) { + return true; + } } // Deselecting workflows in a profile should trigger sync. for (const workflow of ALL_WORKFLOWS) { if (desiredWorkflowSet.has(workflow)) continue; - const cmdPath = adapter.getFilePath(workflow); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - if (fs.existsSync(fullPath)) { + const cmdPaths = getCommandFilePaths(adapter, workflow); + if (cmdPaths.some((cmdPath) => { + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + return fs.existsSync(fullPath); + })) { return true; } } } else if (!shouldGenerateCommands && adapter) { for (const workflow of ALL_WORKFLOWS) { - const cmdPath = adapter.getFilePath(workflow); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - if (fs.existsSync(fullPath)) { + const cmdPaths = getCommandFilePaths(adapter, workflow); + if (cmdPaths.some((cmdPath) => { + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + return fs.existsSync(fullPath); + })) { return true; } } @@ -200,9 +212,11 @@ function getInstalledWorkflowsForTool( const adapter = CommandAdapterRegistry.get(toolId); if (adapter) { for (const workflow of ALL_WORKFLOWS) { - const cmdPath = adapter.getFilePath(workflow); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - if (fs.existsSync(fullPath)) { + const cmdPaths = getCommandFilePaths(adapter, workflow); + if (cmdPaths.some((cmdPath) => { + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + return fs.existsSync(fullPath); + })) { installed.add(workflow); } } diff --git a/src/core/update.ts b/src/core/update.ts index 62db8a08f..27e132582 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -16,6 +16,8 @@ import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js'; import { generateCommands, CommandAdapterRegistry, + getCommandFilePaths, + type ToolCommandAdapter, } from './command-generation/index.js'; import { getToolVersionStatus, @@ -214,9 +216,14 @@ export class UpdateCommand { if (adapter) { const generatedCommands = generateCommands(commandContents, adapter); - for (const cmd of generatedCommands) { + for (let index = 0; index < generatedCommands.length; index++) { + const cmd = generatedCommands[index]; const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path); await FileSystemUtils.writeFile(commandFile, cmd.fileContent); + const commandId = commandContents[index]?.id; + if (commandId) { + await this.removeLegacyCommandAliases(resolvedProjectPath, adapter, commandId); + } } removedDeselectedCommandCount += await this.removeUnselectedCommandFiles( @@ -438,16 +445,17 @@ export class UpdateCommand { if (!adapter) return 0; for (const workflow of ALL_WORKFLOWS) { - const cmdPath = adapter.getFilePath(workflow); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + for (const cmdPath of getCommandFilePaths(adapter, workflow)) { + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - try { - if (fs.existsSync(fullPath)) { - await fs.promises.unlink(fullPath); - removed++; + try { + if (fs.existsSync(fullPath)) { + await fs.promises.unlink(fullPath); + removed++; + } + } catch { + // Ignore errors } - } catch { - // Ignore errors } } @@ -472,20 +480,41 @@ export class UpdateCommand { for (const workflow of ALL_WORKFLOWS) { if (desiredSet.has(workflow)) continue; - const cmdPath = adapter.getFilePath(workflow); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + for (const cmdPath of getCommandFilePaths(adapter, workflow)) { + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + + try { + if (fs.existsSync(fullPath)) { + await fs.promises.unlink(fullPath); + removed++; + } + } catch { + // Ignore errors + } + } + } + + return removed; + } + + private async removeLegacyCommandAliases( + projectPath: string, + adapter: ToolCommandAdapter, + commandId: string + ): Promise { + const [, ...legacyPaths] = getCommandFilePaths(adapter, commandId); + + for (const legacyPath of legacyPaths) { + const fullPath = path.isAbsolute(legacyPath) ? legacyPath : path.join(projectPath, legacyPath); try { if (fs.existsSync(fullPath)) { await fs.promises.unlink(fullPath); - removed++; } } catch { // Ignore errors } } - - return removed; } /** @@ -678,9 +707,14 @@ export class UpdateCommand { if (adapter) { const generatedCommands = generateCommands(commandContents, adapter); - for (const cmd of generatedCommands) { + for (let index = 0; index < generatedCommands.length; index++) { + const cmd = generatedCommands[index]; const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path); await FileSystemUtils.writeFile(commandFile, cmd.fileContent); + const commandId = commandContents[index]?.id; + if (commandId) { + await this.removeLegacyCommandAliases(projectPath, adapter, commandId); + } } } } diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index dab19bf3d..7c88208c8 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -531,12 +531,18 @@ describe('command-generation/adapters', () => { it('should generate correct file path', () => { const filePath = piAdapter.getFilePath('explore'); - expect(filePath).toBe(path.join('.pi', 'prompts', 'opsx-explore.md')); + expect(filePath).toBe(path.join('.pi', 'prompts', 'opsx:explore.md')); }); it('should generate correct file paths for different commands', () => { - expect(piAdapter.getFilePath('new')).toBe(path.join('.pi', 'prompts', 'opsx-new.md')); - expect(piAdapter.getFilePath('bulk-archive')).toBe(path.join('.pi', 'prompts', 'opsx-bulk-archive.md')); + expect(piAdapter.getFilePath('new')).toBe(path.join('.pi', 'prompts', 'opsx:new.md')); + expect(piAdapter.getFilePath('bulk-archive')).toBe(path.join('.pi', 'prompts', 'opsx:bulk-archive.md')); + }); + + it('should expose legacy hyphenated file paths for migration', () => { + expect(piAdapter.getLegacyFilePaths?.('explore')).toEqual([ + path.join('.pi', 'prompts', 'opsx-explore.md'), + ]); }); it('should format file with description frontmatter', () => { @@ -547,6 +553,17 @@ describe('command-generation/adapters', () => { expect(output).toContain('This is the command body.'); }); + it('should inject template arguments into the input section', () => { + const contentWithInput: CommandContent = { + ...sampleContent, + body: '**Input**: The argument after `/opsx:explore` is the topic.\n\n**Steps**\n1. Think.', + }; + + const output = piAdapter.formatFile(contentWithInput); + expect(output).toContain('**Input**: The argument after `/opsx:explore` is the topic.'); + expect(output).toContain('**Provided arguments**: $@'); + }); + it('should escape YAML special characters in description', () => { const contentWithSpecialChars: CommandContent = { ...sampleContent, diff --git a/test/core/update.test.ts b/test/core/update.test.ts index f3d393d93..523f8e2b2 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -250,6 +250,21 @@ Old instructions content expect(exists).toBe(false); } }); + + it('migrates legacy Pi command files to colon-based prompt names', async () => { + const legacyPiPrompt = path.join(testDir, '.pi', 'prompts', 'opsx-explore.md'); + await fs.mkdir(path.dirname(legacyPiPrompt), { recursive: true }); + await fs.writeFile(legacyPiPrompt, 'legacy pi prompt'); + + await updateCommand.execute(testDir); + + const newPiPrompt = path.join(testDir, '.pi', 'prompts', 'opsx:explore.md'); + expect(await FileSystemUtils.fileExists(newPiPrompt)).toBe(true); + expect(await FileSystemUtils.fileExists(legacyPiPrompt)).toBe(false); + + const content = await fs.readFile(newPiPrompt, 'utf-8'); + expect(content).toContain('**Provided arguments**: $@'); + }); }); describe('multi-tool support', () => { From 95e9988bb06dc26f718251542556185ec24b27dd Mon Sep 17 00:00:00 2001 From: TabishB Date: Sun, 12 Apr 2026 00:54:06 +1000 Subject: [PATCH 2/2] fix: pi.dev command references and template args passing (#912) - Transform /opsx: references to /opsx- in Pi command bodies and skills, matching the hyphenated filename convention (same approach as OpenCode) - Inject $@ into template body so user arguments are passed through Pi uses the filename (minus .md) as the slash command name, so opsx-propose.md becomes /opsx-propose. This keeps filenames cross-platform safe while ensuring command references in the body match the actual command names. --- docs/supported-tools.md | 2 +- src/core/command-generation/adapters/pi.ts | 18 +++-- src/core/command-generation/index.ts | 1 - src/core/command-generation/types.ts | 19 ------ src/core/init.ts | 44 +++--------- src/core/profile-sync-drift.ts | 42 ++++-------- src/core/update.ts | 68 +++++-------------- test/core/command-generation/adapters.test.ts | 25 ++++--- test/core/update.test.ts | 14 ---- 9 files changed, 66 insertions(+), 167 deletions(-) diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 193a11607..f652ddd13 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -41,7 +41,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `sync`, `b | Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilocode/workflows/opsx-.md` | | Kiro (`kiro`) | `.kiro/skills/openspec-*/SKILL.md` | `.kiro/prompts/opsx-.prompt.md` | | OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-.md` | -| Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx:.md` | +| Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-.md` | | Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/.md` | | Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-.toml` | | RooCode (`roocode`) | `.roo/skills/openspec-*/SKILL.md` | `.roo/commands/opsx-.md` | diff --git a/src/core/command-generation/adapters/pi.ts b/src/core/command-generation/adapters/pi.ts index b24e754a3..fa11d9d8e 100644 --- a/src/core/command-generation/adapters/pi.ts +++ b/src/core/command-generation/adapters/pi.ts @@ -7,6 +7,7 @@ import path from 'path'; import type { CommandContent, ToolCommandAdapter } from '../types.js'; +import { transformToHyphenCommands } from '../../../utils/command-references.js'; const PI_INPUT_HEADING = /^\*\*Input\*\*:[^\n]*$/m; @@ -38,26 +39,29 @@ function escapeYamlValue(value: string): string { /** * Pi adapter for prompt template generation. - * File path: .pi/prompts/opsx:.md + * File path: .pi/prompts/opsx-.md * Frontmatter: description + * + * Pi uses the filename (minus .md) as the slash command name, so + * opsx-propose.md → /opsx-propose. Command references in the body + * are transformed from /opsx: to /opsx- for consistency. */ export const piAdapter: ToolCommandAdapter = { toolId: 'pi', getFilePath(commandId: string): string { - return path.join('.pi', 'prompts', `opsx:${commandId}.md`); - }, - - getLegacyFilePaths(commandId: string): string[] { - return [path.join('.pi', 'prompts', `opsx-${commandId}.md`)]; + return path.join('.pi', 'prompts', `opsx-${commandId}.md`); }, formatFile(content: CommandContent): string { + // Transform /opsx: references to /opsx- and inject $@ for template args + const transformedBody = transformToHyphenCommands(content.body); + return `--- description: ${escapeYamlValue(content.description)} --- -${injectPiArgs(content.body)} +${injectPiArgs(transformedBody)} `; }, }; diff --git a/src/core/command-generation/index.ts b/src/core/command-generation/index.ts index 3530ca5a4..a067f33b2 100644 --- a/src/core/command-generation/index.ts +++ b/src/core/command-generation/index.ts @@ -22,7 +22,6 @@ export type { ToolCommandAdapter, GeneratedCommand, } from './types.js'; -export { getCommandFilePaths } from './types.js'; // Registry export { CommandAdapterRegistry } from './registry.js'; diff --git a/src/core/command-generation/types.ts b/src/core/command-generation/types.ts index 2c48bd4cf..582d8c784 100644 --- a/src/core/command-generation/types.ts +++ b/src/core/command-generation/types.ts @@ -39,12 +39,6 @@ export interface ToolCommandAdapter { * May be absolute for tools with global-scoped prompts (e.g., Codex). */ getFilePath(commandId: string): string; - /** - * Returns legacy file paths that should be treated as aliases for this command. - * These are used for migration-safe detection and cleanup when a tool changes - * its on-disk command naming convention. - */ - getLegacyFilePaths?(commandId: string): string[]; /** * Formats the complete file content including frontmatter. * @param content - The tool-agnostic command content @@ -53,19 +47,6 @@ export interface ToolCommandAdapter { formatFile(content: CommandContent): string; } -/** - * Returns the canonical file path followed by any legacy aliases for a command. - */ -export function getCommandFilePaths( - adapter: ToolCommandAdapter, - commandId: string -): string[] { - return [ - adapter.getFilePath(commandId), - ...(adapter.getLegacyFilePaths?.(commandId) ?? []), - ].filter((path, index, paths) => paths.indexOf(path) === index); -} - /** * Result of generating a command file. */ diff --git a/src/core/init.ts b/src/core/init.ts index 8bebff3ee..aa38408f2 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -23,8 +23,6 @@ import { serializeConfig } from './config-prompts.js'; import { generateCommands, CommandAdapterRegistry, - getCommandFilePaths, - type ToolCommandAdapter, } from './command-generation/index.js'; import { detectLegacyArtifacts, @@ -539,8 +537,8 @@ export class InitCommand { const skillFile = path.join(skillDir, 'SKILL.md'); // Generate SKILL.md content with YAML frontmatter including generatedBy - // Use hyphen-based command references for OpenCode - const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined; + // Use hyphen-based command references for tools where filename = command name + const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined; const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); // Write the skill file @@ -558,14 +556,9 @@ export class InitCommand { if (adapter) { const generatedCommands = generateCommands(commandContents, adapter); - for (let index = 0; index < generatedCommands.length; index++) { - const cmd = generatedCommands[index]; + for (const cmd of generatedCommands) { const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path); await FileSystemUtils.writeFile(commandFile, cmd.fileContent); - const commandId = commandContents[index]?.id; - if (commandId) { - await this.removeLegacyCommandAliases(projectPath, adapter, commandId); - } } } else { commandsSkipped.push(tool.value); @@ -768,40 +761,19 @@ export class InitCommand { if (!adapter) return 0; for (const workflow of ALL_WORKFLOWS) { - for (const cmdPath of getCommandFilePaths(adapter, workflow)) { - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - - try { - if (fs.existsSync(fullPath)) { - await fs.promises.unlink(fullPath); - removed++; - } - } catch { - // Ignore errors - } - } - } - - return removed; - } - - private async removeLegacyCommandAliases( - projectPath: string, - adapter: ToolCommandAdapter, - commandId: string - ): Promise { - const [, ...legacyPaths] = getCommandFilePaths(adapter, commandId); - - for (const legacyPath of legacyPaths) { - const fullPath = path.isAbsolute(legacyPath) ? legacyPath : path.join(projectPath, legacyPath); + const cmdPath = adapter.getFilePath(workflow); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); try { if (fs.existsSync(fullPath)) { await fs.promises.unlink(fullPath); + removed++; } } catch { // Ignore errors } } + + return removed; } } diff --git a/src/core/profile-sync-drift.ts b/src/core/profile-sync-drift.ts index ffe1542ef..782bdcc9f 100644 --- a/src/core/profile-sync-drift.ts +++ b/src/core/profile-sync-drift.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import { AI_TOOLS } from './config.js'; import type { Delivery } from './global-config.js'; import { ALL_WORKFLOWS } from './profiles.js'; -import { CommandAdapterRegistry, getCommandFilePaths } from './command-generation/index.js'; +import { CommandAdapterRegistry } from './command-generation/index.js'; import { COMMAND_IDS, getConfiguredTools } from './shared/index.js'; type WorkflowId = (typeof ALL_WORKFLOWS)[number]; @@ -40,11 +40,9 @@ export function toolHasAnyConfiguredCommand(projectPath: string, toolId: string) if (!adapter) return false; for (const commandId of COMMAND_IDS) { - const cmdPaths = getCommandFilePaths(adapter, commandId); - if (cmdPaths.some((cmdPath) => { - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - return fs.existsSync(fullPath); - })) { + const cmdPath = adapter.getFilePath(commandId); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + if (fs.existsSync(fullPath)) { return true; } } @@ -133,37 +131,27 @@ export function hasToolProfileOrDeliveryDrift( if (shouldGenerateCommands && adapter) { for (const workflow of knownDesiredWorkflows) { - const [cmdPath, ...legacyPaths] = getCommandFilePaths(adapter, workflow); + const cmdPath = adapter.getFilePath(workflow); const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); if (!fs.existsSync(fullPath)) { return true; } - if (legacyPaths.some((legacyPath) => { - const fullLegacyPath = path.isAbsolute(legacyPath) ? legacyPath : path.join(projectPath, legacyPath); - return fs.existsSync(fullLegacyPath); - })) { - return true; - } } // Deselecting workflows in a profile should trigger sync. for (const workflow of ALL_WORKFLOWS) { if (desiredWorkflowSet.has(workflow)) continue; - const cmdPaths = getCommandFilePaths(adapter, workflow); - if (cmdPaths.some((cmdPath) => { - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - return fs.existsSync(fullPath); - })) { + const cmdPath = adapter.getFilePath(workflow); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + if (fs.existsSync(fullPath)) { return true; } } } else if (!shouldGenerateCommands && adapter) { for (const workflow of ALL_WORKFLOWS) { - const cmdPaths = getCommandFilePaths(adapter, workflow); - if (cmdPaths.some((cmdPath) => { - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - return fs.existsSync(fullPath); - })) { + const cmdPath = adapter.getFilePath(workflow); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + if (fs.existsSync(fullPath)) { return true; } } @@ -212,11 +200,9 @@ function getInstalledWorkflowsForTool( const adapter = CommandAdapterRegistry.get(toolId); if (adapter) { for (const workflow of ALL_WORKFLOWS) { - const cmdPaths = getCommandFilePaths(adapter, workflow); - if (cmdPaths.some((cmdPath) => { - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - return fs.existsSync(fullPath); - })) { + const cmdPath = adapter.getFilePath(workflow); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + if (fs.existsSync(fullPath)) { installed.add(workflow); } } diff --git a/src/core/update.ts b/src/core/update.ts index 27e132582..de922a5ff 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -16,8 +16,6 @@ import { AI_TOOLS, OPENSPEC_DIR_NAME } from './config.js'; import { generateCommands, CommandAdapterRegistry, - getCommandFilePaths, - type ToolCommandAdapter, } from './command-generation/index.js'; import { getToolVersionStatus, @@ -197,7 +195,7 @@ export class UpdateCommand { const skillFile = path.join(skillDir, 'SKILL.md'); // Use hyphen-based command references for OpenCode - const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined; + const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined; const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } @@ -216,14 +214,9 @@ export class UpdateCommand { if (adapter) { const generatedCommands = generateCommands(commandContents, adapter); - for (let index = 0; index < generatedCommands.length; index++) { - const cmd = generatedCommands[index]; + for (const cmd of generatedCommands) { const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path); await FileSystemUtils.writeFile(commandFile, cmd.fileContent); - const commandId = commandContents[index]?.id; - if (commandId) { - await this.removeLegacyCommandAliases(resolvedProjectPath, adapter, commandId); - } } removedDeselectedCommandCount += await this.removeUnselectedCommandFiles( @@ -445,17 +438,16 @@ export class UpdateCommand { if (!adapter) return 0; for (const workflow of ALL_WORKFLOWS) { - for (const cmdPath of getCommandFilePaths(adapter, workflow)) { - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + const cmdPath = adapter.getFilePath(workflow); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - try { - if (fs.existsSync(fullPath)) { - await fs.promises.unlink(fullPath); - removed++; - } - } catch { - // Ignore errors + try { + if (fs.existsSync(fullPath)) { + await fs.promises.unlink(fullPath); + removed++; } + } catch { + // Ignore errors } } @@ -480,41 +472,20 @@ export class UpdateCommand { for (const workflow of ALL_WORKFLOWS) { if (desiredSet.has(workflow)) continue; - for (const cmdPath of getCommandFilePaths(adapter, workflow)) { - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - - try { - if (fs.existsSync(fullPath)) { - await fs.promises.unlink(fullPath); - removed++; - } - } catch { - // Ignore errors - } - } - } - - return removed; - } - - private async removeLegacyCommandAliases( - projectPath: string, - adapter: ToolCommandAdapter, - commandId: string - ): Promise { - const [, ...legacyPaths] = getCommandFilePaths(adapter, commandId); - - for (const legacyPath of legacyPaths) { - const fullPath = path.isAbsolute(legacyPath) ? legacyPath : path.join(projectPath, legacyPath); + const cmdPath = adapter.getFilePath(workflow); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); try { if (fs.existsSync(fullPath)) { await fs.promises.unlink(fullPath); + removed++; } } catch { // Ignore errors } } + + return removed; } /** @@ -695,7 +666,7 @@ export class UpdateCommand { const skillFile = path.join(skillDir, 'SKILL.md'); // Use hyphen-based command references for OpenCode - const transformer = tool.value === 'opencode' ? transformToHyphenCommands : undefined; + const transformer = (tool.value === 'opencode' || tool.value === 'pi') ? transformToHyphenCommands : undefined; const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } @@ -707,14 +678,9 @@ export class UpdateCommand { if (adapter) { const generatedCommands = generateCommands(commandContents, adapter); - for (let index = 0; index < generatedCommands.length; index++) { - const cmd = generatedCommands[index]; + for (const cmd of generatedCommands) { const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(projectPath, cmd.path); await FileSystemUtils.writeFile(commandFile, cmd.fileContent); - const commandId = commandContents[index]?.id; - if (commandId) { - await this.removeLegacyCommandAliases(projectPath, adapter, commandId); - } } } } diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index 7c88208c8..1f502a34c 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -531,18 +531,12 @@ describe('command-generation/adapters', () => { it('should generate correct file path', () => { const filePath = piAdapter.getFilePath('explore'); - expect(filePath).toBe(path.join('.pi', 'prompts', 'opsx:explore.md')); + expect(filePath).toBe(path.join('.pi', 'prompts', 'opsx-explore.md')); }); it('should generate correct file paths for different commands', () => { - expect(piAdapter.getFilePath('new')).toBe(path.join('.pi', 'prompts', 'opsx:new.md')); - expect(piAdapter.getFilePath('bulk-archive')).toBe(path.join('.pi', 'prompts', 'opsx:bulk-archive.md')); - }); - - it('should expose legacy hyphenated file paths for migration', () => { - expect(piAdapter.getLegacyFilePaths?.('explore')).toEqual([ - path.join('.pi', 'prompts', 'opsx-explore.md'), - ]); + expect(piAdapter.getFilePath('new')).toBe(path.join('.pi', 'prompts', 'opsx-new.md')); + expect(piAdapter.getFilePath('bulk-archive')).toBe(path.join('.pi', 'prompts', 'opsx-bulk-archive.md')); }); it('should format file with description frontmatter', () => { @@ -553,6 +547,18 @@ describe('command-generation/adapters', () => { expect(output).toContain('This is the command body.'); }); + it('should transform command references from colon to hyphen format', () => { + const contentWithRefs: CommandContent = { + ...sampleContent, + body: 'Run /opsx:apply to implement. Then /opsx:archive when done.', + }; + + const output = piAdapter.formatFile(contentWithRefs); + expect(output).toContain('/opsx-apply'); + expect(output).toContain('/opsx-archive'); + expect(output).not.toContain('/opsx:apply'); + }); + it('should inject template arguments into the input section', () => { const contentWithInput: CommandContent = { ...sampleContent, @@ -560,7 +566,6 @@ describe('command-generation/adapters', () => { }; const output = piAdapter.formatFile(contentWithInput); - expect(output).toContain('**Input**: The argument after `/opsx:explore` is the topic.'); expect(output).toContain('**Provided arguments**: $@'); }); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 523f8e2b2..6eeae843f 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -251,20 +251,6 @@ Old instructions content } }); - it('migrates legacy Pi command files to colon-based prompt names', async () => { - const legacyPiPrompt = path.join(testDir, '.pi', 'prompts', 'opsx-explore.md'); - await fs.mkdir(path.dirname(legacyPiPrompt), { recursive: true }); - await fs.writeFile(legacyPiPrompt, 'legacy pi prompt'); - - await updateCommand.execute(testDir); - - const newPiPrompt = path.join(testDir, '.pi', 'prompts', 'opsx:explore.md'); - expect(await FileSystemUtils.fileExists(newPiPrompt)).toBe(true); - expect(await FileSystemUtils.fileExists(legacyPiPrompt)).toBe(false); - - const content = await fs.readFile(newPiPrompt, 'utf-8'); - expect(content).toContain('**Provided arguments**: $@'); - }); }); describe('multi-tool support', () => {