Skip to content

Commit 61eb999

Browse files
fsilvaortizclaudeTabishB
authored
fix(opencode): use plural commands/ directory to match OpenCode convention (Fission-AI#760)
* fix(opencode): use plural `commands/` directory to match OpenCode convention The OpenCode adapter was using `.opencode/command/` (singular) but OpenCode's official documentation specifies `.opencode/commands/` (plural). This aligns with every other adapter in the codebase. Legacy cleanup updated to detect old singular-path artifacts. Fixes Fission-AI#748. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(legacy): detect both opsx-* and openspec-* patterns, auto-cleanup in CI - Extend LegacySlashCommandPattern.pattern to accept string | string[] - OpenCode legacy entry now detects both opsx-*.md and openspec-*.md - Auto-cleanup legacy artifacts in non-interactive mode instead of aborting with exit 1 (safe: slash commands are OpenSpec-managed, config cleanup only removes markers) - Add 7 tests (6 legacy detection + 1 non-interactive init) - Update spec with array pattern support and auto-cleanup scenario Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: update task description to reflect dual-pattern support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Tabish Bidiwale <30385142+TabishB@users.noreply.github.com>
1 parent 3d3bf96 commit 61eb999

13 files changed

Lines changed: 292 additions & 24 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@fission-ai/openspec": patch
3+
---
4+
5+
fix: OpenCode adapter now uses `.opencode/commands/` (plural) to match OpenCode's official directory convention. Fixes #748.

docs/supported-tools.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `sync`, `b
3838
| iFlow (`iflow`) | `.iflow/skills/openspec-*/SKILL.md` | `.iflow/commands/opsx-<id>.md` |
3939
| Kilo Code (`kilocode`) | `.kilocode/skills/openspec-*/SKILL.md` | `.kilocode/workflows/opsx-<id>.md` |
4040
| Kiro (`kiro`) | `.kiro/skills/openspec-*/SKILL.md` | `.kiro/prompts/opsx-<id>.prompt.md` |
41-
| OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/command/opsx-<id>.md` |
41+
| OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `.opencode/commands/opsx-<id>.md` |
4242
| Pi (`pi`) | `.pi/skills/openspec-*/SKILL.md` | `.pi/prompts/opsx-<id>.md` |
4343
| Qoder (`qoder`) | `.qoder/skills/openspec-*/SKILL.md` | `.qoder/commands/opsx/<id>.md` |
4444
| Qwen Code (`qwen`) | `.qwen/skills/openspec-*/SKILL.md` | `.qwen/commands/opsx-<id>.toml` |
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-02-25
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
## Context
2+
3+
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.
4+
5+
## Goals / Non-Goals
6+
7+
**Goals:**
8+
- Align the OpenCode adapter path with OpenCode's official `.opencode/commands/` convention
9+
- Add the old singular path `.opencode/command/` to legacy cleanup so existing installations are properly cleaned
10+
- Update documentation to reflect the corrected path
11+
- Update test assertions to match the new path
12+
13+
**Non-Goals:**
14+
- Changing the OpenCode skill path (`.opencode/skills/`) — already correct
15+
- Modifying any other adapter's directory structure
16+
- Adding migration prompts or interactive upgrade flows
17+
18+
## Decisions
19+
20+
### 1. Direct path rename in adapter
21+
22+
**Decision:** Change `path.join('.opencode', 'command', ...)` to `path.join('.opencode', 'commands', ...)` in the adapter's `getFilePath` method.
23+
24+
**Rationale:** This is a single-line change that aligns with the established pattern across all other adapters. No abstraction or indirection needed.
25+
26+
**Alternatives considered:**
27+
- Add a configuration option for the directory name — rejected as over-engineering for a bug fix
28+
- Keep singular and add plural as alias — rejected as it creates ambiguity about which is canonical
29+
30+
### 2. Legacy cleanup via existing constant map
31+
32+
**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.
33+
34+
**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.
35+
36+
**Alternatives considered:**
37+
- Add a separate migration script — rejected; the existing legacy cleanup mechanism handles this scenario
38+
39+
### 3. Documentation update
40+
41+
**Decision:** Update the `docs/supported-tools.md` table entry for OpenCode from `.opencode/command/opsx-<id>.md` to `.opencode/commands/opsx-<id>.md`.
42+
43+
**Rationale:** Documentation must match the actual generated paths.
44+
45+
## Risks / Trade-offs
46+
47+
- **[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/`.
48+
- **[Users referencing old path in custom scripts]** → Low risk. The old path was incorrect per OpenCode's specification, so custom references were already misaligned.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
## Why
2+
3+
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).
4+
5+
## What Changes
6+
7+
- OpenCode adapter path changes from `.opencode/command/` to `.opencode/commands/`
8+
- Legacy cleanup adds `.opencode/command/` (old singular path) for backward compatibility
9+
- Documentation updated to reflect the new plural path
10+
11+
## Capabilities
12+
13+
### New Capabilities
14+
15+
_None._
16+
17+
### Modified Capabilities
18+
19+
- `command-generation`: OpenCode adapter path changes from singular `command/` to plural `commands/` to match OpenCode's official directory convention
20+
21+
## Impact
22+
23+
- `src/core/command-generation/adapters/opencode.ts` — adapter path
24+
- `src/core/legacy-cleanup.ts` — legacy cleanup pattern + add old singular path
25+
- `docs/supported-tools.md` — documentation table
26+
- `test/core/command-generation/adapters.test.ts` — test assertion
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
## MODIFIED Requirements
2+
3+
### Requirement: ToolCommandAdapter interface
4+
5+
The system SHALL define a `ToolCommandAdapter` interface for per-tool formatting.
6+
7+
#### Scenario: Adapter interface structure
8+
9+
- **WHEN** implementing a tool adapter
10+
- **THEN** `ToolCommandAdapter` SHALL require:
11+
- `toolId`: string identifier matching `AIToolOption.value`
12+
- `getFilePath(commandId: string)`: returns file path for command (relative from project root, or absolute for global-scoped tools like Codex)
13+
- `formatFile(content: CommandContent)`: returns complete file content with frontmatter
14+
15+
#### Scenario: Claude adapter formatting
16+
17+
- **WHEN** formatting a command for Claude Code
18+
- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields
19+
- **AND** file path SHALL follow pattern `.claude/commands/opsx/<id>.md`
20+
21+
#### Scenario: Cursor adapter formatting
22+
23+
- **WHEN** formatting a command for Cursor
24+
- **THEN** the adapter SHALL output YAML frontmatter with `name` as `/opsx-<id>`, `id`, `category`, `description` fields
25+
- **AND** file path SHALL follow pattern `.cursor/commands/opsx-<id>.md`
26+
27+
#### Scenario: Windsurf adapter formatting
28+
29+
- **WHEN** formatting a command for Windsurf
30+
- **THEN** the adapter SHALL output YAML frontmatter with `name`, `description`, `category`, `tags` fields
31+
- **AND** file path SHALL follow pattern `.windsurf/workflows/opsx-<id>.md`
32+
33+
#### Scenario: OpenCode adapter formatting
34+
35+
- **WHEN** formatting a command for OpenCode
36+
- **THEN** the adapter SHALL output YAML frontmatter with `description` field
37+
- **AND** file path SHALL follow pattern `.opencode/commands/opsx-<id>.md` using `path.join('.opencode', 'commands', ...)` for cross-platform compatibility
38+
- **AND** the adapter SHALL transform colon-based command references (`/opsx:name`) to hyphen-based (`/opsx-name`) in the body
39+
40+
## ADDED Requirements
41+
42+
### Requirement: Legacy cleanup for renamed OpenCode command directory
43+
44+
The legacy cleanup module SHALL detect and remove old OpenCode command files from the previous singular `.opencode/command/` directory path.
45+
46+
#### Scenario: Detect old singular-path OpenCode command files
47+
48+
- **WHEN** running legacy artifact detection on a project with files matching `.opencode/command/opsx-*.md` or `.opencode/command/openspec-*.md`
49+
- **THEN** the system SHALL include those files in the legacy slash command files list via `LEGACY_SLASH_COMMAND_PATHS`
50+
- **AND** `LegacySlashCommandPattern.pattern` SHALL accept `string | string[]` to support multiple glob patterns per tool
51+
52+
#### Scenario: Clean up old OpenCode command files on init
53+
54+
- **WHEN** a user runs `openspec init` in a project with old `.opencode/command/` artifacts
55+
- **THEN** the system SHALL remove the old files
56+
- **AND** generate new command files at `.opencode/commands/`
57+
58+
#### Scenario: Auto-cleanup legacy artifacts in non-interactive mode
59+
60+
- **WHEN** a user runs `openspec init` in non-interactive mode (e.g., CI) and legacy artifacts are detected
61+
- **THEN** the system SHALL auto-cleanup legacy artifacts without requiring `--force`
62+
- **AND** legacy slash command files (100% OpenSpec-managed) SHALL be removed
63+
- **AND** config file cleanup SHALL only remove OpenSpec markers (never delete user files)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
## 1. Adapter Fix
2+
3+
- [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
4+
5+
## 2. Legacy Cleanup
6+
7+
- [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
8+
9+
## 3. Documentation
10+
11+
- [x] 3.1 Update `docs/supported-tools.md`: change OpenCode command path from `.opencode/command/opsx-<id>.md` to `.opencode/commands/opsx-<id>.md`
12+
13+
## 4. Tests
14+
15+
- [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')`
16+
17+
## 5. Changeset
18+
19+
- [x] 5.1 Create a changeset file (`.changeset/fix-opencode-commands-directory.md`) with a patch bump describing the path fix

src/core/command-generation/adapters/opencode.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ import { transformToHyphenCommands } from '../../../utils/command-references.js'
1010

1111
/**
1212
* OpenCode adapter for command generation.
13-
* File path: .opencode/command/opsx-<id>.md
13+
* File path: .opencode/commands/opsx-<id>.md
1414
* Frontmatter: description
1515
*/
1616
export const opencodeAdapter: ToolCommandAdapter = {
1717
toolId: 'opencode',
1818

1919
getFilePath(commandId: string): string {
20-
return path.join('.opencode', 'command', `opsx-${commandId}.md`);
20+
return path.join('.opencode', 'commands', `opsx-${commandId}.md`);
2121
},
2222

2323
formatFile(content: CommandContent): string {

src/core/init.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -208,19 +208,14 @@ export class InitCommand {
208208

209209
const canPrompt = this.canPromptInteractively();
210210

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

217-
if (!canPrompt) {
218-
// Non-interactive mode without --force: abort
219-
console.log(chalk.red('Legacy files detected in non-interactive mode.'));
220-
console.log(chalk.dim('Run interactively to upgrade, or use --force to auto-cleanup.'));
221-
process.exit(1);
222-
}
223-
224219
// Interactive mode: prompt for confirmation
225220
const { confirm } = await import('@inquirer/prompts');
226221
const shouldCleanup = await confirm({

src/core/legacy-cleanup.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
4949
'roocode': { type: 'files', pattern: '.roo/commands/openspec-*.md' },
5050
'auggie': { type: 'files', pattern: '.augment/commands/openspec-*.md' },
5151
'factory': { type: 'files', pattern: '.factory/commands/openspec-*.md' },
52-
'opencode': { type: 'files', pattern: '.opencode/command/openspec-*.md' },
52+
'opencode': { type: 'files', pattern: ['.opencode/command/opsx-*.md', '.opencode/command/openspec-*.md'] },
5353
'continue': { type: 'files', pattern: '.continue/prompts/openspec-*.prompt' },
5454
'antigravity': { type: 'files', pattern: '.agent/workflows/openspec-*.md' },
5555
'iflow': { type: 'files', pattern: '.iflow/commands/openspec-*.md' },
@@ -63,7 +63,7 @@ export const LEGACY_SLASH_COMMAND_PATHS: Record<string, LegacySlashCommandPatter
6363
export interface LegacySlashCommandPattern {
6464
type: 'directory' | 'files';
6565
path?: string; // For directory type
66-
pattern?: string; // For files type (glob pattern)
66+
pattern?: string | string[]; // For files type (glob pattern or array of patterns)
6767
}
6868

6969
/**
@@ -192,8 +192,11 @@ export async function detectLegacySlashCommands(
192192
}
193193
} else if (pattern.type === 'files' && pattern.pattern) {
194194
// For file-based patterns, check for individual files
195-
const foundFiles = await findLegacySlashCommandFiles(projectPath, pattern.pattern);
196-
files.push(...foundFiles);
195+
const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
196+
for (const p of patterns) {
197+
const foundFiles = await findLegacySlashCommandFiles(projectPath, p);
198+
files.push(...foundFiles);
199+
}
197200
}
198201
}
199202

@@ -604,14 +607,20 @@ export function getToolsFromLegacyArtifacts(detection: LegacyDetectionResult): s
604607
if (pattern.type === 'files' && pattern.pattern) {
605608
// Convert glob pattern to regex for matching
606609
// e.g., '.cursor/commands/openspec-*.md' -> /^\.cursor\/commands\/openspec-.*\.md$/
607-
const regexPattern = pattern.pattern
608-
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
609-
.replace(/\*/g, '.*'); // Replace * with .*
610-
const regex = new RegExp(`^${regexPattern}$`);
611-
if (regex.test(normalizedFile)) {
612-
tools.add(toolId);
613-
break;
610+
const patterns = Array.isArray(pattern.pattern) ? pattern.pattern : [pattern.pattern];
611+
let matched = false;
612+
for (const p of patterns) {
613+
const regexPattern = p
614+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars except *
615+
.replace(/\*/g, '.*'); // Replace * with .*
616+
const regex = new RegExp(`^${regexPattern}$`);
617+
if (regex.test(normalizedFile)) {
618+
tools.add(toolId);
619+
matched = true;
620+
break;
621+
}
614622
}
623+
if (matched) break;
615624
}
616625
}
617626
}

0 commit comments

Comments
 (0)