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"); + } +}