Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-opencode-commands-directory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@fission-ai/openspec": patch
---

fix: OpenCode adapter now uses `.opencode/commands/` (plural) to match OpenCode's official directory convention. Fixes #748.
2 changes: 1 addition & 1 deletion docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `sync`, `b
| iFlow (`iflow`) | `.iflow/skills/openspec-*/SKILL.md` | `.iflow/commands/opsx-<id>.md` |
| Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilocode/workflows/opsx-<id>.md` |
| Kiro (`kiro`) | `.kiro/skills/openspec-*/SKILL.md` | `.kiro/prompts/opsx-<id>.prompt.md` |
| OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/command/opsx-<id>.md` |
| OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-<id>.md` |
| Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-<id>.md` |
| Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/<id>.md` |
| Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-<id>.toml` |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-25
48 changes: 48 additions & 0 deletions openspec/changes/fix-opencode-commands-directory/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
## Context

The OpenCode adapter in `src/core/command-generation/adapters/opencode.ts` currently generates command files at `.opencode/command/opsx-<id>.md` (singular `command`). OpenCode's official documentation uses `.opencode/commands/` (plural), and every other adapter in the codebase follows the plural convention for commands directories. The legacy cleanup module in `src/core/legacy-cleanup.ts` also references the singular form for detecting old artifacts.

## Goals / Non-Goals

**Goals:**
- Align the OpenCode adapter path with OpenCode's official `.opencode/commands/` convention
- Add the old singular path `.opencode/command/` to legacy cleanup so existing installations are properly cleaned
- Update documentation to reflect the corrected path
- Update test assertions to match the new path

**Non-Goals:**
- Changing the OpenCode skill path (`.opencode/skills/`) — already correct
- Modifying any other adapter's directory structure
- Adding migration prompts or interactive upgrade flows

## Decisions

### 1. Direct path rename in adapter

**Decision:** Change `path.join('.opencode', 'command', ...)` to `path.join('.opencode', 'commands', ...)` in the adapter's `getFilePath` method.

**Rationale:** This is a single-line change that aligns with the established pattern across all other adapters. No abstraction or indirection needed.

**Alternatives considered:**
- Add a configuration option for the directory name — rejected as over-engineering for a bug fix
- Keep singular and add plural as alias — rejected as it creates ambiguity about which is canonical

### 2. Legacy cleanup via existing constant map

**Decision:** Update the `LEGACY_SLASH_COMMAND_PATHS` entry for `'opencode'` from `'.opencode/command/openspec-*.md'` to `'.opencode/command/opsx-*.md'` (the old singular path becomes the legacy pattern) and ensure the new path is handled by the current command generation pipeline.

**Rationale:** The existing legacy cleanup infrastructure uses `LEGACY_SLASH_COMMAND_PATHS` as an explicit lookup. The old singular-path pattern already matches the legacy format (`openspec-*` prefix from the old SlashCommandRegistry era). The current command generation uses the `opsx-*` prefix, so we also need to add a legacy pattern for `opsx-*` files in the old singular directory.

**Alternatives considered:**
- Add a separate migration script — rejected; the existing legacy cleanup mechanism handles this scenario

### 3. Documentation update

**Decision:** Update the `docs/supported-tools.md` table entry for OpenCode from `.opencode/command/opsx-<id>.md` to `.opencode/commands/opsx-<id>.md`.

**Rationale:** Documentation must match the actual generated paths.

## Risks / Trade-offs

- **[Existing installations have files at old path]** → Mitigated by legacy cleanup detecting `.opencode/command/` artifacts. On next `openspec init`, old files are cleaned up and new files written to `.opencode/commands/`.
- **[Users referencing old path in custom scripts]** → Low risk. The old path was incorrect per OpenCode's specification, so custom references were already misaligned.
26 changes: 26 additions & 0 deletions openspec/changes/fix-opencode-commands-directory/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
## Why

The OpenCode adapter uses `.opencode/command/` (singular) for its commands directory, but OpenCode's official documentation specifies `.opencode/commands/` (plural). Every other adapter in the codebase also uses plural directory names (`.claude/commands/`, `.cursor/commands/`, `.factory/commands/`, etc.). This inconsistency was introduced in Oct 2025 without documented rationale. Fixes [#748](https://github.com/Fission-AI/OpenSpec/issues/748).

## What Changes

- OpenCode adapter path changes from `.opencode/command/` to `.opencode/commands/`
- Legacy cleanup adds `.opencode/command/` (old singular path) for backward compatibility
- Documentation updated to reflect the new plural path

## Capabilities

### New Capabilities

_None._

### Modified Capabilities

- `command-generation`: OpenCode adapter path changes from singular `command/` to plural `commands/` to match OpenCode's official directory convention

## Impact

- `src/core/command-generation/adapters/opencode.ts` — adapter path
- `src/core/legacy-cleanup.ts` — legacy cleanup pattern + add old singular path
- `docs/supported-tools.md` — documentation table
- `test/core/command-generation/adapters.test.ts` — test assertion
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
## MODIFIED Requirements

### Requirement: ToolCommandAdapter interface

The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting.

#### Scenario: Adapter interface structure

- **WHEN** implementing a tool adapter
- **THEN** `ToolCommandAdapter` SHALL require:
- `toolId`: string identifier matching `AIToolOption.value`
- `getFilePath(commandId: string)`: returns file path for command (relative from project root, or absolute for global-scoped tools like Codex)
- `formatFile(content: CommandContent)`: returns complete file content with frontmatter

#### Scenario: Claude adapter formatting

- **WHEN** formatting a command for Claude Code
- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields
- **AND** file path SHALL follow pattern `.claude/commands/opsx/<id>.md`

#### Scenario: Cursor adapter formatting

- **WHEN** formatting a command for Cursor
- **THEN** the adapter SHALL output YAML frontmatter with `name` as `/opsx-<id>`, `id`, `category`, `description` fields
- **AND** file path SHALL follow pattern `.cursor/commands/opsx-<id>.md`

#### Scenario: Windsurf adapter formatting

- **WHEN** formatting a command for Windsurf
- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields
- **AND** file path SHALL follow pattern `.windsurf/workflows/opsx-<id>.md`

#### Scenario: OpenCode adapter formatting

- **WHEN** formatting a command for OpenCode
- **THEN** the adapter SHALL output YAML frontmatter with `description` field
- **AND** file path SHALL follow pattern `.opencode/commands/opsx-<id>.md` using `path.join('.opencode', 'commands', ...)` for cross-platform compatibility
- **AND** the adapter SHALL transform colon-based command references (`/opsx:name`) to hyphen-based (`/opsx-name`) in the body

## ADDED Requirements

### Requirement: Legacy cleanup for renamed OpenCode command directory

The legacy cleanup module SHALL detect and remove old OpenCode command files from the previous singular `.opencode/command/` directory path.

#### Scenario: Detect old singular-path OpenCode command files

- **WHEN** running legacy artifact detection on a project with files matching `.opencode/command/opsx-*.md` or `.opencode/command/openspec-*.md`
- **THEN** the system SHALL include those files in the legacy slash command files list via `LEGACY_SLASH_COMMAND_PATHS`
- **AND** `LegacySlashCommandPattern.pattern` SHALL accept `string | string[]` to support multiple glob patterns per tool

#### Scenario: Clean up old OpenCode command files on init

- **WHEN** a user runs `openspec init` in a project with old `.opencode/command/` artifacts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also account for non-interactive openspec init here? Right now legacy artifacts cause init to exit unless --force is set. That could surprise existing /command users in CI. Either auto-handle this rename migration or call out the one-time --force requirement in release notes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — handleLegacyCleanup now auto-cleans in non-interactive mode instead of aborting with exit 1. Legacy slash commands are 100% OpenSpec-managed, and config file cleanup only removes markers (never deletes files), so auto-cleanup is safe without --force.

Added a test verifying that openspec init --tools opencode in non-interactive mode cleans up .opencode/command/ legacy files and generates new ones at .opencode/commands/. Spec updated with the new scenario.

- **THEN** the system SHALL remove the old files
- **AND** generate new command files at `.opencode/commands/`

#### Scenario: Auto-cleanup legacy artifacts in non-interactive mode

- **WHEN** a user runs `openspec init` in non-interactive mode (e.g., CI) and legacy artifacts are detected
- **THEN** the system SHALL auto-cleanup legacy artifacts without requiring `--force`
- **AND** legacy slash command files (100% OpenSpec-managed) SHALL be removed
- **AND** config file cleanup SHALL only remove OpenSpec markers (never delete user files)
19 changes: 19 additions & 0 deletions openspec/changes/fix-opencode-commands-directory/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
## 1. Adapter Fix

- [x] 1.1 Update `src/core/command-generation/adapters/opencode.ts`: change `path.join('.opencode', 'command', ...)` to `path.join('.opencode', 'commands', ...)` and update the JSDoc comment

## 2. Legacy Cleanup

- [x] 2.1 Update `src/core/legacy-cleanup.ts`: change the `'opencode'` entry in `LEGACY_SLASH_COMMAND_PATHS` from `'.opencode/command/openspec-*.md'` to `'.opencode/command/opsx-*.md'` to detect old singular-path artifacts with the current `opsx-*` prefix

## 3. Documentation

- [x] 3.1 Update `docs/supported-tools.md`: change OpenCode command path from `.opencode/command/opsx-<id>.md` to `.opencode/commands/opsx-<id>.md`

## 4. Tests

- [x] 4.1 Update `test/core/command-generation/adapters.test.ts`: change the OpenCode file path assertion from `path.join('.opencode', 'command', 'opsx-explore.md')` to `path.join('.opencode', 'commands', 'opsx-explore.md')`

## 5. Changeset

- [x] 5.1 Create a changeset file (`.changeset/fix-opencode-commands-directory.md`) with a patch bump describing the path fix
4 changes: 2 additions & 2 deletions src/core/command-generation/adapters/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import { transformToHyphenCommands } from '../../../utils/command-references.js'

/**
* OpenCode adapter for command generation.
* File path: .opencode/command/opsx-<id>.md
* File path: .opencode/commands/opsx-<id>.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 {
Expand Down
13 changes: 4 additions & 9 deletions src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,19 +208,14 @@ export class InitCommand {

const canPrompt = this.canPromptInteractively();

if (this.force) {
// --force flag: proceed with cleanup automatically
if (this.force || !canPrompt) {
// --force flag or non-interactive mode: proceed with cleanup automatically.
// Legacy slash commands are 100% OpenSpec-managed, and config file cleanup
// only removes markers (never deletes files), so auto-cleanup is safe.
await this.performLegacyCleanup(projectPath, detection);
return;
}

if (!canPrompt) {
// Non-interactive mode without --force: abort
console.log(chalk.red('Legacy files detected in non-interactive mode.'));
console.log(chalk.dim('Run interactively to upgrade, or use --force to auto-cleanup.'));
process.exit(1);
}

// Interactive mode: prompt for confirmation
const { confirm } = await import('@inquirer/prompts');
const shouldCleanup = await confirm({
Expand Down
31 changes: 20 additions & 11 deletions src/core/legacy-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
'roocode': { type: 'files', pattern: '.roo/commands/openspec-*.md' },
'auggie': { type: 'files', pattern: '.augment/commands/openspec-*.md' },
'factory': { type: 'files', pattern: '.factory/commands/openspec-*.md' },
'opencode': { type: 'files', pattern: '.opencode/command/openspec-*.md' },
'opencode': { type: 'files', pattern: ['.opencode/command/opsx-*.md', '.opencode/command/openspec-*.md'] },
'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' },
'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },
'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },
Expand All @@ -63,7 +63,7 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
export interface LegacySlashCommandPattern {
type: 'directory' | 'files';
path?: string; // For directory type
pattern?: string; // For files type (glob pattern)
pattern?: string | string[]; // For files type (glob pattern or array of patterns)
}

/**
Expand Down Expand Up @@ -192,8 +192,11 @@ export async function detectLegacySlashCommands(
}
} else if (pattern.type === 'files' && pattern.pattern) {
// For file-based patterns, check for individual files
const foundFiles = await findLegacySlashCommandFiles(projectPath, pattern.pattern);
files.push(...foundFiles);
const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
for (const p of patterns) {
const foundFiles = await findLegacySlashCommandFiles(projectPath, p);
files.push(...foundFiles);
}
}
}

Expand Down Expand Up @@ -604,14 +607,20 @@ export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): s
if (pattern.type === 'files' && pattern.pattern) {
// Convert glob pattern to regex for matching
// e.g., '.cursor/commands/openspec-*.md' -> /^\.cursor\/commands\/openspec-.*\.md$/
const regexPattern = pattern.pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
.replace(/\*/g, '.*'); // Replace * with .*
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(normalizedFile)) {
tools.add(toolId);
break;
const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
let matched = false;
for (const p of patterns) {
const regexPattern = p
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
.replace(/\*/g, '.*'); // Replace * with .*
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(normalizedFile)) {
tools.add(toolId);
matched = true;
break;
}
}
if (matched) break;
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion test/core/command-generation/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,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', () => {
Expand Down
18 changes: 18 additions & 0 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,24 @@ describe('InitCommand - profile and detection features', () => {
expect(await fileExists(skillFile)).toBe(true);
});

it('should auto-cleanup legacy artifacts in non-interactive mode without --force', async () => {
// Create legacy OpenCode command files (singular 'command' path)
const legacyDir = path.join(testDir, '.opencode', 'command');
await fs.mkdir(legacyDir, { recursive: true });
await fs.writeFile(path.join(legacyDir, 'opsx-propose.md'), 'legacy content');

// Run init in non-interactive mode without --force
const initCommand = new InitCommand({ tools: 'opencode' });
await initCommand.execute(testDir);

// Legacy files should be cleaned up automatically
expect(await fileExists(path.join(legacyDir, 'opsx-propose.md'))).toBe(false);

// New commands should be at the correct plural path
const newCommandsDir = path.join(testDir, '.opencode', 'commands');
expect(await directoryExists(newCommandsDir)).toBe(true);
});

it('should preselect configured tools but not directory-detected tools in extend mode', async () => {
// Simulate existing OpenSpec project (extend mode).
await fs.mkdir(path.join(testDir, 'openspec'), { recursive: true });
Expand Down
Loading