From 895d359c9d845f92e34d3f6fba764d6b4f43ff81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jc=20Mi=C3=B1arro?= Date: Fri, 6 Feb 2026 14:54:06 +0100 Subject: [PATCH 1/6] fix: align OpenCode command directory handling Use the documented .opencode/commands path with legacy migration and update-only behavior to prevent mismatched locations and unintended command creation. --- docs/supported-tools.md | 2 +- .../.openspec.yaml | 2 + .../design.md | 39 ++++++++ .../proposal.md | 22 +++++ .../specs/cli-update/spec.md | 98 +++++++++++++++++++ .../tasks.md | 18 ++++ openspec/specs/cli-update/spec.md | 2 +- .../command-generation/adapters/opencode.ts | 9 +- src/core/init.ts | 12 +++ src/core/shared/index.ts | 2 + src/core/shared/opencode-migration.ts | 39 ++++++++ src/core/update.ts | 18 ++++ test/core/command-generation/adapters.test.ts | 15 ++- test/core/update.test.ts | 29 +++++- 14 files changed, 297 insertions(+), 10 deletions(-) create mode 100644 openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/.openspec.yaml create mode 100644 openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/design.md create mode 100644 openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/proposal.md create mode 100644 openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/specs/cli-update/spec.md create mode 100644 openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/tasks.md create mode 100644 src/core/shared/opencode-migration.ts diff --git a/docs/supported-tools.md b/docs/supported-tools.md index 2f9b61526..3e70f1133 100644 --- a/docs/supported-tools.md +++ b/docs/supported-tools.md @@ -29,7 +29,7 @@ For each tool you select, OpenSpec installs: | GitHub Copilot | `.github/skills/` | `.github/prompts/` | | iFlow | `.iflow/skills/` | `.iflow/commands/` | | Kilo Code | `.kilocode/skills/` | `.kilocode/workflows/` | -| OpenCode | `.opencode/skills/` | `.opencode/command/` | +| OpenCode | `.opencode/skills/` | `.opencode/commands/` | | Qoder | `.qoder/skills/` | `.qoder/commands/opsx/` | | Qwen Code | `.qwen/skills/` | `.qwen/commands/` | | RooCode | `.roo/skills/` | `.roo/commands/` | diff --git a/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/.openspec.yaml b/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/.openspec.yaml new file mode 100644 index 000000000..41094ca05 --- /dev/null +++ b/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-06 diff --git a/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/design.md b/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/design.md new file mode 100644 index 000000000..446023136 --- /dev/null +++ b/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/design.md @@ -0,0 +1,39 @@ +## Context + +OpenSpec integration scripts currently generate OpenCode AI command markdown files under `.opencode/command/`, but the official documentation specifies `.opencode/commands/`. This change focuses on the OpenSpec initialization script that writes those markdown files. + +## Goals / Non-Goals + +**Goals:** +- Align generated command file locations with `.opencode/commands/`. +- Limit changes to OpenSpec init/update flows that generate or migrate command markdown files. + +**Non-Goals:** +- Redesign the command format or command execution model. +- Change the existing command content or add new command types. +- Update CLI behavior unrelated to command generation or migration. + +## Decisions + +- Use `.opencode/commands/` as the single source of truth for generated command files, matching documentation. + - Alternative: keep `.opencode/command/` as-is and update docs. Rejected because documentation is authoritative and users already rely on it. +- Update OpenSpec init/update to write command markdown files directly into `.opencode/commands/`. + - Alternative: keep `.opencode/command/` and update docs. Rejected because the documentation is the source of truth. +- If `.opencode/command/` exists, migrate its files into `.opencode/commands/` and prompt to delete the old directory when it is empty. + - Alternative: leave the old directory indefinitely. Rejected to avoid confusion and duplicated locations. + +## Risks / Trade-offs + +- [Risk] Existing users may have custom tooling pointing at `.opencode/command/` → Mitigation: migrate files and communicate the new location in OpenSpec output. +- [Trade-off] Migration logic adds a small amount of complexity to initialization → Mitigation: keep migration to a straightforward move/copy step plus optional cleanup. + +## Migration Plan + +- Update OpenSpec init/update to create `.opencode/commands/` and generate command files there. +- If `.opencode/command/` exists, move or copy known command files into `.opencode/commands/`. +- If `.opencode/command/` is empty after migration, prompt the user to delete it. +- Update tests and documentation to reference `.opencode/commands/`. + +## Open Questions + +- Should the cleanup prompt be opt-in or on by default when `.opencode/command/` is empty? diff --git a/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/proposal.md b/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/proposal.md new file mode 100644 index 000000000..b897a7731 --- /dev/null +++ b/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/proposal.md @@ -0,0 +1,22 @@ +## Why + +The OpenSpec integration scripts currently generate command files under `.opencode/command/`, while the official documentation requires `.opencode/commands/`. This mismatch breaks expected tooling behavior and creates confusion for users following the docs. + +## What Changes + +- Align OpenCode command generation and update behavior to use the documented `.opencode/commands/` directory. +- Normalize any references that assume the singular directory so the CLI and docs agree. + +## Capabilities + +### New Capabilities +- (none) + +### Modified Capabilities +- `cli-update`: Update the requirements to point to `.opencode/commands/` instead of `.opencode/command/`. + +## Impact + +- OpenCode CLI command generation and update logic. +- Documentation references for command locations. +- Tests that assert command file paths. diff --git a/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/specs/cli-update/spec.md b/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/specs/cli-update/spec.md new file mode 100644 index 000000000..5a19de4cf --- /dev/null +++ b/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/specs/cli-update/spec.md @@ -0,0 +1,98 @@ +## MODIFIED Requirements + +### Requirement: Slash Command Updates + +The update command SHALL refresh existing slash command files for configured tools without creating new ones, and ensure the OpenCode archive command accepts change ID arguments. + +#### Scenario: Updating slash commands for Antigravity +- **WHEN** `.agent/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh the OpenSpec-managed portion of each file so the workflow copy matches other tools while preserving the existing single-field `description` frontmatter +- **AND** skip creating any missing workflow files during update, mirroring the behavior for Windsurf and other IDEs + +#### Scenario: Updating slash commands for Claude Code +- **WHEN** `.claude/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for CodeBuddy Code +- **WHEN** `.codebuddy/commands/openspec/` contains `proposal.md`, `apply.md`, and `archive.md` +- **THEN** refresh each file using the shared CodeBuddy templates that include YAML frontmatter for the `description` and `argument-hint` fields +- **AND** use square bracket format for `argument-hint` parameters (e.g., `[change-id]`) +- **AND** preserve any user customizations outside the OpenSpec managed markers + +#### Scenario: Updating slash commands for Cline +- **WHEN** `.clinerules/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** include Cline-specific Markdown heading frontmatter +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Continue +- **WHEN** `.continue/prompts/` contains `openspec-proposal.prompt`, `openspec-apply.prompt`, and `openspec-archive.prompt` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Crush +- **WHEN** `.crush/commands/` contains `openspec/proposal.md`, `openspec/apply.md`, and `openspec/archive.md` +- **THEN** refresh each file using shared templates +- **AND** include Crush-specific frontmatter with OpenSpec category and tags +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Cursor +- **WHEN** `.cursor/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Factory Droid +- **WHEN** `.factory/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using the shared Factory templates that include YAML frontmatter for the `description` and `argument-hint` fields +- **AND** ensure the template body retains the `$ARGUMENTS` placeholder so user input keeps flowing into droid +- **AND** update only the content inside the OpenSpec managed markers, leaving any unmanaged notes untouched +- **AND** skip creating missing files during update + +#### Scenario: Updating slash commands for OpenCode +- **WHEN** `.opencode/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** ensure templates include instructions for the relevant workflow stage +- **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments + +#### Scenario: Updating slash commands for Windsurf +- **WHEN** `.windsurf/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates wrapped in OpenSpec markers +- **AND** ensure templates include instructions for the relevant workflow stage +- **AND** skip creating missing files (the update command only refreshes what already exists) + +#### Scenario: Updating slash commands for Kilo Code +- **WHEN** `.kilocode/workflows/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates wrapped in OpenSpec markers +- **AND** ensure templates include instructions for the relevant workflow stage +- **AND** skip creating missing files (the update command only refreshes what already exists) + +#### Scenario: Updating slash commands for Codex +- **GIVEN** the global Codex prompt directory contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **WHEN** a user runs `openspec update` +- **THEN** refresh each file using the shared slash-command templates (including placeholder guidance) +- **AND** preserve any unmanaged content outside the OpenSpec marker block +- **AND** skip creation when a Codex prompt file is missing + +#### Scenario: Updating slash commands for GitHub Copilot +- **WHEN** `.github/prompts/` contains `openspec-proposal.prompt.md`, `openspec-apply.prompt.md`, and `openspec-archive.prompt.md` +- **THEN** refresh each file using shared templates while preserving the YAML frontmatter +- **AND** update only the OpenSpec-managed block between markers +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Updating slash commands for Gemini CLI +- **WHEN** `.gemini/commands/openspec/` contains `proposal.toml`, `apply.toml`, and `archive.toml` +- **THEN** refresh the body of each file using the shared proposal/apply/archive templates +- **AND** replace only the content between `` and `` markers inside the `prompt = """` block so the TOML framing (`description`, `prompt`) stays intact +- **AND** skip creating any missing `.toml` files during update; only pre-existing Gemini commands are refreshed + +#### Scenario: Updating slash commands for iFlow CLI +- **WHEN** `.iflow/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **THEN** refresh each file using shared templates +- **AND** preserve the YAML frontmatter with `name`, `id`, `category`, and `description` fields +- **AND** update only the OpenSpec-managed block between markers +- **AND** ensure templates include instructions for the relevant workflow stage + +#### Scenario: Missing slash command file +- **WHEN** a tool lacks a slash command file +- **THEN** do not create a new file during update diff --git a/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/tasks.md b/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/tasks.md new file mode 100644 index 000000000..815b0cb56 --- /dev/null +++ b/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/tasks.md @@ -0,0 +1,18 @@ +## 1. Scope And Specs + +- [x] 1.1 Confirm no new capabilities and only `cli-update` spec is modified +- [x] 1.2 Review `openspec/specs/cli-update/spec.md` to locate the existing OpenCode slash command scenario +- [x] 1.3 Update the delta spec to replace `.opencode/command/` with `.opencode/commands/` and remove the Windows path variant if not required + +## 2. OpenSpec Initialization Script Update + +- [x] 2.1 Find the OpenSpec initialization script that generates OpenCode command markdown files +- [x] 2.2 Change the OpenCode command output directory to `.opencode/commands/` +- [x] 2.3 If `.opencode/command/` exists, migrate known OpenCode command files into `.opencode/commands/` +- [x] 2.4 If `.opencode/command/` is empty after migration, prompt the user to delete it and remove it on confirmation +- [x] 2.5 Ensure the cleanup prompt is visible (pause spinner before asking) + +## 3. Tests And Verification + +- [x] 3.1 Update any tests or fixtures that assert the OpenCode command directory path +- [x] 3.3 Run existing tests related to OpenSpec init/update and ensure they pass diff --git a/openspec/specs/cli-update/spec.md b/openspec/specs/cli-update/spec.md index 5ba070c4d..c14c5086c 100644 --- a/openspec/specs/cli-update/spec.md +++ b/openspec/specs/cli-update/spec.md @@ -99,7 +99,7 @@ The update command SHALL refresh existing slash command files for configured too - **AND** skip creating missing files during update #### Scenario: Updating slash commands for OpenCode -- **WHEN** `.opencode/command/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` +- **WHEN** `.opencode/commands/` contains `openspec-proposal.md`, `openspec-apply.md`, and `openspec-archive.md` - **THEN** refresh each file using shared templates - **AND** ensure templates include instructions for the relevant workflow stage - **AND** ensure the archive command includes `$ARGUMENTS` placeholder in frontmatter for accepting change ID arguments diff --git a/src/core/command-generation/adapters/opencode.ts b/src/core/command-generation/adapters/opencode.ts index 2b078fc6c..908a27362 100644 --- a/src/core/command-generation/adapters/opencode.ts +++ b/src/core/command-generation/adapters/opencode.ts @@ -10,23 +10,26 @@ import { transformToHyphenCommands } from '../../../utils/command-references.js' /** * OpenCode adapter for command generation. - * File path: .opencode/command/opsx-.md + * File path: .opencode/commands/opsx-.md * Frontmatter: description */ export const opencodeAdapter: ToolCommandAdapter = { toolId: 'opencode', getFilePath(commandId: string): string { - return path.join('.opencode', 'command', `opsx-${commandId}.md`); + return path.join('.opencode', 'commands', `opsx-${commandId}.md`); }, formatFile(content: CommandContent): string { // Transform command references from colon to hyphen format for OpenCode const transformedBody = transformToHyphenCommands(content.body); + const archiveArguments = content.id === 'archive' + ? 'arguments: |\n \n $ARGUMENTS\n \n' + : ''; return `--- description: ${content.description} ---- +${archiveArguments}--- ${transformedBody} `; diff --git a/src/core/init.ts b/src/core/init.ts index ff314c120..a135ece31 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -39,6 +39,7 @@ import { getSkillTemplates, getCommandContents, generateSkillContent, + migrateOpenCodeCommands, type ToolSkillStatus, } from './shared/index.js'; @@ -430,6 +431,7 @@ export class InitCommand { // Process each tool for (const tool of tools) { const spinner = ora(`Setting up ${tool.name}...`).start(); + const canPrompt = this.canPromptInteractively(); try { // Use tool-specific skillsDir @@ -449,6 +451,16 @@ export class InitCommand { await FileSystemUtils.writeFile(skillFile, skillContent); } + if (tool.value === 'opencode') { + if (canPrompt) { + spinner.stop(); + } + await migrateOpenCodeCommands(projectPath, canPrompt); + if (canPrompt) { + spinner.start(); + } + } + // Generate commands using the adapter system const adapter = CommandAdapterRegistry.get(tool.value); if (adapter) { diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts index 8ff856051..363755b9b 100644 --- a/src/core/shared/index.ts +++ b/src/core/shared/index.ts @@ -26,3 +26,5 @@ export { getCommandContents, generateSkillContent, } from './skill-generation.js'; + +export { migrateOpenCodeCommands } from './opencode-migration.js'; diff --git a/src/core/shared/opencode-migration.ts b/src/core/shared/opencode-migration.ts new file mode 100644 index 000000000..ab0343817 --- /dev/null +++ b/src/core/shared/opencode-migration.ts @@ -0,0 +1,39 @@ +import path from 'path'; +import * as fs from 'fs'; +import { FileSystemUtils } from '../../utils/file-system.js'; + +export async function migrateOpenCodeCommands( + projectPath: string, + canPrompt: boolean +): Promise { + const legacyDir = path.join(projectPath, '.opencode', 'command'); + if (!await FileSystemUtils.directoryExists(legacyDir)) { + return; + } + + const nextDir = path.join(projectPath, '.opencode', 'commands'); + await FileSystemUtils.createDirectory(nextDir); + + const entries = await fs.promises.readdir(legacyDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + const sourcePath = path.join(legacyDir, entry.name); + const destinationPath = path.join(nextDir, entry.name); + if (await FileSystemUtils.fileExists(destinationPath)) { + await fs.promises.unlink(destinationPath); + } + await fs.promises.rename(sourcePath, destinationPath); + } + + const remaining = await fs.promises.readdir(legacyDir); + if (remaining.length === 0 && canPrompt) { + const { confirm } = await import('@inquirer/prompts'); + const shouldRemove = await confirm({ + message: 'Delete empty .opencode/command directory?', + default: true, + }); + if (shouldRemove) { + await fs.promises.rmdir(legacyDir); + } + } +} diff --git a/src/core/update.ts b/src/core/update.ts index a9368a213..ad7d80566 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -23,6 +23,7 @@ import { getCommandContents, generateSkillContent, getToolsWithSkillsDir, + migrateOpenCodeCommands, type ToolVersionStatus, } from './shared/index.js'; import { @@ -62,6 +63,9 @@ export class UpdateCommand { throw new Error(`No OpenSpec directory found. Run 'openspec init' first.`); } + // 1b. Migrate legacy OpenCode commands if present + await migrateOpenCodeCommands(resolvedProjectPath, isInteractive()); + // 2. Detect and handle legacy artifacts + upgrade legacy tools to new skills const newlyConfiguredTools = await this.handleLegacyCleanup(resolvedProjectPath); @@ -109,6 +113,7 @@ export class UpdateCommand { if (!tool?.skillsDir) continue; const spinner = ora(`Updating ${tool.name}...`).start(); + const canPrompt = isInteractive(); try { const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills'); @@ -124,6 +129,16 @@ export class UpdateCommand { await FileSystemUtils.writeFile(skillFile, skillContent); } + if (tool.value === 'opencode') { + if (canPrompt) { + spinner.stop(); + } + await migrateOpenCodeCommands(resolvedProjectPath, canPrompt); + if (canPrompt) { + spinner.start(); + } + } + // Update commands const adapter = CommandAdapterRegistry.get(tool.value); if (adapter) { @@ -131,6 +146,9 @@ export class UpdateCommand { for (const cmd of generatedCommands) { const commandFile = path.isAbsolute(cmd.path) ? cmd.path : path.join(resolvedProjectPath, cmd.path); + if (!await FileSystemUtils.fileExists(commandFile)) { + continue; + } await FileSystemUtils.writeFile(commandFile, cmd.fileContent); } } diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index 5341f6a25..ecd387bea 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -443,7 +443,7 @@ describe('command-generation/adapters', () => { it('should generate correct file path', () => { const filePath = opencodeAdapter.getFilePath('explore'); - expect(filePath).toBe(path.join('.opencode', 'command', 'opsx-explore.md')); + expect(filePath).toBe(path.join('.opencode', 'commands', 'opsx-explore.md')); }); it('should format file with description frontmatter', () => { @@ -480,6 +480,19 @@ describe('command-generation/adapters', () => { expect(output).toContain('/opsx-continue'); expect(output).toContain('/opsx-apply'); }); + + it('should include $ARGUMENTS placeholder in archive frontmatter', () => { + const archiveContent: CommandContent = { + ...sampleContent, + id: 'archive', + name: 'OpenSpec Archive', + description: 'Archive a completed change', + }; + const output = opencodeAdapter.formatFile(archiveContent); + expect(output).toContain('arguments: |'); + expect(output).toContain(''); + expect(output).toContain('$ARGUMENTS'); + }); }); describe('qoderAdapter', () => { diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 21bb00f59..fd44e000f 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -158,10 +158,13 @@ Old instructions content 'old content' ); + const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); + await fs.mkdir(commandsDir, { recursive: true }); + await fs.writeFile(path.join(commandsDir, 'explore.md'), 'old command'); + await updateCommand.execute(testDir); // Check opsx command files were created - const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); const exploreCmd = path.join(commandsDir, 'explore.md'); const exists = await FileSystemUtils.fileExists(exploreCmd); expect(exists).toBe(true); @@ -185,8 +188,8 @@ Old instructions content 'old content' ); - await updateCommand.execute(testDir); - + const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); + await fs.mkdir(commandsDir, { recursive: true }); const commandIds = [ 'explore', 'new', @@ -198,8 +201,12 @@ Old instructions content 'bulk-archive', 'verify', ]; + for (const cmdId of commandIds) { + await fs.writeFile(path.join(commandsDir, `${cmdId}.md`), 'old command'); + } + + await updateCommand.execute(testDir); - const commandsDir = path.join(testDir, '.claude', 'commands', 'opsx'); for (const cmdId of commandIds) { const cmdFile = path.join(commandsDir, `${cmdId}.md`); const exists = await FileSystemUtils.fileExists(cmdFile); @@ -267,6 +274,13 @@ Old instructions content 'old' ); + const qwenCommandsDir = path.join(testDir, '.qwen', 'commands'); + await fs.mkdir(qwenCommandsDir, { recursive: true }); + await fs.writeFile( + path.join(qwenCommandsDir, 'opsx-explore.toml'), + 'old command' + ); + await updateCommand.execute(testDir); // Check Qwen command format (TOML) - Qwen uses flat path structure: opsx-.toml @@ -295,6 +309,13 @@ Old instructions content 'old' ); + const windsurfCommandsDir = path.join(testDir, '.windsurf', 'workflows'); + await fs.mkdir(windsurfCommandsDir, { recursive: true }); + await fs.writeFile( + path.join(windsurfCommandsDir, 'opsx-explore.md'), + 'old command' + ); + await updateCommand.execute(testDir); // Check Windsurf command format From 27e5c9fc0ee991c777632ac9f92a1b55c6605406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jc=20Mi=C3=B1arro?= Date: Fri, 6 Feb 2026 15:09:28 +0100 Subject: [PATCH 2/6] test: cover OpenCode command migration Add focused migration tests and adjust update migration flow to avoid duplicate prompts while still upgrading legacy directories. --- src/core/shared/opencode-migration.ts | 2 +- src/core/update.ts | 10 --- test/core/shared/opencode-migration.test.ts | 85 +++++++++++++++++++++ 3 files changed, 86 insertions(+), 11 deletions(-) create mode 100644 test/core/shared/opencode-migration.test.ts diff --git a/src/core/shared/opencode-migration.ts b/src/core/shared/opencode-migration.ts index ab0343817..a0ec17f47 100644 --- a/src/core/shared/opencode-migration.ts +++ b/src/core/shared/opencode-migration.ts @@ -29,7 +29,7 @@ export async function migrateOpenCodeCommands( if (remaining.length === 0 && canPrompt) { const { confirm } = await import('@inquirer/prompts'); const shouldRemove = await confirm({ - message: 'Delete empty .opencode/command directory?', + message: 'OpenCode commands have moved to .opencode/commands. The old .opencode/command directory is now empty and can be removed. Delete it?', default: true, }); if (shouldRemove) { diff --git a/src/core/update.ts b/src/core/update.ts index ad7d80566..46aa8907f 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -129,16 +129,6 @@ export class UpdateCommand { await FileSystemUtils.writeFile(skillFile, skillContent); } - if (tool.value === 'opencode') { - if (canPrompt) { - spinner.stop(); - } - await migrateOpenCodeCommands(resolvedProjectPath, canPrompt); - if (canPrompt) { - spinner.start(); - } - } - // Update commands const adapter = CommandAdapterRegistry.get(tool.value); if (adapter) { diff --git a/test/core/shared/opencode-migration.test.ts b/test/core/shared/opencode-migration.test.ts new file mode 100644 index 000000000..e8ecfb6b2 --- /dev/null +++ b/test/core/shared/opencode-migration.test.ts @@ -0,0 +1,85 @@ +import path from 'path'; +import os from 'os'; +import { promises as fs } from 'fs'; +import { randomUUID } from 'crypto'; +import { migrateOpenCodeCommands } from '../../../src/core/shared/opencode-migration.js'; + +// Mock @inquirer/prompts +vi.mock('@inquirer/prompts', () => ({ + confirm: vi.fn() +})); + +describe('migrateOpenCodeCommands', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = path.join(os.tmpdir(), `openspec-opencode-migration-${randomUUID()}`); + await fs.mkdir(tempDir, { recursive: true }); + vi.restoreAllMocks(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('moves legacy command files into .opencode/commands', async () => { + const legacyDir = path.join(tempDir, '.opencode', 'command'); + const nextDir = path.join(tempDir, '.opencode', 'commands'); + await fs.mkdir(legacyDir, { recursive: true }); + + const legacyFile = path.join(legacyDir, 'opsx-archive.md'); + await fs.writeFile(legacyFile, 'legacy content'); + + await migrateOpenCodeCommands(tempDir, false); + + const migratedFile = path.join(nextDir, 'opsx-archive.md'); + const migratedContent = await fs.readFile(migratedFile, 'utf-8'); + expect(migratedContent).toBe('legacy content'); + + const legacyEntries = await fs.readdir(legacyDir); + expect(legacyEntries).toHaveLength(0); + }); + + it('overwrites destination files on conflict', async () => { + const legacyDir = path.join(tempDir, '.opencode', 'command'); + const nextDir = path.join(tempDir, '.opencode', 'commands'); + await fs.mkdir(legacyDir, { recursive: true }); + await fs.mkdir(nextDir, { recursive: true }); + + const legacyFile = path.join(legacyDir, 'opsx-archive.md'); + const destFile = path.join(nextDir, 'opsx-archive.md'); + await fs.writeFile(destFile, 'current content'); + await fs.writeFile(legacyFile, 'legacy content'); + + await migrateOpenCodeCommands(tempDir, false); + + const migratedContent = await fs.readFile(destFile, 'utf-8'); + expect(migratedContent).toBe('legacy content'); + }); + + it('removes empty legacy directory when confirmed', async () => { + const legacyDir = path.join(tempDir, '.opencode', 'command'); + await fs.mkdir(legacyDir, { recursive: true }); + + const { confirm } = await import('@inquirer/prompts'); + (confirm as unknown as ReturnType).mockResolvedValue(true); + + await migrateOpenCodeCommands(tempDir, true); + + await expect(fs.readdir(legacyDir)).rejects.toThrow(); + }); + + it('keeps empty legacy directory when deletion is declined', async () => { + const legacyDir = path.join(tempDir, '.opencode', 'command'); + await fs.mkdir(legacyDir, { recursive: true }); + + const { confirm } = await import('@inquirer/prompts'); + (confirm as unknown as ReturnType).mockResolvedValue(false); + + await migrateOpenCodeCommands(tempDir, true); + + const entries = await fs.readdir(legacyDir); + expect(entries).toHaveLength(0); + }); +}); From fdb65168aaf3bb17c35a714f903a98ee491d53c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jc=20Mi=C3=B1arro?= Date: Fri, 6 Feb 2026 15:15:16 +0100 Subject: [PATCH 3/6] fix: typo on numeration --- .../archive/2026-02-06-fix-opencode-commands-dir/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/tasks.md b/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/tasks.md index 815b0cb56..814f86876 100644 --- a/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/tasks.md +++ b/openspec/changes/archive/2026-02-06-fix-opencode-commands-dir/tasks.md @@ -15,4 +15,4 @@ ## 3. Tests And Verification - [x] 3.1 Update any tests or fixtures that assert the OpenCode command directory path -- [x] 3.3 Run existing tests related to OpenSpec init/update and ensure they pass +- [x] 3.2 Run existing tests related to OpenSpec init/update and ensure they pass From c97eda372643e2952aac03a0e701063ea90f2a8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jc=20Mi=C3=B1arro?= Date: Sun, 8 Feb 2026 16:56:07 +0100 Subject: [PATCH 4/6] fix: address PR review feedback --- src/core/init.ts | 2 +- src/core/legacy-cleanup.ts | 2 +- src/core/shared/opencode-migration.ts | 23 +++++++++++++++---- src/core/update.ts | 3 ++- test/core/command-generation/adapters.test.ts | 1 + test/core/shared/opencode-migration.test.ts | 15 +++++++++++- 6 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/core/init.ts b/src/core/init.ts index a135ece31..e26ce36bd 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -429,9 +429,9 @@ export class InitCommand { const commandContents = getCommandContents(); // Process each tool + const canPrompt = this.canPromptInteractively(); for (const tool of tools) { const spinner = ora(`Setting up ${tool.name}...`).start(); - const canPrompt = this.canPromptInteractively(); try { // Use tool-specific skillsDir diff --git a/src/core/legacy-cleanup.ts b/src/core/legacy-cleanup.ts index 498e49dd0..40b0d0e69 100644 --- a/src/core/legacy-cleanup.ts +++ b/src/core/legacy-cleanup.ts @@ -48,7 +48,7 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record = []; + const canPrompt = isInteractive(); + for (const toolId of toolsToUpdate) { const tool = AI_TOOLS.find((t) => t.value === toolId); if (!tool?.skillsDir) continue; const spinner = ora(`Updating ${tool.name}...`).start(); - const canPrompt = isInteractive(); try { const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills'); diff --git a/test/core/command-generation/adapters.test.ts b/test/core/command-generation/adapters.test.ts index ecd387bea..b10d58692 100644 --- a/test/core/command-generation/adapters.test.ts +++ b/test/core/command-generation/adapters.test.ts @@ -452,6 +452,7 @@ describe('command-generation/adapters', () => { expect(output).toContain('description: Enter explore mode for thinking'); expect(output).toContain('---\n\n'); expect(output).toContain('This is the command body.'); + expect(output).not.toContain('arguments:'); }); it('should transform colon-based command references to hyphen-based', () => { diff --git a/test/core/shared/opencode-migration.test.ts b/test/core/shared/opencode-migration.test.ts index e8ecfb6b2..2eb46c0ff 100644 --- a/test/core/shared/opencode-migration.test.ts +++ b/test/core/shared/opencode-migration.test.ts @@ -55,13 +55,26 @@ describe('migrateOpenCodeCommands', () => { await migrateOpenCodeCommands(tempDir, false); const migratedContent = await fs.readFile(destFile, 'utf-8'); - expect(migratedContent).toBe('legacy content'); + expect(migratedContent).toBe('current content'); + + const legacyEntries = await fs.readdir(legacyDir); + expect(legacyEntries).toHaveLength(0); + }); + + it('does nothing when legacy directory does not exist', async () => { + await migrateOpenCodeCommands(tempDir, false); + + const legacyDir = path.join(tempDir, '.opencode', 'command'); + await expect(fs.readdir(legacyDir)).rejects.toThrow(); }); it('removes empty legacy directory when confirmed', async () => { const legacyDir = path.join(tempDir, '.opencode', 'command'); await fs.mkdir(legacyDir, { recursive: true }); + // Ensure the migration actually changes something so cleanup prompt applies. + await fs.writeFile(path.join(legacyDir, 'opsx-archive.md'), 'legacy content'); + const { confirm } = await import('@inquirer/prompts'); (confirm as unknown as ReturnType).mockResolvedValue(true); From 6aeb012be46ed13edf54995927dd1f79780066c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jc=20Mi=C3=B1arro?= Date: Sun, 8 Feb 2026 19:13:57 +0100 Subject: [PATCH 5/6] fix: address PR review feedback on migration order and unused variable - Reorder init.ts to call migrateOpenCodeCommands BEFORE handleLegacyCleanup to ensure legacy OpenCode commands are detected and cleaned up properly. This aligns with the correct execution order already present in update.ts. - Remove unused 'canPrompt' variable from update.ts tool-update loop (leftover from refactoring, variable was assigned but never referenced). --- src/core/init.ts | 16 +++++----------- src/core/update.ts | 2 -- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/core/init.ts b/src/core/init.ts index e26ce36bd..6b803feb8 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -90,11 +90,15 @@ export class InitCommand { // Validation happens silently in the background const extendMode = await this.validate(projectPath, openspecPath); + // Migrate OpenCode commands from legacy .opencode/command/ to .opencode/commands/ + // This must run BEFORE legacy cleanup so migrated files are properly detected + const canPrompt = this.canPromptInteractively(); + await migrateOpenCodeCommands(projectPath, canPrompt); + // Check for legacy artifacts and handle cleanup await this.handleLegacyCleanup(projectPath, extendMode); // Show animated welcome screen (interactive mode only) - const canPrompt = this.canPromptInteractively(); if (canPrompt) { const { showWelcomeScreen } = await import('../ui/welcome-screen.js'); await showWelcomeScreen(); @@ -451,16 +455,6 @@ export class InitCommand { await FileSystemUtils.writeFile(skillFile, skillContent); } - if (tool.value === 'opencode') { - if (canPrompt) { - spinner.stop(); - } - await migrateOpenCodeCommands(projectPath, canPrompt); - if (canPrompt) { - spinner.start(); - } - } - // Generate commands using the adapter system const adapter = CommandAdapterRegistry.get(tool.value); if (adapter) { diff --git a/src/core/update.ts b/src/core/update.ts index f23a8ee90..9a4072ba5 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -108,8 +108,6 @@ export class UpdateCommand { const updatedTools: string[] = []; const failedTools: Array<{ name: string; error: string }> = []; - const canPrompt = isInteractive(); - for (const toolId of toolsToUpdate) { const tool = AI_TOOLS.find((t) => t.value === toolId); if (!tool?.skillsDir) continue; From 460e96528ee75aa31c80168c30d9c93230a70177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jc=20Mi=C3=B1arro?= Date: Sun, 8 Feb 2026 19:23:54 +0100 Subject: [PATCH 6/6] fix: remove unused canPrompt variable in generateSkillsAndCommands Remove the unused 'canPrompt' local binding from generateSkillsAndCommands method in init.ts. The variable was declared but never referenced. --- src/core/init.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/init.ts b/src/core/init.ts index 6b803feb8..097051622 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -433,7 +433,6 @@ export class InitCommand { const commandContents = getCommandContents(); // Process each tool - const canPrompt = this.canPromptInteractively(); for (const tool of tools) { const spinner = ora(`Setting up ${tool.name}...`).start();