diff --git a/README.md b/README.md index e1a3a01..b2cd645 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ JavaClaw is a Java-based personal AI assistant that runs on your own devices. It - **Multi-Channel Support** — Chat UI (WebSocket), Telegram, Discord, and an extensible plugin-based channel architecture - **Task Management** — Create, schedule (one-off, delayed, or recurring via cron), and track tasks as human-readable Markdown files -- **Extensible Skills** — Drop a `SKILL.md` into `workspace/skills/` and the agent picks it up at runtime +- **Extensible Skills** — Load skills from `workspace/skills/` and from `skills jar` packages on the classpath - **LLM Provider Choice** — Plug in OpenAI, Anthropic, or Ollama (local); switchable during onboarding - **MCP Support** — Model Context Protocol client for connecting external tool servers - **Shell & File Access** — Agent can read/write files and run bash commands on your machine @@ -130,6 +130,44 @@ agent: Skills extend the agent's capabilities at runtime without code changes. Create a directory under `workspace/skills//` containing a `SKILL.md` file and the agent will load it automatically via `SkillsTool`. +### Skills As Jars (SkillsJars) + +You can also package skills into a jar (or use a prebuilt one) and put it on the runtime classpath. JavaClaw can load skills from `classpath:/META-INF/skills`. + +1. Add a dependency that contains the skills (example): + +```gradle +dependencies { + runtimeOnly("com.skillsjars:some-skill-pack:VERSION") +} +``` + +2. Configure the classpath scan path: + +```yaml +agent: + skills: + paths: classpath:/META-INF/skills +``` + +3. Ensure the jar contains this layout: + +```text +META-INF/skills//SKILL.md +``` + +`SKILL.md` must include YAML frontmatter with a `name` (this is what you invoke): + +```md +--- +name: +description: +--- + +``` + +See the SkillsJars docs for packaging and published skill packs: https://www.skillsjars.com/ + ## Dashboard JobRunr's job dashboard is available at [http://localhost:8081](http://localhost:8081) for monitoring background task execution. @@ -142,6 +180,7 @@ Key properties in `application.yaml`: |---|---| | `agent.workspace` | Path to the workspace root (default: `file:./workspace/`) | | `agent.onboarding.completed` | Set to `true` after onboarding is done | +| `agent.skills.paths` | Classpath resource roots to scan for skills (e.g. `classpath:/META-INF/skills`) | | `spring.ai.model.chat` | Active LLM provider/model | | `javaclaw.tools.dynamic-discovery.enabled` | Enable dynamic tool discovery (Tool Search Tool pattern) instead of exposing all tools up front | | `jobrunr.dashboard.port` | JobRunr dashboard port (default: `8081`) | diff --git a/app/src/main/resources/application.yaml b/app/src/main/resources/application.yaml index eddb9a0..3e8f294 100644 --- a/app/src/main/resources/application.yaml +++ b/app/src/main/resources/application.yaml @@ -25,6 +25,8 @@ agent: onboarding: completed: false workspace: file:./workspace/ + skills: + paths: classpath:/META-INF/skills jobrunr: background-job-server: enabled: true diff --git a/base/build.gradle b/base/build.gradle index 7ff73c7..e84bd4b 100644 --- a/base/build.gradle +++ b/base/build.gradle @@ -1,5 +1,6 @@ plugins { id 'java-library' + id("com.skillsjars.gradle-plugin") version "0.0.2" } dependencies { diff --git a/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java b/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java index f97fcd7..4074e87 100644 --- a/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java +++ b/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java @@ -44,6 +44,10 @@ public class JavaClawConfiguration { public static final String AGENT_MD = "AGENT.private.md"; + @Value("${agent.skills.paths}") + List skillPaths; + + @Bean @ConditionalOnProperty(name = SpringAIModelProperties.CHAT_MODEL, havingValue = "unknown", matchIfMissing = true) public ChatModel chatModel() { @@ -94,7 +98,11 @@ public ChatClient chatClient(ChatClient.Builder chatClientBuilder, .defaultAdvisors(new SimpleLoggerAdvisor()) .defaultSystem(p -> p.text(agentPrompt).param(AgentEnvironment.ENVIRONMENT_INFO_KEY, AgentEnvironment.info())) .defaultToolCallbacks(mcpToolProvider.getToolCallbacks()) - .defaultToolCallbacks(SkillsTool.builder().addSkillsDirectory(skillsDir(workspace).toString()).build()) + .defaultToolCallbacks(SkillsTool.builder() + .addSkillsDirectory(skillsDir(workspace).toString()) + .addSkillsResources(skillPaths) + .build() + ) .defaultTools( TaskTool.builder().taskManager(taskManager).build(), CheckListTool.builder().build(), diff --git a/base/src/test/java/ai/javaclaw/tools/SkillsJarSupportTest.java b/base/src/test/java/ai/javaclaw/tools/SkillsJarSupportTest.java new file mode 100644 index 0000000..0963c18 --- /dev/null +++ b/base/src/test/java/ai/javaclaw/tools/SkillsJarSupportTest.java @@ -0,0 +1,81 @@ +package ai.javaclaw.tools; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springaicommunity.agent.tools.SkillsTool; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; + +import static org.assertj.core.api.Assertions.assertThat; + +class SkillsJarSupportTest { + + @TempDir + Path tempDir; + + @Test + void loadsSkillsFromJarResource() throws Exception { + // GIVEN + Path jarPath = tempDir.resolve("skills.jar"); + writeSkillJar(jarPath); + + // WHEN + ToolCallback callback = getSkillsToolWithSkillsJar(jarPath, "META-INF/skills"); + + // THEM + String result = callback.call("{\"command\":\"jar-skill\"}"); + assertThat(result).contains("Base directory for this skill:"); + assertThat(result).contains("This skill came from a jar."); + } + + private static ToolCallback getSkillsToolWithSkillsJar(Path jarPath, String path) throws IOException { + ClassLoader originalCl = Thread.currentThread().getContextClassLoader(); + try (URLClassLoader cl = new URLClassLoader(new URL[]{jarPath.toUri().toURL()}, originalCl)) { + Thread.currentThread().setContextClassLoader(cl); + + return SkillsTool.builder() + .addSkillsResource(new ClassPathResource(path, cl)) + .build(); + } finally { + Thread.currentThread().setContextClassLoader(originalCl); + } + } + + private static void writeSkillJar(Path jarPath) throws Exception { + Files.createDirectories(jarPath.getParent()); + try (OutputStream out = Files.newOutputStream(jarPath); + JarOutputStream jar = new JarOutputStream(out, manifest())) { + JarEntry entry = new JarEntry("META-INF/skills/jar-skill/SKILL.md"); + jar.putNextEntry(entry); + jar.write(skillMarkdown().getBytes(StandardCharsets.UTF_8)); + jar.closeEntry(); + } + } + + private static Manifest manifest() { + Manifest manifest = new Manifest(); + manifest.getMainAttributes().putValue("Manifest-Version", "1.0"); + return manifest; + } + + private static String skillMarkdown() { + return """ + --- + name: jar-skill + description: Test skill packaged in a jar + --- + This skill came from a jar. + """; + } +}