From ca919071285ad7753b250ff98d3673a13e04a5a7 Mon Sep 17 00:00:00 2001 From: bercianor Date: Tue, 26 May 2026 01:14:30 +0200 Subject: [PATCH 1/3] feat(cli): add install-skills command Add a new top-level `install-skills` command to install official Flamingock skills into `./.agents/skills` for the current project. Accept `-g/--global` for future compatibility, but return a clear not-implemented message for now. Use Java-native HTTP/ZIP/NIO APIs to download and extract the official skills archive without depending on external tools like git, curl, or wget. Implement full per-skill replacement for official `flamingock-*` directories while preserving user-defined custom skill folders. Harden failure handling with actionable user-facing errors, HTTP timeouts, zip-slip protection, and robust temporary-workspace cleanup. --- .../cli/executor/FlamingockExecutorCli.java | 4 +- .../command/InstallSkillsCommand.java | 94 ++++++++ .../skills/InstallDestinationResolver.java | 56 +++++ .../cli/executor/skills/InstallMode.java | 24 ++ .../executor/skills/InstallModeResolver.java | 32 +++ .../skills/SkillArchiveEnumerator.java | 46 ++++ .../skills/SkillDirectoryReplacer.java | 58 +++++ .../skills/SkillsArchiveDownloader.java | 92 ++++++++ .../skills/SkillsArchiveExtractor.java | 85 +++++++ .../cli/executor/skills/SkillsFileUtils.java | 41 ++++ .../skills/SkillsInstallationPipeline.java | 134 +++++++++++ .../skills/SkillsInstallationResult.java | 28 +++ .../command/InstallSkillsCommandTest.java | 220 ++++++++++++++++++ .../InstallDestinationResolverTest.java | 66 ++++++ .../skills/InstallModeResolverTest.java | 35 +++ .../skills/SkillArchiveEnumeratorTest.java | 61 +++++ .../skills/SkillDirectoryReplacerTest.java | 61 +++++ .../skills/SkillsArchiveDownloaderTest.java | 161 +++++++++++++ .../skills/SkillsArchiveExtractorTest.java | 92 ++++++++ .../SkillsInstallationPipelineTest.java | 148 ++++++++++++ 20 files changed, 1537 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/flamingock/cli/executor/command/InstallSkillsCommand.java create mode 100644 src/main/java/io/flamingock/cli/executor/skills/InstallDestinationResolver.java create mode 100644 src/main/java/io/flamingock/cli/executor/skills/InstallMode.java create mode 100644 src/main/java/io/flamingock/cli/executor/skills/InstallModeResolver.java create mode 100644 src/main/java/io/flamingock/cli/executor/skills/SkillArchiveEnumerator.java create mode 100644 src/main/java/io/flamingock/cli/executor/skills/SkillDirectoryReplacer.java create mode 100644 src/main/java/io/flamingock/cli/executor/skills/SkillsArchiveDownloader.java create mode 100644 src/main/java/io/flamingock/cli/executor/skills/SkillsArchiveExtractor.java create mode 100644 src/main/java/io/flamingock/cli/executor/skills/SkillsFileUtils.java create mode 100644 src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationPipeline.java create mode 100644 src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationResult.java create mode 100644 src/test/java/io/flamingock/cli/executor/command/InstallSkillsCommandTest.java create mode 100644 src/test/java/io/flamingock/cli/executor/skills/InstallDestinationResolverTest.java create mode 100644 src/test/java/io/flamingock/cli/executor/skills/InstallModeResolverTest.java create mode 100644 src/test/java/io/flamingock/cli/executor/skills/SkillArchiveEnumeratorTest.java create mode 100644 src/test/java/io/flamingock/cli/executor/skills/SkillDirectoryReplacerTest.java create mode 100644 src/test/java/io/flamingock/cli/executor/skills/SkillsArchiveDownloaderTest.java create mode 100644 src/test/java/io/flamingock/cli/executor/skills/SkillsArchiveExtractorTest.java create mode 100644 src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationPipelineTest.java diff --git a/src/main/java/io/flamingock/cli/executor/FlamingockExecutorCli.java b/src/main/java/io/flamingock/cli/executor/FlamingockExecutorCli.java index dd119ae..972822a 100644 --- a/src/main/java/io/flamingock/cli/executor/FlamingockExecutorCli.java +++ b/src/main/java/io/flamingock/cli/executor/FlamingockExecutorCli.java @@ -17,6 +17,7 @@ import io.flamingock.cli.executor.command.AuditCommand; import io.flamingock.cli.executor.command.ExecuteCommand; +import io.flamingock.cli.executor.command.InstallSkillsCommand; import io.flamingock.cli.executor.command.IssueCommand; import io.flamingock.cli.executor.handler.ExecutorExceptionHandler; import io.flamingock.cli.executor.util.VersionProvider; @@ -45,6 +46,7 @@ "", "@|bold Examples:|@", " flamingock execute apply --jar ./app.jar", + " flamingock install-skills", " flamingock audit list --jar ./app.jar", " flamingock audit fix --jar ./app.jar -c my-change-id -r APPLIED", " flamingock issue list --jar ./app.jar", @@ -59,7 +61,7 @@ "", "For detailed help on any command, use: flamingock --help" }, - subcommands = {ExecuteCommand.class, AuditCommand.class, IssueCommand.class}, + subcommands = {ExecuteCommand.class, AuditCommand.class, IssueCommand.class, InstallSkillsCommand.class}, mixinStandardHelpOptions = true, versionProvider = VersionProvider.class ) diff --git a/src/main/java/io/flamingock/cli/executor/command/InstallSkillsCommand.java b/src/main/java/io/flamingock/cli/executor/command/InstallSkillsCommand.java new file mode 100644 index 0000000..917b976 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/command/InstallSkillsCommand.java @@ -0,0 +1,94 @@ +/* + * 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.command; + +import io.flamingock.cli.executor.output.ConsoleFormatter; +import io.flamingock.cli.executor.skills.InstallDestinationResolver; +import io.flamingock.cli.executor.skills.InstallMode; +import io.flamingock.cli.executor.skills.InstallModeResolver; +import io.flamingock.cli.executor.skills.SkillsInstallationPipeline; +import io.flamingock.cli.executor.skills.SkillsInstallationResult; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; + +import java.nio.file.Path; +import java.util.concurrent.Callable; + +/** + * Installs the official Flamingock AI skills into the current project. + */ +@Command( + name = "install-skills", + description = "Install official Flamingock AI skills into the current project", + mixinStandardHelpOptions = true +) +public class InstallSkillsCommand implements Callable { + + private static final String GLOBAL_MODE_NOT_IMPLEMENTED = + "Global skills installation is not implemented yet. Run 'flamingock install-skills' to install into ./.agents/skills."; + + @Option(names = {"-g", "--global"}, description = "Install skills globally (not implemented yet)") + private boolean global; + + private final InstallModeResolver modeResolver; + private final InstallDestinationResolver destinationResolver; + private final SkillsInstallationPipeline pipeline; + private final Path workingDirectory; + + /** + * Creates a command with the default production collaborators. + */ + public InstallSkillsCommand() { + this(new InstallModeResolver(), new InstallDestinationResolver(), new SkillsInstallationPipeline(), Path.of("")); + } + + InstallSkillsCommand( + InstallModeResolver modeResolver, + InstallDestinationResolver destinationResolver, + SkillsInstallationPipeline pipeline, + Path workingDirectory + ) { + this.modeResolver = modeResolver; + this.destinationResolver = destinationResolver; + this.pipeline = pipeline; + this.workingDirectory = workingDirectory; + } + + /** + * Executes the install-skills command. + * + * @return process exit code + */ + @Override + public Integer call() { + InstallMode mode = modeResolver.resolve(global); + if (mode == InstallMode.GLOBAL) { + ConsoleFormatter.printError(GLOBAL_MODE_NOT_IMPLEMENTED); + return 1; + } + + try { + Path destination = destinationResolver.resolveLocal(workingDirectory.toAbsolutePath().normalize()); + SkillsInstallationResult result = pipeline.install(destination); + ConsoleFormatter.printInfo("Installed " + result.installedSkills().size() + + " Flamingock skill(s) into " + result.destinationSkillsDir()); + return 0; + } catch (IllegalStateException e) { + ConsoleFormatter.printError(e.getMessage()); + return 1; + } + } +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/InstallDestinationResolver.java b/src/main/java/io/flamingock/cli/executor/skills/InstallDestinationResolver.java new file mode 100644 index 0000000..9359084 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/skills/InstallDestinationResolver.java @@ -0,0 +1,56 @@ +/* + * 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 java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Resolves and validates the local skills installation destination. + */ +public class InstallDestinationResolver { + + /** + * Resolves the local destination under {@code ./.agents/skills}. + * + * @param workingDirectory current working directory + * @return validated destination directory + */ + public Path resolveLocal(Path workingDirectory) { + Path agentsDir = workingDirectory.resolve(".agents"); + validateDirectoryPath(agentsDir, "'.agents' must be a directory before installing skills."); + + Path skillsDir = agentsDir.resolve("skills"); + validateDirectoryPath(skillsDir, "'.agents/skills' must be a directory before installing skills."); + + try { + Files.createDirectories(skillsDir); + } catch (IOException e) { + throw new IllegalStateException("Unable to create skills directory '" + skillsDir + + "'. Check filesystem permissions and try again.", e); + } + + return skillsDir; + } + + private void validateDirectoryPath(Path path, String guidance) { + if (Files.exists(path) && !Files.isDirectory(path)) { + throw new IllegalStateException("Invalid skills destination path: '" + path + "' is not a directory. " + + guidance + " Delete or rename that path and run the command again."); + } + } +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/InstallMode.java b/src/main/java/io/flamingock/cli/executor/skills/InstallMode.java new file mode 100644 index 0000000..c86f911 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/skills/InstallMode.java @@ -0,0 +1,24 @@ +/* + * 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; + +/** + * Supported install modes for the skills command. + */ +public enum InstallMode { + LOCAL, + GLOBAL +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/InstallModeResolver.java b/src/main/java/io/flamingock/cli/executor/skills/InstallModeResolver.java new file mode 100644 index 0000000..66eb578 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/skills/InstallModeResolver.java @@ -0,0 +1,32 @@ +/* + * 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; + +/** + * Resolves the requested skills installation mode from CLI flags. + */ +public class InstallModeResolver { + + /** + * Resolves the requested installation mode. + * + * @param globalRequested whether the global flag was requested + * @return the resolved install mode + */ + public InstallMode resolve(boolean globalRequested) { + return globalRequested ? InstallMode.GLOBAL : InstallMode.LOCAL; + } +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillArchiveEnumerator.java b/src/main/java/io/flamingock/cli/executor/skills/SkillArchiveEnumerator.java new file mode 100644 index 0000000..8225152 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillArchiveEnumerator.java @@ -0,0 +1,46 @@ +/* + * 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 java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +/** + * Enumerates official skill directories from an extracted archive snapshot. + */ +public class SkillArchiveEnumerator { + + /** + * Lists top-level skill directories whose names start with {@code flamingock-}. + * + * @param snapshotRoot extracted archive root + * @return sorted list of skill directories + * @throws IOException if directory listing fails + */ + public List listSkillDirectories(Path snapshotRoot) throws IOException { + try (Stream children = Files.list(snapshotRoot)) { + return children + .filter(Files::isDirectory) + .filter(path -> path.getFileName().toString().startsWith("flamingock-")) + .sorted(Comparator.comparing(path -> path.getFileName().toString())) + .toList(); + } + } +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillDirectoryReplacer.java b/src/main/java/io/flamingock/cli/executor/skills/SkillDirectoryReplacer.java new file mode 100644 index 0000000..31509d5 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillDirectoryReplacer.java @@ -0,0 +1,58 @@ +/* + * 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 java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.stream.Stream; + +/** + * Replaces one installed skill directory with a fresh copy from the archive. + */ +public class SkillDirectoryReplacer { + + /** + * Deletes the existing destination skill tree and copies the new one in its place. + * + * @param sourceSkillDir source skill directory from the extracted archive + * @param destinationSkillsDir destination root containing installed skills + * @throws IOException if replacement fails + */ + public void replaceSkill(Path sourceSkillDir, Path destinationSkillsDir) throws IOException { + Path destinationSkillDir = destinationSkillsDir.resolve(sourceSkillDir.getFileName().toString()); + SkillsFileUtils.deleteRecursively(destinationSkillDir); + Files.createDirectories(destinationSkillDir); + + try (Stream sourceTree = Files.walk(sourceSkillDir)) { + for (Path sourcePath : sourceTree.sorted(Comparator.naturalOrder()).toList()) { + Path relative = sourceSkillDir.relativize(sourcePath); + Path target = destinationSkillDir.resolve(relative.toString()); + if (Files.isDirectory(sourcePath)) { + Files.createDirectories(target); + } else { + Path parent = target.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.copy(sourcePath, target, StandardCopyOption.REPLACE_EXISTING); + } + } + } + } +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsArchiveDownloader.java b/src/main/java/io/flamingock/cli/executor/skills/SkillsArchiveDownloader.java new file mode 100644 index 0000000..e55e415 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsArchiveDownloader.java @@ -0,0 +1,92 @@ +/* + * 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 java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +/** + * Downloads the official Flamingock skills archive using Java-native HTTP support. + */ +public class SkillsArchiveDownloader { + + static final String OFFICIAL_SKILLS_ARCHIVE_URL = + "https://github.com/flamingock/flamingock-skills/archive/refs/heads/release.zip"; + static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); + static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30); + static final String USER_AGENT = "Flamingock CLI/1.0 install-skills"; + + private final DownloadExecutor downloadExecutor; + + public SkillsArchiveDownloader() { + this((request, target) -> HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .build() + .send(request, HttpResponse.BodyHandlers.ofFile(target))); + } + + SkillsArchiveDownloader(DownloadExecutor downloadExecutor) { + this.downloadExecutor = downloadExecutor; + } + + /** + * Downloads the official archive into the provided workspace. + * + * @param workspace temporary workspace + * @return downloaded ZIP path + * @throws IOException if the download fails + */ + public Path downloadTo(Path workspace) throws IOException { + Files.createDirectories(workspace); + Path archive = workspace.resolve("flamingock-skills.zip"); + HttpRequest request = HttpRequest.newBuilder(URI.create(OFFICIAL_SKILLS_ARCHIVE_URL)) + .timeout(REQUEST_TIMEOUT) + .header("User-Agent", USER_AGENT) + .GET() + .build(); + try { + HttpResponse response = downloadExecutor.download(request, archive); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + deletePartialArchive(archive); + throw new IOException("Download failed with HTTP " + response.statusCode() + + " from " + OFFICIAL_SKILLS_ARCHIVE_URL + ". Check your network connection and retry."); + } + return archive; + } catch (IOException e) { + deletePartialArchive(archive); + throw e; + } catch (InterruptedException e) { + deletePartialArchive(archive); + Thread.currentThread().interrupt(); + throw new IOException("Download interrupted while fetching official Flamingock skills. Retry the command once the interruption is cleared.", e); + } + } + + private void deletePartialArchive(Path archive) throws IOException { + Files.deleteIfExists(archive); + } + + @FunctionalInterface + interface DownloadExecutor { + HttpResponse download(HttpRequest request, Path target) throws IOException, InterruptedException; + } +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsArchiveExtractor.java b/src/main/java/io/flamingock/cli/executor/skills/SkillsArchiveExtractor.java new file mode 100644 index 0000000..8f39ac5 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsArchiveExtractor.java @@ -0,0 +1,85 @@ +/* + * 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 java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * Extracts the official skills ZIP archive into a temporary workspace. + */ +public class SkillsArchiveExtractor { + + /** + * Extracts the ZIP archive and returns the repository snapshot root directory. + * + * @param archive downloaded ZIP archive + * @param workspace temporary workspace directory + * @return extracted snapshot root directory + * @throws IOException if extraction fails + */ + public Path extractSnapshotRoot(Path archive, Path workspace) throws IOException { + Path extractionDir = workspace.resolve("extracted"); + Files.createDirectories(extractionDir); + + Set rootDirectories = new LinkedHashSet<>(); + try (InputStream inputStream = Files.newInputStream(archive); + ZipInputStream zipInputStream = new ZipInputStream(inputStream)) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + if (entry.isDirectory()) { + Path targetDir = safeResolve(extractionDir, entry.getName()); + Files.createDirectories(targetDir); + } else { + Path targetFile = safeResolve(extractionDir, entry.getName()); + Path parent = targetFile.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.copy(zipInputStream, targetFile, StandardCopyOption.REPLACE_EXISTING); + } + rootDirectories.add(firstSegment(entry.getName())); + zipInputStream.closeEntry(); + } + } + + if (rootDirectories.size() != 1 || rootDirectories.contains("")) { + throw new IllegalStateException("Expected skills archive to contain a single root directory."); + } + + return extractionDir.resolve(rootDirectories.iterator().next()); + } + + private String firstSegment(String entryName) { + int separatorIndex = entryName.indexOf('/'); + return separatorIndex >= 0 ? entryName.substring(0, separatorIndex) : ""; + } + + private Path safeResolve(Path extractionDir, String entryName) { + Path target = extractionDir.resolve(entryName).normalize(); + if (!target.startsWith(extractionDir)) { + throw new IllegalStateException("Skills archive contains unsafe entry: " + entryName); + } + return target; + } +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsFileUtils.java b/src/main/java/io/flamingock/cli/executor/skills/SkillsFileUtils.java new file mode 100644 index 0000000..1b23861 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsFileUtils.java @@ -0,0 +1,41 @@ +/* + * 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 java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.stream.Stream; + +final class SkillsFileUtils { + + private SkillsFileUtils() { + } + + static void deleteRecursively(Path path) { + if (path == null || !Files.exists(path)) { + return; + } + try (Stream tree = Files.walk(path)) { + for (Path current : tree.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(current); + } + } catch (IOException e) { + throw new IllegalStateException("Failed to clean temporary path '" + path + "'.", e); + } + } +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationPipeline.java b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationPipeline.java new file mode 100644 index 0000000..132da97 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationPipeline.java @@ -0,0 +1,134 @@ +/* + * 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 java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +/** + * Executes the shared staged pipeline that installs official Flamingock skills. + */ +public class SkillsInstallationPipeline { + + private final DownloadStage downloader; + private final ArchiveExtractor extractor; + private final ArchiveEnumerator enumerator; + private final DirectoryReplacer replacer; + private final Supplier workspaceSupplier; + private final CleanupStage cleanup; + + public SkillsInstallationPipeline() { + this(new SkillsArchiveDownloader()::downloadTo, + new SkillsArchiveExtractor()::extractSnapshotRoot, + new SkillArchiveEnumerator()::listSkillDirectories, + new SkillDirectoryReplacer()::replaceSkill, + TemporaryWorkspaceSupplier::create, + SkillsFileUtils::deleteRecursively); + } + + SkillsInstallationPipeline( + DownloadStage downloader, + ArchiveExtractor extractor, + ArchiveEnumerator enumerator, + DirectoryReplacer replacer, + Supplier workspaceSupplier, + CleanupStage cleanup + ) { + this.downloader = downloader; + this.extractor = extractor; + this.enumerator = enumerator; + this.replacer = replacer; + this.workspaceSupplier = workspaceSupplier; + this.cleanup = cleanup; + } + + /** + * Runs the download, extract, enumerate, replace, and cleanup stages. + * + * @param destinationSkillsDir destination skills directory + * @return installation result summary + */ + public SkillsInstallationResult install(Path destinationSkillsDir) { + Path workspace = workspaceSupplier.get(); + RuntimeException installationFailure = null; + try { + Path archive = downloader.downloadTo(workspace); + Path snapshotRoot = extractor.extractSnapshotRoot(archive, workspace); + List skillDirectories = enumerator.listSkillDirectories(snapshotRoot); + List installedSkills = new ArrayList<>(); + for (Path skillDirectory : skillDirectories) { + replacer.replaceSkill(skillDirectory, destinationSkillsDir); + installedSkills.add(skillDirectory.getFileName().toString()); + } + return new SkillsInstallationResult(destinationSkillsDir, List.copyOf(installedSkills)); + } catch (IOException e) { + installationFailure = new IllegalStateException("Failed to install Flamingock skills: " + e.getMessage(), e); + throw installationFailure; + } finally { + try { + cleanup.delete(workspace); + } catch (RuntimeException cleanupFailure) { + if (installationFailure != null) { + installationFailure.addSuppressed(cleanupFailure); + } else { + throw cleanupFailure; + } + } + } + } + + @FunctionalInterface + interface ArchiveExtractor { + Path extractSnapshotRoot(Path archive, Path workspace) throws IOException; + } + + @FunctionalInterface + interface DownloadStage { + Path downloadTo(Path workspace) throws IOException; + } + + @FunctionalInterface + interface ArchiveEnumerator { + List listSkillDirectories(Path snapshotRoot) throws IOException; + } + + @FunctionalInterface + interface DirectoryReplacer { + void replaceSkill(Path sourceSkillDir, Path destinationSkillsDir) throws IOException; + } + + @FunctionalInterface + interface CleanupStage { + void delete(Path workspace); + } + + private static final class TemporaryWorkspaceSupplier { + + private TemporaryWorkspaceSupplier() { + } + + private static Path create() { + try { + return java.nio.file.Files.createTempDirectory("flamingock-skills-"); + } catch (IOException e) { + throw new IllegalStateException("Unable to create temporary workspace for skill installation.", e); + } + } + } +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationResult.java b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationResult.java new file mode 100644 index 0000000..bb2fbd3 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationResult.java @@ -0,0 +1,28 @@ +/* + * 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 java.nio.file.Path; +import java.util.List; + +/** + * Summary of a completed skills installation. + * + * @param destinationSkillsDir destination directory that received the installed skills + * @param installedSkills installed official skill folder names + */ +public record SkillsInstallationResult(Path destinationSkillsDir, List installedSkills) { +} diff --git a/src/test/java/io/flamingock/cli/executor/command/InstallSkillsCommandTest.java b/src/test/java/io/flamingock/cli/executor/command/InstallSkillsCommandTest.java new file mode 100644 index 0000000..d029716 --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/command/InstallSkillsCommandTest.java @@ -0,0 +1,220 @@ +/* + * 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.command; + +import io.flamingock.cli.executor.FlamingockExecutorCli; +import io.flamingock.cli.executor.skills.InstallDestinationResolver; +import io.flamingock.cli.executor.skills.InstallMode; +import io.flamingock.cli.executor.skills.InstallModeResolver; +import io.flamingock.cli.executor.skills.SkillsInstallationPipeline; +import io.flamingock.cli.executor.skills.SkillsInstallationResult; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import picocli.CommandLine; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InstallSkillsCommandTest { + + @TempDir + Path tempDir; + + @Test + void rootCommand_registersInstallSkillsSubcommand() { + CommandLine commandLine = new CommandLine(new FlamingockExecutorCli()); + + assertTrue(commandLine.getSubcommands().containsKey("install-skills")); + } + + @Test + void call_localInvocationInstallsSkillsIntoResolvedDestination() throws Exception { + RecordingModeResolver modeResolver = new RecordingModeResolver(InstallMode.LOCAL); + RecordingDestinationResolver destinationResolver = new RecordingDestinationResolver(tempDir.resolve(".agents/skills")); + RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult( + destinationResolver.destination, + List.of("flamingock-core", "flamingock-java") + )); + InstallSkillsCommand command = new InstallSkillsCommand(modeResolver, destinationResolver, pipeline, tempDir); + + int exitCode = new CommandLine(command).execute(); + + assertEquals(0, exitCode); + assertTrue(modeResolver.called); + assertFalse(modeResolver.globalRequested); + assertEquals(tempDir, destinationResolver.workingDirectory); + assertEquals(destinationResolver.destination, pipeline.destination); + } + + @Test + void call_globalFlagPrintsNotImplementedAndDoesNotTouchFilesystem() throws Exception { + RecordingModeResolver modeResolver = new RecordingModeResolver(InstallMode.GLOBAL); + RecordingDestinationResolver destinationResolver = new RecordingDestinationResolver(tempDir.resolve(".agents/skills")); + RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult(tempDir, List.of())); + InstallSkillsCommand command = new InstallSkillsCommand(modeResolver, destinationResolver, 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("--global"); + + assertEquals(1, exitCode); + } finally { + System.setErr(originalErr); + } + + assertTrue(modeResolver.called); + assertTrue(modeResolver.globalRequested); + assertFalse(destinationResolver.called); + assertFalse(pipeline.called); + assertFalse(Files.exists(tempDir.resolve(".agents"))); + assertTrue(errContent.toString(StandardCharsets.UTF_8).contains("not implemented")); + } + + @Test + void call_localInvocationUsesSharedPipelineAndPrintsInstalledSkillCount() throws Exception { + RecordingModeResolver modeResolver = new RecordingModeResolver(InstallMode.LOCAL); + RecordingDestinationResolver destinationResolver = new RecordingDestinationResolver(tempDir.resolve(".agents/skills")); + RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult( + destinationResolver.destination, + List.of("flamingock-core") + )); + InstallSkillsCommand command = new InstallSkillsCommand(modeResolver, destinationResolver, pipeline, tempDir); + + ByteArrayOutputStream outContent = new ByteArrayOutputStream(); + PrintStream originalOut = System.out; + System.setOut(new PrintStream(outContent, true, StandardCharsets.UTF_8)); + try { + int exitCode = new CommandLine(command).execute(); + + assertEquals(0, exitCode); + } finally { + System.setOut(originalOut); + } + + assertTrue(pipeline.called); + assertTrue(outContent.toString(StandardCharsets.UTF_8).contains("Installed 1 Flamingock skill(s)")); + } + + @Test + void call_localInvocationPrintsActionableFailureWithoutStackTraceAndReturnsExitCodeOne() throws Exception { + RecordingModeResolver modeResolver = new RecordingModeResolver(InstallMode.LOCAL); + RecordingDestinationResolver destinationResolver = new RecordingDestinationResolver(tempDir.resolve(".agents/skills")); + FailingPipeline pipeline = new FailingPipeline( + new IllegalStateException("Download timed out while fetching official Flamingock skills. Check your network connection and retry.") + ); + InstallSkillsCommand command = new InstallSkillsCommand(modeResolver, destinationResolver, 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(); + + assertEquals(1, exitCode); + } finally { + System.setErr(originalErr); + } + + String stderr = errContent.toString(StandardCharsets.UTF_8); + assertTrue(modeResolver.called); + assertTrue(destinationResolver.called); + assertTrue(pipeline.called); + assertTrue(stderr.contains("timed out")); + assertTrue(stderr.contains("retry")); + assertFalse(stderr.contains("IllegalStateException")); + assertFalse(stderr.contains("\tat ")); + } + + private static final class RecordingModeResolver extends InstallModeResolver { + + private final InstallMode resolvedMode; + private boolean called; + private boolean globalRequested; + + private RecordingModeResolver(InstallMode resolvedMode) { + this.resolvedMode = resolvedMode; + } + + @Override + public InstallMode resolve(boolean globalRequested) { + this.called = true; + this.globalRequested = globalRequested; + return resolvedMode; + } + } + + private static final class RecordingDestinationResolver extends InstallDestinationResolver { + + private final Path destination; + private boolean called; + private Path workingDirectory; + + private RecordingDestinationResolver(Path destination) { + this.destination = destination; + } + + @Override + public Path resolveLocal(Path workingDirectory) { + this.called = true; + this.workingDirectory = workingDirectory; + return destination; + } + } + + private static final class RecordingPipeline extends SkillsInstallationPipeline { + + private final SkillsInstallationResult result; + private boolean called; + private Path destination; + + private RecordingPipeline(SkillsInstallationResult result) { + this.result = result; + } + + @Override + public SkillsInstallationResult install(Path destinationSkillsDir) { + this.called = true; + this.destination = destinationSkillsDir; + return result; + } + } + + private static final class FailingPipeline extends SkillsInstallationPipeline { + + private final IllegalStateException failure; + private boolean called; + + private FailingPipeline(IllegalStateException failure) { + this.failure = failure; + } + + @Override + public SkillsInstallationResult install(Path destinationSkillsDir) { + this.called = true; + throw failure; + } + } +} diff --git a/src/test/java/io/flamingock/cli/executor/skills/InstallDestinationResolverTest.java b/src/test/java/io/flamingock/cli/executor/skills/InstallDestinationResolverTest.java new file mode 100644 index 0000000..1606b57 --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/skills/InstallDestinationResolverTest.java @@ -0,0 +1,66 @@ +/* + * 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 org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InstallDestinationResolverTest { + + @TempDir + Path tempDir; + + private final InstallDestinationResolver resolver = new InstallDestinationResolver(); + + @Test + void resolveLocal_createsMissingAgentsSkillsTree() throws Exception { + Path destination = resolver.resolveLocal(tempDir); + + assertEquals(tempDir.resolve(".agents").resolve("skills"), destination); + assertTrue(Files.isDirectory(tempDir.resolve(".agents"))); + assertTrue(Files.isDirectory(destination)); + } + + @Test + void resolveLocal_failsWhenAgentsPathIsAFile() throws Exception { + Path agentsFile = Files.writeString(tempDir.resolve(".agents"), "not a directory"); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> resolver.resolveLocal(tempDir)); + + assertTrue(exception.getMessage().contains(agentsFile.toString())); + assertTrue(exception.getMessage().contains("Delete or rename")); + } + + @Test + void resolveLocal_failsWhenSkillsPathIsAFile() throws Exception { + Files.createDirectories(tempDir.resolve(".agents")); + Path skillsFile = Files.writeString(tempDir.resolve(".agents").resolve("skills"), "not a directory"); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> resolver.resolveLocal(tempDir)); + + assertTrue(exception.getMessage().contains(skillsFile.toString())); + assertTrue(exception.getMessage().contains("Delete or rename")); + } +} diff --git a/src/test/java/io/flamingock/cli/executor/skills/InstallModeResolverTest.java b/src/test/java/io/flamingock/cli/executor/skills/InstallModeResolverTest.java new file mode 100644 index 0000000..f5d23ed --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/skills/InstallModeResolverTest.java @@ -0,0 +1,35 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; + +class InstallModeResolverTest { + + private final InstallModeResolver resolver = new InstallModeResolver(); + + @Test + void resolve_returnsLocalWhenGlobalFlagIsFalse() { + assertEquals(InstallMode.LOCAL, resolver.resolve(false)); + } + + @Test + void resolve_returnsGlobalWhenGlobalFlagIsTrue() { + assertEquals(InstallMode.GLOBAL, resolver.resolve(true)); + } +} diff --git a/src/test/java/io/flamingock/cli/executor/skills/SkillArchiveEnumeratorTest.java b/src/test/java/io/flamingock/cli/executor/skills/SkillArchiveEnumeratorTest.java new file mode 100644 index 0000000..f34b5ac --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/skills/SkillArchiveEnumeratorTest.java @@ -0,0 +1,61 @@ +/* + * 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 org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SkillArchiveEnumeratorTest { + + @TempDir + Path tempDir; + + private final SkillArchiveEnumerator enumerator = new SkillArchiveEnumerator(); + + @Test + void listSkillDirectories_returnsOnlyTopLevelFlamingockDirectories() throws Exception { + Path snapshotRoot = tempDir.resolve("flamingock-skills-master"); + Files.createDirectories(snapshotRoot.resolve("flamingock-core")); + Files.createDirectories(snapshotRoot.resolve("flamingock-java")); + Files.createDirectories(snapshotRoot.resolve("docs")); + Files.writeString(snapshotRoot.resolve("README.md"), "readme"); + Files.writeString(snapshotRoot.resolve(".gitignore"), "*"); + + List skillDirectories = enumerator.listSkillDirectories(snapshotRoot); + + assertEquals(List.of( + snapshotRoot.resolve("flamingock-core"), + snapshotRoot.resolve("flamingock-java") + ), skillDirectories); + } + + @Test + void listSkillDirectories_ignoresNestedFlamingockDirectoriesOutsideTopLevel() throws Exception { + Path snapshotRoot = tempDir.resolve("flamingock-skills-master"); + Files.createDirectories(snapshotRoot.resolve("docs").resolve("flamingock-not-a-skill")); + Files.createDirectories(snapshotRoot.resolve("flamingock-core")); + + List skillDirectories = enumerator.listSkillDirectories(snapshotRoot); + + assertEquals(List.of(snapshotRoot.resolve("flamingock-core")), skillDirectories); + } +} diff --git a/src/test/java/io/flamingock/cli/executor/skills/SkillDirectoryReplacerTest.java b/src/test/java/io/flamingock/cli/executor/skills/SkillDirectoryReplacerTest.java new file mode 100644 index 0000000..a5f30e5 --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/skills/SkillDirectoryReplacerTest.java @@ -0,0 +1,61 @@ +/* + * 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 org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SkillDirectoryReplacerTest { + + @TempDir + Path tempDir; + + private final SkillDirectoryReplacer replacer = new SkillDirectoryReplacer(); + + @Test + void replaceSkill_deletesExistingSkillBeforeCopyingFreshTree() throws Exception { + Path destinationSkillsDir = Files.createDirectories(tempDir.resolve(".agents").resolve("skills")); + Path existingSkillDir = Files.createDirectories(destinationSkillsDir.resolve("flamingock-core")); + Files.writeString(existingSkillDir.resolve("old-file.txt"), "old"); + Path sourceSkillDir = Files.createDirectories(tempDir.resolve("source").resolve("flamingock-core")); + Files.writeString(sourceSkillDir.resolve("SKILL.md"), "new"); + + replacer.replaceSkill(sourceSkillDir, destinationSkillsDir); + + assertFalse(Files.exists(destinationSkillsDir.resolve("flamingock-core").resolve("old-file.txt"))); + assertEquals("new", Files.readString(destinationSkillsDir.resolve("flamingock-core").resolve("SKILL.md"))); + } + + @Test + void replaceSkill_preservesOtherUserCreatedFolders() throws Exception { + Path destinationSkillsDir = Files.createDirectories(tempDir.resolve(".agents").resolve("skills")); + Files.createDirectories(destinationSkillsDir.resolve("my-custom-skill")); + Path sourceSkillDir = Files.createDirectories(tempDir.resolve("source").resolve("flamingock-java")); + Files.writeString(sourceSkillDir.resolve("SKILL.md"), "java"); + + replacer.replaceSkill(sourceSkillDir, destinationSkillsDir); + + assertTrue(Files.isDirectory(destinationSkillsDir.resolve("my-custom-skill"))); + assertTrue(Files.exists(destinationSkillsDir.resolve("flamingock-java").resolve("SKILL.md"))); + } +} diff --git a/src/test/java/io/flamingock/cli/executor/skills/SkillsArchiveDownloaderTest.java b/src/test/java/io/flamingock/cli/executor/skills/SkillsArchiveDownloaderTest.java new file mode 100644 index 0000000..04a204d --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/skills/SkillsArchiveDownloaderTest.java @@ -0,0 +1,161 @@ +/* + * 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 org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import javax.net.ssl.SSLSession; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SkillsArchiveDownloaderTest { + + @TempDir + Path tempDir; + + @Test + void downloadTo_usesOfficialArchiveUrlAndReturnsDownloadedFile() throws Exception { + RecordingDownloadExecutor executor = new RecordingDownloadExecutor(200); + SkillsArchiveDownloader downloader = new SkillsArchiveDownloader(executor); + + Path archive = downloader.downloadTo(tempDir.resolve("workspace")); + + assertEquals(URI.create(SkillsArchiveDownloader.OFFICIAL_SKILLS_ARCHIVE_URL), executor.lastRequest.uri()); + assertEquals(tempDir.resolve("workspace").resolve("flamingock-skills.zip"), archive); + assertTrue(Files.exists(archive)); + assertEquals("zip-content", Files.readString(archive)); + } + + @Test + void downloadTo_deletesPartialArchiveWhenServerReturnsFailure() { + RecordingDownloadExecutor executor = new RecordingDownloadExecutor(503); + SkillsArchiveDownloader downloader = new SkillsArchiveDownloader(executor); + Path workspace = tempDir.resolve("workspace"); + + IOException exception = assertThrows(IOException.class, () -> downloader.downloadTo(workspace)); + + assertTrue(exception.getMessage().contains("HTTP 503")); + assertFalse(Files.exists(workspace.resolve("flamingock-skills.zip"))); + } + + @Test + void downloadTo_setsReasonableTimeoutsAndUserAgent() throws Exception { + RecordingDownloadExecutor executor = new RecordingDownloadExecutor(200); + SkillsArchiveDownloader downloader = new SkillsArchiveDownloader(executor); + + downloader.downloadTo(tempDir.resolve("workspace")); + + assertEquals(Duration.ofSeconds(30), executor.lastRequest.timeout().orElseThrow()); + assertEquals("Flamingock CLI/1.0 install-skills", executor.lastRequest.headers().firstValue("User-Agent").orElseThrow()); + } + + @Test + void downloadTo_deletesPartialArchiveWhenDownloadThrowsIoException() { + SkillsArchiveDownloader downloader = new SkillsArchiveDownloader((request, target) -> { + Files.createDirectories(target.getParent()); + Files.writeString(target, "partial"); + throw new IOException("socket closed"); + }); + Path workspace = tempDir.resolve("workspace"); + + IOException exception = assertThrows(IOException.class, () -> downloader.downloadTo(workspace)); + + assertEquals("socket closed", exception.getMessage()); + assertFalse(Files.exists(workspace.resolve("flamingock-skills.zip"))); + } + + @Test + void downloadTo_wrapsInterruptedDownloadsWithActionableMessage() { + SkillsArchiveDownloader downloader = new SkillsArchiveDownloader((request, target) -> { + throw new InterruptedException("cancelled"); + }); + + IOException exception = assertThrows(IOException.class, () -> downloader.downloadTo(tempDir.resolve("workspace"))); + + assertTrue(exception.getMessage().contains("interrupted")); + assertTrue(Thread.currentThread().isInterrupted()); + Thread.interrupted(); + } + + private static final class RecordingDownloadExecutor implements SkillsArchiveDownloader.DownloadExecutor { + + private final int statusCode; + private HttpRequest lastRequest; + + private RecordingDownloadExecutor(int statusCode) { + this.statusCode = statusCode; + } + + @Override + public HttpResponse download(HttpRequest request, Path target) throws IOException { + this.lastRequest = request; + Files.createDirectories(target.getParent()); + Files.writeString(target, "zip-content"); + return new HttpResponseStub(statusCode, target, request); + } + } + + private record HttpResponseStub(int statusCode, Path body, HttpRequest request) implements HttpResponse { + + @Override + public HttpRequest request() { + return request; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(java.util.Map.of(), (a, b) -> true); + } + + @Override + public Path body() { + return body; + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return request.uri(); + } + + @Override + public java.net.http.HttpClient.Version version() { + return java.net.http.HttpClient.Version.HTTP_1_1; + } + } +} diff --git a/src/test/java/io/flamingock/cli/executor/skills/SkillsArchiveExtractorTest.java b/src/test/java/io/flamingock/cli/executor/skills/SkillsArchiveExtractorTest.java new file mode 100644 index 0000000..287adbb --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/skills/SkillsArchiveExtractorTest.java @@ -0,0 +1,92 @@ +/* + * 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 org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SkillsArchiveExtractorTest { + + @TempDir + Path tempDir; + + private final SkillsArchiveExtractor extractor = new SkillsArchiveExtractor(); + + @Test + void extractSnapshotRoot_returnsArchiveRootDirectory() throws Exception { + Path archive = createZip(tempDir.resolve("skills.zip"), + "flamingock-skills-master/README.md", + "flamingock-skills-master/flamingock-core/SKILL.md", + "flamingock-skills-master/flamingock-java/SKILL.md"); + + Path snapshotRoot = extractor.extractSnapshotRoot(archive, tempDir.resolve("workspace")); + + assertEquals(tempDir.resolve("workspace").resolve("extracted").resolve("flamingock-skills-master"), snapshotRoot); + assertTrue(Files.exists(snapshotRoot.resolve("README.md"))); + assertTrue(Files.exists(snapshotRoot.resolve("flamingock-core").resolve("SKILL.md"))); + } + + @Test + void extractSnapshotRoot_rejectsArchiveWithoutSingleRootDirectory() throws Exception { + Path archive = createZip(tempDir.resolve("invalid.zip"), + "README.md", + "flamingock-core/SKILL.md"); + + IllegalStateException exception = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, + () -> extractor.extractSnapshotRoot(archive, tempDir.resolve("workspace"))); + + assertTrue(exception.getMessage().contains("single root directory")); + } + + @Test + void extractSnapshotRoot_rejectsZipSlipEntriesOutsideWorkspace() throws Exception { + Path archive = createZip(tempDir.resolve("zip-slip.zip"), + "flamingock-skills-master/flamingock-core/SKILL.md", + "flamingock-skills-master/../../evil.txt"); + + IllegalStateException exception = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, + () -> extractor.extractSnapshotRoot(archive, tempDir.resolve("workspace"))); + + assertTrue(exception.getMessage().contains("unsafe")); + assertFalse(Files.exists(tempDir.resolve("evil.txt"))); + } + + private Path createZip(Path zipPath, String... entries) throws IOException { + try (OutputStream outputStream = Files.newOutputStream(zipPath); + ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { + for (String entry : entries) { + ZipEntry zipEntry = new ZipEntry(entry); + zipOutputStream.putNextEntry(zipEntry); + if (!entry.endsWith("/")) { + zipOutputStream.write("content".getBytes()); + } + zipOutputStream.closeEntry(); + } + } + return zipPath; + } +} diff --git a/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationPipelineTest.java b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationPipelineTest.java new file mode 100644 index 0000000..3684217 --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationPipelineTest.java @@ -0,0 +1,148 @@ +/* + * 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 org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SkillsInstallationPipelineTest { + + @TempDir + Path tempDir; + + @Test + void install_runsStagesInOrderAndCleansWorkspace() throws Exception { + List events = new ArrayList<>(); + Path destination = Files.createDirectories(tempDir.resolve(".agents").resolve("skills")); + Path downloadedArchive = tempDir.resolve("downloaded.zip"); + Path snapshotRoot = Files.createDirectories(tempDir.resolve("snapshot")); + Path firstSkill = Files.createDirectories(snapshotRoot.resolve("flamingock-core")); + Path secondSkill = Files.createDirectories(snapshotRoot.resolve("flamingock-java")); + + SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( + workspace -> { + events.add("download"); + Files.writeString(downloadedArchive, "zip"); + return downloadedArchive; + }, + (archive, workspace) -> { + events.add("extract"); + return snapshotRoot; + }, + root -> { + events.add("enumerate"); + return List.of(firstSkill, secondSkill); + }, + (sourceSkillDir, destinationSkillsDir) -> events.add("replace:" + sourceSkillDir.getFileName()), + () -> tempDir.resolve("workspace"), + SkillsFileUtils::deleteRecursively + ); + + SkillsInstallationResult result = pipeline.install(destination); + + assertEquals(List.of("download", "extract", "enumerate", "replace:flamingock-core", "replace:flamingock-java"), events); + assertEquals(List.of("flamingock-core", "flamingock-java"), result.installedSkills()); + assertFalse(Files.exists(tempDir.resolve("workspace"))); + } + + @Test + void install_cleansWorkspaceWhenStageFails() { + SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( + workspace -> tempDir.resolve("downloaded.zip"), + (archive, workspace) -> { + throw new IOException("boom"); + }, + root -> List.of(), + (sourceSkillDir, destinationSkillsDir) -> { + }, + () -> tempDir.resolve("workspace"), + SkillsFileUtils::deleteRecursively + ); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> pipeline.install(tempDir.resolve(".agents").resolve("skills"))); + + assertTrue(exception.getMessage().contains("Failed to install Flamingock skills")); + assertFalse(Files.exists(tempDir.resolve("workspace"))); + } + + @Test + void install_preservesOriginalFailureWhenCleanupAlsoFails() throws Exception { + Path workspace = Files.createDirectories(tempDir.resolve("workspace")); + Files.writeString(workspace.resolve("stubborn.txt"), "keep"); + + SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( + stageWorkspace -> tempDir.resolve("downloaded.zip"), + (archive, stageWorkspace) -> { + throw new IOException("download exploded"); + }, + root -> List.of(), + (sourceSkillDir, destinationSkillsDir) -> { + }, + () -> workspace, + path -> { + throw new IllegalStateException("cleanup exploded"); + } + ); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> pipeline.install(tempDir.resolve(".agents").resolve("skills"))); + + assertTrue(exception.getMessage().contains("download exploded")); + assertEquals(1, exception.getSuppressed().length); + assertTrue(exception.getSuppressed()[0].getMessage().contains("cleanup exploded")); + } + + @Test + void install_replacesOnlyEnumeratedOfficialSkillsAndPreservesCustomFolders() throws Exception { + Path destination = Files.createDirectories(tempDir.resolve(".agents").resolve("skills")); + Path existingOfficialSkill = Files.createDirectories(destination.resolve("flamingock-core")); + Files.writeString(existingOfficialSkill.resolve("old.txt"), "old"); + Path customSkill = Files.createDirectories(destination.resolve("my-custom-skill")); + Files.writeString(customSkill.resolve("SKILL.md"), "custom"); + + Path snapshotRoot = Files.createDirectories(tempDir.resolve("snapshot")); + Path officialSkill = Files.createDirectories(snapshotRoot.resolve("flamingock-core")); + Files.writeString(officialSkill.resolve("SKILL.md"), "fresh"); + + SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( + workspace -> tempDir.resolve("downloaded.zip"), + (archive, workspace) -> snapshotRoot, + root -> List.of(officialSkill), + new SkillDirectoryReplacer()::replaceSkill, + () -> tempDir.resolve("workspace"), + SkillsFileUtils::deleteRecursively + ); + + SkillsInstallationResult result = pipeline.install(destination); + + assertEquals(List.of("flamingock-core"), result.installedSkills()); + assertFalse(Files.exists(destination.resolve("flamingock-core").resolve("old.txt"))); + assertEquals("fresh", Files.readString(destination.resolve("flamingock-core").resolve("SKILL.md"))); + assertEquals("custom", Files.readString(destination.resolve("my-custom-skill").resolve("SKILL.md"))); + } +} From 56758f5ba652da6d5596144f5e98459deda0529b Mon Sep 17 00:00:00 2001 From: bercianor Date: Wed, 27 May 2026 00:33:29 +0200 Subject: [PATCH 2/3] refactor: generalize install-skills helpers --- .../ZipArchiveExtractor.java} | 27 +-- .../command/InstallSkillsCommand.java | 43 ++--- .../DirectoryLister.java} | 22 ++- .../DirectoryReplacer.java} | 25 ++- .../filesystem/DirectoryResolver.java | 57 ++++++ .../FileSystemUtils.java} | 11 +- .../TemporaryDirectoryFactory.java} | 27 +-- .../cli/executor/http/HttpFileDownloader.java | 101 ++++++++++ .../skills/InstallDestinationResolver.java | 56 ------ .../cli/executor/skills/InstallMode.java | 24 --- .../executor/skills/InstallationTarget.java | 43 +++++ .../skills/SkillsArchiveDownloader.java | 92 --------- .../skills/SkillsInstallationPipeline.java | 129 +++++++------ .../skills/SkillsInstallationResult.java | 22 ++- .../SkillsInstallationTargetResolver.java | 57 ++++++ .../ZipArchiveExtractorTest.java} | 25 ++- .../command/InstallSkillsCommandTest.java | 94 ++++----- .../DirectoryListerTest.java} | 21 ++- .../filesystem/DirectoryReplacerTest.java | 60 ++++++ .../DirectoryResolverTest.java} | 18 +- .../HttpFileDownloaderTest.java} | 66 ++++--- .../skills/InstallModeResolverTest.java | 35 ---- .../skills/SkillDirectoryReplacerTest.java | 61 ------ .../SkillsInstallationPipelineTest.java | 178 ++++++++++++++---- .../SkillsInstallationTargetResolverTest.java | 82 ++++++++ 25 files changed, 840 insertions(+), 536 deletions(-) rename src/main/java/io/flamingock/cli/executor/{skills/SkillsArchiveExtractor.java => archive/ZipArchiveExtractor.java} (72%) rename src/main/java/io/flamingock/cli/executor/{skills/SkillArchiveEnumerator.java => filesystem/DirectoryLister.java} (62%) rename src/main/java/io/flamingock/cli/executor/{skills/SkillDirectoryReplacer.java => filesystem/DirectoryReplacer.java} (60%) create mode 100644 src/main/java/io/flamingock/cli/executor/filesystem/DirectoryResolver.java rename src/main/java/io/flamingock/cli/executor/{skills/SkillsFileUtils.java => filesystem/FileSystemUtils.java} (84%) rename src/main/java/io/flamingock/cli/executor/{skills/InstallModeResolver.java => filesystem/TemporaryDirectoryFactory.java} (50%) create mode 100644 src/main/java/io/flamingock/cli/executor/http/HttpFileDownloader.java delete mode 100644 src/main/java/io/flamingock/cli/executor/skills/InstallDestinationResolver.java delete mode 100644 src/main/java/io/flamingock/cli/executor/skills/InstallMode.java create mode 100644 src/main/java/io/flamingock/cli/executor/skills/InstallationTarget.java delete mode 100644 src/main/java/io/flamingock/cli/executor/skills/SkillsArchiveDownloader.java create mode 100644 src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolver.java rename src/test/java/io/flamingock/cli/executor/{skills/SkillsArchiveExtractorTest.java => archive/ZipArchiveExtractorTest.java} (77%) rename src/test/java/io/flamingock/cli/executor/{skills/SkillArchiveEnumeratorTest.java => filesystem/DirectoryListerTest.java} (71%) create mode 100644 src/test/java/io/flamingock/cli/executor/filesystem/DirectoryReplacerTest.java rename src/test/java/io/flamingock/cli/executor/{skills/InstallDestinationResolverTest.java => filesystem/DirectoryResolverTest.java} (76%) rename src/test/java/io/flamingock/cli/executor/{skills/SkillsArchiveDownloaderTest.java => http/HttpFileDownloaderTest.java} (58%) delete mode 100644 src/test/java/io/flamingock/cli/executor/skills/InstallModeResolverTest.java delete mode 100644 src/test/java/io/flamingock/cli/executor/skills/SkillDirectoryReplacerTest.java create mode 100644 src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolverTest.java diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsArchiveExtractor.java b/src/main/java/io/flamingock/cli/executor/archive/ZipArchiveExtractor.java similarity index 72% rename from src/main/java/io/flamingock/cli/executor/skills/SkillsArchiveExtractor.java rename to src/main/java/io/flamingock/cli/executor/archive/ZipArchiveExtractor.java index 8f39ac5..d782433 100644 --- a/src/main/java/io/flamingock/cli/executor/skills/SkillsArchiveExtractor.java +++ b/src/main/java/io/flamingock/cli/executor/archive/ZipArchiveExtractor.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.skills; +package io.flamingock.cli.executor.archive; import java.io.IOException; import java.io.InputStream; @@ -26,20 +26,23 @@ import java.util.zip.ZipInputStream; /** - * Extracts the official skills ZIP archive into a temporary workspace. + * Extracts ZIP archives into a workspace and returns their single root directory. */ -public class SkillsArchiveExtractor { +public class ZipArchiveExtractor { /** - * Extracts the ZIP archive and returns the repository snapshot root directory. + * Extracts a ZIP archive and returns the extracted single root directory. * * @param archive downloaded ZIP archive * @param workspace temporary workspace directory - * @return extracted snapshot root directory + * @param extractionDirectoryName extraction folder name within the workspace + * @param archiveDescription archive label for validation messages + * @return extracted root directory * @throws IOException if extraction fails */ - public Path extractSnapshotRoot(Path archive, Path workspace) throws IOException { - Path extractionDir = workspace.resolve("extracted"); + public Path extractSingleRootDirectory(Path archive, Path workspace, String extractionDirectoryName, String archiveDescription) + throws IOException { + Path extractionDir = workspace.resolve(extractionDirectoryName); Files.createDirectories(extractionDir); Set rootDirectories = new LinkedHashSet<>(); @@ -48,10 +51,10 @@ public Path extractSnapshotRoot(Path archive, Path workspace) throws IOException ZipEntry entry; while ((entry = zipInputStream.getNextEntry()) != null) { if (entry.isDirectory()) { - Path targetDir = safeResolve(extractionDir, entry.getName()); + Path targetDir = safeResolve(extractionDir, entry.getName(), archiveDescription); Files.createDirectories(targetDir); } else { - Path targetFile = safeResolve(extractionDir, entry.getName()); + Path targetFile = safeResolve(extractionDir, entry.getName(), archiveDescription); Path parent = targetFile.getParent(); if (parent != null) { Files.createDirectories(parent); @@ -64,7 +67,7 @@ public Path extractSnapshotRoot(Path archive, Path workspace) throws IOException } if (rootDirectories.size() != 1 || rootDirectories.contains("")) { - throw new IllegalStateException("Expected skills archive to contain a single root directory."); + throw new IllegalStateException("Expected " + archiveDescription + " to contain a single root directory."); } return extractionDir.resolve(rootDirectories.iterator().next()); @@ -75,10 +78,10 @@ private String firstSegment(String entryName) { return separatorIndex >= 0 ? entryName.substring(0, separatorIndex) : ""; } - private Path safeResolve(Path extractionDir, String entryName) { + private Path safeResolve(Path extractionDir, String entryName, String archiveDescription) { Path target = extractionDir.resolve(entryName).normalize(); if (!target.startsWith(extractionDir)) { - throw new IllegalStateException("Skills archive contains unsafe entry: " + entryName); + throw new IllegalStateException(archiveDescription + " contains unsafe entry: " + entryName); } return target; } 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 917b976..1ed72dc 100644 --- a/src/main/java/io/flamingock/cli/executor/command/InstallSkillsCommand.java +++ b/src/main/java/io/flamingock/cli/executor/command/InstallSkillsCommand.java @@ -16,15 +16,15 @@ package io.flamingock.cli.executor.command; import io.flamingock.cli.executor.output.ConsoleFormatter; -import io.flamingock.cli.executor.skills.InstallDestinationResolver; -import io.flamingock.cli.executor.skills.InstallMode; -import io.flamingock.cli.executor.skills.InstallModeResolver; +import io.flamingock.cli.executor.skills.InstallationTarget; import io.flamingock.cli.executor.skills.SkillsInstallationPipeline; import io.flamingock.cli.executor.skills.SkillsInstallationResult; +import io.flamingock.cli.executor.skills.SkillsInstallationTargetResolver; import picocli.CommandLine.Command; import picocli.CommandLine.Option; import java.nio.file.Path; +import java.util.List; import java.util.concurrent.Callable; /** @@ -37,14 +37,10 @@ ) public class InstallSkillsCommand implements Callable { - private static final String GLOBAL_MODE_NOT_IMPLEMENTED = - "Global skills installation is not implemented yet. Run 'flamingock install-skills' to install into ./.agents/skills."; - @Option(names = {"-g", "--global"}, description = "Install skills globally (not implemented yet)") private boolean global; - private final InstallModeResolver modeResolver; - private final InstallDestinationResolver destinationResolver; + private final SkillsInstallationTargetResolver targetResolver; private final SkillsInstallationPipeline pipeline; private final Path workingDirectory; @@ -52,17 +48,15 @@ public class InstallSkillsCommand implements Callable { * Creates a command with the default production collaborators. */ public InstallSkillsCommand() { - this(new InstallModeResolver(), new InstallDestinationResolver(), new SkillsInstallationPipeline(), Path.of("")); + this(new SkillsInstallationTargetResolver(), new SkillsInstallationPipeline(), Path.of("")); } InstallSkillsCommand( - InstallModeResolver modeResolver, - InstallDestinationResolver destinationResolver, + SkillsInstallationTargetResolver targetResolver, SkillsInstallationPipeline pipeline, Path workingDirectory ) { - this.modeResolver = modeResolver; - this.destinationResolver = destinationResolver; + this.targetResolver = targetResolver; this.pipeline = pipeline; this.workingDirectory = workingDirectory; } @@ -74,21 +68,24 @@ public InstallSkillsCommand() { */ @Override public Integer call() { - InstallMode mode = modeResolver.resolve(global); - if (mode == InstallMode.GLOBAL) { - ConsoleFormatter.printError(GLOBAL_MODE_NOT_IMPLEMENTED); - return 1; - } - try { - Path destination = destinationResolver.resolveLocal(workingDirectory.toAbsolutePath().normalize()); - SkillsInstallationResult result = pipeline.install(destination); - ConsoleFormatter.printInfo("Installed " + result.installedSkills().size() - + " Flamingock skill(s) into " + result.destinationSkillsDir()); + List targets = targetResolver.resolveTargets(workingDirectory.toAbsolutePath().normalize(), global); + SkillsInstallationResult result = pipeline.install(targets); + ConsoleFormatter.printInfo(buildSuccessMessage(result)); return 0; } catch (IllegalStateException e) { ConsoleFormatter.printError(e.getMessage()); return 1; } } + + private String buildSuccessMessage(SkillsInstallationResult result) { + if (result.targets().size() == 1) { + return "Installed " + result.installedSkills().size() + + " Flamingock skill(s) into " + result.destinationSkillsDir(); + } + + return "Installed " + result.installedSkills().size() + " Flamingock skill(s) into " + + result.targets().size() + " destinations."; + } } diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillArchiveEnumerator.java b/src/main/java/io/flamingock/cli/executor/filesystem/DirectoryLister.java similarity index 62% rename from src/main/java/io/flamingock/cli/executor/skills/SkillArchiveEnumerator.java rename to src/main/java/io/flamingock/cli/executor/filesystem/DirectoryLister.java index 8225152..1c089a8 100644 --- a/src/main/java/io/flamingock/cli/executor/skills/SkillArchiveEnumerator.java +++ b/src/main/java/io/flamingock/cli/executor/filesystem/DirectoryLister.java @@ -13,32 +13,34 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.skills; +package io.flamingock.cli.executor.filesystem; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Comparator; import java.util.List; +import java.util.function.Predicate; import java.util.stream.Stream; /** - * Enumerates official skill directories from an extracted archive snapshot. + * Lists directories under a root using a caller-provided filter. */ -public class SkillArchiveEnumerator { +public class DirectoryLister { /** - * Lists top-level skill directories whose names start with {@code flamingock-}. + * Lists top-level directories matching the supplied filter. * - * @param snapshotRoot extracted archive root - * @return sorted list of skill directories - * @throws IOException if directory listing fails + * @param root root directory to inspect + * @param filter filter applied to each top-level directory + * @return sorted matching directories + * @throws IOException if listing fails */ - public List listSkillDirectories(Path snapshotRoot) throws IOException { - try (Stream children = Files.list(snapshotRoot)) { + public List listDirectories(Path root, Predicate filter) throws IOException { + try (Stream children = Files.list(root)) { return children .filter(Files::isDirectory) - .filter(path -> path.getFileName().toString().startsWith("flamingock-")) + .filter(filter) .sorted(Comparator.comparing(path -> path.getFileName().toString())) .toList(); } diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillDirectoryReplacer.java b/src/main/java/io/flamingock/cli/executor/filesystem/DirectoryReplacer.java similarity index 60% rename from src/main/java/io/flamingock/cli/executor/skills/SkillDirectoryReplacer.java rename to src/main/java/io/flamingock/cli/executor/filesystem/DirectoryReplacer.java index 31509d5..a63e9d5 100644 --- a/src/main/java/io/flamingock/cli/executor/skills/SkillDirectoryReplacer.java +++ b/src/main/java/io/flamingock/cli/executor/filesystem/DirectoryReplacer.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.skills; +package io.flamingock.cli.executor.filesystem; import java.io.IOException; import java.nio.file.Files; @@ -23,26 +23,25 @@ import java.util.stream.Stream; /** - * Replaces one installed skill directory with a fresh copy from the archive. + * Replaces a destination directory with a fresh copy from a source directory. */ -public class SkillDirectoryReplacer { +public class DirectoryReplacer { /** - * Deletes the existing destination skill tree and copies the new one in its place. + * Deletes the existing destination tree and copies the source tree in its place. * - * @param sourceSkillDir source skill directory from the extracted archive - * @param destinationSkillsDir destination root containing installed skills + * @param sourceDirectory source directory + * @param destinationDirectory destination directory to replace * @throws IOException if replacement fails */ - public void replaceSkill(Path sourceSkillDir, Path destinationSkillsDir) throws IOException { - Path destinationSkillDir = destinationSkillsDir.resolve(sourceSkillDir.getFileName().toString()); - SkillsFileUtils.deleteRecursively(destinationSkillDir); - Files.createDirectories(destinationSkillDir); + public void replaceDirectory(Path sourceDirectory, Path destinationDirectory) throws IOException { + FileSystemUtils.deleteRecursively(destinationDirectory); + Files.createDirectories(destinationDirectory); - try (Stream sourceTree = Files.walk(sourceSkillDir)) { + try (Stream sourceTree = Files.walk(sourceDirectory)) { for (Path sourcePath : sourceTree.sorted(Comparator.naturalOrder()).toList()) { - Path relative = sourceSkillDir.relativize(sourcePath); - Path target = destinationSkillDir.resolve(relative.toString()); + Path relative = sourceDirectory.relativize(sourcePath); + Path target = destinationDirectory.resolve(relative.toString()); if (Files.isDirectory(sourcePath)) { Files.createDirectories(target); } else { diff --git a/src/main/java/io/flamingock/cli/executor/filesystem/DirectoryResolver.java b/src/main/java/io/flamingock/cli/executor/filesystem/DirectoryResolver.java new file mode 100644 index 0000000..9015e92 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/filesystem/DirectoryResolver.java @@ -0,0 +1,57 @@ +/* + * 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.filesystem; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Resolves nested directory paths under a base directory. + */ +public class DirectoryResolver { + + /** + * Resolves the nested directory under the provided base path, validating each segment. + * + * @param baseDirectory base directory + * @param segments nested path segments to resolve + * @return validated destination directory + */ + public Path resolveDirectory(Path baseDirectory, String... segments) { + Path resolved = baseDirectory; + for (String segment : segments) { + resolved = resolved.resolve(segment); + validateDirectoryPath(resolved); + } + + try { + Files.createDirectories(resolved); + } catch (IOException e) { + throw new IllegalStateException("Unable to create directory '" + resolved + + "'. Check filesystem permissions and try again.", e); + } + + return resolved; + } + + private void validateDirectoryPath(Path path) { + if (Files.exists(path) && !Files.isDirectory(path)) { + throw new IllegalStateException("Invalid destination path: '" + path + "' is not a directory. " + + "Delete or rename that path and run the command again."); + } + } +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsFileUtils.java b/src/main/java/io/flamingock/cli/executor/filesystem/FileSystemUtils.java similarity index 84% rename from src/main/java/io/flamingock/cli/executor/skills/SkillsFileUtils.java rename to src/main/java/io/flamingock/cli/executor/filesystem/FileSystemUtils.java index 1b23861..c53a13a 100644 --- a/src/main/java/io/flamingock/cli/executor/skills/SkillsFileUtils.java +++ b/src/main/java/io/flamingock/cli/executor/filesystem/FileSystemUtils.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.skills; +package io.flamingock.cli.executor.filesystem; import java.io.IOException; import java.nio.file.Files; @@ -21,12 +21,15 @@ import java.util.Comparator; import java.util.stream.Stream; -final class SkillsFileUtils { +/** + * Shared filesystem helpers for CLI operations. + */ +public final class FileSystemUtils { - private SkillsFileUtils() { + private FileSystemUtils() { } - static void deleteRecursively(Path path) { + public static void deleteRecursively(Path path) { if (path == null || !Files.exists(path)) { return; } diff --git a/src/main/java/io/flamingock/cli/executor/skills/InstallModeResolver.java b/src/main/java/io/flamingock/cli/executor/filesystem/TemporaryDirectoryFactory.java similarity index 50% rename from src/main/java/io/flamingock/cli/executor/skills/InstallModeResolver.java rename to src/main/java/io/flamingock/cli/executor/filesystem/TemporaryDirectoryFactory.java index 66eb578..ba34a54 100644 --- a/src/main/java/io/flamingock/cli/executor/skills/InstallModeResolver.java +++ b/src/main/java/io/flamingock/cli/executor/filesystem/TemporaryDirectoryFactory.java @@ -13,20 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.skills; +package io.flamingock.cli.executor.filesystem; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; /** - * Resolves the requested skills installation mode from CLI flags. + * Creates temporary directories with caller-provided naming and purpose context. */ -public class InstallModeResolver { +public final class TemporaryDirectoryFactory { + + private TemporaryDirectoryFactory() { + } - /** - * Resolves the requested installation mode. - * - * @param globalRequested whether the global flag was requested - * @return the resolved install mode - */ - public InstallMode resolve(boolean globalRequested) { - return globalRequested ? InstallMode.GLOBAL : InstallMode.LOCAL; + public static Path create(String prefix, String purposeDescription) { + try { + return Files.createTempDirectory(prefix); + } catch (IOException e) { + throw new IllegalStateException("Unable to create temporary workspace for " + purposeDescription + ".", e); + } } } diff --git a/src/main/java/io/flamingock/cli/executor/http/HttpFileDownloader.java b/src/main/java/io/flamingock/cli/executor/http/HttpFileDownloader.java new file mode 100644 index 0000000..bea8ef8 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/http/HttpFileDownloader.java @@ -0,0 +1,101 @@ +/* + * 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.http; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +/** + * Downloads a remote file into a workspace using Java-native HTTP support. + */ +public class HttpFileDownloader { + + static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(10); + static final Duration DEFAULT_REQUEST_TIMEOUT = Duration.ofSeconds(30); + + private final DownloadExecutor downloadExecutor; + private final Duration connectTimeout; + private final Duration requestTimeout; + + public HttpFileDownloader() { + this(DEFAULT_CONNECT_TIMEOUT, DEFAULT_REQUEST_TIMEOUT, (request, target, resolvedConnectTimeout) -> HttpClient.newBuilder() + .connectTimeout(resolvedConnectTimeout) + .build() + .send(request, HttpResponse.BodyHandlers.ofFile(target))); + } + + HttpFileDownloader(Duration connectTimeout, Duration requestTimeout, DownloadExecutor downloadExecutor) { + this.connectTimeout = connectTimeout; + this.requestTimeout = requestTimeout; + this.downloadExecutor = downloadExecutor; + } + + /** + * Downloads a remote file into the provided workspace. + * + * @param workspace temporary workspace + * @param sourceUri source URI + * @param targetFileName target file name inside the workspace + * @param userAgent user agent header value + * @param downloadLabel contextual label for actionable error messages + * @return downloaded file path + * @throws IOException if the download fails + */ + public Path downloadTo(Path workspace, URI sourceUri, String targetFileName, String userAgent, String downloadLabel) + throws IOException { + Files.createDirectories(workspace); + Path targetFile = workspace.resolve(targetFileName); + HttpRequest request = HttpRequest.newBuilder(sourceUri) + .timeout(requestTimeout) + .header("User-Agent", userAgent) + .GET() + .build(); + try { + HttpResponse response = downloadExecutor.download(request, targetFile, connectTimeout); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + deletePartialFile(targetFile); + throw new IOException("Download failed while fetching " + downloadLabel + + " with HTTP " + response.statusCode() + " from " + sourceUri + + ". Check your network connection and retry."); + } + return targetFile; + } catch (IOException e) { + deletePartialFile(targetFile); + throw new IOException("Download failed while fetching " + downloadLabel + + ": " + e.getMessage(), e); + } catch (InterruptedException e) { + deletePartialFile(targetFile); + Thread.currentThread().interrupt(); + throw new IOException("Download interrupted while fetching " + downloadLabel + + ". Retry the command once the interruption is cleared.", e); + } + } + + private void deletePartialFile(Path targetFile) throws IOException { + Files.deleteIfExists(targetFile); + } + + @FunctionalInterface + interface DownloadExecutor { + HttpResponse download(HttpRequest request, Path target, Duration connectTimeout) throws IOException, InterruptedException; + } +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/InstallDestinationResolver.java b/src/main/java/io/flamingock/cli/executor/skills/InstallDestinationResolver.java deleted file mode 100644 index 9359084..0000000 --- a/src/main/java/io/flamingock/cli/executor/skills/InstallDestinationResolver.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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 java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * Resolves and validates the local skills installation destination. - */ -public class InstallDestinationResolver { - - /** - * Resolves the local destination under {@code ./.agents/skills}. - * - * @param workingDirectory current working directory - * @return validated destination directory - */ - public Path resolveLocal(Path workingDirectory) { - Path agentsDir = workingDirectory.resolve(".agents"); - validateDirectoryPath(agentsDir, "'.agents' must be a directory before installing skills."); - - Path skillsDir = agentsDir.resolve("skills"); - validateDirectoryPath(skillsDir, "'.agents/skills' must be a directory before installing skills."); - - try { - Files.createDirectories(skillsDir); - } catch (IOException e) { - throw new IllegalStateException("Unable to create skills directory '" + skillsDir - + "'. Check filesystem permissions and try again.", e); - } - - return skillsDir; - } - - private void validateDirectoryPath(Path path, String guidance) { - if (Files.exists(path) && !Files.isDirectory(path)) { - throw new IllegalStateException("Invalid skills destination path: '" + path + "' is not a directory. " - + guidance + " Delete or rename that path and run the command again."); - } - } -} diff --git a/src/main/java/io/flamingock/cli/executor/skills/InstallMode.java b/src/main/java/io/flamingock/cli/executor/skills/InstallMode.java deleted file mode 100644 index c86f911..0000000 --- a/src/main/java/io/flamingock/cli/executor/skills/InstallMode.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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; - -/** - * Supported install modes for the skills command. - */ -public enum InstallMode { - LOCAL, - GLOBAL -} diff --git a/src/main/java/io/flamingock/cli/executor/skills/InstallationTarget.java b/src/main/java/io/flamingock/cli/executor/skills/InstallationTarget.java new file mode 100644 index 0000000..065f14f --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/skills/InstallationTarget.java @@ -0,0 +1,43 @@ +/* + * 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 java.nio.file.Path; +import java.util.Objects; + +/** + * Resolved destination for a skills installation run. + * + * @param identifier stable identifier for the target + * @param destinationSkillsDir destination directory that will receive the skills + */ +public record InstallationTarget(String identifier, Path destinationSkillsDir) { + + public InstallationTarget { + identifier = Objects.requireNonNull(identifier, "identifier must not be null"); + destinationSkillsDir = Objects.requireNonNull(destinationSkillsDir, "destinationSkillsDir must not be null"); + } + + /** + * Creates the current project-local installation target. + * + * @param destinationSkillsDir local destination directory + * @return local installation target + */ + public static InstallationTarget local(Path destinationSkillsDir) { + return new InstallationTarget("local", destinationSkillsDir); + } +} diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsArchiveDownloader.java b/src/main/java/io/flamingock/cli/executor/skills/SkillsArchiveDownloader.java deleted file mode 100644 index e55e415..0000000 --- a/src/main/java/io/flamingock/cli/executor/skills/SkillsArchiveDownloader.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * 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 java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; - -/** - * Downloads the official Flamingock skills archive using Java-native HTTP support. - */ -public class SkillsArchiveDownloader { - - static final String OFFICIAL_SKILLS_ARCHIVE_URL = - "https://github.com/flamingock/flamingock-skills/archive/refs/heads/release.zip"; - static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); - static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30); - static final String USER_AGENT = "Flamingock CLI/1.0 install-skills"; - - private final DownloadExecutor downloadExecutor; - - public SkillsArchiveDownloader() { - this((request, target) -> HttpClient.newBuilder() - .connectTimeout(CONNECT_TIMEOUT) - .build() - .send(request, HttpResponse.BodyHandlers.ofFile(target))); - } - - SkillsArchiveDownloader(DownloadExecutor downloadExecutor) { - this.downloadExecutor = downloadExecutor; - } - - /** - * Downloads the official archive into the provided workspace. - * - * @param workspace temporary workspace - * @return downloaded ZIP path - * @throws IOException if the download fails - */ - public Path downloadTo(Path workspace) throws IOException { - Files.createDirectories(workspace); - Path archive = workspace.resolve("flamingock-skills.zip"); - HttpRequest request = HttpRequest.newBuilder(URI.create(OFFICIAL_SKILLS_ARCHIVE_URL)) - .timeout(REQUEST_TIMEOUT) - .header("User-Agent", USER_AGENT) - .GET() - .build(); - try { - HttpResponse response = downloadExecutor.download(request, archive); - if (response.statusCode() < 200 || response.statusCode() >= 300) { - deletePartialArchive(archive); - throw new IOException("Download failed with HTTP " + response.statusCode() - + " from " + OFFICIAL_SKILLS_ARCHIVE_URL + ". Check your network connection and retry."); - } - return archive; - } catch (IOException e) { - deletePartialArchive(archive); - throw e; - } catch (InterruptedException e) { - deletePartialArchive(archive); - Thread.currentThread().interrupt(); - throw new IOException("Download interrupted while fetching official Flamingock skills. Retry the command once the interruption is cleared.", e); - } - } - - private void deletePartialArchive(Path archive) throws IOException { - Files.deleteIfExists(archive); - } - - @FunctionalInterface - interface DownloadExecutor { - HttpResponse download(HttpRequest request, Path target) throws IOException, InterruptedException; - } -} 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 132da97..61c2be2 100644 --- a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationPipeline.java +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationPipeline.java @@ -15,10 +15,20 @@ */ package io.flamingock.cli.executor.skills; +import io.flamingock.cli.executor.archive.ZipArchiveExtractor; +import io.flamingock.cli.executor.filesystem.DirectoryLister; +import io.flamingock.cli.executor.filesystem.DirectoryReplacer; +import io.flamingock.cli.executor.filesystem.FileSystemUtils; +import io.flamingock.cli.executor.filesystem.TemporaryDirectoryFactory; +import io.flamingock.cli.executor.http.HttpFileDownloader; + import java.io.IOException; +import java.net.URI; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; import java.util.function.Supplier; /** @@ -26,33 +36,43 @@ */ public class SkillsInstallationPipeline { - private final DownloadStage downloader; - private final ArchiveExtractor extractor; - private final ArchiveEnumerator enumerator; + private static final URI OFFICIAL_SKILLS_ARCHIVE_URI = + URI.create("https://github.com/flamingock/flamingock-skills/archive/refs/heads/release.zip"); + private static final String ARCHIVE_FILE_NAME = "flamingock-skills.zip"; + private static final String EXTRACTION_DIRECTORY_NAME = "extracted"; + private static final String SKILL_DIRECTORY_PREFIX = "flamingock-"; + private static final String TEMP_DIRECTORY_PREFIX = "flamingock-skills-"; + private static final String DOWNLOAD_LABEL = "official Flamingock skills"; + private static final String ARCHIVE_DESCRIPTION = "skills archive"; + private static final String USER_AGENT = "Flamingock CLI/1.0 install-skills"; + + private final HttpFileDownloader downloader; + private final ZipArchiveExtractor extractor; + private final DirectoryLister directoryLister; private final DirectoryReplacer replacer; private final Supplier workspaceSupplier; - private final CleanupStage cleanup; + private final Consumer cleanup; public SkillsInstallationPipeline() { - this(new SkillsArchiveDownloader()::downloadTo, - new SkillsArchiveExtractor()::extractSnapshotRoot, - new SkillArchiveEnumerator()::listSkillDirectories, - new SkillDirectoryReplacer()::replaceSkill, - TemporaryWorkspaceSupplier::create, - SkillsFileUtils::deleteRecursively); + this(new HttpFileDownloader(), + new ZipArchiveExtractor(), + new DirectoryLister(), + new DirectoryReplacer(), + () -> TemporaryDirectoryFactory.create(TEMP_DIRECTORY_PREFIX, "skill installation"), + FileSystemUtils::deleteRecursively); } SkillsInstallationPipeline( - DownloadStage downloader, - ArchiveExtractor extractor, - ArchiveEnumerator enumerator, + HttpFileDownloader downloader, + ZipArchiveExtractor extractor, + DirectoryLister directoryLister, DirectoryReplacer replacer, Supplier workspaceSupplier, - CleanupStage cleanup + Consumer cleanup ) { this.downloader = downloader; this.extractor = extractor; - this.enumerator = enumerator; + this.directoryLister = directoryLister; this.replacer = replacer; this.workspaceSupplier = workspaceSupplier; this.cleanup = cleanup; @@ -61,28 +81,52 @@ public SkillsInstallationPipeline() { /** * Runs the download, extract, enumerate, replace, and cleanup stages. * - * @param destinationSkillsDir destination skills directory + * @param targets resolved installation targets * @return installation result summary */ - public SkillsInstallationResult install(Path destinationSkillsDir) { + public SkillsInstallationResult install(List targets) { + Objects.requireNonNull(targets, "targets must not be null"); + if (targets.isEmpty()) { + throw new IllegalStateException("No skills installation targets were resolved. Choose a destination and retry."); + } + Path workspace = workspaceSupplier.get(); RuntimeException installationFailure = null; try { - Path archive = downloader.downloadTo(workspace); - Path snapshotRoot = extractor.extractSnapshotRoot(archive, workspace); - List skillDirectories = enumerator.listSkillDirectories(snapshotRoot); + Path archive = downloader.downloadTo( + workspace, + OFFICIAL_SKILLS_ARCHIVE_URI, + ARCHIVE_FILE_NAME, + USER_AGENT, + DOWNLOAD_LABEL + ); + Path snapshotRoot = extractor.extractSingleRootDirectory( + archive, + workspace, + EXTRACTION_DIRECTORY_NAME, + ARCHIVE_DESCRIPTION + ); + List skillDirectories = directoryLister.listDirectories( + snapshotRoot, + path -> path.getFileName().toString().startsWith(SKILL_DIRECTORY_PREFIX) + ); List installedSkills = new ArrayList<>(); for (Path skillDirectory : skillDirectories) { - replacer.replaceSkill(skillDirectory, destinationSkillsDir); installedSkills.add(skillDirectory.getFileName().toString()); } - return new SkillsInstallationResult(destinationSkillsDir, List.copyOf(installedSkills)); + for (InstallationTarget target : targets) { + for (Path skillDirectory : skillDirectories) { + Path destinationSkillDirectory = target.destinationSkillsDir().resolve(skillDirectory.getFileName().toString()); + replacer.replaceDirectory(skillDirectory, destinationSkillDirectory); + } + } + return new SkillsInstallationResult(targets, installedSkills); } catch (IOException e) { installationFailure = new IllegalStateException("Failed to install Flamingock skills: " + e.getMessage(), e); throw installationFailure; } finally { try { - cleanup.delete(workspace); + cleanup.accept(workspace); } catch (RuntimeException cleanupFailure) { if (installationFailure != null) { installationFailure.addSuppressed(cleanupFailure); @@ -92,43 +136,4 @@ public SkillsInstallationResult install(Path destinationSkillsDir) { } } } - - @FunctionalInterface - interface ArchiveExtractor { - Path extractSnapshotRoot(Path archive, Path workspace) throws IOException; - } - - @FunctionalInterface - interface DownloadStage { - Path downloadTo(Path workspace) throws IOException; - } - - @FunctionalInterface - interface ArchiveEnumerator { - List listSkillDirectories(Path snapshotRoot) throws IOException; - } - - @FunctionalInterface - interface DirectoryReplacer { - void replaceSkill(Path sourceSkillDir, Path destinationSkillsDir) throws IOException; - } - - @FunctionalInterface - interface CleanupStage { - void delete(Path workspace); - } - - private static final class TemporaryWorkspaceSupplier { - - private TemporaryWorkspaceSupplier() { - } - - private static Path create() { - try { - return java.nio.file.Files.createTempDirectory("flamingock-skills-"); - } catch (IOException e) { - throw new IllegalStateException("Unable to create temporary workspace for skill installation.", e); - } - } - } } diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationResult.java b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationResult.java index bb2fbd3..ce933e6 100644 --- a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationResult.java +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationResult.java @@ -21,8 +21,26 @@ /** * Summary of a completed skills installation. * - * @param destinationSkillsDir destination directory that received the installed skills + * @param targets installation targets that received the installed skills * @param installedSkills installed official skill folder names */ -public record SkillsInstallationResult(Path destinationSkillsDir, List installedSkills) { +public record SkillsInstallationResult(List targets, List installedSkills) { + + public SkillsInstallationResult { + targets = List.copyOf(targets); + installedSkills = List.copyOf(installedSkills); + } + + /** + * Returns the single destination directory when the installation resolved to one target. + * + * @return single destination directory + */ + public Path destinationSkillsDir() { + if (targets.size() != 1) { + throw new IllegalStateException("Installation resolved to " + targets.size() + + " targets; destinationSkillsDir() is only available for single-target installs."); + } + return targets.get(0).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 new file mode 100644 index 0000000..8009004 --- /dev/null +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolver.java @@ -0,0 +1,57 @@ +/* + * 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 io.flamingock.cli.executor.filesystem.DirectoryResolver; + +import java.nio.file.Path; +import java.util.List; + +/** + * Resolves the install intent into concrete destination targets. + */ +public class SkillsInstallationTargetResolver { + + private static final String[] LOCAL_SKILLS_PATH = {".agents", "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."; + + private final DirectoryResolver directoryResolver; + + public SkillsInstallationTargetResolver() { + this(new DirectoryResolver()); + } + + SkillsInstallationTargetResolver(DirectoryResolver directoryResolver) { + this.directoryResolver = directoryResolver; + } + + /** + * Resolves the skills installation targets for the requested mode. + * + * @param workingDirectory current command working directory + * @param global whether global mode was requested + * @return resolved installation targets + */ + public List resolveTargets(Path workingDirectory, boolean global) { + if (global) { + throw new IllegalStateException(GLOBAL_MODE_NOT_IMPLEMENTED); + } + + Path destination = directoryResolver.resolveDirectory(workingDirectory, LOCAL_SKILLS_PATH); + return List.of(InstallationTarget.local(destination)); + } +} diff --git a/src/test/java/io/flamingock/cli/executor/skills/SkillsArchiveExtractorTest.java b/src/test/java/io/flamingock/cli/executor/archive/ZipArchiveExtractorTest.java similarity index 77% rename from src/test/java/io/flamingock/cli/executor/skills/SkillsArchiveExtractorTest.java rename to src/test/java/io/flamingock/cli/executor/archive/ZipArchiveExtractorTest.java index 287adbb..c23baa5 100644 --- a/src/test/java/io/flamingock/cli/executor/skills/SkillsArchiveExtractorTest.java +++ b/src/test/java/io/flamingock/cli/executor/archive/ZipArchiveExtractorTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.skills; +package io.flamingock.cli.executor.archive; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -25,25 +25,30 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -class SkillsArchiveExtractorTest { +class ZipArchiveExtractorTest { @TempDir Path tempDir; - private final SkillsArchiveExtractor extractor = new SkillsArchiveExtractor(); + private final ZipArchiveExtractor extractor = new ZipArchiveExtractor(); @Test - void extractSnapshotRoot_returnsArchiveRootDirectory() throws Exception { + void extractSingleRootDirectory_returnsArchiveRootDirectory() throws Exception { Path archive = createZip(tempDir.resolve("skills.zip"), "flamingock-skills-master/README.md", "flamingock-skills-master/flamingock-core/SKILL.md", "flamingock-skills-master/flamingock-java/SKILL.md"); - Path snapshotRoot = extractor.extractSnapshotRoot(archive, tempDir.resolve("workspace")); + Path snapshotRoot = extractor.extractSingleRootDirectory( + archive, + tempDir.resolve("workspace"), + "extracted", + "skills archive" + ); assertEquals(tempDir.resolve("workspace").resolve("extracted").resolve("flamingock-skills-master"), snapshotRoot); assertTrue(Files.exists(snapshotRoot.resolve("README.md"))); @@ -51,25 +56,25 @@ void extractSnapshotRoot_returnsArchiveRootDirectory() throws Exception { } @Test - void extractSnapshotRoot_rejectsArchiveWithoutSingleRootDirectory() throws Exception { + void extractSingleRootDirectory_rejectsArchiveWithoutSingleRootDirectory() throws Exception { Path archive = createZip(tempDir.resolve("invalid.zip"), "README.md", "flamingock-core/SKILL.md"); IllegalStateException exception = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, - () -> extractor.extractSnapshotRoot(archive, tempDir.resolve("workspace"))); + () -> extractor.extractSingleRootDirectory(archive, tempDir.resolve("workspace"), "extracted", "skills archive")); assertTrue(exception.getMessage().contains("single root directory")); } @Test - void extractSnapshotRoot_rejectsZipSlipEntriesOutsideWorkspace() throws Exception { + void extractSingleRootDirectory_rejectsZipSlipEntriesOutsideWorkspace() throws Exception { Path archive = createZip(tempDir.resolve("zip-slip.zip"), "flamingock-skills-master/flamingock-core/SKILL.md", "flamingock-skills-master/../../evil.txt"); IllegalStateException exception = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, - () -> extractor.extractSnapshotRoot(archive, tempDir.resolve("workspace"))); + () -> extractor.extractSingleRootDirectory(archive, tempDir.resolve("workspace"), "extracted", "skills archive")); assertTrue(exception.getMessage().contains("unsafe")); assertFalse(Files.exists(tempDir.resolve("evil.txt"))); 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 d029716..5e1fb1c 100644 --- a/src/test/java/io/flamingock/cli/executor/command/InstallSkillsCommandTest.java +++ b/src/test/java/io/flamingock/cli/executor/command/InstallSkillsCommandTest.java @@ -16,11 +16,10 @@ package io.flamingock.cli.executor.command; import io.flamingock.cli.executor.FlamingockExecutorCli; -import io.flamingock.cli.executor.skills.InstallDestinationResolver; -import io.flamingock.cli.executor.skills.InstallMode; -import io.flamingock.cli.executor.skills.InstallModeResolver; +import io.flamingock.cli.executor.skills.InstallationTarget; import io.flamingock.cli.executor.skills.SkillsInstallationPipeline; import io.flamingock.cli.executor.skills.SkillsInstallationResult; +import io.flamingock.cli.executor.skills.SkillsInstallationTargetResolver; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import picocli.CommandLine; @@ -50,29 +49,30 @@ void rootCommand_registersInstallSkillsSubcommand() { @Test void call_localInvocationInstallsSkillsIntoResolvedDestination() throws Exception { - RecordingModeResolver modeResolver = new RecordingModeResolver(InstallMode.LOCAL); - RecordingDestinationResolver destinationResolver = new RecordingDestinationResolver(tempDir.resolve(".agents/skills")); + InstallationTarget resolvedTarget = InstallationTarget.local(tempDir.resolve(".agents/skills")); + RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult( - destinationResolver.destination, + List.of(resolvedTarget), List.of("flamingock-core", "flamingock-java") )); - InstallSkillsCommand command = new InstallSkillsCommand(modeResolver, destinationResolver, pipeline, tempDir); + InstallSkillsCommand command = new InstallSkillsCommand(targetResolver, pipeline, tempDir); int exitCode = new CommandLine(command).execute(); assertEquals(0, exitCode); - assertTrue(modeResolver.called); - assertFalse(modeResolver.globalRequested); - assertEquals(tempDir, destinationResolver.workingDirectory); - assertEquals(destinationResolver.destination, pipeline.destination); + assertTrue(targetResolver.called); + assertFalse(targetResolver.global); + assertEquals(tempDir, targetResolver.workingDirectory); + assertEquals(List.of(resolvedTarget), pipeline.targets); } @Test void call_globalFlagPrintsNotImplementedAndDoesNotTouchFilesystem() throws Exception { - RecordingModeResolver modeResolver = new RecordingModeResolver(InstallMode.GLOBAL); - RecordingDestinationResolver destinationResolver = new RecordingDestinationResolver(tempDir.resolve(".agents/skills")); - RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult(tempDir, List.of())); - InstallSkillsCommand command = new InstallSkillsCommand(modeResolver, destinationResolver, pipeline, tempDir); + FailingTargetResolver targetResolver = new FailingTargetResolver( + new IllegalStateException("Global skills installation is not implemented yet. Run 'flamingock install-skills' to install into ./.agents/skills.") + ); + 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; @@ -85,9 +85,8 @@ void call_globalFlagPrintsNotImplementedAndDoesNotTouchFilesystem() throws Excep System.setErr(originalErr); } - assertTrue(modeResolver.called); - assertTrue(modeResolver.globalRequested); - assertFalse(destinationResolver.called); + assertTrue(targetResolver.called); + assertTrue(targetResolver.global); assertFalse(pipeline.called); assertFalse(Files.exists(tempDir.resolve(".agents"))); assertTrue(errContent.toString(StandardCharsets.UTF_8).contains("not implemented")); @@ -95,13 +94,13 @@ void call_globalFlagPrintsNotImplementedAndDoesNotTouchFilesystem() throws Excep @Test void call_localInvocationUsesSharedPipelineAndPrintsInstalledSkillCount() throws Exception { - RecordingModeResolver modeResolver = new RecordingModeResolver(InstallMode.LOCAL); - RecordingDestinationResolver destinationResolver = new RecordingDestinationResolver(tempDir.resolve(".agents/skills")); + InstallationTarget resolvedTarget = InstallationTarget.local(tempDir.resolve(".agents/skills")); + RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult( - destinationResolver.destination, + List.of(resolvedTarget), List.of("flamingock-core") )); - InstallSkillsCommand command = new InstallSkillsCommand(modeResolver, destinationResolver, pipeline, tempDir); + InstallSkillsCommand command = new InstallSkillsCommand(targetResolver, pipeline, tempDir); ByteArrayOutputStream outContent = new ByteArrayOutputStream(); PrintStream originalOut = System.out; @@ -120,12 +119,12 @@ void call_localInvocationUsesSharedPipelineAndPrintsInstalledSkillCount() throws @Test void call_localInvocationPrintsActionableFailureWithoutStackTraceAndReturnsExitCodeOne() throws Exception { - RecordingModeResolver modeResolver = new RecordingModeResolver(InstallMode.LOCAL); - RecordingDestinationResolver destinationResolver = new RecordingDestinationResolver(tempDir.resolve(".agents/skills")); + InstallationTarget resolvedTarget = InstallationTarget.local(tempDir.resolve(".agents/skills")); + RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); FailingPipeline pipeline = new FailingPipeline( new IllegalStateException("Download timed out while fetching official Flamingock skills. Check your network connection and retry.") ); - InstallSkillsCommand command = new InstallSkillsCommand(modeResolver, destinationResolver, pipeline, tempDir); + InstallSkillsCommand command = new InstallSkillsCommand(targetResolver, pipeline, tempDir); ByteArrayOutputStream errContent = new ByteArrayOutputStream(); PrintStream originalErr = System.err; @@ -139,8 +138,7 @@ void call_localInvocationPrintsActionableFailureWithoutStackTraceAndReturnsExitC } String stderr = errContent.toString(StandardCharsets.UTF_8); - assertTrue(modeResolver.called); - assertTrue(destinationResolver.called); + assertTrue(targetResolver.called); assertTrue(pipeline.called); assertTrue(stderr.contains("timed out")); assertTrue(stderr.contains("retry")); @@ -148,39 +146,41 @@ void call_localInvocationPrintsActionableFailureWithoutStackTraceAndReturnsExitC assertFalse(stderr.contains("\tat ")); } - private static final class RecordingModeResolver extends InstallModeResolver { + private static final class RecordingTargetResolver extends SkillsInstallationTargetResolver { - private final InstallMode resolvedMode; + private final List targets; private boolean called; - private boolean globalRequested; + private Path workingDirectory; + private boolean global; - private RecordingModeResolver(InstallMode resolvedMode) { - this.resolvedMode = resolvedMode; + private RecordingTargetResolver(List targets) { + this.targets = targets; } @Override - public InstallMode resolve(boolean globalRequested) { + public List resolveTargets(Path workingDirectory, boolean global) { this.called = true; - this.globalRequested = globalRequested; - return resolvedMode; + this.workingDirectory = workingDirectory; + this.global = global; + return targets; } } - private static final class RecordingDestinationResolver extends InstallDestinationResolver { + private static final class FailingTargetResolver extends SkillsInstallationTargetResolver { - private final Path destination; + private final IllegalStateException failure; private boolean called; - private Path workingDirectory; + private boolean global; - private RecordingDestinationResolver(Path destination) { - this.destination = destination; + private FailingTargetResolver(IllegalStateException failure) { + this.failure = failure; } @Override - public Path resolveLocal(Path workingDirectory) { + public List resolveTargets(Path workingDirectory, boolean global) { this.called = true; - this.workingDirectory = workingDirectory; - return destination; + this.global = global; + throw failure; } } @@ -188,16 +188,16 @@ private static final class RecordingPipeline extends SkillsInstallationPipeline private final SkillsInstallationResult result; private boolean called; - private Path destination; + private List targets; private RecordingPipeline(SkillsInstallationResult result) { this.result = result; } @Override - public SkillsInstallationResult install(Path destinationSkillsDir) { + public SkillsInstallationResult install(List targets) { this.called = true; - this.destination = destinationSkillsDir; + this.targets = targets; return result; } } @@ -212,7 +212,7 @@ private FailingPipeline(IllegalStateException failure) { } @Override - public SkillsInstallationResult install(Path destinationSkillsDir) { + public SkillsInstallationResult install(List targets) { this.called = true; throw failure; } diff --git a/src/test/java/io/flamingock/cli/executor/skills/SkillArchiveEnumeratorTest.java b/src/test/java/io/flamingock/cli/executor/filesystem/DirectoryListerTest.java similarity index 71% rename from src/test/java/io/flamingock/cli/executor/skills/SkillArchiveEnumeratorTest.java rename to src/test/java/io/flamingock/cli/executor/filesystem/DirectoryListerTest.java index f34b5ac..13f9281 100644 --- a/src/test/java/io/flamingock/cli/executor/skills/SkillArchiveEnumeratorTest.java +++ b/src/test/java/io/flamingock/cli/executor/filesystem/DirectoryListerTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.skills; +package io.flamingock.cli.executor.filesystem; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -24,23 +24,25 @@ import static org.junit.jupiter.api.Assertions.assertEquals; -class SkillArchiveEnumeratorTest { +class DirectoryListerTest { @TempDir Path tempDir; - private final SkillArchiveEnumerator enumerator = new SkillArchiveEnumerator(); + private final DirectoryLister directoryLister = new DirectoryLister(); @Test - void listSkillDirectories_returnsOnlyTopLevelFlamingockDirectories() throws Exception { + void listDirectories_returnsOnlyTopLevelMatchingDirectories() throws Exception { Path snapshotRoot = tempDir.resolve("flamingock-skills-master"); Files.createDirectories(snapshotRoot.resolve("flamingock-core")); Files.createDirectories(snapshotRoot.resolve("flamingock-java")); Files.createDirectories(snapshotRoot.resolve("docs")); Files.writeString(snapshotRoot.resolve("README.md"), "readme"); - Files.writeString(snapshotRoot.resolve(".gitignore"), "*"); - List skillDirectories = enumerator.listSkillDirectories(snapshotRoot); + List skillDirectories = directoryLister.listDirectories( + snapshotRoot, + path -> path.getFileName().toString().startsWith("flamingock-") + ); assertEquals(List.of( snapshotRoot.resolve("flamingock-core"), @@ -49,12 +51,15 @@ void listSkillDirectories_returnsOnlyTopLevelFlamingockDirectories() throws Exce } @Test - void listSkillDirectories_ignoresNestedFlamingockDirectoriesOutsideTopLevel() throws Exception { + void listDirectories_ignoresNestedMatchingDirectoriesOutsideTopLevel() throws Exception { Path snapshotRoot = tempDir.resolve("flamingock-skills-master"); Files.createDirectories(snapshotRoot.resolve("docs").resolve("flamingock-not-a-skill")); Files.createDirectories(snapshotRoot.resolve("flamingock-core")); - List skillDirectories = enumerator.listSkillDirectories(snapshotRoot); + List skillDirectories = directoryLister.listDirectories( + snapshotRoot, + path -> path.getFileName().toString().startsWith("flamingock-") + ); assertEquals(List.of(snapshotRoot.resolve("flamingock-core")), skillDirectories); } diff --git a/src/test/java/io/flamingock/cli/executor/filesystem/DirectoryReplacerTest.java b/src/test/java/io/flamingock/cli/executor/filesystem/DirectoryReplacerTest.java new file mode 100644 index 0000000..8031997 --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/filesystem/DirectoryReplacerTest.java @@ -0,0 +1,60 @@ +/* + * 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.filesystem; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DirectoryReplacerTest { + + @TempDir + Path tempDir; + + private final DirectoryReplacer replacer = new DirectoryReplacer(); + + @Test + void replaceDirectory_deletesExistingDestinationBeforeCopyingFreshTree() throws Exception { + Path destinationDirectory = Files.createDirectories(tempDir.resolve(".agents").resolve("skills").resolve("flamingock-core")); + Files.writeString(destinationDirectory.resolve("old-file.txt"), "old"); + Path sourceDirectory = Files.createDirectories(tempDir.resolve("source").resolve("flamingock-core")); + Files.writeString(sourceDirectory.resolve("SKILL.md"), "new"); + + replacer.replaceDirectory(sourceDirectory, destinationDirectory); + + assertFalse(Files.exists(destinationDirectory.resolve("old-file.txt"))); + assertEquals("new", Files.readString(destinationDirectory.resolve("SKILL.md"))); + } + + @Test + void replaceDirectory_preservesSiblingFoldersOutsideDestination() throws Exception { + Path skillsRoot = Files.createDirectories(tempDir.resolve(".agents").resolve("skills")); + Files.createDirectories(skillsRoot.resolve("my-custom-skill")); + Path sourceDirectory = Files.createDirectories(tempDir.resolve("source").resolve("flamingock-java")); + Files.writeString(sourceDirectory.resolve("SKILL.md"), "java"); + + replacer.replaceDirectory(sourceDirectory, skillsRoot.resolve("flamingock-java")); + + assertTrue(Files.isDirectory(skillsRoot.resolve("my-custom-skill"))); + assertTrue(Files.exists(skillsRoot.resolve("flamingock-java").resolve("SKILL.md"))); + } +} diff --git a/src/test/java/io/flamingock/cli/executor/skills/InstallDestinationResolverTest.java b/src/test/java/io/flamingock/cli/executor/filesystem/DirectoryResolverTest.java similarity index 76% rename from src/test/java/io/flamingock/cli/executor/skills/InstallDestinationResolverTest.java rename to src/test/java/io/flamingock/cli/executor/filesystem/DirectoryResolverTest.java index 1606b57..1d01cfd 100644 --- a/src/test/java/io/flamingock/cli/executor/skills/InstallDestinationResolverTest.java +++ b/src/test/java/io/flamingock/cli/executor/filesystem/DirectoryResolverTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.skills; +package io.flamingock.cli.executor.filesystem; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -25,16 +25,16 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -class InstallDestinationResolverTest { +class DirectoryResolverTest { @TempDir Path tempDir; - private final InstallDestinationResolver resolver = new InstallDestinationResolver(); + private final DirectoryResolver resolver = new DirectoryResolver(); @Test - void resolveLocal_createsMissingAgentsSkillsTree() throws Exception { - Path destination = resolver.resolveLocal(tempDir); + void resolveDirectory_createsMissingNestedTree() throws Exception { + Path destination = resolver.resolveDirectory(tempDir, ".agents", "skills"); assertEquals(tempDir.resolve(".agents").resolve("skills"), destination); assertTrue(Files.isDirectory(tempDir.resolve(".agents"))); @@ -42,23 +42,23 @@ void resolveLocal_createsMissingAgentsSkillsTree() throws Exception { } @Test - void resolveLocal_failsWhenAgentsPathIsAFile() throws Exception { + void resolveDirectory_failsWhenIntermediatePathIsAFile() throws Exception { Path agentsFile = Files.writeString(tempDir.resolve(".agents"), "not a directory"); IllegalStateException exception = assertThrows(IllegalStateException.class, - () -> resolver.resolveLocal(tempDir)); + () -> resolver.resolveDirectory(tempDir, ".agents", "skills")); assertTrue(exception.getMessage().contains(agentsFile.toString())); assertTrue(exception.getMessage().contains("Delete or rename")); } @Test - void resolveLocal_failsWhenSkillsPathIsAFile() throws Exception { + void resolveDirectory_failsWhenFinalPathIsAFile() throws Exception { Files.createDirectories(tempDir.resolve(".agents")); Path skillsFile = Files.writeString(tempDir.resolve(".agents").resolve("skills"), "not a directory"); IllegalStateException exception = assertThrows(IllegalStateException.class, - () -> resolver.resolveLocal(tempDir)); + () -> resolver.resolveDirectory(tempDir, ".agents", "skills")); assertTrue(exception.getMessage().contains(skillsFile.toString())); assertTrue(exception.getMessage().contains("Delete or rename")); diff --git a/src/test/java/io/flamingock/cli/executor/skills/SkillsArchiveDownloaderTest.java b/src/test/java/io/flamingock/cli/executor/http/HttpFileDownloaderTest.java similarity index 58% rename from src/test/java/io/flamingock/cli/executor/skills/SkillsArchiveDownloaderTest.java rename to src/test/java/io/flamingock/cli/executor/http/HttpFileDownloaderTest.java index 04a204d..5150346 100644 --- a/src/test/java/io/flamingock/cli/executor/skills/SkillsArchiveDownloaderTest.java +++ b/src/test/java/io/flamingock/cli/executor/http/HttpFileDownloaderTest.java @@ -13,41 +13,46 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.skills; +package io.flamingock.cli.executor.http; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import javax.net.ssl.SSLSession; import java.io.IOException; import java.net.URI; import java.net.http.HttpHeaders; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.time.Duration; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; import java.util.Optional; -import javax.net.ssl.SSLSession; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -class SkillsArchiveDownloaderTest { +class HttpFileDownloaderTest { + + private static final URI SOURCE_URI = URI.create("https://example.com/release.zip"); + private static final String TARGET_FILE_NAME = "download.zip"; + private static final String USER_AGENT = "Flamingock CLI/1.0 install-skills"; + private static final String DOWNLOAD_LABEL = "official Flamingock skills"; @TempDir Path tempDir; @Test - void downloadTo_usesOfficialArchiveUrlAndReturnsDownloadedFile() throws Exception { + void downloadTo_usesProvidedArchiveSettingsAndReturnsDownloadedFile() throws Exception { RecordingDownloadExecutor executor = new RecordingDownloadExecutor(200); - SkillsArchiveDownloader downloader = new SkillsArchiveDownloader(executor); + HttpFileDownloader downloader = new HttpFileDownloader(Duration.ofSeconds(10), Duration.ofSeconds(30), executor); - Path archive = downloader.downloadTo(tempDir.resolve("workspace")); + Path archive = downloader.downloadTo(tempDir.resolve("workspace"), SOURCE_URI, TARGET_FILE_NAME, USER_AGENT, DOWNLOAD_LABEL); - assertEquals(URI.create(SkillsArchiveDownloader.OFFICIAL_SKILLS_ARCHIVE_URL), executor.lastRequest.uri()); - assertEquals(tempDir.resolve("workspace").resolve("flamingock-skills.zip"), archive); + assertEquals(SOURCE_URI, executor.lastRequest.uri()); + assertEquals(tempDir.resolve("workspace").resolve(TARGET_FILE_NAME), archive); assertTrue(Files.exists(archive)); assertEquals("zip-content", Files.readString(archive)); } @@ -55,66 +60,77 @@ void downloadTo_usesOfficialArchiveUrlAndReturnsDownloadedFile() throws Exceptio @Test void downloadTo_deletesPartialArchiveWhenServerReturnsFailure() { RecordingDownloadExecutor executor = new RecordingDownloadExecutor(503); - SkillsArchiveDownloader downloader = new SkillsArchiveDownloader(executor); + HttpFileDownloader downloader = new HttpFileDownloader(Duration.ofSeconds(10), Duration.ofSeconds(30), executor); Path workspace = tempDir.resolve("workspace"); - IOException exception = assertThrows(IOException.class, () -> downloader.downloadTo(workspace)); + IOException exception = assertThrows(IOException.class, + () -> downloader.downloadTo(workspace, SOURCE_URI, TARGET_FILE_NAME, USER_AGENT, DOWNLOAD_LABEL)); + assertTrue(exception.getMessage().contains(DOWNLOAD_LABEL)); assertTrue(exception.getMessage().contains("HTTP 503")); - assertFalse(Files.exists(workspace.resolve("flamingock-skills.zip"))); + assertFalse(Files.exists(workspace.resolve(TARGET_FILE_NAME))); } @Test void downloadTo_setsReasonableTimeoutsAndUserAgent() throws Exception { RecordingDownloadExecutor executor = new RecordingDownloadExecutor(200); - SkillsArchiveDownloader downloader = new SkillsArchiveDownloader(executor); + HttpFileDownloader downloader = new HttpFileDownloader(Duration.ofSeconds(10), Duration.ofSeconds(30), executor); - downloader.downloadTo(tempDir.resolve("workspace")); + downloader.downloadTo(tempDir.resolve("workspace"), SOURCE_URI, TARGET_FILE_NAME, USER_AGENT, DOWNLOAD_LABEL); + assertEquals(Duration.ofSeconds(10), executor.connectTimeout); assertEquals(Duration.ofSeconds(30), executor.lastRequest.timeout().orElseThrow()); - assertEquals("Flamingock CLI/1.0 install-skills", executor.lastRequest.headers().firstValue("User-Agent").orElseThrow()); + assertEquals(USER_AGENT, executor.lastRequest.headers().firstValue("User-Agent").orElseThrow()); } @Test void downloadTo_deletesPartialArchiveWhenDownloadThrowsIoException() { - SkillsArchiveDownloader downloader = new SkillsArchiveDownloader((request, target) -> { + HttpFileDownloader downloader = new HttpFileDownloader(Duration.ofSeconds(10), Duration.ofSeconds(30), (request, target, connectTimeout) -> { Files.createDirectories(target.getParent()); Files.writeString(target, "partial"); throw new IOException("socket closed"); }); Path workspace = tempDir.resolve("workspace"); - IOException exception = assertThrows(IOException.class, () -> downloader.downloadTo(workspace)); + IOException exception = assertThrows(IOException.class, + () -> downloader.downloadTo(workspace, SOURCE_URI, TARGET_FILE_NAME, USER_AGENT, DOWNLOAD_LABEL)); - assertEquals("socket closed", exception.getMessage()); - assertFalse(Files.exists(workspace.resolve("flamingock-skills.zip"))); + assertTrue(exception.getMessage().contains(DOWNLOAD_LABEL)); + assertTrue(exception.getMessage().contains("socket closed")); + assertEquals("socket closed", exception.getCause().getMessage()); + assertFalse(Files.exists(workspace.resolve(TARGET_FILE_NAME))); } @Test void downloadTo_wrapsInterruptedDownloadsWithActionableMessage() { - SkillsArchiveDownloader downloader = new SkillsArchiveDownloader((request, target) -> { - throw new InterruptedException("cancelled"); - }); + HttpFileDownloader downloader = new HttpFileDownloader(Duration.ofSeconds(10), Duration.ofSeconds(30), + (request, target, connectTimeout) -> { + throw new InterruptedException("cancelled"); + }); - IOException exception = assertThrows(IOException.class, () -> downloader.downloadTo(tempDir.resolve("workspace"))); + IOException exception = assertThrows(IOException.class, + () -> downloader.downloadTo(tempDir.resolve("workspace"), SOURCE_URI, TARGET_FILE_NAME, USER_AGENT, DOWNLOAD_LABEL)); + assertTrue(exception.getMessage().contains("official Flamingock skills")); assertTrue(exception.getMessage().contains("interrupted")); assertTrue(Thread.currentThread().isInterrupted()); Thread.interrupted(); } - private static final class RecordingDownloadExecutor implements SkillsArchiveDownloader.DownloadExecutor { + private static final class RecordingDownloadExecutor implements HttpFileDownloader.DownloadExecutor { private final int statusCode; private HttpRequest lastRequest; + private Duration connectTimeout; private RecordingDownloadExecutor(int statusCode) { this.statusCode = statusCode; } @Override - public HttpResponse download(HttpRequest request, Path target) throws IOException { + public HttpResponse download(HttpRequest request, Path target, Duration connectTimeout) throws IOException { this.lastRequest = request; + this.connectTimeout = connectTimeout; Files.createDirectories(target.getParent()); Files.writeString(target, "zip-content"); return new HttpResponseStub(statusCode, target, request); diff --git a/src/test/java/io/flamingock/cli/executor/skills/InstallModeResolverTest.java b/src/test/java/io/flamingock/cli/executor/skills/InstallModeResolverTest.java deleted file mode 100644 index f5d23ed..0000000 --- a/src/test/java/io/flamingock/cli/executor/skills/InstallModeResolverTest.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 static org.junit.jupiter.api.Assertions.assertEquals; - -class InstallModeResolverTest { - - private final InstallModeResolver resolver = new InstallModeResolver(); - - @Test - void resolve_returnsLocalWhenGlobalFlagIsFalse() { - assertEquals(InstallMode.LOCAL, resolver.resolve(false)); - } - - @Test - void resolve_returnsGlobalWhenGlobalFlagIsTrue() { - assertEquals(InstallMode.GLOBAL, resolver.resolve(true)); - } -} diff --git a/src/test/java/io/flamingock/cli/executor/skills/SkillDirectoryReplacerTest.java b/src/test/java/io/flamingock/cli/executor/skills/SkillDirectoryReplacerTest.java deleted file mode 100644 index a5f30e5..0000000 --- a/src/test/java/io/flamingock/cli/executor/skills/SkillDirectoryReplacerTest.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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 org.junit.jupiter.api.io.TempDir; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class SkillDirectoryReplacerTest { - - @TempDir - Path tempDir; - - private final SkillDirectoryReplacer replacer = new SkillDirectoryReplacer(); - - @Test - void replaceSkill_deletesExistingSkillBeforeCopyingFreshTree() throws Exception { - Path destinationSkillsDir = Files.createDirectories(tempDir.resolve(".agents").resolve("skills")); - Path existingSkillDir = Files.createDirectories(destinationSkillsDir.resolve("flamingock-core")); - Files.writeString(existingSkillDir.resolve("old-file.txt"), "old"); - Path sourceSkillDir = Files.createDirectories(tempDir.resolve("source").resolve("flamingock-core")); - Files.writeString(sourceSkillDir.resolve("SKILL.md"), "new"); - - replacer.replaceSkill(sourceSkillDir, destinationSkillsDir); - - assertFalse(Files.exists(destinationSkillsDir.resolve("flamingock-core").resolve("old-file.txt"))); - assertEquals("new", Files.readString(destinationSkillsDir.resolve("flamingock-core").resolve("SKILL.md"))); - } - - @Test - void replaceSkill_preservesOtherUserCreatedFolders() throws Exception { - Path destinationSkillsDir = Files.createDirectories(tempDir.resolve(".agents").resolve("skills")); - Files.createDirectories(destinationSkillsDir.resolve("my-custom-skill")); - Path sourceSkillDir = Files.createDirectories(tempDir.resolve("source").resolve("flamingock-java")); - Files.writeString(sourceSkillDir.resolve("SKILL.md"), "java"); - - replacer.replaceSkill(sourceSkillDir, destinationSkillsDir); - - assertTrue(Files.isDirectory(destinationSkillsDir.resolve("my-custom-skill"))); - assertTrue(Files.exists(destinationSkillsDir.resolve("flamingock-java").resolve("SKILL.md"))); - } -} 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 3684217..3630a3a 100644 --- a/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationPipelineTest.java +++ b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationPipelineTest.java @@ -15,10 +15,16 @@ */ package io.flamingock.cli.executor.skills; +import io.flamingock.cli.executor.filesystem.DirectoryReplacer; +import io.flamingock.cli.executor.filesystem.FileSystemUtils; +import io.flamingock.cli.executor.archive.ZipArchiveExtractor; +import io.flamingock.cli.executor.filesystem.DirectoryLister; +import io.flamingock.cli.executor.http.HttpFileDownloader; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import java.io.IOException; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -38,53 +44,80 @@ class SkillsInstallationPipelineTest { void install_runsStagesInOrderAndCleansWorkspace() throws Exception { List events = new ArrayList<>(); Path destination = Files.createDirectories(tempDir.resolve(".agents").resolve("skills")); + InstallationTarget target = InstallationTarget.local(destination); Path downloadedArchive = tempDir.resolve("downloaded.zip"); Path snapshotRoot = Files.createDirectories(tempDir.resolve("snapshot")); Path firstSkill = Files.createDirectories(snapshotRoot.resolve("flamingock-core")); Path secondSkill = Files.createDirectories(snapshotRoot.resolve("flamingock-java")); SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( - workspace -> { - events.add("download"); - Files.writeString(downloadedArchive, "zip"); - return downloadedArchive; + new HttpFileDownloader() { + @Override + public Path downloadTo(Path workspace, URI sourceUri, String targetFileName, String userAgent, String downloadLabel) + throws IOException { + events.add("download"); + Files.writeString(downloadedArchive, "zip"); + return downloadedArchive; + } }, - (archive, workspace) -> { - events.add("extract"); - return snapshotRoot; + new ZipArchiveExtractor() { + @Override + public Path extractSingleRootDirectory(Path archive, Path workspace, String extractionDirectoryName, + String archiveDescription) { + events.add("extract"); + return snapshotRoot; + } }, - root -> { - events.add("enumerate"); - return List.of(firstSkill, secondSkill); + new DirectoryLister() { + @Override + public List listDirectories(Path root, java.util.function.Predicate filter) { + events.add("enumerate"); + return List.of(firstSkill, secondSkill); + } + }, + new DirectoryReplacer() { + @Override + public void replaceDirectory(Path sourceSkillDir, Path destinationSkillDir) { + events.add("replace:" + sourceSkillDir.getFileName()); + } }, - (sourceSkillDir, destinationSkillsDir) -> events.add("replace:" + sourceSkillDir.getFileName()), () -> tempDir.resolve("workspace"), - SkillsFileUtils::deleteRecursively + FileSystemUtils::deleteRecursively ); - SkillsInstallationResult result = pipeline.install(destination); + SkillsInstallationResult result = pipeline.install(List.of(target)); assertEquals(List.of("download", "extract", "enumerate", "replace:flamingock-core", "replace:flamingock-java"), events); assertEquals(List.of("flamingock-core", "flamingock-java"), result.installedSkills()); + assertEquals(List.of(target), result.targets()); assertFalse(Files.exists(tempDir.resolve("workspace"))); } @Test void install_cleansWorkspaceWhenStageFails() { + InstallationTarget target = InstallationTarget.local(tempDir.resolve(".agents").resolve("skills")); SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( - workspace -> tempDir.resolve("downloaded.zip"), - (archive, workspace) -> { - throw new IOException("boom"); + new HttpFileDownloader() { + @Override + public Path downloadTo(Path workspace, URI sourceUri, String targetFileName, String userAgent, String downloadLabel) { + return tempDir.resolve("downloaded.zip"); + } }, - root -> List.of(), - (sourceSkillDir, destinationSkillsDir) -> { + new ZipArchiveExtractor() { + @Override + public Path extractSingleRootDirectory(Path archive, Path workspace, String extractionDirectoryName, + String archiveDescription) throws IOException { + throw new IOException("boom"); + } }, + new DirectoryLister(), + new DirectoryReplacer(), () -> tempDir.resolve("workspace"), - SkillsFileUtils::deleteRecursively + FileSystemUtils::deleteRecursively ); IllegalStateException exception = assertThrows(IllegalStateException.class, - () -> pipeline.install(tempDir.resolve(".agents").resolve("skills"))); + () -> pipeline.install(List.of(target))); assertTrue(exception.getMessage().contains("Failed to install Flamingock skills")); assertFalse(Files.exists(tempDir.resolve("workspace"))); @@ -94,15 +127,24 @@ void install_cleansWorkspaceWhenStageFails() { void install_preservesOriginalFailureWhenCleanupAlsoFails() throws Exception { Path workspace = Files.createDirectories(tempDir.resolve("workspace")); Files.writeString(workspace.resolve("stubborn.txt"), "keep"); + InstallationTarget target = InstallationTarget.local(tempDir.resolve(".agents").resolve("skills")); SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( - stageWorkspace -> tempDir.resolve("downloaded.zip"), - (archive, stageWorkspace) -> { - throw new IOException("download exploded"); + new HttpFileDownloader() { + @Override + public Path downloadTo(Path workspace, URI sourceUri, String targetFileName, String userAgent, String downloadLabel) { + return tempDir.resolve("downloaded.zip"); + } }, - root -> List.of(), - (sourceSkillDir, destinationSkillsDir) -> { + new ZipArchiveExtractor() { + @Override + public Path extractSingleRootDirectory(Path archive, Path workspace, String extractionDirectoryName, + String archiveDescription) throws IOException { + throw new IOException("download exploded"); + } }, + new DirectoryLister(), + new DirectoryReplacer(), () -> workspace, path -> { throw new IllegalStateException("cleanup exploded"); @@ -110,7 +152,7 @@ void install_preservesOriginalFailureWhenCleanupAlsoFails() throws Exception { ); IllegalStateException exception = assertThrows(IllegalStateException.class, - () -> pipeline.install(tempDir.resolve(".agents").resolve("skills"))); + () -> pipeline.install(List.of(target))); assertTrue(exception.getMessage().contains("download exploded")); assertEquals(1, exception.getSuppressed().length); @@ -120,6 +162,7 @@ void install_preservesOriginalFailureWhenCleanupAlsoFails() throws Exception { @Test void install_replacesOnlyEnumeratedOfficialSkillsAndPreservesCustomFolders() throws Exception { Path destination = Files.createDirectories(tempDir.resolve(".agents").resolve("skills")); + InstallationTarget target = InstallationTarget.local(destination); Path existingOfficialSkill = Files.createDirectories(destination.resolve("flamingock-core")); Files.writeString(existingOfficialSkill.resolve("old.txt"), "old"); Path customSkill = Files.createDirectories(destination.resolve("my-custom-skill")); @@ -130,19 +173,90 @@ void install_replacesOnlyEnumeratedOfficialSkillsAndPreservesCustomFolders() thr Files.writeString(officialSkill.resolve("SKILL.md"), "fresh"); SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( - workspace -> tempDir.resolve("downloaded.zip"), - (archive, workspace) -> snapshotRoot, - root -> List.of(officialSkill), - new SkillDirectoryReplacer()::replaceSkill, + 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(officialSkill); + } + }, + new DirectoryReplacer(), () -> tempDir.resolve("workspace"), - SkillsFileUtils::deleteRecursively + FileSystemUtils::deleteRecursively ); - SkillsInstallationResult result = pipeline.install(destination); + SkillsInstallationResult result = pipeline.install(List.of(target)); assertEquals(List.of("flamingock-core"), result.installedSkills()); assertFalse(Files.exists(destination.resolve("flamingock-core").resolve("old.txt"))); assertEquals("fresh", Files.readString(destination.resolve("flamingock-core").resolve("SKILL.md"))); assertEquals("custom", Files.readString(destination.resolve("my-custom-skill").resolve("SKILL.md"))); } + + @Test + void install_downloadsAndExtractsOnceForMultipleTargets() throws Exception { + List events = new ArrayList<>(); + InstallationTarget firstTarget = new InstallationTarget("local", Files.createDirectories(tempDir.resolve("project-a/.agents/skills"))); + InstallationTarget secondTarget = new InstallationTarget("secondary", Files.createDirectories(tempDir.resolve("project-b/.agents/skills"))); + Path snapshotRoot = Files.createDirectories(tempDir.resolve("snapshot")); + Path firstSkill = Files.createDirectories(snapshotRoot.resolve("flamingock-core")); + Path secondSkill = Files.createDirectories(snapshotRoot.resolve("flamingock-java")); + + SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( + new HttpFileDownloader() { + @Override + public Path downloadTo(Path workspace, URI sourceUri, String targetFileName, String userAgent, String downloadLabel) + throws IOException { + events.add("download"); + return Files.writeString(tempDir.resolve("downloaded.zip"), "zip"); + } + }, + new ZipArchiveExtractor() { + @Override + public Path extractSingleRootDirectory(Path archive, Path workspace, String extractionDirectoryName, + String archiveDescription) { + events.add("extract"); + return snapshotRoot; + } + }, + new DirectoryLister() { + @Override + public List listDirectories(Path root, java.util.function.Predicate filter) { + events.add("enumerate"); + return List.of(firstSkill, secondSkill); + } + }, + new DirectoryReplacer() { + @Override + public void replaceDirectory(Path sourceSkillDir, Path destinationSkillDir) { + events.add(destinationSkillDir.toString()); + } + }, + () -> tempDir.resolve("workspace"), + FileSystemUtils::deleteRecursively + ); + + SkillsInstallationResult result = pipeline.install(List.of(firstTarget, secondTarget)); + + assertEquals(List.of(firstTarget, secondTarget), result.targets()); + assertEquals(List.of("flamingock-core", "flamingock-java"), result.installedSkills()); + assertEquals(List.of("download", "extract", "enumerate"), events.subList(0, 3)); + assertEquals(7, events.size()); + assertTrue(events.contains(firstTarget.destinationSkillsDir().resolve("flamingock-core").toString())); + assertTrue(events.contains(firstTarget.destinationSkillsDir().resolve("flamingock-java").toString())); + assertTrue(events.contains(secondTarget.destinationSkillsDir().resolve("flamingock-core").toString())); + assertTrue(events.contains(secondTarget.destinationSkillsDir().resolve("flamingock-java").toString())); + } } diff --git a/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolverTest.java b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolverTest.java new file mode 100644 index 0000000..f9bc199 --- /dev/null +++ b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolverTest.java @@ -0,0 +1,82 @@ +/* + * 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 io.flamingock.cli.executor.filesystem.DirectoryResolver; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SkillsInstallationTargetResolverTest { + + @TempDir + Path tempDir; + + @Test + void resolveTargets_localModeResolvesSingleProjectTarget() { + RecordingDirectoryResolver directoryResolver = new RecordingDirectoryResolver(tempDir.resolve(".agents/skills")); + SkillsInstallationTargetResolver resolver = new SkillsInstallationTargetResolver(directoryResolver); + + List targets = resolver.resolveTargets(tempDir, false); + + assertTrue(directoryResolver.called); + assertEquals(tempDir, directoryResolver.workingDirectory); + assertArrayEquals(new String[]{".agents", "skills"}, directoryResolver.segments); + assertEquals(List.of(InstallationTarget.local(tempDir.resolve(".agents/skills"))), targets); + } + + @Test + void resolveTargets_globalModeFailsWithoutCreatingDirectories() { + RecordingDirectoryResolver directoryResolver = new RecordingDirectoryResolver(tempDir.resolve(".agents/skills")); + SkillsInstallationTargetResolver resolver = new SkillsInstallationTargetResolver(directoryResolver); + + IllegalStateException exception = assertThrows(IllegalStateException.class, + () -> resolver.resolveTargets(tempDir, true)); + + assertFalse(directoryResolver.called); + assertFalse(Files.exists(tempDir.resolve(".agents"))); + assertTrue(exception.getMessage().contains("not implemented yet")); + } + + private static final class RecordingDirectoryResolver extends DirectoryResolver { + + private final Path destination; + private boolean called; + private Path workingDirectory; + private String[] segments; + + private RecordingDirectoryResolver(Path destination) { + this.destination = destination; + } + + @Override + public Path resolveDirectory(Path workingDirectory, String... segments) { + this.called = true; + this.workingDirectory = workingDirectory; + this.segments = segments; + return destination; + } + } +} From af796847a4514313c8df30d2de4c4a5b1e91b65e Mon Sep 17 00:00:00 2001 From: bercianor Date: Wed, 27 May 2026 12:11:37 +0200 Subject: [PATCH 3/3] refactor: move http, filesystem and archive to util, and rename InstallationTarget --- .../command/InstallSkillsCommand.java | 4 ++-- .../skills/SkillsInstallationPipeline.java | 16 +++++++------- .../skills/SkillsInstallationResult.java | 2 +- ...get.java => SkillsInstallationTarget.java} | 8 +++---- .../SkillsInstallationTargetResolver.java | 6 ++--- .../archive/ZipArchiveExtractor.java | 2 +- .../filesystem/DirectoryLister.java | 2 +- .../filesystem/DirectoryReplacer.java | 2 +- .../filesystem/DirectoryResolver.java | 2 +- .../filesystem/FileSystemUtils.java | 2 +- .../filesystem/TemporaryDirectoryFactory.java | 2 +- .../{ => util}/http/HttpFileDownloader.java | 2 +- .../command/InstallSkillsCommandTest.java | 22 +++++++++---------- .../SkillsInstallationPipelineTest.java | 22 +++++++++---------- .../SkillsInstallationTargetResolverTest.java | 6 ++--- .../archive/ZipArchiveExtractorTest.java | 2 +- .../filesystem/DirectoryListerTest.java | 2 +- .../filesystem/DirectoryReplacerTest.java | 2 +- .../filesystem/DirectoryResolverTest.java | 2 +- .../http/HttpFileDownloaderTest.java | 2 +- 20 files changed, 55 insertions(+), 55 deletions(-) rename src/main/java/io/flamingock/cli/executor/skills/{InstallationTarget.java => SkillsInstallationTarget.java} (82%) rename src/main/java/io/flamingock/cli/executor/{ => util}/archive/ZipArchiveExtractor.java (98%) rename src/main/java/io/flamingock/cli/executor/{ => util}/filesystem/DirectoryLister.java (96%) rename src/main/java/io/flamingock/cli/executor/{ => util}/filesystem/DirectoryReplacer.java (97%) rename src/main/java/io/flamingock/cli/executor/{ => util}/filesystem/DirectoryResolver.java (97%) rename src/main/java/io/flamingock/cli/executor/{ => util}/filesystem/FileSystemUtils.java (96%) rename src/main/java/io/flamingock/cli/executor/{ => util}/filesystem/TemporaryDirectoryFactory.java (95%) rename src/main/java/io/flamingock/cli/executor/{ => util}/http/HttpFileDownloader.java (98%) rename src/test/java/io/flamingock/cli/executor/{ => util}/archive/ZipArchiveExtractorTest.java (98%) rename src/test/java/io/flamingock/cli/executor/{ => util}/filesystem/DirectoryListerTest.java (97%) rename src/test/java/io/flamingock/cli/executor/{ => util}/filesystem/DirectoryReplacerTest.java (98%) rename src/test/java/io/flamingock/cli/executor/{ => util}/filesystem/DirectoryResolverTest.java (97%) rename src/test/java/io/flamingock/cli/executor/{ => util}/http/HttpFileDownloaderTest.java (99%) 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 1ed72dc..33c3fe2 100644 --- a/src/main/java/io/flamingock/cli/executor/command/InstallSkillsCommand.java +++ b/src/main/java/io/flamingock/cli/executor/command/InstallSkillsCommand.java @@ -16,7 +16,7 @@ package io.flamingock.cli.executor.command; import io.flamingock.cli.executor.output.ConsoleFormatter; -import io.flamingock.cli.executor.skills.InstallationTarget; +import io.flamingock.cli.executor.skills.SkillsInstallationTarget; import io.flamingock.cli.executor.skills.SkillsInstallationPipeline; import io.flamingock.cli.executor.skills.SkillsInstallationResult; import io.flamingock.cli.executor.skills.SkillsInstallationTargetResolver; @@ -69,7 +69,7 @@ public InstallSkillsCommand() { @Override public Integer call() { try { - List targets = targetResolver.resolveTargets(workingDirectory.toAbsolutePath().normalize(), global); + List targets = targetResolver.resolveTargets(workingDirectory.toAbsolutePath().normalize(), global); 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 61c2be2..ff8cd66 100644 --- a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationPipeline.java +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationPipeline.java @@ -15,12 +15,12 @@ */ package io.flamingock.cli.executor.skills; -import io.flamingock.cli.executor.archive.ZipArchiveExtractor; -import io.flamingock.cli.executor.filesystem.DirectoryLister; -import io.flamingock.cli.executor.filesystem.DirectoryReplacer; -import io.flamingock.cli.executor.filesystem.FileSystemUtils; -import io.flamingock.cli.executor.filesystem.TemporaryDirectoryFactory; -import io.flamingock.cli.executor.http.HttpFileDownloader; +import io.flamingock.cli.executor.util.archive.ZipArchiveExtractor; +import io.flamingock.cli.executor.util.filesystem.DirectoryLister; +import io.flamingock.cli.executor.util.filesystem.DirectoryReplacer; +import io.flamingock.cli.executor.util.filesystem.FileSystemUtils; +import io.flamingock.cli.executor.util.filesystem.TemporaryDirectoryFactory; +import io.flamingock.cli.executor.util.http.HttpFileDownloader; import java.io.IOException; import java.net.URI; @@ -84,7 +84,7 @@ public SkillsInstallationPipeline() { * @param targets resolved installation targets * @return installation result summary */ - public SkillsInstallationResult install(List targets) { + public SkillsInstallationResult install(List targets) { Objects.requireNonNull(targets, "targets must not be null"); if (targets.isEmpty()) { throw new IllegalStateException("No skills installation targets were resolved. Choose a destination and retry."); @@ -114,7 +114,7 @@ public SkillsInstallationResult install(List targets) { for (Path skillDirectory : skillDirectories) { installedSkills.add(skillDirectory.getFileName().toString()); } - for (InstallationTarget target : targets) { + for (SkillsInstallationTarget target : targets) { for (Path skillDirectory : skillDirectories) { Path destinationSkillDirectory = target.destinationSkillsDir().resolve(skillDirectory.getFileName().toString()); replacer.replaceDirectory(skillDirectory, destinationSkillDirectory); diff --git a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationResult.java b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationResult.java index ce933e6..a98b53c 100644 --- a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationResult.java +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationResult.java @@ -24,7 +24,7 @@ * @param targets installation targets that received the installed skills * @param installedSkills installed official skill folder names */ -public record SkillsInstallationResult(List targets, List installedSkills) { +public record SkillsInstallationResult(List targets, List installedSkills) { public SkillsInstallationResult { targets = List.copyOf(targets); diff --git a/src/main/java/io/flamingock/cli/executor/skills/InstallationTarget.java b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTarget.java similarity index 82% rename from src/main/java/io/flamingock/cli/executor/skills/InstallationTarget.java rename to src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTarget.java index 065f14f..0fad640 100644 --- a/src/main/java/io/flamingock/cli/executor/skills/InstallationTarget.java +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTarget.java @@ -24,9 +24,9 @@ * @param identifier stable identifier for the target * @param destinationSkillsDir destination directory that will receive the skills */ -public record InstallationTarget(String identifier, Path destinationSkillsDir) { +public record SkillsInstallationTarget(String identifier, Path destinationSkillsDir) { - public InstallationTarget { + public SkillsInstallationTarget { identifier = Objects.requireNonNull(identifier, "identifier must not be null"); destinationSkillsDir = Objects.requireNonNull(destinationSkillsDir, "destinationSkillsDir must not be null"); } @@ -37,7 +37,7 @@ public record InstallationTarget(String identifier, Path destinationSkillsDir) { * @param destinationSkillsDir local destination directory * @return local installation target */ - public static InstallationTarget local(Path destinationSkillsDir) { - return new InstallationTarget("local", destinationSkillsDir); + public static SkillsInstallationTarget local(Path destinationSkillsDir) { + return new SkillsInstallationTarget("local", 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 8009004..34ec77d 100644 --- a/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolver.java +++ b/src/main/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolver.java @@ -15,7 +15,7 @@ */ package io.flamingock.cli.executor.skills; -import io.flamingock.cli.executor.filesystem.DirectoryResolver; +import io.flamingock.cli.executor.util.filesystem.DirectoryResolver; import java.nio.file.Path; import java.util.List; @@ -46,12 +46,12 @@ public SkillsInstallationTargetResolver() { * @param global whether global mode was requested * @return resolved installation targets */ - public List resolveTargets(Path workingDirectory, boolean global) { + public List resolveTargets(Path workingDirectory, boolean global) { if (global) { throw new IllegalStateException(GLOBAL_MODE_NOT_IMPLEMENTED); } Path destination = directoryResolver.resolveDirectory(workingDirectory, LOCAL_SKILLS_PATH); - return List.of(InstallationTarget.local(destination)); + return List.of(SkillsInstallationTarget.local(destination)); } } diff --git a/src/main/java/io/flamingock/cli/executor/archive/ZipArchiveExtractor.java b/src/main/java/io/flamingock/cli/executor/util/archive/ZipArchiveExtractor.java similarity index 98% rename from src/main/java/io/flamingock/cli/executor/archive/ZipArchiveExtractor.java rename to src/main/java/io/flamingock/cli/executor/util/archive/ZipArchiveExtractor.java index d782433..2be99ad 100644 --- a/src/main/java/io/flamingock/cli/executor/archive/ZipArchiveExtractor.java +++ b/src/main/java/io/flamingock/cli/executor/util/archive/ZipArchiveExtractor.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.archive; +package io.flamingock.cli.executor.util.archive; import java.io.IOException; import java.io.InputStream; diff --git a/src/main/java/io/flamingock/cli/executor/filesystem/DirectoryLister.java b/src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryLister.java similarity index 96% rename from src/main/java/io/flamingock/cli/executor/filesystem/DirectoryLister.java rename to src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryLister.java index 1c089a8..a06d75f 100644 --- a/src/main/java/io/flamingock/cli/executor/filesystem/DirectoryLister.java +++ b/src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryLister.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.filesystem; +package io.flamingock.cli.executor.util.filesystem; import java.io.IOException; import java.nio.file.Files; diff --git a/src/main/java/io/flamingock/cli/executor/filesystem/DirectoryReplacer.java b/src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryReplacer.java similarity index 97% rename from src/main/java/io/flamingock/cli/executor/filesystem/DirectoryReplacer.java rename to src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryReplacer.java index a63e9d5..c64cfe8 100644 --- a/src/main/java/io/flamingock/cli/executor/filesystem/DirectoryReplacer.java +++ b/src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryReplacer.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.filesystem; +package io.flamingock.cli.executor.util.filesystem; import java.io.IOException; import java.nio.file.Files; diff --git a/src/main/java/io/flamingock/cli/executor/filesystem/DirectoryResolver.java b/src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryResolver.java similarity index 97% rename from src/main/java/io/flamingock/cli/executor/filesystem/DirectoryResolver.java rename to src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryResolver.java index 9015e92..8c8ce5f 100644 --- a/src/main/java/io/flamingock/cli/executor/filesystem/DirectoryResolver.java +++ b/src/main/java/io/flamingock/cli/executor/util/filesystem/DirectoryResolver.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.filesystem; +package io.flamingock.cli.executor.util.filesystem; import java.io.IOException; import java.nio.file.Files; diff --git a/src/main/java/io/flamingock/cli/executor/filesystem/FileSystemUtils.java b/src/main/java/io/flamingock/cli/executor/util/filesystem/FileSystemUtils.java similarity index 96% rename from src/main/java/io/flamingock/cli/executor/filesystem/FileSystemUtils.java rename to src/main/java/io/flamingock/cli/executor/util/filesystem/FileSystemUtils.java index c53a13a..cd3d36f 100644 --- a/src/main/java/io/flamingock/cli/executor/filesystem/FileSystemUtils.java +++ b/src/main/java/io/flamingock/cli/executor/util/filesystem/FileSystemUtils.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.filesystem; +package io.flamingock.cli.executor.util.filesystem; import java.io.IOException; import java.nio.file.Files; diff --git a/src/main/java/io/flamingock/cli/executor/filesystem/TemporaryDirectoryFactory.java b/src/main/java/io/flamingock/cli/executor/util/filesystem/TemporaryDirectoryFactory.java similarity index 95% rename from src/main/java/io/flamingock/cli/executor/filesystem/TemporaryDirectoryFactory.java rename to src/main/java/io/flamingock/cli/executor/util/filesystem/TemporaryDirectoryFactory.java index ba34a54..97cd226 100644 --- a/src/main/java/io/flamingock/cli/executor/filesystem/TemporaryDirectoryFactory.java +++ b/src/main/java/io/flamingock/cli/executor/util/filesystem/TemporaryDirectoryFactory.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.filesystem; +package io.flamingock.cli.executor.util.filesystem; import java.io.IOException; import java.nio.file.Files; diff --git a/src/main/java/io/flamingock/cli/executor/http/HttpFileDownloader.java b/src/main/java/io/flamingock/cli/executor/util/http/HttpFileDownloader.java similarity index 98% rename from src/main/java/io/flamingock/cli/executor/http/HttpFileDownloader.java rename to src/main/java/io/flamingock/cli/executor/util/http/HttpFileDownloader.java index bea8ef8..bcff9b3 100644 --- a/src/main/java/io/flamingock/cli/executor/http/HttpFileDownloader.java +++ b/src/main/java/io/flamingock/cli/executor/util/http/HttpFileDownloader.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.http; +package io.flamingock.cli.executor.util.http; import java.io.IOException; import java.net.URI; 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 5e1fb1c..8f48eac 100644 --- a/src/test/java/io/flamingock/cli/executor/command/InstallSkillsCommandTest.java +++ b/src/test/java/io/flamingock/cli/executor/command/InstallSkillsCommandTest.java @@ -16,7 +16,7 @@ package io.flamingock.cli.executor.command; import io.flamingock.cli.executor.FlamingockExecutorCli; -import io.flamingock.cli.executor.skills.InstallationTarget; +import io.flamingock.cli.executor.skills.SkillsInstallationTarget; import io.flamingock.cli.executor.skills.SkillsInstallationPipeline; import io.flamingock.cli.executor.skills.SkillsInstallationResult; import io.flamingock.cli.executor.skills.SkillsInstallationTargetResolver; @@ -49,7 +49,7 @@ void rootCommand_registersInstallSkillsSubcommand() { @Test void call_localInvocationInstallsSkillsIntoResolvedDestination() throws Exception { - InstallationTarget resolvedTarget = InstallationTarget.local(tempDir.resolve(".agents/skills")); + SkillsInstallationTarget resolvedTarget = SkillsInstallationTarget.local(tempDir.resolve(".agents/skills")); RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult( List.of(resolvedTarget), @@ -94,7 +94,7 @@ void call_globalFlagPrintsNotImplementedAndDoesNotTouchFilesystem() throws Excep @Test void call_localInvocationUsesSharedPipelineAndPrintsInstalledSkillCount() throws Exception { - InstallationTarget resolvedTarget = InstallationTarget.local(tempDir.resolve(".agents/skills")); + SkillsInstallationTarget resolvedTarget = SkillsInstallationTarget.local(tempDir.resolve(".agents/skills")); RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); RecordingPipeline pipeline = new RecordingPipeline(new SkillsInstallationResult( List.of(resolvedTarget), @@ -119,7 +119,7 @@ void call_localInvocationUsesSharedPipelineAndPrintsInstalledSkillCount() throws @Test void call_localInvocationPrintsActionableFailureWithoutStackTraceAndReturnsExitCodeOne() throws Exception { - InstallationTarget resolvedTarget = InstallationTarget.local(tempDir.resolve(".agents/skills")); + SkillsInstallationTarget resolvedTarget = SkillsInstallationTarget.local(tempDir.resolve(".agents/skills")); RecordingTargetResolver targetResolver = new RecordingTargetResolver(List.of(resolvedTarget)); FailingPipeline pipeline = new FailingPipeline( new IllegalStateException("Download timed out while fetching official Flamingock skills. Check your network connection and retry.") @@ -148,17 +148,17 @@ void call_localInvocationPrintsActionableFailureWithoutStackTraceAndReturnsExitC private static final class RecordingTargetResolver extends SkillsInstallationTargetResolver { - private final List targets; + private final List targets; private boolean called; private Path workingDirectory; private boolean global; - private RecordingTargetResolver(List targets) { + private RecordingTargetResolver(List targets) { this.targets = targets; } @Override - public List resolveTargets(Path workingDirectory, boolean global) { + public List resolveTargets(Path workingDirectory, boolean global) { this.called = true; this.workingDirectory = workingDirectory; this.global = global; @@ -177,7 +177,7 @@ private FailingTargetResolver(IllegalStateException failure) { } @Override - public List resolveTargets(Path workingDirectory, boolean global) { + public List resolveTargets(Path workingDirectory, boolean global) { this.called = true; this.global = global; throw failure; @@ -188,14 +188,14 @@ private static final class RecordingPipeline extends SkillsInstallationPipeline private final SkillsInstallationResult result; private boolean called; - private List targets; + private List targets; private RecordingPipeline(SkillsInstallationResult result) { this.result = result; } @Override - public SkillsInstallationResult install(List targets) { + public SkillsInstallationResult install(List targets) { this.called = true; this.targets = targets; return result; @@ -212,7 +212,7 @@ private FailingPipeline(IllegalStateException failure) { } @Override - public SkillsInstallationResult install(List targets) { + public SkillsInstallationResult install(List targets) { this.called = true; 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 3630a3a..f8a30f6 100644 --- a/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationPipelineTest.java +++ b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationPipelineTest.java @@ -15,11 +15,11 @@ */ package io.flamingock.cli.executor.skills; -import io.flamingock.cli.executor.filesystem.DirectoryReplacer; -import io.flamingock.cli.executor.filesystem.FileSystemUtils; -import io.flamingock.cli.executor.archive.ZipArchiveExtractor; -import io.flamingock.cli.executor.filesystem.DirectoryLister; -import io.flamingock.cli.executor.http.HttpFileDownloader; +import io.flamingock.cli.executor.util.filesystem.DirectoryReplacer; +import io.flamingock.cli.executor.util.filesystem.FileSystemUtils; +import io.flamingock.cli.executor.util.archive.ZipArchiveExtractor; +import io.flamingock.cli.executor.util.filesystem.DirectoryLister; +import io.flamingock.cli.executor.util.http.HttpFileDownloader; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -44,7 +44,7 @@ class SkillsInstallationPipelineTest { void install_runsStagesInOrderAndCleansWorkspace() throws Exception { List events = new ArrayList<>(); Path destination = Files.createDirectories(tempDir.resolve(".agents").resolve("skills")); - InstallationTarget target = InstallationTarget.local(destination); + SkillsInstallationTarget target = SkillsInstallationTarget.local(destination); Path downloadedArchive = tempDir.resolve("downloaded.zip"); Path snapshotRoot = Files.createDirectories(tempDir.resolve("snapshot")); Path firstSkill = Files.createDirectories(snapshotRoot.resolve("flamingock-core")); @@ -95,7 +95,7 @@ public void replaceDirectory(Path sourceSkillDir, Path destinationSkillDir) { @Test void install_cleansWorkspaceWhenStageFails() { - InstallationTarget target = InstallationTarget.local(tempDir.resolve(".agents").resolve("skills")); + SkillsInstallationTarget target = SkillsInstallationTarget.local(tempDir.resolve(".agents").resolve("skills")); SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( new HttpFileDownloader() { @Override @@ -127,7 +127,7 @@ public Path extractSingleRootDirectory(Path archive, Path workspace, String extr void install_preservesOriginalFailureWhenCleanupAlsoFails() throws Exception { Path workspace = Files.createDirectories(tempDir.resolve("workspace")); Files.writeString(workspace.resolve("stubborn.txt"), "keep"); - InstallationTarget target = InstallationTarget.local(tempDir.resolve(".agents").resolve("skills")); + SkillsInstallationTarget target = SkillsInstallationTarget.local(tempDir.resolve(".agents").resolve("skills")); SkillsInstallationPipeline pipeline = new SkillsInstallationPipeline( new HttpFileDownloader() { @@ -162,7 +162,7 @@ public Path extractSingleRootDirectory(Path archive, Path workspace, String extr @Test void install_replacesOnlyEnumeratedOfficialSkillsAndPreservesCustomFolders() throws Exception { Path destination = Files.createDirectories(tempDir.resolve(".agents").resolve("skills")); - InstallationTarget target = InstallationTarget.local(destination); + SkillsInstallationTarget target = SkillsInstallationTarget.local(destination); Path existingOfficialSkill = Files.createDirectories(destination.resolve("flamingock-core")); Files.writeString(existingOfficialSkill.resolve("old.txt"), "old"); Path customSkill = Files.createDirectories(destination.resolve("my-custom-skill")); @@ -208,8 +208,8 @@ public List listDirectories(Path root, java.util.function.Predicate @Test void install_downloadsAndExtractsOnceForMultipleTargets() throws Exception { List events = new ArrayList<>(); - InstallationTarget firstTarget = new InstallationTarget("local", Files.createDirectories(tempDir.resolve("project-a/.agents/skills"))); - InstallationTarget secondTarget = new InstallationTarget("secondary", Files.createDirectories(tempDir.resolve("project-b/.agents/skills"))); + SkillsInstallationTarget firstTarget = new SkillsInstallationTarget("local", Files.createDirectories(tempDir.resolve("project-a/.agents/skills"))); + SkillsInstallationTarget secondTarget = new SkillsInstallationTarget("secondary", Files.createDirectories(tempDir.resolve("project-b/.agents/skills"))); Path snapshotRoot = Files.createDirectories(tempDir.resolve("snapshot")); Path firstSkill = Files.createDirectories(snapshotRoot.resolve("flamingock-core")); Path secondSkill = Files.createDirectories(snapshotRoot.resolve("flamingock-java")); 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 f9bc199..d0d9303 100644 --- a/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolverTest.java +++ b/src/test/java/io/flamingock/cli/executor/skills/SkillsInstallationTargetResolverTest.java @@ -15,7 +15,7 @@ */ package io.flamingock.cli.executor.skills; -import io.flamingock.cli.executor.filesystem.DirectoryResolver; +import io.flamingock.cli.executor.util.filesystem.DirectoryResolver; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -39,12 +39,12 @@ void resolveTargets_localModeResolvesSingleProjectTarget() { RecordingDirectoryResolver directoryResolver = new RecordingDirectoryResolver(tempDir.resolve(".agents/skills")); SkillsInstallationTargetResolver resolver = new SkillsInstallationTargetResolver(directoryResolver); - List targets = resolver.resolveTargets(tempDir, false); + List targets = resolver.resolveTargets(tempDir, false); assertTrue(directoryResolver.called); assertEquals(tempDir, directoryResolver.workingDirectory); assertArrayEquals(new String[]{".agents", "skills"}, directoryResolver.segments); - assertEquals(List.of(InstallationTarget.local(tempDir.resolve(".agents/skills"))), targets); + assertEquals(List.of(SkillsInstallationTarget.local(tempDir.resolve(".agents/skills"))), targets); } @Test diff --git a/src/test/java/io/flamingock/cli/executor/archive/ZipArchiveExtractorTest.java b/src/test/java/io/flamingock/cli/executor/util/archive/ZipArchiveExtractorTest.java similarity index 98% rename from src/test/java/io/flamingock/cli/executor/archive/ZipArchiveExtractorTest.java rename to src/test/java/io/flamingock/cli/executor/util/archive/ZipArchiveExtractorTest.java index c23baa5..2ed43ec 100644 --- a/src/test/java/io/flamingock/cli/executor/archive/ZipArchiveExtractorTest.java +++ b/src/test/java/io/flamingock/cli/executor/util/archive/ZipArchiveExtractorTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.archive; +package io.flamingock.cli.executor.util.archive; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; diff --git a/src/test/java/io/flamingock/cli/executor/filesystem/DirectoryListerTest.java b/src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryListerTest.java similarity index 97% rename from src/test/java/io/flamingock/cli/executor/filesystem/DirectoryListerTest.java rename to src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryListerTest.java index 13f9281..56f2310 100644 --- a/src/test/java/io/flamingock/cli/executor/filesystem/DirectoryListerTest.java +++ b/src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryListerTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.filesystem; +package io.flamingock.cli.executor.util.filesystem; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; diff --git a/src/test/java/io/flamingock/cli/executor/filesystem/DirectoryReplacerTest.java b/src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryReplacerTest.java similarity index 98% rename from src/test/java/io/flamingock/cli/executor/filesystem/DirectoryReplacerTest.java rename to src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryReplacerTest.java index 8031997..1bb78c4 100644 --- a/src/test/java/io/flamingock/cli/executor/filesystem/DirectoryReplacerTest.java +++ b/src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryReplacerTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.filesystem; +package io.flamingock.cli.executor.util.filesystem; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; diff --git a/src/test/java/io/flamingock/cli/executor/filesystem/DirectoryResolverTest.java b/src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryResolverTest.java similarity index 97% rename from src/test/java/io/flamingock/cli/executor/filesystem/DirectoryResolverTest.java rename to src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryResolverTest.java index 1d01cfd..9616bce 100644 --- a/src/test/java/io/flamingock/cli/executor/filesystem/DirectoryResolverTest.java +++ b/src/test/java/io/flamingock/cli/executor/util/filesystem/DirectoryResolverTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.filesystem; +package io.flamingock.cli.executor.util.filesystem; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; diff --git a/src/test/java/io/flamingock/cli/executor/http/HttpFileDownloaderTest.java b/src/test/java/io/flamingock/cli/executor/util/http/HttpFileDownloaderTest.java similarity index 99% rename from src/test/java/io/flamingock/cli/executor/http/HttpFileDownloaderTest.java rename to src/test/java/io/flamingock/cli/executor/util/http/HttpFileDownloaderTest.java index 5150346..b8b87ca 100644 --- a/src/test/java/io/flamingock/cli/executor/http/HttpFileDownloaderTest.java +++ b/src/test/java/io/flamingock/cli/executor/util/http/HttpFileDownloaderTest.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.flamingock.cli.executor.http; +package io.flamingock.cli.executor.util.http; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir;