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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions lib/src/ide/adapters/opencode_adapter.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import '../ide.dart';
import 'agent_skills_adapter.dart';

/// OpenCode adapter.
///
/// Installs skills to `.opencode/skills/<skill-name>/` per
/// [OpenCode skills](https://opencode.ai/docs/skills/).
class OpenCodeAdapter extends AgentSkillsAdapter {
OpenCodeAdapter(String projectPath)
: super(Ide.opencode.skillsPath(projectPath));
}
4 changes: 3 additions & 1 deletion lib/src/ide/ide.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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(),
};
}

Expand Down
2 changes: 2 additions & 0 deletions lib/src/ide/ide_adapter_factory.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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),
};
}
12 changes: 12 additions & 0 deletions test/ide/ide_detection_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', [
Expand Down Expand Up @@ -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', () {
Expand Down
112 changes: 112 additions & 0 deletions test/ide/opencode_adapter_test.dart
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
}