diff --git a/README.md b/README.md index 686050b..f1b46f9 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,14 @@ Built for **DevOps engineers**, **Platform engineers**, **Administrators**, and ### Operations -| Command | Description | -|-----------------|---------------------------------------------------------| -| `execute apply` | Apply pending changes | -| `audit list` | List audit entries (snapshot or full history) | -| `audit fix` | Fix a change's audit state (`APPLIED` or `ROLLED_BACK`) | -| `issue list` | List changes with audit issues | -| `issue get` | Get details and resolution guidance for an issue | +| Command | Description | +|------------------|----------------------------------------------------------------| +| `execute apply` | Apply pending changes | +| `audit list` | List audit entries (snapshot or full history) | +| `audit fix` | Fix a change's audit state (`APPLIED` or `ROLLED_BACK`) | +| `issue list` | List changes with audit issues | +| `issue get` | Get details and resolution guidance for an issue | +| `install-skills` | Install official Flamingock AI skills into the current project | ### Global Options @@ -127,6 +128,56 @@ flamingock audit list --jar ./my-app.jar -J -Xmx1g -- --spring.profiles.active=s --- +## 🧠 Installing AI Skills + +Use `install-skills` to copy the official Flamingock skills into the directory for the AI assistant you use in the current project. + +### Quick path + +```bash +# Default project-local install +flamingock install-skills + +# Claude project-local install +flamingock install-skills --agent claude + +# GitHub Copilot project-local install +flamingock install-skills --agent github + +# Cursor project-local install +flamingock install-skills --agent cursor + +# OpenCode project-local install +flamingock install-skills --agent opencode + +# Google Gemini project-local install +flamingock install-skills --agent gemini + +# Windsurf project-local install +flamingock install-skills --agent windsurf + +# PI Agent project-local install +flamingock install-skills --agent pi +``` + +### Current targets + +| Command | Destination | +|----------------------------------------------|----------------------| +| `flamingock install-skills` | `./.agents/skills` | +| `flamingock install-skills --agent claude` | `./.claude/skills` | +| `flamingock install-skills --agent github` | `./.github/skills` | +| `flamingock install-skills --agent cursor` | `./.cursor/skills` | +| `flamingock install-skills --agent opencode` | `./.opencode/skills` | +| `flamingock install-skills --agent gemini` | `./.gemini/skills` | +| `flamingock install-skills --agent windsurf` | `./.windsurf/skills` | +| `flamingock install-skills --agent pi` | `./.pi/skills` | + +Note: +- Paths are resolved relative to the directory where you run the command. + +--- + ## 📦 Installation Choose the option that best fits your platform: diff --git a/gradle.properties b/gradle.properties index c6cb592..fa79a43 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,2 @@ group=io.flamingock -version=1.2.0 +version=1.2.1 diff --git a/src/main/java/io/flamingock/cli/executor/command/InstallSkillsCommand.java b/src/main/java/io/flamingock/cli/executor/command/InstallSkillsCommand.java index 33c3fe2..79e57f7 100644 --- a/src/main/java/io/flamingock/cli/executor/command/InstallSkillsCommand.java +++ b/src/main/java/io/flamingock/cli/executor/command/InstallSkillsCommand.java @@ -40,6 +40,10 @@ public class InstallSkillsCommand implements Callable { @Option(names = {"-g", "--global"}, description = "Install skills globally (not implemented yet)") private boolean global; + @Option(names = {"-a", "--agent"}, + description = "Target AI assistant: claude, github, cursor, opencode, gemini, windsurf, pi") + private String agent; + private final SkillsInstallationTargetResolver targetResolver; private final SkillsInstallationPipeline pipeline; private final Path workingDirectory; @@ -69,7 +73,7 @@ public InstallSkillsCommand() { @Override public Integer call() { try { - List targets = targetResolver.resolveTargets(workingDirectory.toAbsolutePath().normalize(), global); + List targets = targetResolver.resolveTargets(workingDirectory.toAbsolutePath().normalize(), global, agent); SkillsInstallationResult result = pipeline.install(targets); ConsoleFormatter.printInfo(buildSuccessMessage(result)); return 0; diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationPipeline.java b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationPipeline.java index ff8cd66..ca7b548 100644 --- a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationPipeline.java +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationPipeline.java @@ -110,6 +110,9 @@ public SkillsInstallationResult install(List targets) snapshotRoot, path -> path.getFileName().toString().startsWith(SKILL_DIRECTORY_PREFIX) ); + for (Path skillDirectory : skillDirectories) { + FileSystemUtils.deleteRecursively(skillDirectory.resolve("evals")); + } List installedSkills = new ArrayList<>(); for (Path skillDirectory : skillDirectories) { installedSkills.add(skillDirectory.getFileName().toString()); diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTarget.java b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTarget.java index 0fad640..7cca7c5 100644 --- a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTarget.java +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTarget.java @@ -40,4 +40,84 @@ public record SkillsInstallationTarget(String identifier, Path destinationSkills public static SkillsInstallationTarget local(Path destinationSkillsDir) { return new SkillsInstallationTarget("local", destinationSkillsDir); } + + /** + * Creates an agent-specific installation target for the agents assistant. + * + * @param destinationSkillsDir agent destination directory + * @return agents installation target + */ + public static SkillsInstallationTarget agents(Path destinationSkillsDir) { + return new SkillsInstallationTarget("local:agents", destinationSkillsDir); + } + + /** + * Creates an agent-specific installation target for the claude assistant. + * + * @param destinationSkillsDir claude destination directory + * @return claude installation target + */ + public static SkillsInstallationTarget claude(Path destinationSkillsDir) { + return new SkillsInstallationTarget("local:claude", destinationSkillsDir); + } + + /** + * Creates an agent-specific installation target for the github assistant. + * + * @param destinationSkillsDir github destination directory + * @return github installation target + */ + public static SkillsInstallationTarget github(Path destinationSkillsDir) { + return new SkillsInstallationTarget("local:github", destinationSkillsDir); + } + + /** + * Creates an agent-specific installation target for the cursor assistant. + * + * @param destinationSkillsDir cursor destination directory + * @return cursor installation target + */ + public static SkillsInstallationTarget cursor(Path destinationSkillsDir) { + return new SkillsInstallationTarget("local:cursor", destinationSkillsDir); + } + + /** + * Creates an agent-specific installation target for the opencode assistant. + * + * @param destinationSkillsDir opencode destination directory + * @return opencode installation target + */ + public static SkillsInstallationTarget opencode(Path destinationSkillsDir) { + return new SkillsInstallationTarget("local:opencode", destinationSkillsDir); + } + + /** + * Creates an agent-specific installation target for the gemini assistant. + * + * @param destinationSkillsDir gemini destination directory + * @return gemini installation target + */ + public static SkillsInstallationTarget gemini(Path destinationSkillsDir) { + return new SkillsInstallationTarget("local:gemini", destinationSkillsDir); + } + + /** + * Creates an agent-specific installation target for the windsurf assistant. + * + * @param destinationSkillsDir windsurf destination directory + * @return windsurf installation target + */ + public static SkillsInstallationTarget windsurf(Path destinationSkillsDir) { + return new SkillsInstallationTarget("local:windsurf", destinationSkillsDir); + } + + /** + * Creates an agent-specific installation target for the pi agent assistant. + * + * @param destinationSkillsDir pi agent destination directory + * @return pi agent installation target + */ + public static SkillsInstallationTarget pi(Path destinationSkillsDir) { + return new SkillsInstallationTarget("local:pi", destinationSkillsDir); + } } diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolver.java b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolver.java index 34ec77d..c01bdf3 100644 --- a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolver.java +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolver.java @@ -26,6 +26,13 @@ public class SkillsInstallationTargetResolver { private static final String[] LOCAL_SKILLS_PATH = {".agents", "skills"}; + private static final String[] CLAUDE_PATH = {".claude", "skills"}; + private static final String[] GITHUB_PATH = {".github", "skills"}; + private static final String[] CURSOR_PATH = {".cursor", "skills"}; + private static final String[] OPENCODE_PATH = {".opencode", "skills"}; + private static final String[] GEMINI_PATH = {".gemini", "skills"}; + private static final String[] WINDSURF_PATH = {".windsurf", "skills"}; + private static final String[] PI_PATH = {".pi", "skills"}; private static final String GLOBAL_MODE_NOT_IMPLEMENTED = "Global skills installation is not implemented yet. Run 'flamingock install-skills' to install into ./.agents/skills."; @@ -40,18 +47,65 @@ public SkillsInstallationTargetResolver() { } /** - * Resolves the skills installation targets for the requested mode. + * Resolves the skills installation targets for the requested mode (default agent). * * @param workingDirectory current command working directory * @param global whether global mode was requested * @return resolved installation targets */ public List resolveTargets(Path workingDirectory, boolean global) { + return resolveTargets(workingDirectory, global, null); + } + + /** + * Resolves the skills installation targets for the requested mode and agent. + * + * @param workingDirectory current command working directory + * @param global whether global mode was requested + * @param agent target AI assistant identifier (claude, github, cursor, opencode, gemini, windsurf, pi) + * @return resolved installation targets + */ + public List resolveTargets(Path workingDirectory, boolean global, String agent) { if (global) { throw new IllegalStateException(GLOBAL_MODE_NOT_IMPLEMENTED); } - Path destination = directoryResolver.resolveDirectory(workingDirectory, LOCAL_SKILLS_PATH); - return List.of(SkillsInstallationTarget.local(destination)); + if (agent == null) { + Path destination = directoryResolver.resolveDirectory(workingDirectory, LOCAL_SKILLS_PATH); + return List.of(SkillsInstallationTarget.agents(destination)); + } + + return switch (agent) { + case "claude" -> { + Path destination = directoryResolver.resolveDirectory(workingDirectory, CLAUDE_PATH); + yield List.of(SkillsInstallationTarget.claude(destination)); + } + case "github" -> { + Path destination = directoryResolver.resolveDirectory(workingDirectory, GITHUB_PATH); + yield List.of(SkillsInstallationTarget.github(destination)); + } + case "cursor" -> { + Path destination = directoryResolver.resolveDirectory(workingDirectory, CURSOR_PATH); + yield List.of(SkillsInstallationTarget.cursor(destination)); + } + case "opencode" -> { + Path destination = directoryResolver.resolveDirectory(workingDirectory, OPENCODE_PATH); + yield List.of(SkillsInstallationTarget.opencode(destination)); + } + case "gemini" -> { + Path destination = directoryResolver.resolveDirectory(workingDirectory, GEMINI_PATH); + yield List.of(SkillsInstallationTarget.gemini(destination)); + } + case "windsurf" -> { + Path destination = directoryResolver.resolveDirectory(workingDirectory, WINDSURF_PATH); + yield List.of(SkillsInstallationTarget.windsurf(destination)); + } + case "pi" -> { + Path destination = directoryResolver.resolveDirectory(workingDirectory, PI_PATH); + yield List.of(SkillsInstallationTarget.pi(destination)); + } + default -> throw new IllegalStateException( + "Unsupported agent: '" + agent + "'. Supported values: claude, github, cursor, opencode, gemini, windsurf, pi."); + }; } } diff --git a/src/test/java/io/flamingock/cli/executor/command/InstallSkillsCommandTest.java b/src/test/java/io/flamingock/cli/executor/command/InstallSkillsCommandTest.java index 8f48eac..a088a29 100644 --- a/src/test/java/io/flamingock/cli/executor/command/InstallSkillsCommandTest.java +++ b/src/test/java/io/flamingock/cli/executor/command/InstallSkillsCommandTest.java @@ -33,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; class InstallSkillsCommandTest { @@ -40,6 +41,173 @@ class InstallSkillsCommandTest { @TempDir Path tempDir; + @Test + void call_defaultAgentPassesNullToResolver() { + SkillsInstallationTarget resolvedTarget = SkillsInstallationTarget.agents(tempDir.resolve(".agents/skills")); + RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); + RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult( + List.of(resolvedTarget), + List.of("flamingock-core") + )); + InstallSkillsCommand command = new InstallSkillsCommand(targetResolver, pipeline, tempDir); + + int exitCode = new CommandLine(command).execute(); + + assertEquals(0, exitCode); + assertTrue(targetResolver.called); + assertNull(targetResolver.agent); + } + + @Test + void call_withInvalidAgentAgentsReturnsExitCodeOne() { + FailingTargetResolver targetResolver = new FailingTargetResolver( + new IllegalStateException("Unsupported agent: 'agents'. Supported values: claude, github, cursor, opencode, gemini, windsurf, pi.") + ); + RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult(List.of(), List.of())); + InstallSkillsCommand command = new InstallSkillsCommand(targetResolver, pipeline, tempDir); + + ByteArrayOutputStream errContent = new ByteArrayOutputStream(); + PrintStream originalErr = System.err; + System.setErr(new PrintStream(errContent, true, StandardCharsets.UTF_8)); + try { + int exitCode = new CommandLine(command).execute("-a", "agents"); + + assertEquals(1, exitCode); + } finally { + System.setErr(originalErr); + } + + assertTrue(targetResolver.called); + String stderr = errContent.toString(StandardCharsets.UTF_8); + assertTrue(stderr.contains("agents")); + assertTrue(stderr.contains("claude")); + assertTrue(stderr.contains("github")); + assertTrue(stderr.contains("cursor")); + assertTrue(stderr.contains("opencode")); + assertTrue(stderr.contains("gemini")); + assertTrue(stderr.contains("windsurf")); + assertTrue(stderr.contains("pi")); + } + + @Test + void call_withAgentClaudePassesClaudeToResolver() { + SkillsInstallationTarget resolvedTarget = SkillsInstallationTarget.claude(tempDir.resolve(".claude/skills")); + RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); + RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult( + List.of(resolvedTarget), + List.of("flamingock-core") + )); + InstallSkillsCommand command = new InstallSkillsCommand(targetResolver, pipeline, tempDir); + + int exitCode = new CommandLine(command).execute("-a", "claude"); + + assertEquals(0, exitCode); + assertTrue(targetResolver.called); + assertEquals("claude", targetResolver.agent); + } + + @Test + void call_withAgentGithubPassesGithubToResolver() { + SkillsInstallationTarget resolvedTarget = SkillsInstallationTarget.github(tempDir.resolve(".github/skills")); + RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); + RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult( + List.of(resolvedTarget), + List.of("flamingock-core") + )); + InstallSkillsCommand command = new InstallSkillsCommand(targetResolver, pipeline, tempDir); + + int exitCode = new CommandLine(command).execute("-a", "github"); + + assertEquals(0, exitCode); + assertTrue(targetResolver.called); + assertEquals("github", targetResolver.agent); + } + + @Test + void call_withAgentCursorPassesCursorToResolver() { + SkillsInstallationTarget resolvedTarget = SkillsInstallationTarget.cursor(tempDir.resolve(".cursor/skills")); + RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); + RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult( + List.of(resolvedTarget), + List.of("flamingock-core") + )); + InstallSkillsCommand command = new InstallSkillsCommand(targetResolver, pipeline, tempDir); + + int exitCode = new CommandLine(command).execute("-a", "cursor"); + + assertEquals(0, exitCode); + assertTrue(targetResolver.called); + assertEquals("cursor", targetResolver.agent); + } + + @Test + void call_withAgentOpencodePassesOpencodeToResolver() { + SkillsInstallationTarget resolvedTarget = SkillsInstallationTarget.opencode(tempDir.resolve(".opencode/skills")); + RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); + RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult( + List.of(resolvedTarget), + List.of("flamingock-core") + )); + InstallSkillsCommand command = new InstallSkillsCommand(targetResolver, pipeline, tempDir); + + int exitCode = new CommandLine(command).execute("-a", "opencode"); + + assertEquals(0, exitCode); + assertTrue(targetResolver.called); + assertEquals("opencode", targetResolver.agent); + } + + @Test + void call_withAgentGeminiPassesGeminiToResolver() { + SkillsInstallationTarget resolvedTarget = SkillsInstallationTarget.gemini(tempDir.resolve(".gemini/skills")); + RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); + RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult( + List.of(resolvedTarget), + List.of("flamingock-core") + )); + InstallSkillsCommand command = new InstallSkillsCommand(targetResolver, pipeline, tempDir); + + int exitCode = new CommandLine(command).execute("-a", "gemini"); + + assertEquals(0, exitCode); + assertTrue(targetResolver.called); + assertEquals("gemini", targetResolver.agent); + } + + @Test + void call_withAgentWindsurfPassesWindsurfToResolver() { + SkillsInstallationTarget resolvedTarget = SkillsInstallationTarget.windsurf(tempDir.resolve(".windsurf/skills")); + RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); + RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult( + List.of(resolvedTarget), + List.of("flamingock-core") + )); + InstallSkillsCommand command = new InstallSkillsCommand(targetResolver, pipeline, tempDir); + + int exitCode = new CommandLine(command).execute("-a", "windsurf"); + + assertEquals(0, exitCode); + assertTrue(targetResolver.called); + assertEquals("windsurf", targetResolver.agent); + } + + @Test + void call_withAgentPiPassesPiToResolver() { + SkillsInstallationTarget resolvedTarget = SkillsInstallationTarget.pi(tempDir.resolve(".pi/skills")); + RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); + RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult( + List.of(resolvedTarget), + List.of("flamingock-core") + )); + InstallSkillsCommand command = new InstallSkillsCommand(targetResolver, pipeline, tempDir); + + int exitCode = new CommandLine(command).execute("-a", "pi"); + + assertEquals(0, exitCode); + assertTrue(targetResolver.called); + assertEquals("pi", targetResolver.agent); + } + @Test void rootCommand_registersInstallSkillsSubcommand() { CommandLine commandLine = new CommandLine(new FlamingockExecutorCli()); @@ -152,6 +320,7 @@ private static final class RecordingTargetResolver extends SkillsInstallationTar private boolean called; private Path workingDirectory; private boolean global; + private String agent; private RecordingTargetResolver(List targets) { this.targets = targets; @@ -159,9 +328,15 @@ private RecordingTargetResolver(List targets) { @Override public List resolveTargets(Path workingDirectory, boolean global) { + return resolveTargets(workingDirectory, global, null); + } + + @Override + public List resolveTargets(Path workingDirectory, boolean global, String agent) { this.called = true; this.workingDirectory = workingDirectory; this.global = global; + this.agent = agent; return targets; } } @@ -178,6 +353,11 @@ private FailingTargetResolver(IllegalStateException failure) { @Override public List resolveTargets(Path workingDirectory, boolean global) { + return resolveTargets(workingDirectory, global, null); + } + + @Override + public List resolveTargets(Path workingDirectory, boolean global, String agent) { this.called = true; this.global = global; throw failure; diff --git a/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationPipelineTest.java b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationPipelineTest.java index f8a30f6..5cfb398 100644 --- a/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationPipelineTest.java +++ b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationPipelineTest.java @@ -205,6 +205,53 @@ public List listDirectories(Path root, java.util.function.Predicate assertEquals("custom", Files.readString(destination.resolve("my-custom-skill").resolve("SKILL.md"))); } + @Test + void install_removesEvalsFromExtractedSkillsBeforeCopy() throws Exception { + Path destination = Files.createDirectories(tempDir.resolve(".agents").resolve("skills")); + SkillsInstallationTarget target = SkillsInstallationTarget.local(destination); + + Path snapshotRoot = Files.createDirectories(tempDir.resolve("snapshot")); + Path skillWithEvals = Files.createDirectories(snapshotRoot.resolve("flamingock-core")); + Files.writeString(skillWithEvals.resolve("SKILL.md"), "main"); + Path evalsDir = Files.createDirectories(skillWithEvals.resolve("evals")); + Files.writeString(evalsDir.resolve("test.java"), "// eval"); + + Path skillWithoutEvals = Files.createDirectories(snapshotRoot.resolve("flamingock-java")); + Files.writeString(skillWithoutEvals.resolve("SKILL.md"), "main"); + + SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( + new HttpFileDownloader() { + @Override + public Path downloadTo(Path workspace, URI sourceUri, String targetFileName, String userAgent, String downloadLabel) { + return tempDir.resolve("downloaded.zip"); + } + }, + new ZipArchiveExtractor() { + @Override + public Path extractSingleRootDirectory(Path archive, Path workspace, String extractionDirectoryName, + String archiveDescription) { + return snapshotRoot; + } + }, + new DirectoryLister() { + @Override + public List listDirectories(Path root, java.util.function.Predicate filter) { + return List.of(skillWithEvals, skillWithoutEvals); + } + }, + new DirectoryReplacer(), + () -> tempDir.resolve("workspace"), + FileSystemUtils::deleteRecursively + ); + + SkillsInstallationResult result = pipeline.install(List.of(target)); + + assertEquals(List.of("flamingock-core", "flamingock-java"), result.installedSkills()); + assertFalse(Files.exists(destination.resolve("flamingock-core").resolve("evals"))); + assertEquals("main", Files.readString(destination.resolve("flamingock-core").resolve("SKILL.md"))); + assertEquals("main", Files.readString(destination.resolve("flamingock-java").resolve("SKILL.md"))); + } + @Test void install_downloadsAndExtractsOnceForMultipleTargets() throws Exception { List events = new ArrayList<>(); diff --git a/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolverTest.java b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolverTest.java index d0d9303..704f629 100644 --- a/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolverTest.java +++ b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolverTest.java @@ -44,7 +44,164 @@ void resolveTargets_localModeResolvesSingleProjectTarget() { assertTrue(directoryResolver.called); assertEquals(tempDir, directoryResolver.workingDirectory); assertArrayEquals(new String[]{".agents", "skills"}, directoryResolver.segments); - assertEquals(List.of(SkillsInstallationTarget.local(tempDir.resolve(".agents/skills"))), targets); + assertEquals(List.of(SkillsInstallationTarget.agents(tempDir.resolve(".agents/skills"))), targets); + } + + @Test + void resolveTargets_nullAgentResolvesToAgentsPath() { + RecordingDirectoryResolver directoryResolver = new RecordingDirectoryResolver(tempDir.resolve(".agents/skills")); + SkillsInstallationTargetResolver resolver = new SkillsInstallationTargetResolver(directoryResolver); + + List targets = resolver.resolveTargets(tempDir, false, (String) null); + + assertEquals(1, targets.size()); + assertEquals("local:agents", targets.get(0).identifier()); + assertArrayEquals(new String[]{".agents", "skills"}, directoryResolver.segments); + assertTrue(directoryResolver.called); + } + + @Test + void resolveTargets_agentsAgentThrowsWithSupportedValues() { + RecordingDirectoryResolver directoryResolver = new RecordingDirectoryResolver(tempDir.resolve(".agents/skills")); + SkillsInstallationTargetResolver resolver = new SkillsInstallationTargetResolver(directoryResolver); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> resolver.resolveTargets(tempDir, false, "agents")); + + assertFalse(directoryResolver.called); + String message = exception.getMessage(); + assertTrue(message.contains("agents")); + assertTrue(message.contains("claude")); + assertTrue(message.contains("github")); + assertTrue(message.contains("cursor")); + assertTrue(message.contains("opencode")); + assertTrue(message.contains("gemini")); + assertTrue(message.contains("windsurf")); + assertTrue(message.contains("pi")); + } + + @Test + void resolveTargets_claudeAgentResolvesToClaudePath() { + RecordingDirectoryResolver directoryResolver = new RecordingDirectoryResolver(tempDir.resolve(".claude/skills")); + SkillsInstallationTargetResolver resolver = new SkillsInstallationTargetResolver(directoryResolver); + + List targets = resolver.resolveTargets(tempDir, false, "claude"); + + assertEquals(1, targets.size()); + assertEquals("local:claude", targets.get(0).identifier()); + assertArrayEquals(new String[]{".claude", "skills"}, directoryResolver.segments); + assertTrue(directoryResolver.called); + } + + @Test + void resolveTargets_geminiAgentResolvesToGeminiPath() { + RecordingDirectoryResolver directoryResolver = new RecordingDirectoryResolver(tempDir.resolve(".gemini/skills")); + SkillsInstallationTargetResolver resolver = new SkillsInstallationTargetResolver(directoryResolver); + + List targets = resolver.resolveTargets(tempDir, false, "gemini"); + + assertEquals(1, targets.size()); + assertEquals("local:gemini", targets.get(0).identifier()); + assertEquals(tempDir.resolve(".gemini/skills"), targets.get(0).destinationSkillsDir()); + assertArrayEquals(new String[]{".gemini", "skills"}, directoryResolver.segments); + assertTrue(directoryResolver.called); + } + + @Test + void resolveTargets_githubAgentResolvesToGithubPath() { + RecordingDirectoryResolver directoryResolver = new RecordingDirectoryResolver(tempDir.resolve(".github/skills")); + SkillsInstallationTargetResolver resolver = new SkillsInstallationTargetResolver(directoryResolver); + + List targets = resolver.resolveTargets(tempDir, false, "github"); + + assertEquals(1, targets.size()); + assertEquals("local:github", targets.get(0).identifier()); + assertArrayEquals(new String[]{".github", "skills"}, directoryResolver.segments); + assertTrue(directoryResolver.called); + } + + @Test + void resolveTargets_cursorAgentResolvesToCursorPath() { + RecordingDirectoryResolver directoryResolver = new RecordingDirectoryResolver(tempDir.resolve(".cursor/skills")); + SkillsInstallationTargetResolver resolver = new SkillsInstallationTargetResolver(directoryResolver); + + List targets = resolver.resolveTargets(tempDir, false, "cursor"); + + assertEquals(1, targets.size()); + assertEquals("local:cursor", targets.get(0).identifier()); + assertArrayEquals(new String[]{".cursor", "skills"}, directoryResolver.segments); + assertTrue(directoryResolver.called); + } + + @Test + void resolveTargets_opencodeAgentResolvesToOpencodePath() { + RecordingDirectoryResolver directoryResolver = new RecordingDirectoryResolver(tempDir.resolve(".opencode/skills")); + SkillsInstallationTargetResolver resolver = new SkillsInstallationTargetResolver(directoryResolver); + + List targets = resolver.resolveTargets(tempDir, false, "opencode"); + + assertEquals(1, targets.size()); + assertEquals("local:opencode", targets.get(0).identifier()); + assertArrayEquals(new String[]{".opencode", "skills"}, directoryResolver.segments); + assertTrue(directoryResolver.called); + } + + @Test + void resolveTargets_windsurfAgentResolvesToWindsurfPath() { + RecordingDirectoryResolver directoryResolver = new RecordingDirectoryResolver(tempDir.resolve(".windsurf/skills")); + SkillsInstallationTargetResolver resolver = new SkillsInstallationTargetResolver(directoryResolver); + + List targets = resolver.resolveTargets(tempDir, false, "windsurf"); + + assertEquals(1, targets.size()); + assertEquals("local:windsurf", targets.get(0).identifier()); + assertArrayEquals(new String[]{".windsurf", "skills"}, directoryResolver.segments); + assertTrue(directoryResolver.called); + } + + @Test + void resolveTargets_piAgentResolvesToPiPath() { + RecordingDirectoryResolver directoryResolver = new RecordingDirectoryResolver(tempDir.resolve(".pi/skills")); + SkillsInstallationTargetResolver resolver = new SkillsInstallationTargetResolver(directoryResolver); + + List targets = resolver.resolveTargets(tempDir, false, "pi"); + + assertEquals(1, targets.size()); + assertEquals("local:pi", targets.get(0).identifier()); + assertArrayEquals(new String[]{".pi", "skills"}, directoryResolver.segments); + assertTrue(directoryResolver.called); + } + + @Test + void resolveTargets_invalidAgentThrowsWithSupportedValues() { + RecordingDirectoryResolver directoryResolver = new RecordingDirectoryResolver(tempDir.resolve(".agents/skills")); + SkillsInstallationTargetResolver resolver = new SkillsInstallationTargetResolver(directoryResolver); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> resolver.resolveTargets(tempDir, false, "foo")); + + assertFalse(directoryResolver.called); + String message = exception.getMessage(); + assertTrue(message.contains("foo")); + assertTrue(message.contains("claude")); + assertTrue(message.contains("github")); + assertTrue(message.contains("cursor")); + assertTrue(message.contains("opencode")); + assertTrue(message.contains("gemini")); + assertTrue(message.contains("windsurf")); + assertTrue(message.contains("pi")); + } + + @Test + void resolveTargets_globalModeStillThrowsBeforeAgentEval() { + RecordingDirectoryResolver directoryResolver = new RecordingDirectoryResolver(tempDir.resolve(".claude/skills")); + SkillsInstallationTargetResolver resolver = new SkillsInstallationTargetResolver(directoryResolver); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> resolver.resolveTargets(tempDir, true, "claude")); + + assertFalse(directoryResolver.called); + assertTrue(exception.getMessage().contains("not implemented yet")); } @Test diff --git a/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetTest.java b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetTest.java new file mode 100644 index 0000000..1d9f885 --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetTest.java @@ -0,0 +1,153 @@ +/* + * Copyright 2026 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.cli.executor.skills; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class SkillsInstallationTargetTest { + + @Test + void agentsFactoryRejectsNullPath() { + assertThrows(NullPointerException.class, + () -> SkillsInstallationTarget.agents(null), + "destinationSkillsDir must not be null"); + } + + @Test + void claudeFactoryRejectsNullPath() { + assertThrows(NullPointerException.class, + () -> SkillsInstallationTarget.claude(null), + "destinationSkillsDir must not be null"); + } + + @Test + void agentsFactoryCreatesTargetWithAgentsIdentifier() { + Path dest = Path.of("/some/path/.agents/skills"); + SkillsInstallationTarget target = SkillsInstallationTarget.agents(dest); + + assertEquals("local:agents", target.identifier()); + assertEquals(dest, target.destinationSkillsDir()); + } + + @Test + void claudeFactoryCreatesTargetWithClaudeIdentifier() { + Path dest = Path.of("/some/path/.claude/skills"); + SkillsInstallationTarget target = SkillsInstallationTarget.claude(dest); + + assertEquals("local:claude", target.identifier()); + assertEquals(dest, target.destinationSkillsDir()); + } + + @Test + void githubFactoryCreatesTargetWithGithubIdentifier() { + Path dest = Path.of("/some/path/.github/skills"); + SkillsInstallationTarget target = SkillsInstallationTarget.github(dest); + + assertEquals("local:github", target.identifier()); + assertEquals(dest, target.destinationSkillsDir()); + } + + @Test + void githubFactoryRejectsNullPath() { + assertThrows(NullPointerException.class, + () -> SkillsInstallationTarget.github(null), + "destinationSkillsDir must not be null"); + } + + @Test + void cursorFactoryCreatesTargetWithCursorIdentifier() { + Path dest = Path.of("/some/path/.cursor/skills"); + SkillsInstallationTarget target = SkillsInstallationTarget.cursor(dest); + + assertEquals("local:cursor", target.identifier()); + assertEquals(dest, target.destinationSkillsDir()); + } + + @Test + void cursorFactoryRejectsNullPath() { + assertThrows(NullPointerException.class, + () -> SkillsInstallationTarget.cursor(null), + "destinationSkillsDir must not be null"); + } + + @Test + void opencodeFactoryCreatesTargetWithOpencodeIdentifier() { + Path dest = Path.of("/some/path/.opencode/skills"); + SkillsInstallationTarget target = SkillsInstallationTarget.opencode(dest); + + assertEquals("local:opencode", target.identifier()); + assertEquals(dest, target.destinationSkillsDir()); + } + + @Test + void opencodeFactoryRejectsNullPath() { + assertThrows(NullPointerException.class, + () -> SkillsInstallationTarget.opencode(null), + "destinationSkillsDir must not be null"); + } + + @Test + void geminiFactoryCreatesTargetWithGeminiIdentifier() { + Path dest = Path.of("/some/path/.gemini/skills"); + SkillsInstallationTarget target = SkillsInstallationTarget.gemini(dest); + + assertEquals("local:gemini", target.identifier()); + assertEquals(dest, target.destinationSkillsDir()); + } + + @Test + void geminiFactoryRejectsNullPath() { + assertThrows(NullPointerException.class, + () -> SkillsInstallationTarget.gemini(null), + "destinationSkillsDir must not be null"); + } + + @Test + void windsurfFactoryCreatesTargetWithWindsurfIdentifier() { + Path dest = Path.of("/some/path/.windsurf/skills"); + SkillsInstallationTarget target = SkillsInstallationTarget.windsurf(dest); + + assertEquals("local:windsurf", target.identifier()); + assertEquals(dest, target.destinationSkillsDir()); + } + + @Test + void windsurfFactoryRejectsNullPath() { + assertThrows(NullPointerException.class, + () -> SkillsInstallationTarget.windsurf(null), + "destinationSkillsDir must not be null"); + } + + @Test + void piFactoryCreatesTargetWithPiIdentifier() { + Path dest = Path.of("/some/path/.pi/skills"); + SkillsInstallationTarget target = SkillsInstallationTarget.pi(dest); + + assertEquals("local:pi", target.identifier()); + assertEquals(dest, target.destinationSkillsDir()); + } + + @Test + void piFactoryRejectsNullPath() { + assertThrows(NullPointerException.class, + () -> SkillsInstallationTarget.pi(null), + "destinationSkillsDir must not be null"); + } +}