|
| 1 | +package dev.dochia.cli.core.command; |
| 2 | + |
| 3 | +import dev.dochia.cli.core.util.VersionProvider; |
| 4 | +import io.github.ludovicianul.prettylogger.PrettyLogger; |
| 5 | +import io.github.ludovicianul.prettylogger.PrettyLoggerFactory; |
| 6 | +import io.quarkus.arc.Unremovable; |
| 7 | +import picocli.CommandLine; |
| 8 | + |
| 9 | +import java.io.IOException; |
| 10 | +import java.io.InputStream; |
| 11 | +import java.nio.charset.StandardCharsets; |
| 12 | +import java.nio.file.Files; |
| 13 | +import java.nio.file.Path; |
| 14 | +import java.util.List; |
| 15 | +import java.util.Map; |
| 16 | + |
| 17 | +/** |
| 18 | + * Generates Agent Skills files for agentic IDE integration. |
| 19 | + * Skills are placed in .agents/skills/ following the open Agent Skills specification |
| 20 | + * (https://agentskills.io), which is supported by Windsurf, Cursor, Claude Code, and OpenAI Codex. |
| 21 | + */ |
| 22 | +@CommandLine.Command( |
| 23 | + name = "init-skills", |
| 24 | + mixinStandardHelpOptions = true, |
| 25 | + usageHelpAutoWidth = true, |
| 26 | + exitCodeListHeading = "%n@|bold,underline Exit Codes:|@%n", |
| 27 | + exitCodeList = {"@|bold 0|@:Successful program execution", |
| 28 | + "@|bold 2|@:Usage error: user input for the command was incorrect", |
| 29 | + "@|bold 1|@:Internal execution error: an exception occurred when executing command"}, |
| 30 | + footerHeading = "%n@|bold,underline Examples:|@%n", |
| 31 | + footer = {" Initialize agent skills in the current directory:", |
| 32 | + " dochia init-skills", |
| 33 | + "", " Initialize in a specific directory:", |
| 34 | + " dochia init-skills --dir /path/to/project", |
| 35 | + "", " Force overwrite existing files:", |
| 36 | + " dochia init-skills --force"}, |
| 37 | + description = "Generate Agent Skills for agentic IDE integration (Windsurf, Cursor, Claude Code, Codex).", |
| 38 | + versionProvider = VersionProvider.class) |
| 39 | +@Unremovable |
| 40 | +public class InitSkillsCommand implements Runnable, CommandLine.IExitCodeGenerator { |
| 41 | + private final PrettyLogger logger = PrettyLoggerFactory.getConsoleLogger(); |
| 42 | + |
| 43 | + @CommandLine.Option(names = {"--dir", "-d"}, |
| 44 | + description = "Target directory to generate skills in. Defaults to current directory.", |
| 45 | + defaultValue = ".") |
| 46 | + private String directory; |
| 47 | + |
| 48 | + @CommandLine.Option(names = {"--force", "-f"}, |
| 49 | + description = "Overwrite existing files if they already exist.") |
| 50 | + private boolean force; |
| 51 | + |
| 52 | + private int exitCode; |
| 53 | + |
| 54 | + private static final List<String> SKILL_NAMES = List.of( |
| 55 | + "dochia-test", |
| 56 | + "dochia-fuzz", |
| 57 | + "dochia-replay", |
| 58 | + "dochia-list", |
| 59 | + "dochia-explain" |
| 60 | + ); |
| 61 | + |
| 62 | + private static final Map<String, List<String>> SKILL_EXTRA_FILES = Map.of( |
| 63 | + "dochia-test", List.of("references/report-output.md") |
| 64 | + ); |
| 65 | + |
| 66 | + @Override |
| 67 | + public void run() { |
| 68 | + try { |
| 69 | + Path baseDir = Path.of(directory).toAbsolutePath().normalize(); |
| 70 | + logger.info("Generating Dochia agent skills in {}", baseDir); |
| 71 | + |
| 72 | + int created = 0; |
| 73 | + int skipped = 0; |
| 74 | + |
| 75 | + for (String skillName : SKILL_NAMES) { |
| 76 | + Path skillDir = baseDir.resolve(".agents").resolve("skills").resolve(skillName); |
| 77 | + Path skillFile = skillDir.resolve("SKILL.md"); |
| 78 | + String resourcePath = "skills/" + skillName + "/SKILL.md"; |
| 79 | + |
| 80 | + if (writeResourceFile(resourcePath, skillFile)) { |
| 81 | + created++; |
| 82 | + } else { |
| 83 | + skipped++; |
| 84 | + } |
| 85 | + |
| 86 | + for (String extraFile : SKILL_EXTRA_FILES.getOrDefault(skillName, List.of())) { |
| 87 | + Path extraTarget = skillDir.resolve(extraFile); |
| 88 | + String extraResource = "skills/" + skillName + "/" + extraFile; |
| 89 | + if (writeResourceFile(extraResource, extraTarget)) { |
| 90 | + created++; |
| 91 | + } else { |
| 92 | + skipped++; |
| 93 | + } |
| 94 | + } |
| 95 | + } |
| 96 | + |
| 97 | + logger.noFormat(""); |
| 98 | + logger.info("Done! {} files created, {} skipped (already exist).", created, skipped); |
| 99 | + |
| 100 | + if (created > 0) { |
| 101 | + logger.noFormat(""); |
| 102 | + logger.info("Generated files:"); |
| 103 | + for (String skillName : SKILL_NAMES) { |
| 104 | + Path skillFile = baseDir.resolve(".agents").resolve("skills").resolve(skillName).resolve("SKILL.md"); |
| 105 | + if (Files.exists(skillFile)) { |
| 106 | + logger.noFormat(" .agents/skills/{}/SKILL.md", skillName); |
| 107 | + } |
| 108 | + } |
| 109 | + logger.noFormat(""); |
| 110 | + logger.info("These files are automatically discovered by:"); |
| 111 | + logger.noFormat(" - Windsurf (Cascade)"); |
| 112 | + logger.noFormat(" - Cursor"); |
| 113 | + logger.noFormat(" - Claude Code"); |
| 114 | + logger.noFormat(" - OpenAI Codex"); |
| 115 | + logger.noFormat(""); |
| 116 | + logger.info("Commit them to your repository so your team benefits too."); |
| 117 | + } |
| 118 | + |
| 119 | + if (skipped > 0 && !force) { |
| 120 | + logger.note("Use --force to overwrite existing files."); |
| 121 | + } |
| 122 | + |
| 123 | + exitCode = 0; |
| 124 | + } catch (Exception e) { |
| 125 | + logger.error("Failed to generate agent skills: {}", e.getMessage()); |
| 126 | + logger.debug("Stacktrace", e); |
| 127 | + exitCode = 1; |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + /** |
| 132 | + * Writes a classpath resource to a target file path. |
| 133 | + * |
| 134 | + * @return true if the file was written, false if it was skipped |
| 135 | + */ |
| 136 | + boolean writeResourceFile(String resourcePath, Path targetFile) throws IOException { |
| 137 | + if (Files.exists(targetFile) && !force) { |
| 138 | + logger.skip(" Skipping {} (already exists)", targetFile.toAbsolutePath().normalize()); |
| 139 | + return false; |
| 140 | + } |
| 141 | + |
| 142 | + String content = readResource(resourcePath); |
| 143 | + if (content == null) { |
| 144 | + logger.error(" Resource not found: {}", resourcePath); |
| 145 | + return false; |
| 146 | + } |
| 147 | + |
| 148 | + Files.createDirectories(targetFile.getParent()); |
| 149 | + Files.writeString(targetFile, content, StandardCharsets.UTF_8); |
| 150 | + logger.noFormat(" Created {}", targetFile.toAbsolutePath().normalize()); |
| 151 | + return true; |
| 152 | + } |
| 153 | + |
| 154 | + String readResource(String resourcePath) { |
| 155 | + try (InputStream in = Thread.currentThread() |
| 156 | + .getContextClassLoader() |
| 157 | + .getResourceAsStream(resourcePath)) { |
| 158 | + if (in == null) { |
| 159 | + return null; |
| 160 | + } |
| 161 | + return new String(in.readAllBytes(), StandardCharsets.UTF_8); |
| 162 | + } catch (IOException e) { |
| 163 | + logger.debug("Error reading resource: {}", resourcePath, e); |
| 164 | + return null; |
| 165 | + } |
| 166 | + } |
| 167 | + |
| 168 | + @Override |
| 169 | + public int getExitCode() { |
| 170 | + return exitCode; |
| 171 | + } |
| 172 | +} |
0 commit comments