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}