diff --git a/README.md b/README.md index 7cc485f..7eac70a 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ The CLI auto-detects your IDE from project directory markers. If multiple IDEs a | [Codex](https://developers.openai.com/codex/skills/) | `--ide codex` | `.agent/skills/` | Agent Skills | | [Cursor](https://cursor.com/docs/skills) | `--ide cursor` | `.cursor/skills/` | Agent Skills | | [GitHub Copilot](https://docs.github.com/en/copilot/concepts/agents/about-agent-skills) | `--ide copilot` | `.github/skills/` | Agent Skills | +| [OpenCode](https://opencode.ai) | `--ide opencode` | `.opencode/skills/` | Agent Skills | | Generic | `--ide generic` | `.agent/skills/` | Agent Skills | Antigravity, Codex, and generic all install to the same `.agent/skills/` directory (only `generic` is stored in the config). GitHub Copilot is not auto-detected (`.github/` is often used for other purposes); use `--ide copilot` to install skills for Copilot explicitly. diff --git a/lib/src/ide/adapters/opencode_adapter.dart b/lib/src/ide/adapters/opencode_adapter.dart new file mode 100644 index 0000000..333b828 --- /dev/null +++ b/lib/src/ide/adapters/opencode_adapter.dart @@ -0,0 +1,11 @@ +import '../ide.dart'; +import 'agent_skills_adapter.dart'; + +/// OpenCode adapter. +/// +/// Installs skills to `.opencode/skills//` per +/// [OpenCode skills](https://opencode.ai/docs/skills/). +class OpenCodeAdapter extends AgentSkillsAdapter { + OpenCodeAdapter(String projectPath) + : super(Ide.opencode.skillsPath(projectPath)); +} diff --git a/lib/src/ide/ide.dart b/lib/src/ide/ide.dart index bc86093..f159659 100644 --- a/lib/src/ide/ide.dart +++ b/lib/src/ide/ide.dart @@ -12,7 +12,8 @@ enum Ide { generic('generic', '.agent/skills'), claude('claude', '.claude/skills'), copilot('copilot', '.github/skills'), - cline('cline', '.cline/skills'); + cline('cline', '.cline/skills'), + opencode('opencode', '.opencode/skills'); final String cliName; @@ -44,6 +45,7 @@ enum Ide { Ide.cline => Directory(p.join(projectPath, '.cline')).existsSync() || Directory(p.join(projectPath, '.clinerules')).existsSync(), Ide.copilot => false, + Ide.opencode => Directory(p.join(projectPath, '.opencode')).existsSync(), }; } diff --git a/lib/src/ide/ide_adapter_factory.dart b/lib/src/ide/ide_adapter_factory.dart index 9573466..1043c1e 100644 --- a/lib/src/ide/ide_adapter_factory.dart +++ b/lib/src/ide/ide_adapter_factory.dart @@ -3,6 +3,7 @@ import 'adapters/cline_adapter.dart'; import 'adapters/copilot_adapter.dart'; import 'adapters/cursor_adapter.dart'; import 'adapters/generic_adapter.dart'; +import 'adapters/opencode_adapter.dart'; import 'ide.dart'; import 'ide_adapter.dart'; @@ -14,5 +15,6 @@ IdeAdapter createIdeAdapter(Ide ide, String projectPath) { Ide.claude => ClaudeAdapter(projectPath), Ide.copilot => CopilotAdapter(projectPath), Ide.cline => ClineAdapter(projectPath), + Ide.opencode => OpenCodeAdapter(projectPath), }; } diff --git a/test/ide/ide_detection_test.dart b/test/ide/ide_detection_test.dart index 63280a0..5883a0c 100644 --- a/test/ide/ide_detection_test.dart +++ b/test/ide/ide_detection_test.dart @@ -47,6 +47,17 @@ void main() { }); }); + group('Given a project with a .opencode directory', () { + test('when detecting IDE then returns opencode', () async { + await d.dir('opencode_project', [d.dir('.opencode')]).create(); + + const detector = IdeDetector(); + final ide = detector.detect(d.path('opencode_project')); + + expect(ide, equals(Ide.opencode)); + }); + }); + group('Given a project with .github/copilot-instructions.md', () { test('when detecting IDE then does not auto-detect copilot', () async { await d.dir('copilot_project', [ @@ -119,6 +130,7 @@ void main() { expect(Ide.fromCliName('claude'), equals(Ide.claude)); expect(Ide.fromCliName('copilot'), equals(Ide.copilot)); expect(Ide.fromCliName('cline'), equals(Ide.cline)); + expect(Ide.fromCliName('opencode'), equals(Ide.opencode)); }); test('when given generic aliases then returns generic', () { diff --git a/test/ide/opencode_adapter_test.dart b/test/ide/opencode_adapter_test.dart new file mode 100644 index 0000000..5e9387f --- /dev/null +++ b/test/ide/opencode_adapter_test.dart @@ -0,0 +1,112 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:skills/src/core/skill_scanner.dart'; +import 'package:skills/src/ide/adapters/opencode_adapter.dart'; +import 'package:test/test.dart'; +import 'package:test_descriptor/test_descriptor.dart' as d; + +void main() { + group('Given an OpenCodeAdapter', () { + late OpenCodeAdapter adapter; + + setUp(() async { + await d.dir('project', [ + d.dir('.opencode', [d.dir('skills')]), + ]).create(); + + adapter = OpenCodeAdapter(d.path('project')); + }); + + group('and a scanned skill', () { + late ScannedSkill skill; + + setUp(() async { + await d.dir('opencode_pkg', [ + d.dir('skills', [ + d.dir('opencode_pkg-code-review', [ + d.file('SKILL.md', ''' +--- +name: opencode_pkg-code-review +description: Reviews code. +--- + +# Code Review + +Review guidelines here. +'''), + ]), + ]), + ]).create(); + + skill = ScannedSkill( + packageName: 'opencode_pkg', + skillName: 'opencode_pkg-code-review', + skillPath: d.path('opencode_pkg/skills/opencode_pkg-code-review'), + ); + }); + + test( + 'when installing then creates skill directory in .opencode/skills/', + () async { + final name = await adapter.installSkill(skill); + + expect(name, equals('opencode_pkg-code-review')); + + final installed = Directory( + p.join( + d.path('project'), + '.opencode', + 'skills', + 'opencode_pkg-code-review', + ), + ); + expect(await installed.exists(), isTrue); + }, + ); + + test('when installing then SKILL.md is copied unchanged', () async { + await adapter.installSkill(skill); + + final content = await File( + p.join( + d.path('project'), + '.opencode', + 'skills', + 'opencode_pkg-code-review', + 'SKILL.md', + ), + ).readAsString(); + + expect(content, contains('name: opencode_pkg-code-review')); + expect(content, contains('# Code Review')); + }); + }); + + test('when removing then deletes the skill directory', () async { + await d.dir('project', [ + d.dir('.opencode', [ + d.dir('skills', [ + d.dir('pkg-skill', [ + d.file( + 'SKILL.md', + '---\nname: pkg-skill\n' + 'description: x\n---\nbody', + ), + ]), + ]), + ]), + ]).create(); + + adapter = OpenCodeAdapter(d.path('project')); + await adapter.removeSkill('pkg-skill'); + + expect( + await Directory( + p.join(d.path('project'), '.opencode', 'skills', 'pkg-skill'), + ).exists(), + isFalse, + ); + }); + }); +}