Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions spring-ai-agent-utils/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@
<version>1.12.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.skillsjars</groupId>
<artifactId>anthropics__skills__pdf</artifactId>
<version>2026_02_06-1ed29a0</version>
<scope>test</scope>
</dependency>
</dependencies>

<dependencyManagement>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
*/
package org.springaicommunity.agent.tools;

import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
Expand Down Expand Up @@ -77,8 +76,7 @@ public String apply(SkillsInput input) {
Skill skill = this.skillsMap.get(input.command());

if (skill != null) {
var skillBaseDirectory = skill.path().getParent().toString();
return "Base directory for this skill: %s\n\n%s".formatted(skillBaseDirectory, skill.content());
return "Base directory for this skill: %s\n\n%s".formatted(skill.basePath(), skill.content());
}

return "Skill not found: " + input.command();
Expand Down Expand Up @@ -143,11 +141,11 @@ public ToolCallback build() {
/**
* Represents a SKILL.md file with its location and parsed content.
*/
public static record Skill(Path path, Map<String, Object> frontMatter, String content) {
public static record Skill(String basePath, Map<String, Object> frontMatter, String content) {

public String name() {
return this.frontMatter().get("name").toString();
}
}

public String toXml() {
String frontMatterXml = this.frontMatter()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,8 @@ public String execute(TaskCall taskCall, SubagentDefinition subagent) {

preloadedSkillsSystemSuffix = "\n"
+ skills.stream().filter(s -> claudeSubagent.skills().contains(s.name())).map(skill -> {
var skillBaseDirectory = skill.path().getParent().toString();
return "%s\nBase directory for this skill: %s\n\n%s".formatted(skill.toXml(),
skillBaseDirectory, skill.content());
skill.basePath(), skill.content());
}).collect(Collectors.joining("\n\n"));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,28 @@
*/
package org.springaicommunity.agent.utils;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.JarURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.stream.Stream;

import org.springaicommunity.agent.tools.SkillsTool.Skill;

import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;

/**
* @author Christian Tzolov
Expand All @@ -35,11 +45,11 @@
public class Skills {

/**
* Loads skills from the given resources, which can be directories containing
* SKILL.md files.
* Loads skills from the given resources, which can be directories or classpath
* locations containing SKILL.md files.
* @param skillsResources the resources to load skills from
* @return a list of Skill objects containing the path, front-matter, and content of
* each SKILL.md file found in the resources
* @return a list of Skill objects containing the basePath, front-matter, and content
* of each SKILL.md file found in the resources
*/
public static List<Skill> loadResources(List<Resource> skillsResources) {
List<Skill> skills = new ArrayList<>();
Expand All @@ -50,11 +60,11 @@ public static List<Skill> loadResources(List<Resource> skillsResources) {
}

/**
* Loads skills from the given resource, which can be a directory containing SKILL.md
* files.
* @param skillsResource the resource to load skills from
* @return a list of Skill objects containing the path, front-matter, and content of
* each SKILL.md file found in the resource
* Loads skills from the given resources. Supports filesystem directories, JAR-based
* classpath resources, and {@link ClassPathResource} references to directories inside
* JARs.
* @param skillsResources the resources to load skills from
* @return a list of Skill objects
* @throws RuntimeException if an I/O error occurs while reading the resource
*/
public static List<Skill> loadResource(Resource... skillsResources) {
Expand All @@ -67,7 +77,12 @@ public static List<Skill> loadResource(Resource... skillsResources) {
skills.addAll(loadDirectory(path));
}
catch (IOException ex) {
throw new RuntimeException("Failed to load skills from directory: " + skillsResource, ex);
try {
skills.addAll(loadJarResource(skillsResource));
}
catch (IOException jarEx) {
throw new RuntimeException("Failed to load skills from resource: " + skillsResource, jarEx);
}
}
}
return skills;
Expand All @@ -78,15 +93,15 @@ public static List<Skill> loadDirectories(List<String> rootDirectories) {
for (String rootDirectory : rootDirectories) {
skills.addAll(loadDirectory(rootDirectory));
}
return skills;
return skills;
}

/**
* Recursively finds all SKILL.md files in the given root directory and returns their
* parsed contents.
* @param rootDirectory the root directory to search for SKILL.md files
* @return a list of Skill objects containing the path, front-matter, and content of
* each SKILL.md file
* @return a list of Skill objects containing the basePath, front-matter, and content
* of each SKILL.md file
* @throws RuntimeException if an I/O error occurs while reading the directory or
* files
*/
Expand All @@ -111,7 +126,8 @@ public static List<Skill> loadDirectory(String rootDirectory) {
try {
String markdown = Files.readString(path, StandardCharsets.UTF_8);
MarkdownParser parser = new MarkdownParser(markdown);
skills.add(new Skill(path, parser.getFrontMatter(), parser.getContent()));
skills.add(new Skill(path.getParent().toString(), parser.getFrontMatter(),
parser.getContent()));
}
catch (IOException e) {
throw new RuntimeException("Failed to read SKILL.md file: " + path, e);
Expand All @@ -125,4 +141,158 @@ public static List<Skill> loadDirectory(String rootDirectory) {
return skills;
}

/**
* Loads skills from a non-filesystem resource. Handles two cases:
* <ul>
* <li>Resources with resolvable {@code jar:} URLs (e.g.,
* {@link org.springframework.core.io.UrlResource}) — uses
* {@link JarURLConnection}</li>
* <li>{@link ClassPathResource} where the directory lacks an explicit JAR entry —
* uses Spring's {@link ResourcePatternResolver} with a manual JAR scan fallback</li>
* </ul>
* @param resource the resource pointing to a skills directory
* @return a list of Skill objects parsed from SKILL.md files
* @throws IOException if an I/O error occurs while reading
*/
private static List<Skill> loadJarResource(Resource resource) throws IOException {
URL resourceUrl;
try {
resourceUrl = resource.getURL();
}
catch (FileNotFoundException ex) {
// ClassPathResource for a JAR directory without an explicit directory entry
// cannot resolve to a URL. Fall back to classpath scanning.
if (resource instanceof ClassPathResource classPathResource) {
return loadFromClasspath(classPathResource.getPath());
}
throw ex;
}

String protocol = resourceUrl.getProtocol();

if (!"jar".equals(protocol)) {
throw new IOException("Unsupported resource protocol for JAR loading: " + protocol);
}

JarURLConnection jarConnection = (JarURLConnection) resourceUrl.openConnection();
String entryPrefix = jarConnection.getEntryName();
if (!entryPrefix.endsWith("/")) {
entryPrefix = entryPrefix + "/";
}
return scanJarForSkills(jarConnection.getJarFile(), entryPrefix);
}

/**
* Discovers SKILL.md files under the given classpath prefix using Spring's
* {@link ResourcePatternResolver}. Falls back to manual JAR scanning for JARs that
* lack explicit directory entries (a known limitation of
* {@link PathMatchingResourcePatternResolver} — see Spring Framework issue #16711).
* @param classpathPrefix the classpath prefix to scan (e.g.,
* "META-INF/resources/skills")
* @return a list of Skill objects parsed from discovered SKILL.md files
* @throws IOException if an I/O error occurs during scanning or reading
*/
private static List<Skill> loadFromClasspath(String classpathPrefix) throws IOException {
// Primary: Spring's ResourcePatternResolver — works for well-formed JARs with
// explicit directory entries and for resources on the filesystem.
ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
Resource[] resources = resolver.getResources("classpath*:" + classpathPrefix + "/**/SKILL.md");

if (resources.length > 0) {
List<Skill> skills = new ArrayList<>();
for (Resource skillResource : resources) {
try (InputStream is = skillResource.getInputStream()) {
String basePath = deriveBasePathFromUrl(skillResource.getURL());
skills.add(parseSkill(is, basePath));
}
}
return skills;
}

// Fallback: Manual JAR scanning for JARs without directory entries.
// Uses the same strategy as Spring's own
// PathMatchingResourcePatternResolver.addAllClassLoaderJarRoots().
return scanClasspathJarsForSkills(classpathPrefix);
}

/**
* Scans all classpath JARs for SKILL.md files under the given prefix. Discovers JARs
* via {@code ClassLoader.getResources("META-INF/MANIFEST.MF")} — a technique used by
* Spring internally when standard classpath resolution is insufficient.
*/
private static List<Skill> scanClasspathJarsForSkills(String classpathPrefix) throws IOException {
String prefix = classpathPrefix.endsWith("/") ? classpathPrefix : classpathPrefix + "/";

ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
if (classLoader == null) {
classLoader = Skills.class.getClassLoader();
}

List<Skill> skills = new ArrayList<>();

Enumeration<URL> manifests = classLoader.getResources("META-INF/MANIFEST.MF");
while (manifests.hasMoreElements()) {
URL manifestUrl = manifests.nextElement();
if (!"jar".equals(manifestUrl.getProtocol())) {
continue;
}

JarURLConnection jarConnection = (JarURLConnection) manifestUrl.openConnection();
skills.addAll(scanJarForSkills(jarConnection.getJarFile(), prefix));
}

return skills;
}

/**
* Scans a single JAR file for SKILL.md entries under the given prefix.
* @param jarFile the JAR to scan
* @param entryPrefix the entry prefix to match (must end with '/')
* @return a list of Skill objects found in this JAR
*/
private static List<Skill> scanJarForSkills(JarFile jarFile, String entryPrefix) throws IOException {
List<Skill> skills = new ArrayList<>();
Enumeration<JarEntry> entries = jarFile.entries();

while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String entryName = entry.getName();

if (!entry.isDirectory() && entryName.startsWith(entryPrefix)
&& entryName.endsWith("/SKILL.md")) {
try (InputStream is = jarFile.getInputStream(entry)) {
skills.add(parseSkill(is, entryName));
}
}
}
return skills;
}

/**
* Parses a SKILL.md file from an input stream into a {@link Skill}.
* @param is the input stream containing the SKILL.md markdown content
* @param entryPath the JAR entry path — used to derive the base directory
*/
private static Skill parseSkill(InputStream is, String entryPath) throws IOException {
String markdown = new String(is.readAllBytes(), StandardCharsets.UTF_8);
MarkdownParser parser = new MarkdownParser(markdown);
String basePath = entryPath.endsWith("/SKILL.md")
? entryPath.substring(0, entryPath.lastIndexOf('/'))
: entryPath;
return new Skill(basePath, parser.getFrontMatter(), parser.getContent());
}

/**
* Derives the JAR-internal base path from a resource URL by stripping the SKILL.md
* filename and the {@code jar:file:...!/} prefix.
*/
private static String deriveBasePathFromUrl(URL skillUrl) {
String urlStr = skillUrl.toString();
String basePath = urlStr.substring(0, urlStr.lastIndexOf("/SKILL.md"));
if (basePath.contains("!/")) {
basePath = basePath.substring(basePath.indexOf("!/") + 2);
}
return basePath;
}

}
Loading