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
4 changes: 3 additions & 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/commands/opsx-<id>.md` |
| OpenCode (`opencode`) | `.opencode/skills/openspec-*/SKILL.md` | `$OPENCODE_HOME/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 All @@ -50,6 +50,8 @@ You can enable expanded workflows (`new`, `continue`, `ff`, `verify`, `sync`, `b

\*\* GitHub Copilot prompt files are recognized as custom slash commands in IDE extensions (VS Code, JetBrains, Visual Studio). Copilot CLI does not currently consume `.github/prompts/*.prompt.md` directly.

\*\*\* OpenCode commands are installed in the global OpenCode home (`$OPENCODE_HOME/commands/` if set, otherwise `~/.config/opencode/commands/`), not your project directory.

## Non-Interactive Setup

For CI/CD or scripted setup, use `--tools` (and optionally `--profile`):
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 15 additions & 2 deletions src/core/command-generation/adapters/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,35 @@
* OpenCode Command Adapter
*
* Formats commands for OpenCode following its frontmatter specification.
* OpenCode custom commands live in the global home directory
* (~/.config/opencode/commands/) and are not shared through the repository.
* The OPENCODE_HOME env var can override the default ~/.config/opencode location.
*/

import os from 'os';
import path from 'path';
import type { CommandContent, ToolCommandAdapter } from '../types.js';
import { transformToHyphenCommands } from '../../../utils/command-references.js';

/**
* Returns the OpenCode home directory.
* Respects the OPENCODE_HOME env var, defaulting to ~/.config/opencode.
*/
function getOpenCodeHome(): string {
const envHome = process.env.OPENCODE_HOME?.trim();
return path.resolve(envHome ? envHome : path.join(os.homedir(), '.config', 'opencode'));
}

/**
* OpenCode adapter for command generation.
* File path: .opencode/commands/opsx-<id>.md
* File path: <OPENCODE_HOME>/commands/opsx-<id>.md (absolute, global)
* Frontmatter: description
*/
export const opencodeAdapter: ToolCommandAdapter = {
toolId: 'opencode',

getFilePath(commandId: string): string {
return path.join('.opencode', 'commands', `opsx-${commandId}.md`);
return path.join(getOpenCodeHome(), 'commands', `opsx-${commandId}.md`);
},

formatFile(content: CommandContent): string {
Expand Down
38 changes: 36 additions & 2 deletions test/core/command-generation/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,9 +442,43 @@ describe('command-generation/adapters', () => {
expect(opencodeAdapter.toolId).toBe('opencode');
});

it('should generate correct file path', () => {
it('should return an absolute path', () => {
const filePath = opencodeAdapter.getFilePath('explore');
expect(path.isAbsolute(filePath)).toBe(true);
});

it('should generate path ending with correct structure', () => {
const filePath = opencodeAdapter.getFilePath('explore');
expect(filePath).toBe(path.join('.opencode', 'commands', 'opsx-explore.md'));
expect(filePath).toMatch(/commands[/\\]opsx-explore\.md$/);
});

it('should default to homedir/.config/opencode', () => {
const original = process.env.OPENCODE_HOME;
delete process.env.OPENCODE_HOME;
try {
const filePath = opencodeAdapter.getFilePath('explore');
const expected = path.join(os.homedir(), '.config', 'opencode', 'commands', 'opsx-explore.md');
expect(filePath).toBe(expected);
} finally {
if (original !== undefined) {
process.env.OPENCODE_HOME = original;
}
}
});

it('should respect OPENCODE_HOME env var', () => {
const original = process.env.OPENCODE_HOME;
process.env.OPENCODE_HOME = '/custom/opencode-home';
try {
const filePath = opencodeAdapter.getFilePath('explore');
expect(filePath).toBe(path.join(path.resolve('/custom/opencode-home'), 'commands', 'opsx-explore.md'));
} finally {
if (original !== undefined) {
process.env.OPENCODE_HOME = original;
} else {
delete process.env.OPENCODE_HOME;
}
}
});

it('should format file with description frontmatter', () => {
Expand Down
34 changes: 21 additions & 13 deletions test/core/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,21 +537,29 @@ describe('InitCommand - profile and detection features', () => {
});

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);
// Use a temp dir for OpenCode home to avoid writing to real home
const opencodeHome = path.join(os.tmpdir(), `openspec-opencode-home-${Date.now()}`);
process.env.OPENCODE_HOME = opencodeHome;

try {
// 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);
// 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);
// New commands should be at the global OpenCode path
const newCommandsDir = path.join(opencodeHome, 'commands');
expect(await directoryExists(newCommandsDir)).toBe(true);
} finally {
await fs.rm(opencodeHome, { recursive: true, force: true });
}
});

it('should preselect configured tools but not directory-detected tools in extend mode', async () => {
Expand Down