diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..bb93c68
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,20 @@
+name: CI
+
+on:
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up JDK
+ uses: actions/setup-java@v3
+ with:
+ java-version: '11'
+ distribution: 'temurin'
+ - name: Build and test
+ run: mvn clean verify
diff --git a/.github/workflows/jdeploy-npm.yml b/.github/workflows/jdeploy-npm.yml
new file mode 100644
index 0000000..007117d
--- /dev/null
+++ b/.github/workflows/jdeploy-npm.yml
@@ -0,0 +1,39 @@
+name: jDeploy npm Publish
+
+on:
+ push:
+ branches:
+ - '*-snapshot'
+ tags:
+ - 'v*'
+
+concurrency:
+ group: jdeploy-npm-${{ github.repository }}
+ cancel-in-progress: false
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up JDK
+ uses: actions/setup-java@v3
+ with:
+ java-version: '11'
+ distribution: 'temurin'
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: '18'
+ registry-url: 'https://registry.npmjs.org'
+ - name: Build with Maven
+ run: mvn clean package -DskipTests
+ - name: Install jDeploy
+ run: npm install -g jdeploy
+ - name: Package with jDeploy
+ run: jdeploy package
+ - name: Publish to npm
+ run: npm publish
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.github/workflows/jdeploy.yml b/.github/workflows/jdeploy.yml
new file mode 100644
index 0000000..03a8284
--- /dev/null
+++ b/.github/workflows/jdeploy.yml
@@ -0,0 +1,65 @@
+name: jDeploy CI
+
+on:
+ push:
+ branches:
+ - '*-snapshot'
+ tags:
+ - 'v*'
+
+concurrency:
+ group: jdeploy-release-${{ github.repository }}
+ cancel-in-progress: false
+
+jobs:
+ build:
+ permissions:
+ contents: write
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Set up JDK
+ uses: actions/setup-java@v3
+ with:
+ java-version: '11'
+ distribution: 'temurin'
+ - name: Build with Maven
+ run: mvn clean package -DskipTests
+ - name: Build App Installer Bundles
+ uses: shannah/jdeploy@master
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ - name: Upload Build Artifacts for DMG Action
+ if: ${{ vars.JDEPLOY_CREATE_DMG == 'true' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-target
+ path: ./target
+
+ create_and_upload_dmg:
+ if: ${{ vars.JDEPLOY_CREATE_DMG == 'true' }}
+ name: Create and upload DMG
+ permissions:
+ contents: write
+ runs-on: macos-latest
+ needs: build
+ steps:
+ - name: Set up Git
+ run: |
+ git config --global user.email "${{ github.actor }}@users.noreply.github.com"
+ git config --global user.name "${{ github.actor }}"
+ - uses: actions/checkout@v3
+ - name: Download Build Artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: build-target
+ path: ./target
+ - name: Create DMG and Upload to Release
+ uses: shannah/jdeploy-action-dmg@main
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ developer_id: ${{ secrets.MAC_DEVELOPER_ID }}
+ developer_certificate_p12_base64: ${{ secrets.MAC_DEVELOPER_CERTIFICATE_P12_BASE64 }}
+ developer_certificate_password: ${{ secrets.MAC_DEVELOPER_CERTIFICATE_PASSWORD }}
+ notarization_password: ${{ secrets.MAC_NOTARIZATION_PASSWORD }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6b8fa27
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+target/
+*.class
+*.jar
+*.log
+.idea/
+*.iml
+.settings/
+.project
+.classpath
+jdeploy-bundle/
+jdeploy/
+node_modules/
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..b19eaa6
--- /dev/null
+++ b/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "install-skill-cli",
+ "version": "1.0.6",
+ "description": "CLI tool for installing skills deployed with the skills-jar-maven-plugin",
+ "bin": {
+ "install-skill": "jdeploy-bundle/jdeploy.js"
+ },
+ "preferGlobal": true,
+ "jdeploy": {
+ "jdk": false,
+ "javaVersion": "11",
+ "javafx": false,
+ "jar": "target/install-skill-cli-1.0.0-SNAPSHOT.jar",
+ "title": "Install Skill CLI",
+ "commands": {
+ "install-skill": {
+ "description": "Install skills deployed with the skills-jar-maven-plugin"
+ }
+ }
+ },
+ "dependencies": {
+ "command-exists-promise": "^2.0.2",
+ "node-fetch": "2.6.7",
+ "tar": "^4.4.8",
+ "yauzl": "^2.10.0",
+ "shelljs": "^0.8.4"
+ },
+ "files": [
+ "jdeploy-bundle"
+ ],
+ "license": "ISC"
+}
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..7cf0eeb
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,141 @@
+
+
+ 4.0.0
+
+ ca.weblite
+ install-skill-cli
+ 1.0.0-SNAPSHOT
+ jar
+
+ Install Skill CLI
+ CLI tool for installing skills deployed with the skills-jar-maven-plugin
+
+
+ UTF-8
+ 11
+ 4.7.5
+ 5.10.2
+
+
+
+
+ info.picocli
+ picocli
+ ${picocli.version}
+
+
+
+
+ org.apache.maven
+ maven-embedder
+ 3.9.9
+
+
+ org.apache.maven
+ maven-compat
+ 3.9.9
+
+
+ org.apache.maven.resolver
+ maven-resolver-connector-basic
+ 1.9.22
+
+
+ org.apache.maven.resolver
+ maven-resolver-transport-http
+ 1.9.22
+
+
+ org.apache.maven.wagon
+ wagon-http
+ 3.5.3
+
+
+ org.slf4j
+ slf4j-simple
+ 1.7.36
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ ${junit.version}
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ ${junit.version}
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.11.0
+
+
+
+ info.picocli
+ picocli-codegen
+ ${picocli.version}
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.2.5
+
+
+
+ org.apache.maven.plugins
+ maven-failsafe-plugin
+ 3.2.5
+
+
+
+ integration-test
+ verify
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.5.1
+
+
+ package
+
+ shade
+
+
+
+
+ ca.weblite.installskill.InstallSkillCommand
+
+
+
+
+ META-INF/sisu/javax.inject.Named
+
+
+ false
+
+
+
+
+
+
+
diff --git a/src/main/java/ca/weblite/installskill/InstallSkillCommand.java b/src/main/java/ca/weblite/installskill/InstallSkillCommand.java
new file mode 100644
index 0000000..5602584
--- /dev/null
+++ b/src/main/java/ca/weblite/installskill/InstallSkillCommand.java
@@ -0,0 +1,358 @@
+package ca.weblite.installskill;
+
+import org.apache.maven.cli.MavenCli;
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.stream.Stream;
+
+/**
+ * CLI tool that installs skills deployed with the skills-jar-maven-plugin.
+ *
+ *
Sets up a temporary Maven project with the specified skill artifact as a dependency,
+ * runs the skills-jar plugin install goal to resolve and unpack skill bundles,
+ * then copies the installed skills to the target directory.
+ */
+@Command(name = "install-skill",
+ mixinStandardHelpOptions = true,
+ version = "1.0.0",
+ description = "Install skills deployed with the skills-jar-maven-plugin.")
+public class InstallSkillCommand implements Callable {
+
+ private static final String PLUGIN_GROUP_ID = "ca.weblite";
+ private static final String PLUGIN_ARTIFACT_ID = "skills-jar-plugin";
+ private static final String PLUGIN_VERSION = "0.1.1";
+ private static final String DEFAULT_VERSION = "RELEASE";
+
+ @Parameters(index = "0",
+ description = "Skill coordinates: groupId:artifactId[:version]. Version defaults to RELEASE (latest release).",
+ paramLabel = "")
+ private String coordinates;
+
+ @Option(names = {"-r"},
+ description = "Repository URL with optional credentials: [user:pass@]repositoryUrl",
+ paramLabel = "")
+ private String repository;
+
+ @Option(names = {"-g", "--global"},
+ description = "Install globally to ~/.claude/skills (default is local: ./.claude/skills).")
+ private boolean global;
+
+ @Option(names = {"-d"},
+ description = "Skills installation directory (overrides --global).",
+ paramLabel = "")
+ private String skillsDir;
+
+ public static void main(String[] args) {
+ String mode = System.getProperty("jdeploy.mode", null);
+ if ("gui".equals(mode)) {
+ javax.swing.SwingUtilities.invokeLater(() -> {
+ javax.swing.JOptionPane.showMessageDialog(
+ null,
+ "Install Skill CLI v1.0.0\n\nThis is a command-line tool.\nRun 'install-skill' in a terminal for usage.",
+ "About Install Skill CLI",
+ javax.swing.JOptionPane.INFORMATION_MESSAGE
+ );
+ System.exit(0);
+ });
+ return;
+ }
+ int exitCode = new CommandLine(new InstallSkillCommand()).execute(args);
+ System.exit(exitCode);
+ }
+
+ @Override
+ public Integer call() throws Exception {
+ // 1. Parse coordinates
+ String[] parts = coordinates.split(":", -1);
+ if (parts.length < 2 || parts.length > 3) {
+ System.err.println("Error: Invalid coordinates format. Expected: groupId:artifactId[:version]");
+ return 1;
+ }
+
+ String groupId = parts[0].trim();
+ String artifactId = parts[1].trim();
+ String version = (parts.length == 3 && !parts[2].trim().isEmpty())
+ ? parts[2].trim() : DEFAULT_VERSION;
+
+ if (groupId.isEmpty() || artifactId.isEmpty()) {
+ System.err.println("Error: groupId and artifactId must not be empty.");
+ return 1;
+ }
+
+ // 2. Parse repository option
+ String repoUrl = null;
+ String repoUser = null;
+ String repoPass = null;
+ if (repository != null && !repository.isEmpty()) {
+ String[] repoParts = parseRepository(repository);
+ repoUrl = repoParts[0];
+ repoUser = repoParts[1];
+ repoPass = repoParts[2];
+ }
+
+ // 3. Create temp directory
+ Path tempDir = Files.createTempDirectory("install-skill-");
+ System.out.println("Setting up temporary Maven project...");
+
+ try {
+ // 4. Generate pom.xml in the temp project
+ generatePom(tempDir, groupId, artifactId, version, repoUrl);
+
+ // 5. Generate settings.xml if credentials are provided
+ boolean hasSettings = false;
+ if (repoUser != null) {
+ generateSettings(tempDir, repoUser, repoPass);
+ hasSettings = true;
+ }
+
+ // 6. Run embedded Maven to install skills
+ System.out.println("Resolving skill " + groupId + ":" + artifactId + ":" + version + "...");
+ int exitCode = runMaven(tempDir, hasSettings);
+ if (exitCode != 0) {
+ System.err.println("Error: Maven execution failed (exit code " + exitCode + ").");
+ return exitCode;
+ }
+
+ // 8. Copy installed skills to target directory
+ Path installedSkillsDir = tempDir.resolve(".claude").resolve("skills");
+ String resolvedDir = skillsDir != null ? skillsDir
+ : global ? Paths.get(System.getProperty("user.home"), ".claude", "skills").toString()
+ : ".claude/skills";
+ Path targetDir = Paths.get(resolvedDir).toAbsolutePath().normalize();
+
+ if (!Files.exists(installedSkillsDir) || isDirectoryEmpty(installedSkillsDir)) {
+ System.err.println("Error: No skills were found after installation.");
+ return 1;
+ }
+
+ Files.createDirectories(targetDir);
+ copyDirectory(installedSkillsDir, targetDir);
+ System.out.println("Skills installed successfully to " + targetDir);
+
+ return 0;
+ } finally {
+ // Clean up temp directory
+ deleteDirectory(tempDir);
+ }
+ }
+
+ /**
+ * Parses a repository string that may contain credentials.
+ * Format: [user:pass@]repositoryUrl
+ *
+ * @return array of [url, user, password] where user/password may be null
+ */
+ static String[] parseRepository(String repo) {
+ // Look for credentials pattern: text@http:// or text@https://
+ int httpAt = repo.indexOf("@http://");
+ int httpsAt = repo.indexOf("@https://");
+ int atIdx = Math.max(httpAt, httpsAt);
+
+ if (atIdx > 0) {
+ String credentials = repo.substring(0, atIdx);
+ String url = repo.substring(atIdx + 1);
+ int colonIdx = credentials.indexOf(':');
+ if (colonIdx > 0) {
+ return new String[]{url, credentials.substring(0, colonIdx),
+ credentials.substring(colonIdx + 1)};
+ }
+ }
+
+ return new String[]{repo, null, null};
+ }
+
+ /**
+ * Runs the Maven skills-jar:install goal using the embedded Maven runtime.
+ */
+ private int runMaven(Path projectDir, boolean hasSettings) {
+ System.setProperty("maven.multiModuleProjectDirectory", projectDir.toString());
+
+ List args = new ArrayList<>();
+ args.add("-B");
+ if (hasSettings) {
+ args.add("-s");
+ args.add(projectDir.resolve("settings.xml").toString());
+ }
+ args.add(PLUGIN_GROUP_ID + ":" + PLUGIN_ARTIFACT_ID + ":"
+ + PLUGIN_VERSION + ":install");
+
+ MavenCli cli = new MavenCli();
+ return cli.doMain(args.toArray(new String[0]),
+ projectDir.toString(), System.out, System.err);
+ }
+
+ /**
+ * Generates a temporary pom.xml with the skill artifact as a dependency.
+ */
+ private void generatePom(Path projectDir, String groupId, String artifactId,
+ String version, String repoUrl) throws IOException {
+ StringBuilder pom = new StringBuilder();
+ pom.append("\n");
+ pom.append("\n");
+ pom.append(" 4.0.0\n\n");
+ pom.append(" install-skill-temp\n");
+ pom.append(" install-skill-temp\n");
+ pom.append(" 1.0.0\n");
+ pom.append(" pom\n\n");
+
+ // Dependencies
+ pom.append(" \n");
+ pom.append(" \n");
+ pom.append(" ").append(escapeXml(groupId)).append("\n");
+ pom.append(" ").append(escapeXml(artifactId)).append("\n");
+ pom.append(" ").append(escapeXml(version)).append("\n");
+ pom.append(" pom\n");
+ pom.append(" \n");
+ pom.append(" \n\n");
+
+ // Repositories (if custom repo specified)
+ if (repoUrl != null) {
+ pom.append(" \n");
+ pom.append(" \n");
+ pom.append(" custom-repo\n");
+ pom.append(" ").append(escapeXml(repoUrl)).append("\n");
+ pom.append(" \n");
+ pom.append(" true\n");
+ pom.append(" \n");
+ pom.append(" \n");
+ pom.append(" true\n");
+ pom.append(" \n");
+ pom.append(" \n");
+ pom.append(" \n\n");
+
+ // Also add as plugin repository so the skills-jar-plugin can be resolved
+ pom.append(" \n");
+ pom.append(" \n");
+ pom.append(" custom-repo\n");
+ pom.append(" ").append(escapeXml(repoUrl)).append("\n");
+ pom.append(" \n");
+ pom.append(" true\n");
+ pom.append(" \n");
+ pom.append(" \n");
+ pom.append(" true\n");
+ pom.append(" \n");
+ pom.append(" \n");
+ pom.append(" \n\n");
+ }
+
+ // Build with skills-jar-plugin
+ pom.append(" \n");
+ pom.append(" \n");
+ pom.append(" \n");
+ pom.append(" ").append(PLUGIN_GROUP_ID).append("\n");
+ pom.append(" ").append(PLUGIN_ARTIFACT_ID).append("\n");
+ pom.append(" ").append(PLUGIN_VERSION).append("\n");
+ pom.append(" \n");
+ pom.append(" \n");
+ pom.append(" \n");
+ pom.append("\n");
+
+ Files.writeString(projectDir.resolve("pom.xml"), pom.toString());
+ }
+
+ /**
+ * Generates a Maven settings.xml with server credentials for the custom repository.
+ */
+ private void generateSettings(Path projectDir, String user, String password) throws IOException {
+ StringBuilder settings = new StringBuilder();
+ settings.append("\n");
+ settings.append("\n");
+ settings.append(" \n");
+ settings.append(" \n");
+ settings.append(" custom-repo\n");
+ settings.append(" ").append(escapeXml(user)).append("\n");
+ settings.append(" ").append(escapeXml(password)).append("\n");
+ settings.append(" \n");
+ settings.append(" \n");
+ settings.append("\n");
+
+ Files.writeString(projectDir.resolve("settings.xml"), settings.toString());
+ }
+
+ /**
+ * Recursively copies a directory tree from source to target.
+ */
+ private void copyDirectory(Path source, Path target) throws IOException {
+ Files.walkFileTree(source, new SimpleFileVisitor() {
+ @Override
+ public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
+ throws IOException {
+ Path targetPath = target.resolve(source.relativize(dir));
+ Files.createDirectories(targetPath);
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
+ throws IOException {
+ Path targetPath = target.resolve(source.relativize(file));
+ Files.copy(file, targetPath, StandardCopyOption.REPLACE_EXISTING);
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ }
+
+ /**
+ * Recursively deletes a directory tree.
+ */
+ private void deleteDirectory(Path dir) {
+ if (!Files.exists(dir)) {
+ return;
+ }
+ try (Stream walk = Files.walk(dir)) {
+ walk.sorted(Comparator.reverseOrder())
+ .forEach(path -> {
+ try {
+ Files.delete(path);
+ } catch (IOException e) {
+ // best-effort cleanup
+ }
+ });
+ } catch (IOException e) {
+ // best-effort cleanup
+ }
+ }
+
+ /**
+ * Checks if a directory is empty.
+ */
+ private boolean isDirectoryEmpty(Path dir) throws IOException {
+ try (Stream entries = Files.list(dir)) {
+ return entries.findFirst().isEmpty();
+ }
+ }
+
+ /**
+ * Escapes special XML characters in a string.
+ */
+ private static String escapeXml(String text) {
+ return text.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+ .replace("'", "'");
+ }
+}
diff --git a/src/test/java/ca/weblite/installskill/InstallSkillIT.java b/src/test/java/ca/weblite/installskill/InstallSkillIT.java
new file mode 100644
index 0000000..ca8533d
--- /dev/null
+++ b/src/test/java/ca/weblite/installskill/InstallSkillIT.java
@@ -0,0 +1,194 @@
+package ca.weblite.installskill;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import picocli.CommandLine;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Integration test that installs skills for ca.weblite:teavm-lambda-parent.
+ *
+ * The teavm-lambda project publishes skills via the skills-jar-maven-plugin.
+ * This test creates a fixture skills JAR matching the expected format and installs
+ * it to the local Maven repository, then verifies the install-skill CLI can
+ * resolve, unpack, and copy the skill to the target directory.
+ *
+ * Requires Maven on PATH.
+ */
+class InstallSkillIT {
+
+ private static final String TEAVM_LAMBDA_GROUP = "ca.weblite";
+ private static final String TEAVM_LAMBDA_ARTIFACT = "teavm-lambda-parent";
+ private static final String TEAVM_LAMBDA_VERSION = "0.1.2";
+
+ private static final String SKILL_MD_CONTENT =
+ "---\n" +
+ "name: teavm-lambda\n" +
+ "description: Skill for working with teavm-lambda projects. Provides guidance on TeaVM-based serverless functions.\n" +
+ "---\n" +
+ "\n" +
+ "# TeaVM Lambda Skill\n" +
+ "\n" +
+ "This skill provides guidance for working with the teavm-lambda framework.\n";
+
+ private static Path fixtureJar;
+ private Path skillsDir;
+
+ @BeforeAll
+ static void installFixture() throws Exception {
+ // Create a skills JAR matching the format produced by skills-jar-plugin
+ fixtureJar = Files.createTempFile("teavm-lambda-skills-", ".jar");
+ try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(fixtureJar))) {
+ // skill/SKILL.md — the skills-jar plugin stores content under "skill/" prefix
+ jos.putNextEntry(new JarEntry("skill/SKILL.md"));
+ jos.write(SKILL_MD_CONTENT.getBytes());
+ jos.closeEntry();
+ }
+
+ // Install the fixture JAR into the local Maven repository with the
+ // classifier "skills", matching what the skills-jar-plugin produces
+ ProcessBuilder pb = new ProcessBuilder(
+ "mvn", "-B",
+ "install:install-file",
+ "-Dfile=" + fixtureJar,
+ "-DgroupId=" + TEAVM_LAMBDA_GROUP,
+ "-DartifactId=" + TEAVM_LAMBDA_ARTIFACT,
+ "-Dversion=" + TEAVM_LAMBDA_VERSION,
+ "-Dclassifier=skills",
+ "-Dpackaging=jar",
+ "-DgeneratePom=false"
+ );
+ pb.redirectErrorStream(true);
+ Process process = pb.start();
+
+ StringBuilder output = new StringBuilder();
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(process.getInputStream()))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ output.append(line).append('\n');
+ }
+ }
+
+ int exitCode = process.waitFor();
+ assertEquals(0, exitCode,
+ "Failed to install fixture skills JAR to local repo:\n" + output);
+ }
+
+ @AfterAll
+ static void cleanFixture() throws IOException {
+ if (fixtureJar != null) {
+ Files.deleteIfExists(fixtureJar);
+ }
+ }
+
+ @BeforeEach
+ void setUp() throws IOException {
+ skillsDir = Files.createTempDirectory("install-skill-it-");
+ }
+
+ @AfterEach
+ void tearDown() throws IOException {
+ if (skillsDir != null && Files.exists(skillsDir)) {
+ Files.walkFileTree(skillsDir, new SimpleFileVisitor<>() {
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
+ throws IOException {
+ Files.delete(file);
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult postVisitDirectory(Path dir, IOException exc)
+ throws IOException {
+ Files.delete(dir);
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ }
+ }
+
+ @Test
+ void installTeavmLambdaSkill() throws IOException {
+ int exitCode = new CommandLine(new InstallSkillCommand()).execute(
+ TEAVM_LAMBDA_GROUP + ":" + TEAVM_LAMBDA_ARTIFACT + ":" + TEAVM_LAMBDA_VERSION,
+ "-d", skillsDir.toString()
+ );
+
+ assertEquals(0, exitCode, "install-skill should exit successfully");
+
+ // Verify the skill directory was created (named after the artifactId)
+ Path skillDir = skillsDir.resolve(TEAVM_LAMBDA_ARTIFACT);
+ assertTrue(Files.isDirectory(skillDir),
+ "Skill directory should exist: " + skillDir);
+
+ // Verify SKILL.md exists and has content
+ Path skillMd = skillDir.resolve("SKILL.md");
+ assertTrue(Files.isRegularFile(skillMd),
+ "SKILL.md should exist in installed skill directory");
+
+ String skillContent = Files.readString(skillMd);
+ assertFalse(skillContent.isBlank(), "SKILL.md should not be empty");
+ assertTrue(skillContent.startsWith("---"),
+ "SKILL.md should start with YAML frontmatter delimiter");
+ assertTrue(skillContent.contains("name:"),
+ "SKILL.md frontmatter should contain a name field");
+ assertTrue(skillContent.contains("description:"),
+ "SKILL.md frontmatter should contain a description field");
+ assertTrue(skillContent.contains("teavm-lambda"),
+ "SKILL.md should reference teavm-lambda");
+
+ // Verify the manifest was created
+ Path manifest = skillsDir.resolve(".skill-manifest.json");
+ assertTrue(Files.isRegularFile(manifest),
+ ".skill-manifest.json should exist");
+
+ String manifestContent = Files.readString(manifest);
+ assertTrue(manifestContent.contains(
+ TEAVM_LAMBDA_GROUP + ":" + TEAVM_LAMBDA_ARTIFACT + ":" + TEAVM_LAMBDA_VERSION),
+ "Manifest should contain the GAV coordinates");
+
+ // Verify at least the SKILL.md was installed
+ long fileCount;
+ try (Stream files = Files.walk(skillDir)) {
+ fileCount = files.filter(Files::isRegularFile).count();
+ }
+ assertTrue(fileCount >= 1,
+ "Installed skill should contain at least 1 file, found: " + fileCount);
+ }
+
+ @Test
+ void installTeavmLambdaSkillWithDefaultVersion() throws IOException {
+ // Test using RELEASE (default version) — should resolve the latest available version
+ int exitCode = new CommandLine(new InstallSkillCommand()).execute(
+ TEAVM_LAMBDA_GROUP + ":" + TEAVM_LAMBDA_ARTIFACT,
+ "-d", skillsDir.toString()
+ );
+
+ assertEquals(0, exitCode, "install-skill with default version should exit successfully");
+
+ Path skillDir = skillsDir.resolve(TEAVM_LAMBDA_ARTIFACT);
+ assertTrue(Files.isDirectory(skillDir),
+ "Skill directory should exist when using default version");
+
+ Path skillMd = skillDir.resolve("SKILL.md");
+ assertTrue(Files.isRegularFile(skillMd),
+ "SKILL.md should exist when using default version");
+ }
+}