001/* 002 * Copyright 2026 - 2026 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package com.agentsflex.skill.util; 017 018 019import com.agentsflex.core.util.IOUtil; 020import com.agentsflex.skill.Skill; 021 022import java.io.IOException; 023import java.io.InputStream; 024import java.net.JarURLConnection; 025import java.net.URL; 026import java.nio.file.Files; 027import java.nio.file.Path; 028import java.nio.file.Paths; 029import java.util.ArrayList; 030import java.util.Enumeration; 031import java.util.List; 032import java.util.jar.JarEntry; 033import java.util.jar.JarFile; 034import java.util.stream.Stream; 035 036/** 037 * @author Christian Tzolov 038 * @author Micahel Yang 039 */ 040 041public class Skills { 042 043 044 public static List<Skill> loadDirectories(List<String> rootDirectories) { 045 List<Skill> skills = new ArrayList<>(); 046 for (String rootDirectory : rootDirectories) { 047 skills.addAll(loadDirectory(rootDirectory)); 048 } 049 return skills; 050 } 051 052 /** 053 * Recursively finds all SKILL.md files in the given root directory and returns their 054 * parsed contents. 055 * 056 * @param rootDirectory the root directory to search for SKILL.md files 057 * @return a list of Skill objects containing the basePath, front-matter, and content 058 * of each SKILL.md file 059 * @throws RuntimeException if an I/O error occurs while reading the directory or 060 * files 061 */ 062 public static List<Skill> loadDirectory(String rootDirectory) { 063 064 Path rootPath = Paths.get(rootDirectory); 065 066 if (!Files.exists(rootPath)) { 067 throw new RuntimeException("Root directory does not exist: " + rootDirectory); 068 } 069 070 if (!Files.isDirectory(rootPath)) { 071 throw new RuntimeException("Path is not a directory: " + rootDirectory); 072 } 073 074 List<Skill> skills = new ArrayList<>(); 075 076 try (Stream<Path> paths = Files.walk(rootPath)) { 077 paths.filter(Files::isRegularFile) 078 .filter(path -> path.getFileName().toString().equals("SKILL.md")) 079 .forEach(path -> { 080 try { 081 String markdown = IOUtil.readUtf8(Files.newInputStream(path)); 082 MarkdownParser parser = new MarkdownParser(markdown); 083 skills.add(new Skill(path.getParent().toString(), parser.getFrontMatter(), 084 parser.getContent())); 085 } catch (IOException e) { 086 throw new RuntimeException("Failed to read SKILL.md file: " + path, e); 087 } 088 }); 089 } catch (IOException e) { 090 throw new RuntimeException("Failed to walk root directory: " + rootDirectory, e); 091 } 092 093 return skills; 094 } 095 096 097 /** 098 * Scans all classpath JARs for SKILL.md files under the given prefix. Discovers JARs 099 * via {@code ClassLoader.getResources("META-INF/MANIFEST.MF")} — a technique used by 100 * Spring internally when standard classpath resolution is insufficient. 101 */ 102 private static List<Skill> scanClasspathJarsForSkills(String classpathPrefix) throws IOException { 103 String prefix = classpathPrefix.endsWith("/") ? classpathPrefix : classpathPrefix + "/"; 104 105 ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); 106 if (classLoader == null) { 107 classLoader = Skills.class.getClassLoader(); 108 } 109 110 List<Skill> skills = new ArrayList<>(); 111 112 Enumeration<URL> manifests = classLoader.getResources("META-INF/MANIFEST.MF"); 113 while (manifests.hasMoreElements()) { 114 URL manifestUrl = manifests.nextElement(); 115 if (!"jar".equals(manifestUrl.getProtocol())) { 116 continue; 117 } 118 119 JarURLConnection jarConnection = (JarURLConnection) manifestUrl.openConnection(); 120 skills.addAll(scanJarForSkills(jarConnection.getJarFile(), prefix)); 121 } 122 123 return skills; 124 } 125 126 /** 127 * Scans a single JAR file for SKILL.md entries under the given prefix. 128 * 129 * @param jarFile the JAR to scan 130 * @param entryPrefix the entry prefix to match (must end with '/') 131 * @return a list of Skill objects found in this JAR 132 */ 133 private static List<Skill> scanJarForSkills(JarFile jarFile, String entryPrefix) throws IOException { 134 List<Skill> skills = new ArrayList<>(); 135 Enumeration<JarEntry> entries = jarFile.entries(); 136 137 while (entries.hasMoreElements()) { 138 JarEntry entry = entries.nextElement(); 139 String entryName = entry.getName(); 140 141 if (!entry.isDirectory() && entryName.startsWith(entryPrefix) 142 && entryName.endsWith("/SKILL.md")) { 143 try (InputStream is = jarFile.getInputStream(entry)) { 144 skills.add(parseSkill(is, entryName)); 145 } 146 } 147 } 148 return skills; 149 } 150 151 /** 152 * Parses a SKILL.md file from an input stream into a {@link Skill}. 153 * 154 * @param is the input stream containing the SKILL.md markdown content 155 * @param entryPath the JAR entry path — used to derive the base directory 156 */ 157 private static Skill parseSkill(InputStream is, String entryPath) throws IOException { 158 String markdown = IOUtil.readUtf8(is); 159 MarkdownParser parser = new MarkdownParser(markdown); 160 String basePath = entryPath.endsWith("/SKILL.md") 161 ? entryPath.substring(0, entryPath.lastIndexOf('/')) 162 : entryPath; 163 return new Skill(basePath, parser.getFrontMatter(), parser.getContent()); 164 } 165 166 /** 167 * Derives the JAR-internal base path from a resource URL by stripping the SKILL.md 168 * filename and the {@code jar:file:...!/} prefix. 169 */ 170 private static String deriveBasePathFromUrl(URL skillUrl) { 171 String urlStr = skillUrl.toString(); 172 String basePath = urlStr.substring(0, urlStr.lastIndexOf("/SKILL.md")); 173 if (basePath.contains("!/")) { 174 basePath = basePath.substring(basePath.indexOf("!/") + 2); 175 } 176 return basePath; 177 } 178 179}