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
29 changes: 29 additions & 0 deletions lib/src/ide/adapters/claude_adapter.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import 'dart:io';

import 'package:path/path.dart' as p;

import '../../core/skill_scanner.dart';
import '../ide.dart';
import 'agent_skills_adapter.dart';

Expand All @@ -7,4 +12,28 @@ import 'agent_skills_adapter.dart';
/// [Claude Code skills](https://code.claude.com/docs/en/skills).
class ClaudeAdapter extends AgentSkillsAdapter {
ClaudeAdapter(String projectPath) : super(Ide.claude.skillsPath(projectPath));

@override
Future<String> installSkill(ScannedSkill skill) async {
final name = await super.installSkill(skill);

final skillMd = File(
p.join(skillsDirectory, skill.skillName, 'SKILL.md'),
);
if (await skillMd.exists()) {
var content = await skillMd.readAsString();
if (!content.contains('user-invocable:')) {
final closingIndex = content.indexOf('---', 3);
if (closingIndex != -1) {
content =
'${content.substring(0, closingIndex)}'
'user-invocable: false\n'
'${content.substring(closingIndex)}';
await skillMd.writeAsString(content);
}
}
}

return name;
}
}
246 changes: 230 additions & 16 deletions test/ide/claude_adapter_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,22 +65,236 @@ Review guidelines here.
},
);

test('when installing then SKILL.md is copied unchanged', () async {
await adapter.installSkill(skill);

final content = await File(
p.join(
d.path('project'),
'.claude',
'skills',
'claude_pkg-code-review',
'SKILL.md',
),
).readAsString();

expect(content, contains('name: claude_pkg-code-review'));
expect(content, contains('# Code Review'));
});
test(
'when installing then SKILL.md includes user-invocable: false',
() async {
await adapter.installSkill(skill);

final content = await File(
p.join(
d.path('project'),
'.claude',
'skills',
'claude_pkg-code-review',
'SKILL.md',
),
).readAsString();

expect(content, contains('name: claude_pkg-code-review'));
expect(content, contains('user-invocable: false'));
expect(content, contains('# Code Review'));
},
);

test(
'when installing a SKILL.md without user-invocable '
'then injects user-invocable: false and preserves content',
() async {
final name = await adapter.installSkill(skill);

expect(name, equals('claude_pkg-code-review'));

final content = await File(
p.join(
d.path('project'),
'.claude',
'skills',
'claude_pkg-code-review',
'SKILL.md',
),
).readAsString();

expect(content, contains('user-invocable: false'));
expect(content, contains('name: claude_pkg-code-review'));
expect(content, contains('description: Reviews code.'));
expect(content, contains('# Code Review'));
expect(
content,
contains('Review guidelines here.'),
);
},
);
});

group('user-invocable guard edge cases', () {
test(
'preserves explicit user-invocable: true',
() async {
await d.dir('claude_pkg_true', [
d.dir('skills', [
d.dir('claude_pkg_true-review', [
d.file('SKILL.md', '''
---
name: claude_pkg_true-review
description: Reviews code.
user-invocable: true
---

# Code Review
'''),
]),
]),
]).create();

final skill = ScannedSkill(
packageName: 'claude_pkg_true',
skillName: 'claude_pkg_true-review',
skillPath: d.path('claude_pkg_true/skills/claude_pkg_true-review'),
);

await adapter.installSkill(skill);

final content = await File(
p.join(
d.path('project'),
'.claude',
'skills',
'claude_pkg_true-review',
'SKILL.md',
),
).readAsString();

expect(content, contains('user-invocable: true'));
expect(content, isNot(contains('user-invocable: false')));
},
);

test(
'no duplication when user-invocable: false already present',
() async {
await d.dir('claude_pkg_dup', [
d.dir('skills', [
d.dir('claude_pkg_dup-lint', [
d.file('SKILL.md', '''
---
name: claude_pkg_dup-lint
description: Lints code.
user-invocable: false
---

# Lint
'''),
]),
]),
]).create();

final skill = ScannedSkill(
packageName: 'claude_pkg_dup',
skillName: 'claude_pkg_dup-lint',
skillPath: d.path('claude_pkg_dup/skills/claude_pkg_dup-lint'),
);

await adapter.installSkill(skill);

final content = await File(
p.join(
d.path('project'),
'.claude',
'skills',
'claude_pkg_dup-lint',
'SKILL.md',
),
).readAsString();

final matches = 'user-invocable: false'.allMatches(content).length;
expect(matches, equals(1));
},
);

test(
'preserves nested frontmatter fields byte-for-byte',
() async {
await d.dir('claude_pkg_nested', [
d.dir('skills', [
d.dir('claude_pkg_nested-deploy', [
d.file('SKILL.md', '''
---
name: claude_pkg_nested-deploy
description: Deploys stuff.
metadata:
version: 2
tags:
- deploy
- ci
---

# Deploy
'''),
]),
]),
]).create();

final skill = ScannedSkill(
packageName: 'claude_pkg_nested',
skillName: 'claude_pkg_nested-deploy',
skillPath: d.path(
'claude_pkg_nested/skills/claude_pkg_nested-deploy',
),
);

await adapter.installSkill(skill);

final content = await File(
p.join(
d.path('project'),
'.claude',
'skills',
'claude_pkg_nested-deploy',
'SKILL.md',
),
).readAsString();

expect(content, contains('user-invocable: false'));
expect(content, contains('metadata:'));
expect(content, contains(' version: 2'));
expect(content, contains(' tags:'));
expect(content, contains(' - deploy'));
expect(content, contains(' - ci'));
},
);

test(
'handles SKILL.md with no body',
() async {
await d.dir('claude_pkg_nobody', [
d.dir('skills', [
d.dir('claude_pkg_nobody-empty', [
d.file('SKILL.md', '''
---
name: claude_pkg_nobody-empty
description: Empty body skill.
---'''),
]),
]),
]).create();

final skill = ScannedSkill(
packageName: 'claude_pkg_nobody',
skillName: 'claude_pkg_nobody-empty',
skillPath: d.path(
'claude_pkg_nobody/skills/claude_pkg_nobody-empty',
),
);

await adapter.installSkill(skill);

final content = await File(
p.join(
d.path('project'),
'.claude',
'skills',
'claude_pkg_nobody-empty',
'SKILL.md',
),
).readAsString();

expect(content, contains('user-invocable: false'));
expect(content, contains('---'));
// Verify valid format: opening and closing ---
final dashes = '---'.allMatches(content).length;
expect(dashes, greaterThanOrEqualTo(2));
},
);
});

test('when removing then deletes the skill directory', () async {
Expand Down