diff --git a/README.md b/README.md index b93e779..ffe1172 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 | | `jobrunr.dashboard.port` | JobRunr dashboard port (default: `8081`) | | `jobrunr.background-job-server.worker-count` | Concurrent job workers (default: `1`) | diff --git a/app/build.gradle b/app/build.gradle index 8907aab..e49daf2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -36,6 +36,7 @@ dependencies { testImplementation 'org.testcontainers:testcontainers-junit-jupiter' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + } bootRun { diff --git a/app/src/main/resources/application.yaml b/app/src/main/resources/application.yaml index 8137b6f..7b50702 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 9114197..254b7db 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 f818d3e..6c69202 100644 --- a/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java +++ b/base/src/main/java/ai/javaclaw/JavaClawConfiguration.java @@ -43,6 +43,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() { @@ -87,7 +91,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..69c569c --- /dev/null +++ b/base/src/test/java/ai/javaclaw/tools/SkillsJarSupportTest.java @@ -0,0 +1,74 @@ +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.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 { + Path jarPath = tempDir.resolve("skills.jar"); + writeSkillJar(jarPath); + + URL jarUrl = jarPath.toUri().toURL(); + ClassLoader originalCl = Thread.currentThread().getContextClassLoader(); + try (URLClassLoader cl = new URLClassLoader(new URL[]{jarUrl}, originalCl)) { + Thread.currentThread().setContextClassLoader(cl); + + ToolCallback callback = SkillsTool.builder() + .addSkillsResource(new ClassPathResource("META-INF/skills", cl)) + .build(); + + String result = callback.call("{\"command\":\"jar-skill\"}"); + assertThat(result).contains("Base directory for this skill:"); + assertThat(result).contains("This skill came from a jar."); + } 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. + """; + } +}