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
2 changes: 1 addition & 1 deletion docs/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/` |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-06
Original file line number Diff line number Diff line change
@@ -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?
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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 `<!-- OPENSPEC:START -->` and `<!-- OPENSPEC:END -->` 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
Original file line number Diff line number Diff line change
@@ -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.2 Run existing tests related to OpenSpec init/update and ensure they pass
2 changes: 1 addition & 1 deletion openspec/specs/cli-update/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions src/core/command-generation/adapters/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,26 @@ 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 {
// Transform command references from colon to hyphen format for OpenCode
const transformedBody = transformToHyphenCommands(content.body);
const archiveArguments = content.id === 'archive'
? 'arguments: |\n <ChangeId>\n $ARGUMENTS\n </ChangeId>\n'
: '';

return `---
description: ${content.description}
---
${archiveArguments}---

${transformedBody}
`;
Expand Down
7 changes: 6 additions & 1 deletion src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
getSkillTemplates,
getCommandContents,
generateSkillContent,
migrateOpenCodeCommands,
type ToolSkillStatus,
} from './shared/index.js';

Expand Down Expand Up @@ -89,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();
Expand Down
2 changes: 1 addition & 1 deletion src/core/legacy-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,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/commands/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 Down
2 changes: 2 additions & 0 deletions src/core/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ export {
getCommandContents,
generateSkillContent,
} from './skill-generation.js';

export { migrateOpenCodeCommands } from './opencode-migration.js';
54 changes: 54 additions & 0 deletions src/core/shared/opencode-migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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<void> {
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 });
let changedAny = false;
for (const entry of entries) {
if (!entry.isFile()) continue;
const sourcePath = path.join(legacyDir, entry.name);
const destinationPath = path.join(nextDir, entry.name);
// Prefer the new location on conflicts: keep destination, discard legacy.
if (await FileSystemUtils.fileExists(destinationPath)) {
await fs.promises.unlink(sourcePath);
changedAny = true;
continue;
}

try {
await fs.promises.rename(sourcePath, destinationPath);
} catch (error: any) {
if (error?.code !== 'EXDEV') {
throw error;
}
// Fallback for cross-device moves.
await fs.promises.copyFile(sourcePath, destinationPath);
await fs.promises.unlink(sourcePath);
}
changedAny = true;
}
Comment on lines 21 to 41
Copy link

Copilot AI Feb 6, 2026

Choose a reason for hiding this comment

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

The migration currently deletes any existing destination file in .opencode/commands/ before moving the legacy file over. If a user already has newer/customized files in the new directory, this will irreversibly overwrite them. Safer options: skip files that already exist in the destination, or rename the legacy file (e.g., add a suffix) and/or prompt before overwriting.

Copilot uses AI. Check for mistakes.

const remaining = await fs.promises.readdir(legacyDir);
if (changedAny && remaining.length === 0 && canPrompt) {
const { confirm } = await import('@inquirer/prompts');
const shouldRemove = await confirm({
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) {
await fs.promises.rm(legacyDir, { recursive: true, force: true });
}
}
}
7 changes: 7 additions & 0 deletions src/core/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
getCommandContents,
generateSkillContent,
getToolsWithSkillsDir,
migrateOpenCodeCommands,
type ToolVersionStatus,
} from './shared/index.js';
import {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -131,6 +135,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);
}
}
Expand Down
16 changes: 15 additions & 1 deletion test/core/command-generation/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -480,6 +481,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('<ChangeId>');
expect(output).toContain('$ARGUMENTS');
});
});

describe('qoderAdapter', () => {
Expand Down
Loading
Loading