Skip to content

Commit 6db0a05

Browse files
authored
feat(cli): add install-skills command (#6)
1 parent 447377f commit 6db0a05

21 files changed

Lines changed: 1841 additions & 1 deletion

src/main/java/io/flamingock/cli/executor/FlamingockExecutorCli.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import io.flamingock.cli.executor.command.AuditCommand;
1919
import io.flamingock.cli.executor.command.ExecuteCommand;
20+
import io.flamingock.cli.executor.command.InstallSkillsCommand;
2021
import io.flamingock.cli.executor.command.IssueCommand;
2122
import io.flamingock.cli.executor.handler.ExecutorExceptionHandler;
2223
import io.flamingock.cli.executor.util.VersionProvider;
@@ -45,6 +46,7 @@
4546
"",
4647
"@|bold Examples:|@",
4748
" flamingock execute apply --jar ./app.jar",
49+
" flamingock install-skills",
4850
" flamingock audit list --jar ./app.jar",
4951
" flamingock audit fix --jar ./app.jar -c my-change-id -r APPLIED",
5052
" flamingock issue list --jar ./app.jar",
@@ -59,7 +61,7 @@
5961
"",
6062
"For detailed help on any command, use: flamingock <command> --help"
6163
},
62-
subcommands = {ExecuteCommand.class, AuditCommand.class, IssueCommand.class},
64+
subcommands = {ExecuteCommand.class, AuditCommand.class, IssueCommand.class, InstallSkillsCommand.class},
6365
mixinStandardHelpOptions = true,
6466
versionProvider = VersionProvider.class
6567
)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright 2026 Flamingock (https://www.flamingock.io)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.flamingock.cli.executor.command;
17+
18+
import io.flamingock.cli.executor.output.ConsoleFormatter;
19+
import io.flamingock.cli.executor.skills.SkillsInstallationTarget;
20+
import io.flamingock.cli.executor.skills.SkillsInstallationPipeline;
21+
import io.flamingock.cli.executor.skills.SkillsInstallationResult;
22+
import io.flamingock.cli.executor.skills.SkillsInstallationTargetResolver;
23+
import picocli.CommandLine.Command;
24+
import picocli.CommandLine.Option;
25+
26+
import java.nio.file.Path;
27+
import java.util.List;
28+
import java.util.concurrent.Callable;
29+
30+
/**
31+
* Installs the official Flamingock AI skills into the current project.
32+
*/
33+
@Command(
34+
name = "install-skills",
35+
description = "Install official Flamingock AI skills into the current project",
36+
mixinStandardHelpOptions = true
37+
)
38+
public class InstallSkillsCommand implements Callable<Integer> {
39+
40+
@Option(names = {"-g", "--global"}, description = "Install skills globally (not implemented yet)")
41+
private boolean global;
42+
43+
private final SkillsInstallationTargetResolver targetResolver;
44+
private final SkillsInstallationPipeline pipeline;
45+
private final Path workingDirectory;
46+
47+
/**
48+
* Creates a command with the default production collaborators.
49+
*/
50+
public InstallSkillsCommand() {
51+
this(new SkillsInstallationTargetResolver(), new SkillsInstallationPipeline(), Path.of(""));
52+
}
53+
54+
InstallSkillsCommand(
55+
SkillsInstallationTargetResolver targetResolver,
56+
SkillsInstallationPipeline pipeline,
57+
Path workingDirectory
58+
) {
59+
this.targetResolver = targetResolver;
60+
this.pipeline = pipeline;
61+
this.workingDirectory = workingDirectory;
62+
}
63+
64+
/**
65+
* Executes the install-skills command.
66+
*
67+
* @return process exit code
68+
*/
69+
@Override
70+
public Integer call() {
71+
try {
72+
List<SkillsInstallationTarget> targets = targetResolver.resolveTargets(workingDirectory.toAbsolutePath().normalize(), global);
73+
SkillsInstallationResult result = pipeline.install(targets);
74+
ConsoleFormatter.printInfo(buildSuccessMessage(result));
75+
return 0;
76+
} catch (IllegalStateException e) {
77+
ConsoleFormatter.printError(e.getMessage());
78+
return 1;
79+
}
80+
}
81+
82+
private String buildSuccessMessage(SkillsInstallationResult result) {
83+
if (result.targets().size() == 1) {
84+
return "Installed " + result.installedSkills().size()
85+
+ " Flamingock skill(s) into " + result.destinationSkillsDir();
86+
}
87+
88+
return "Installed " + result.installedSkills().size() + " Flamingock skill(s) into "
89+
+ result.targets().size() + " destinations.";
90+
}
91+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
* Copyright 2026 Flamingock (https://www.flamingock.io)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.flamingock.cli.executor.skills;
17+
18+
import io.flamingock.cli.executor.util.archive.ZipArchiveExtractor;
19+
import io.flamingock.cli.executor.util.filesystem.DirectoryLister;
20+
import io.flamingock.cli.executor.util.filesystem.DirectoryReplacer;
21+
import io.flamingock.cli.executor.util.filesystem.FileSystemUtils;
22+
import io.flamingock.cli.executor.util.filesystem.TemporaryDirectoryFactory;
23+
import io.flamingock.cli.executor.util.http.HttpFileDownloader;
24+
25+
import java.io.IOException;
26+
import java.net.URI;
27+
import java.nio.file.Path;
28+
import java.util.ArrayList;
29+
import java.util.List;
30+
import java.util.Objects;
31+
import java.util.function.Consumer;
32+
import java.util.function.Supplier;
33+
34+
/**
35+
* Executes the shared staged pipeline that installs official Flamingock skills.
36+
*/
37+
public class SkillsInstallationPipeline {
38+
39+
private static final URI OFFICIAL_SKILLS_ARCHIVE_URI =
40+
URI.create("https://github.com/flamingock/flamingock-skills/archive/refs/heads/release.zip");
41+
private static final String ARCHIVE_FILE_NAME = "flamingock-skills.zip";
42+
private static final String EXTRACTION_DIRECTORY_NAME = "extracted";
43+
private static final String SKILL_DIRECTORY_PREFIX = "flamingock-";
44+
private static final String TEMP_DIRECTORY_PREFIX = "flamingock-skills-";
45+
private static final String DOWNLOAD_LABEL = "official Flamingock skills";
46+
private static final String ARCHIVE_DESCRIPTION = "skills archive";
47+
private static final String USER_AGENT = "Flamingock CLI/1.0 install-skills";
48+
49+
private final HttpFileDownloader downloader;
50+
private final ZipArchiveExtractor extractor;
51+
private final DirectoryLister directoryLister;
52+
private final DirectoryReplacer replacer;
53+
private final Supplier<Path> workspaceSupplier;
54+
private final Consumer<Path> cleanup;
55+
56+
public SkillsInstallationPipeline() {
57+
this(new HttpFileDownloader(),
58+
new ZipArchiveExtractor(),
59+
new DirectoryLister(),
60+
new DirectoryReplacer(),
61+
() -> TemporaryDirectoryFactory.create(TEMP_DIRECTORY_PREFIX, "skill installation"),
62+
FileSystemUtils::deleteRecursively);
63+
}
64+
65+
SkillsInstallationPipeline(
66+
HttpFileDownloader downloader,
67+
ZipArchiveExtractor extractor,
68+
DirectoryLister directoryLister,
69+
DirectoryReplacer replacer,
70+
Supplier<Path> workspaceSupplier,
71+
Consumer<Path> cleanup
72+
) {
73+
this.downloader = downloader;
74+
this.extractor = extractor;
75+
this.directoryLister = directoryLister;
76+
this.replacer = replacer;
77+
this.workspaceSupplier = workspaceSupplier;
78+
this.cleanup = cleanup;
79+
}
80+
81+
/**
82+
* Runs the download, extract, enumerate, replace, and cleanup stages.
83+
*
84+
* @param targets resolved installation targets
85+
* @return installation result summary
86+
*/
87+
public SkillsInstallationResult install(List<SkillsInstallationTarget> targets) {
88+
Objects.requireNonNull(targets, "targets must not be null");
89+
if (targets.isEmpty()) {
90+
throw new IllegalStateException("No skills installation targets were resolved. Choose a destination and retry.");
91+
}
92+
93+
Path workspace = workspaceSupplier.get();
94+
RuntimeException installationFailure = null;
95+
try {
96+
Path archive = downloader.downloadTo(
97+
workspace,
98+
OFFICIAL_SKILLS_ARCHIVE_URI,
99+
ARCHIVE_FILE_NAME,
100+
USER_AGENT,
101+
DOWNLOAD_LABEL
102+
);
103+
Path snapshotRoot = extractor.extractSingleRootDirectory(
104+
archive,
105+
workspace,
106+
EXTRACTION_DIRECTORY_NAME,
107+
ARCHIVE_DESCRIPTION
108+
);
109+
List<Path> skillDirectories = directoryLister.listDirectories(
110+
snapshotRoot,
111+
path -> path.getFileName().toString().startsWith(SKILL_DIRECTORY_PREFIX)
112+
);
113+
List<String> installedSkills = new ArrayList<>();
114+
for (Path skillDirectory : skillDirectories) {
115+
installedSkills.add(skillDirectory.getFileName().toString());
116+
}
117+
for (SkillsInstallationTarget target : targets) {
118+
for (Path skillDirectory : skillDirectories) {
119+
Path destinationSkillDirectory = target.destinationSkillsDir().resolve(skillDirectory.getFileName().toString());
120+
replacer.replaceDirectory(skillDirectory, destinationSkillDirectory);
121+
}
122+
}
123+
return new SkillsInstallationResult(targets, installedSkills);
124+
} catch (IOException e) {
125+
installationFailure = new IllegalStateException("Failed to install Flamingock skills: " + e.getMessage(), e);
126+
throw installationFailure;
127+
} finally {
128+
try {
129+
cleanup.accept(workspace);
130+
} catch (RuntimeException cleanupFailure) {
131+
if (installationFailure != null) {
132+
installationFailure.addSuppressed(cleanupFailure);
133+
} else {
134+
throw cleanupFailure;
135+
}
136+
}
137+
}
138+
}
139+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright 2026 Flamingock (https://www.flamingock.io)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.flamingock.cli.executor.skills;
17+
18+
import java.nio.file.Path;
19+
import java.util.List;
20+
21+
/**
22+
* Summary of a completed skills installation.
23+
*
24+
* @param targets installation targets that received the installed skills
25+
* @param installedSkills installed official skill folder names
26+
*/
27+
public record SkillsInstallationResult(List<SkillsInstallationTarget> targets, List<String> installedSkills) {
28+
29+
public SkillsInstallationResult {
30+
targets = List.copyOf(targets);
31+
installedSkills = List.copyOf(installedSkills);
32+
}
33+
34+
/**
35+
* Returns the single destination directory when the installation resolved to one target.
36+
*
37+
* @return single destination directory
38+
*/
39+
public Path destinationSkillsDir() {
40+
if (targets.size() != 1) {
41+
throw new IllegalStateException("Installation resolved to " + targets.size()
42+
+ " targets; destinationSkillsDir() is only available for single-target installs.");
43+
}
44+
return targets.get(0).destinationSkillsDir();
45+
}
46+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*
2+
* Copyright 2026 Flamingock (https://www.flamingock.io)
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.flamingock.cli.executor.skills;
17+
18+
import java.nio.file.Path;
19+
import java.util.Objects;
20+
21+
/**
22+
* Resolved destination for a skills installation run.
23+
*
24+
* @param identifier stable identifier for the target
25+
* @param destinationSkillsDir destination directory that will receive the skills
26+
*/
27+
public record SkillsInstallationTarget(String identifier, Path destinationSkillsDir) {
28+
29+
public SkillsInstallationTarget {
30+
identifier = Objects.requireNonNull(identifier, "identifier must not be null");
31+
destinationSkillsDir = Objects.requireNonNull(destinationSkillsDir, "destinationSkillsDir must not be null");
32+
}
33+
34+
/**
35+
* Creates the current project-local installation target.
36+
*
37+
* @param destinationSkillsDir local destination directory
38+
* @return local installation target
39+
*/
40+
public static SkillsInstallationTarget local(Path destinationSkillsDir) {
41+
return new SkillsInstallationTarget("local", destinationSkillsDir);
42+
}
43+
}

0 commit comments

Comments
 (0)