blob: 213eebd5f38d5535e0a8db948a11cd611991047b [file] [edit]
import 'dart:io';
import 'package:logging/logging.dart';
import 'package:skills/src/core/skill_installer.dart';
import 'package:skills/src/core/skill_scanner.dart';
import 'package:skills/src/agent/agent.dart';
import '../fake_dialog_support.dart';
import 'package:skills/src/models/global_config.dart';
import 'package:skills/src/models/skill_manifest.dart';
import 'package:test/test.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;
void main() {
late List<ScannedSkill> pkgASkills;
late List<ScannedSkill> pkgBSkills;
late FakeDialogSupport fakeDialogSupport;
setUpAll(() {
Logger.root.onRecord.listen((r) => printOnFailure(r.toString()));
});
setUp(() async {
fakeDialogSupport = FakeDialogSupport();
// Source packages with skills.
await d.dir('pkg_a', [
d.dir('skills', [
d.dir('pkg_a-code-gen', [
d.file('SKILL.md', '''
---
name: pkg_a-code-gen
description: Code generation skill.
---
# Code Generation
Instructions for code generation.
'''),
]),
]),
]).create();
await d.dir('pkg_b', [
d.dir('skills', [
d.dir('pkg_b-testing', [
d.file('SKILL.md', '''
---
name: pkg_b-testing
description: Testing skill.
---
# Testing
Instructions for testing.
'''),
]),
d.dir('pkg_b-debugging', [
d.file('SKILL.md', '''
---
name: pkg_b-debugging
description: Debugging skill.
---
# Debugging
Instructions for debugging.
'''),
]),
]),
]).create();
pkgASkills = [
ScannedSkill(
packageName: 'pkg_a',
skillName: 'pkg_a-code-gen',
skillPath: d.path('pkg_a/skills/pkg_a-code-gen'),
),
];
pkgBSkills = [
ScannedSkill(
packageName: 'pkg_b',
skillName: 'pkg_b-testing',
skillPath: d.path('pkg_b/skills/pkg_b-testing'),
),
ScannedSkill(
packageName: 'pkg_b',
skillName: 'pkg_b-debugging',
skillPath: d.path('pkg_b/skills/pkg_b-debugging'),
),
];
});
group('Given skills installed to Cursor and generic', () {
late String rootPath;
late SkillManifest manifest;
setUp(() async {
await d.dir('project', [
d.dir('.cursor', [d.dir('skills')]),
d.dir('.agents', [d.dir('skills')]),
]).create();
rootPath = d.path('project');
manifest = const SkillManifest();
final installer = SkillInstaller(fakeDialogSupport);
var result = await installer.installSkillsForIde(
agent: Agent.cursor,
rootPath: rootPath,
skills: [...pkgASkills, ...pkgBSkills],
previousManifest: manifest,
globalConfig: const GlobalConfig(),
);
manifest = result!.manifest;
result = await installer.installSkillsForIde(
agent: Agent.generic,
rootPath: rootPath,
skills: [...pkgASkills, ...pkgBSkills],
previousManifest: manifest,
globalConfig: const GlobalConfig(),
);
manifest = result!.manifest;
});
test('when removing all then both agents are cleaned up', () async {
// Verify files exist before removal.
expect(
Directory('$rootPath/.cursor/skills/pkg_a-code-gen').existsSync(),
isTrue,
);
expect(
Directory('$rootPath/.agents/skills/pkg_a-code-gen').existsSync(),
isTrue,
);
manifest = await SkillInstaller(
fakeDialogSupport,
).removeAllSkills(rootPath: rootPath, manifest: manifest);
// Verify all skill directories are gone.
expect(
Directory('$rootPath/.cursor/skills/pkg_a-code-gen').existsSync(),
isFalse,
);
expect(
Directory('$rootPath/.cursor/skills/pkg_b-testing').existsSync(),
isFalse,
);
expect(
Directory('$rootPath/.cursor/skills/pkg_b-debugging').existsSync(),
isFalse,
);
expect(
Directory('$rootPath/.agents/skills/pkg_a-code-gen').existsSync(),
isFalse,
);
expect(
Directory('$rootPath/.agents/skills/pkg_b-testing').existsSync(),
isFalse,
);
expect(
Directory('$rootPath/.agents/skills/pkg_b-debugging').existsSync(),
isFalse,
);
expect(manifest.isEmpty, isTrue);
});
test('when removing one agent then the other remains intact', () async {
// Remove only Cursor.
final result = await SkillInstaller(fakeDialogSupport).removeSkillsForIde(
agent: Agent.cursor,
rootPath: rootPath,
manifest: manifest,
);
manifest = result.manifest;
// Cursor files gone.
expect(
Directory('$rootPath/.cursor/skills/pkg_a-code-gen').existsSync(),
isFalse,
);
expect(
Directory('$rootPath/.cursor/skills/pkg_b-testing').existsSync(),
isFalse,
);
// Generic (.agents) files still present.
expect(
Directory('$rootPath/.agents/skills/pkg_a-code-gen').existsSync(),
isTrue,
);
expect(
Directory('$rootPath/.agents/skills/pkg_b-testing').existsSync(),
isTrue,
);
expect(manifest.allAgents, equals(['generic']));
expect(manifest.sourceUrisForAgent('generic'), hasLength(2));
});
test(
'when removing one package from all agents then other package remains',
() async {
// Remove pkg_a from both agents.
final installer = SkillInstaller(fakeDialogSupport);
for (final agentName in manifest.allAgents.toList()) {
final agent = Agent.fromCliName(agentName)!;
final result = await installer.removeSkillsForIde(
agent: agent,
rootPath: rootPath,
manifest: manifest,
sourceUris: {'package:pkg_a'},
);
manifest = result.manifest;
}
// pkg_a skills gone from both agents.
expect(
Directory('$rootPath/.cursor/skills/pkg_a-code-gen').existsSync(),
isFalse,
);
expect(
Directory('$rootPath/.agents/skills/pkg_a-code-gen').existsSync(),
isFalse,
);
// pkg_b skills still present in both agents.
expect(
Directory('$rootPath/.cursor/skills/pkg_b-testing').existsSync(),
isTrue,
);
expect(
Directory('$rootPath/.agents/skills/pkg_b-debugging').existsSync(),
isTrue,
);
expect(
manifest.sourceUrisForAgent('cursor'),
contains('package:pkg_b'),
);
expect(
manifest.sourceUrisForAgent('generic'),
contains('package:pkg_b'),
);
expect(
manifest.sourceUrisForAgent('cursor'),
isNot(contains('package:pkg_a')),
);
},
);
});
group('Given skills installed then manually deleted from disk', () {
late String rootPath;
late SkillManifest manifest;
setUp(() async {
await d.dir('project', [
d.dir('.cursor', [d.dir('skills')]),
d.dir('.agents', [d.dir('skills')]),
]).create();
rootPath = d.path('project');
manifest = const SkillManifest();
final installer = SkillInstaller(fakeDialogSupport);
var result = await installer.installSkillsForIde(
agent: Agent.cursor,
rootPath: rootPath,
skills: pkgASkills,
previousManifest: manifest,
globalConfig: const GlobalConfig(),
);
manifest = result!.manifest;
result = await installer.installSkillsForIde(
agent: Agent.generic,
rootPath: rootPath,
skills: pkgASkills,
previousManifest: manifest,
globalConfig: const GlobalConfig(),
);
manifest = result!.manifest;
});
test(
'when remove all is called then manifest cleans up without error',
() async {
// Manually delete the Cursor skill files from disk.
final cursorSkillDir = Directory(
'$rootPath/.cursor/skills/pkg_a-code-gen',
);
expect(cursorSkillDir.existsSync(), isTrue);
cursorSkillDir.deleteSync(recursive: true);
// Remove all -- should not throw even though cursor files are gone.
manifest = await SkillInstaller(
fakeDialogSupport,
).removeAllSkills(rootPath: rootPath, manifest: manifest);
expect(manifest.isEmpty, isTrue);
// Generic (.agents) was removed normally.
expect(
Directory('$rootPath/.agents/skills/pkg_a-code-gen').existsSync(),
isFalse,
);
},
);
test('when some skills are manually deleted then remaining are still '
'removed correctly', () async {
// Install a second package too.
var result = await SkillInstaller(fakeDialogSupport).installSkillsForIde(
agent: Agent.cursor,
rootPath: rootPath,
skills: [...pkgASkills, ...pkgBSkills],
previousManifest: manifest,
globalConfig: const GlobalConfig(),
);
manifest = result!.manifest;
// Manually delete pkg_a skill from cursor.
Directory(
'$rootPath/.cursor/skills/pkg_a-code-gen',
).deleteSync(recursive: true);
// Remove all.
manifest = await SkillInstaller(
fakeDialogSupport,
).removeAllSkills(rootPath: rootPath, manifest: manifest);
// Everything should be clean, no errors.
expect(manifest.isEmpty, isTrue);
expect(
Directory('$rootPath/.cursor/skills/pkg_b-testing').existsSync(),
isFalse,
);
});
});
group('Given skills installed to Cursor and generic', () {
late String rootPath;
late SkillManifest manifest;
setUp(() async {
await d.dir('project2', [
d.dir('.cursor', [d.dir('skills')]),
d.dir('.agents', [d.dir('skills')]),
]).create();
rootPath = d.path('project2');
manifest = const SkillManifest();
final installer = SkillInstaller(fakeDialogSupport);
var result = await installer.installSkillsForIde(
agent: Agent.cursor,
rootPath: rootPath,
skills: pkgASkills,
previousManifest: manifest,
globalConfig: const GlobalConfig(),
);
manifest = result!.manifest;
result = await installer.installSkillsForIde(
agent: Agent.generic,
rootPath: rootPath,
skills: pkgASkills,
previousManifest: manifest,
globalConfig: const GlobalConfig(),
);
manifest = result!.manifest;
});
test(
'when reinstalling to one agent then the other is untouched',
() async {
// Reinstall to Cursor only (simulating `skills get --agent cursor`).
// SkillInstaller removes existing before installing.
final result = await SkillInstaller(fakeDialogSupport)
.installSkillsForIde(
agent: Agent.cursor,
rootPath: rootPath,
skills: pkgASkills,
previousManifest: manifest,
globalConfig: const GlobalConfig(),
);
manifest = result!.manifest;
// Cursor reinstalled.
expect(
Directory('$rootPath/.cursor/skills/pkg_a-code-gen').existsSync(),
isTrue,
);
// Generic (.agents) untouched.
expect(
Directory('$rootPath/.agents/skills/pkg_a-code-gen').existsSync(),
isTrue,
);
expect(manifest.allAgents, containsAll(['cursor', 'generic']));
},
);
});
group('Given skills installed to Cursor and Claude', () {
late String rootPath;
late SkillManifest manifest;
setUp(() async {
await d.dir('project3', [
d.dir('.cursor', [d.dir('skills')]),
d.dir('.claude', [d.dir('skills')]),
]).create();
rootPath = d.path('project3');
manifest = const SkillManifest();
final installer = SkillInstaller(fakeDialogSupport);
var result = await installer.installSkillsForIde(
agent: Agent.cursor,
rootPath: rootPath,
skills: [...pkgASkills, ...pkgBSkills],
previousManifest: manifest,
globalConfig: const GlobalConfig(),
);
manifest = result!.manifest;
result = await installer.installSkillsForIde(
agent: Agent.claude,
rootPath: rootPath,
skills: [...pkgASkills, ...pkgBSkills],
previousManifest: manifest,
globalConfig: const GlobalConfig(),
);
manifest = result!.manifest;
});
test('when listing then manifest reports both agents correctly', () {
expect(manifest.allAgents, containsAll(['cursor', 'claude']));
expect(manifest.sourceUrisForAgent('cursor'), hasLength(2));
expect(manifest.sourceUrisForAgent('claude'), hasLength(2));
expect(manifest.allSkillsForAgent('cursor'), hasLength(3));
expect(manifest.allSkillsForAgent('claude'), hasLength(3));
expect(manifest.allSkills, hasLength(6));
});
test('when removing all then both Cursor and Claude skill directories '
'are cleaned up', () async {
// Verify skill directories exist.
expect(
Directory('$rootPath/.cursor/skills/pkg_a-code-gen').existsSync(),
isTrue,
);
expect(
Directory('$rootPath/.claude/skills/pkg_a-code-gen').existsSync(),
isTrue,
);
manifest = await SkillInstaller(
fakeDialogSupport,
).removeAllSkills(rootPath: rootPath, manifest: manifest);
// Agent Skills directories cleaned.
expect(
Directory('$rootPath/.cursor/skills/pkg_a-code-gen').existsSync(),
isFalse,
);
expect(
Directory('$rootPath/.cursor/skills/pkg_b-testing').existsSync(),
isFalse,
);
expect(
Directory('$rootPath/.claude/skills/pkg_a-code-gen').existsSync(),
isFalse,
);
expect(
Directory('$rootPath/.claude/skills/pkg_b-testing').existsSync(),
isFalse,
);
expect(manifest.isEmpty, isTrue);
});
});
group('Given generic agent (antigravity/codex/generic)', () {
test(
'when installing then manifest stores canonical name generic only',
() async {
await d.dir('generic_project', [
d.dir('.agents', [d.dir('skills')]),
]).create();
final rootPath = d.path('generic_project');
var manifest = const SkillManifest();
final result = await SkillInstaller(fakeDialogSupport)
.installSkillsForIde(
agent: Agent.generic,
rootPath: rootPath,
skills: pkgASkills,
previousManifest: manifest,
globalConfig: const GlobalConfig(),
);
manifest = result!.manifest;
expect(manifest.allAgents, equals(['generic']));
expect(manifest.sourceUrisForAgent('generic'), hasLength(1));
expect(
manifest.sourceUrisForAgent('generic')['package:pkg_a']!.skills,
hasLength(1),
);
expect(manifest.installations.containsKey('antigravity'), isFalse);
expect(manifest.installations.containsKey('codex'), isFalse);
},
);
});
group('Given manifest saved to and loaded from disk', () {
test(
'when round-tripping multi-agent manifest then all data preserved',
() async {
await d.dir('persist_project').create();
final rootPath = d.path('persist_project');
var manifest = const SkillManifest();
manifest = manifest.withSourceUri(
'cursor',
'package:pkg_a',
SkillsEntry(
skills: [
InstalledSkillEntry(
name: 'pkg_a-code-gen',
installedAt: DateTime.utc(2026, 3, 1),
),
],
),
);
manifest = manifest.withSourceUri(
'generic',
'package:pkg_a',
SkillsEntry(
skills: [
InstalledSkillEntry(
name: 'pkg_a-code-gen',
installedAt: DateTime.utc(2026, 3, 1),
),
],
),
);
manifest = manifest.withSourceUri(
'claude',
'package:pkg_b',
SkillsEntry(
skills: [
InstalledSkillEntry(
name: 'pkg_b-testing',
installedAt: DateTime.utc(2026, 3, 1),
),
],
),
);
final file = File(SkillManifest.pathIn(rootPath));
await manifest.save(file);
final loaded = await SkillManifest.loadFromRoot(rootPath);
expect(loaded, isNotNull);
expect(
loaded!.allAgents.toSet(),
equals({'cursor', 'generic', 'claude'}),
);
expect(
loaded.sourceUrisForAgent('cursor')['package:pkg_a']!.skills,
hasLength(1),
);
expect(
loaded.sourceUrisForAgent('generic')['package:pkg_a']!.skills,
hasLength(1),
);
expect(
loaded.sourceUrisForAgent('claude')['package:pkg_b']!.skills,
hasLength(1),
);
},
);
});
}