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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
# Created by `dart pub`
.dart_tool/
BLOG.md
QWEN.md
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Fix any format, analysis, or test failures before finishing.
- **Paths:** Use `package:path/path.dart` and `p.join()` for all path construction so behavior is correct on Windows.
- **Empty manifest:** When all managed skills are removed, delete the `.dart_skills` directory rather than leaving an empty manifest file (see `SkillManifest.cleanupDir` and `RemoveCommand`).
- **Workspace resolution:** The CLI supports (1) a directory with a `pubspec.yaml` (pub workspace, melos, or single package) and (2) an implicit workspace: no root `pubspec.yaml`, but immediate subdirectories that have `pubspec.yaml` are treated as packages. Do not walk up the directory tree to find a project root; the user is expected to run from the project root.
- **IDE install locations:** Install full Agent Skills (SKILL.md plus scripts, references, assets) into each IDEs documented location: `.cursor/skills/`, `.agent/skills/`, `.claude/skills/`, `.cline/skills/`, `.github/skills/`. See README for spec links.
- **IDE install locations:** Install full Agent Skills (SKILL.md plus scripts, references, assets) into each IDE's documented location: `.cursor/skills/`, `.agent/skills/`, `.claude/skills/`, `.cline/skills/`, `.github/skills/`, `.qoderwork/skills/`, `.qwen/skills/`. See README for spec links.
- **Registry repos:** GitHub registry repos are cloned/updated under `.dart_skills/repos/<owner>/<repo>`. The merge step gives Dart-package skills precedence per package: if a dependency ships its own skills, registry skills for that package are not installed.
- **Generic IDE:** Antigravity, Codex, and generic are separate CLI options that all install to `.agent/skills/`. Only `generic` is stored in `skills_config.json`.
- **Listing IDEs:** When listing agents/IDEs (docs, help text, CLI options), use alphabetical order with generic last.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ The CLI auto-detects your IDE from project directory markers. If multiple IDEs a
| [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 |
| Generic | `--ide generic` | `.agent/skills/` | Agent Skills |
| [Qoder](https://docs.qoder.com/qoderwork/skills) | `--ide qoder` | `.qoderwork/skills/` | Agent Skills |
| [Qwen Code](https://qwenlm.github.io/qwen-code-docs/en/users/features/skills/) | `--ide qwen` | `.qwen/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
6 changes: 4 additions & 2 deletions lib/src/core/skill_scanner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class SkillScanner {
if (!await skillsDir.exists()) return [];

final prefix = '${package.name}-';
final prefixWithDashes = '${package.name.replaceAll('_', '-')}-';
final skills = <ScannedSkill>[];

await for (final entity in skillsDir.list()) {
Expand All @@ -53,10 +54,11 @@ class SkillScanner {
final skillMdFile = File(p.join(entity.path, 'SKILL.md'));
if (!await skillMdFile.exists()) continue;

if (!skillName.startsWith(prefix)) {
if (!(skillName.startsWith(prefix) ||
skillName.startsWith(prefixWithDashes))) {
stderr.writeln(
'Warning: Skipping skill "$skillName" in ${package.name} '
'-- name must start with "${package.name}-"',
'-- name must start with "$prefix" or "$prefixWithDashes"',
);
continue;
}
Expand Down
10 changes: 10 additions & 0 deletions lib/src/ide/adapters/qoder_adapter.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import '../ide.dart';
import 'agent_skills_adapter.dart';

/// Qoder adapter.
///
/// Installs skills to `.qoderwork/skills/<skill-name>/` per
/// [Qoder skills](https://docs.qoder.com/qoderwork/skills).
class QoderAdapter extends AgentSkillsAdapter {
QoderAdapter(String projectPath) : super(Ide.qoder.skillsPath(projectPath));
}
10 changes: 10 additions & 0 deletions lib/src/ide/adapters/qwen_adapter.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import '../ide.dart';
import 'agent_skills_adapter.dart';

/// Qwen Code adapter.
///
/// Installs skills to `.qwen/skills/<skill-name>/` per
/// [Qwen Code skills](https://qwenlm.github.io/qwen-code-docs/en/users/features/skills/).
class QwenAdapter extends AgentSkillsAdapter {
QwenAdapter(String projectPath) : super(Ide.qwen.skillsPath(projectPath));
}
6 changes: 5 additions & 1 deletion lib/src/ide/ide.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ enum Ide {
generic('generic', '.agent/skills'),
claude('claude', '.claude/skills'),
copilot('copilot', '.github/skills'),
cline('cline', '.cline/skills');
cline('cline', '.cline/skills'),
qoder('qoder', '.qoderwork/skills'),
qwen('qwen', '.qwen/skills');

final String cliName;

Expand Down Expand Up @@ -43,6 +45,8 @@ enum Ide {
Ide.claude => Directory(p.join(projectPath, '.claude')).existsSync(),
Ide.cline => Directory(p.join(projectPath, '.cline')).existsSync() ||
Directory(p.join(projectPath, '.clinerules')).existsSync(),
Ide.qoder => Directory(p.join(projectPath, '.qoderwork')).existsSync(),
Ide.qwen => Directory(p.join(projectPath, '.qwen')).existsSync(),
Ide.copilot => false,
};
}
Expand Down
4 changes: 4 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,8 @@ import 'adapters/cline_adapter.dart';
import 'adapters/copilot_adapter.dart';
import 'adapters/cursor_adapter.dart';
import 'adapters/generic_adapter.dart';
import 'adapters/qoder_adapter.dart';
import 'adapters/qwen_adapter.dart';
import 'ide.dart';
import 'ide_adapter.dart';

Expand All @@ -14,5 +16,7 @@ IdeAdapter createIdeAdapter(Ide ide, String projectPath) {
Ide.claude => ClaudeAdapter(projectPath),
Ide.copilot => CopilotAdapter(projectPath),
Ide.cline => ClineAdapter(projectPath),
Ide.qoder => QoderAdapter(projectPath),
Ide.qwen => QwenAdapter(projectPath),
};
}
53 changes: 53 additions & 0 deletions test/core/skill_scanner_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,59 @@ Body.
},
);

group(
'Given a package with underscores in its name and dash-normalized skill names',
() {
test('when scanning then accepts dash-normalized prefix', () async {
await d.dir('my_cool_pkg', [
d.dir('skills', [
d.dir('my-cool-pkg-code-gen', [
d.file('SKILL.md', '''
---
name: my-cool-pkg-code-gen
description: Generates code.
---
Body.
'''),
]),
d.dir('my_cool_pkg-api-design', [
d.file('SKILL.md', '''
---
name: my_cool_pkg-api-design
description: Designs APIs.
---
Body.
'''),
]),
d.dir('wrong-prefix-skill', [
d.file('SKILL.md', '''
---
name: wrong-prefix-skill
description: Invalid.
---
Body.
'''),
]),
]),
]).create();

final package = ResolvedPackage(
name: 'my_cool_pkg',
rootPath: d.path('my_cool_pkg'),
);

const scanner = SkillScanner();
final skills = await scanner.scanPackage(package);

expect(skills, hasLength(2));
expect(
skills.map((s) => s.skillName).toSet(),
equals({'my-cool-pkg-code-gen', 'my_cool_pkg-api-design'}),
);
});
},
);

group('Given a package without a skills directory', () {
test('when scanning then returns empty list', () async {
await d.dir('no_skills_package', [
Expand Down
24 changes: 24 additions & 0 deletions test/ide/ide_detection_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,28 @@ void main() {
});
});

group('Given a project with a .qoderwork directory', () {
test('when detecting IDE then returns qoder', () async {
await d.dir('qoder_project', [d.dir('.qoderwork')]).create();

const detector = IdeDetector();
final ide = detector.detect(d.path('qoder_project'));

expect(ide, equals(Ide.qoder));
});
});

group('Given a project with a .qwen directory', () {
test('when detecting IDE then returns qwen', () async {
await d.dir('qwen_project', [d.dir('.qwen')]).create();

const detector = IdeDetector();
final ide = detector.detect(d.path('qwen_project'));

expect(ide, equals(Ide.qwen));
});
});

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 +141,8 @@ 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('qoder'), equals(Ide.qoder));
expect(Ide.fromCliName('qwen'), equals(Ide.qwen));
});

test('when given generic aliases then returns generic', () {
Expand Down
128 changes: 128 additions & 0 deletions test/ide/qoder_adapter_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import 'dart:io';

import 'package:skills/src/core/skill_scanner.dart';
import 'package:skills/src/ide/adapters/qoder_adapter.dart';
import 'package:test/test.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;

void main() {
group('Given a QoderAdapter', () {
late QoderAdapter adapter;

setUp(() async {
await d.dir('project', [
d.dir('.qoderwork', [d.dir('skills')]),
]).create();

adapter = QoderAdapter(d.path('project'));
});

group('and a scanned skill with SKILL.md and supporting files', () {
late ScannedSkill skill;

setUp(() async {
await d.dir('source_pkg', [
d.dir('skills', [
d.dir('source_pkg-my-skill', [
d.file('SKILL.md', '''
---
name: source_pkg-my-skill
description: A test skill.
---

# My Skill

Instructions here.
'''),
d.dir('scripts', [d.file('run.sh', '#!/bin/bash\necho hello')]),
d.dir('references', [d.file('guide.md', '# Guide')]),
]),
]),
]).create();

skill = ScannedSkill(
packageName: 'source_pkg',
skillName: 'source_pkg-my-skill',
skillPath: d.path('source_pkg/skills/source_pkg-my-skill'),
);
});

test('when installing then creates directory with skill name', () async {
final name = await adapter.installSkill(skill);

expect(name, equals('source_pkg-my-skill'));

final installed = Directory(
d.path('project/.qoderwork/skills/source_pkg-my-skill'),
);
expect(await installed.exists(), isTrue);
});

test('when installing then copies SKILL.md as-is', () async {
await adapter.installSkill(skill);

final skillMd = File(
d.path('project/.qoderwork/skills/source_pkg-my-skill/SKILL.md'),
);
final content = await skillMd.readAsString();

expect(content, contains('name: source_pkg-my-skill'));
expect(content, contains('# My Skill'));
});

test('when installing then copies supporting directories', () async {
await adapter.installSkill(skill);

final script = File(
d.path(
'project/.qoderwork/skills/source_pkg-my-skill/scripts/run.sh'),
);
expect(await script.exists(), isTrue);

final ref = File(
d.path(
'project/.qoderwork/skills/source_pkg-my-skill/references/guide.md',
),
);
expect(await ref.exists(), isTrue);
});

test('when reinstalling then replaces existing skill', () async {
await adapter.installSkill(skill);
await adapter.installSkill(skill);

final installed = Directory(
d.path('project/.qoderwork/skills/source_pkg-my-skill'),
);
expect(await installed.exists(), isTrue);
});
});

group('and a previously installed skill', () {
setUp(() async {
await d.dir('project', [
d.dir('.qoderwork', [
d.dir('skills', [
d.dir('pkg-old-skill', [
d.file(
'SKILL.md',
'---\nname: pkg-old-skill\n'
'description: old\n---\nOld.',
),
]),
]),
]),
]).create();
});

test('when removing then deletes the skill directory', () async {
await adapter.removeSkill('pkg-old-skill');

final removed = Directory(
d.path('project/.qoderwork/skills/pkg-old-skill'),
);
expect(await removed.exists(), isFalse);
});
});
});
}
Loading