Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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
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`: update the `'opencode'` entry in `LEGACY_SLASH_COMMAND_PATHS` to detect both `opsx-*.md` and `openspec-*.md` patterns at `.opencode/command/` for backward compatibility

## 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