diff --git a/ai-providers/ai-skills/pom.xml b/ai-providers/ai-skills/pom.xml
new file mode 100644
index 00000000..574b201e
--- /dev/null
+++ b/ai-providers/ai-skills/pom.xml
@@ -0,0 +1,86 @@
+
+
+ 4.0.0
+
+ dev.snowdrop.mtool.ai
+ ai-providers-parent
+ 1.0.5-SNAPSHOT
+
+
+ ai-skills
+ Migration Tool :: AI :: Agent skills
+
+
+ 21
+ 21
+ UTF-8
+
+
+
+
+
+ io.quarkus
+ quarkus-picocli
+
+
+
+
+
+ io.quarkiverse.langchain4j
+ quarkus-langchain4j-core
+ 999-SNAPSHOT
+
+
+ io.quarkiverse.langchain4j
+ quarkus-langchain4j-skills
+ 999-SNAPSHOT
+
+
+ dev.snowdrop.mtool.ai
+ vertex-ai-anthropic
+ 1.0.5-SNAPSHOT
+
+
+
+ dev.langchain4j
+ langchain4j-vertex-ai-anthropic
+
+
+
+
+
+
+ ${quarkus.platform.group-id}
+ quarkus-maven-plugin
+ true
+
+
+
+ build
+ generate-code
+
+ none
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ 25
+ 25
+ --enable-preview
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ai-providers/ai-skills/src/main/java/AiHttpAgent.java b/ai-providers/ai-skills/src/main/java/AiHttpAgent.java
new file mode 100644
index 00000000..52e86a5d
--- /dev/null
+++ b/ai-providers/ai-skills/src/main/java/AiHttpAgent.java
@@ -0,0 +1,260 @@
+///usr/bin/env jbang "$0" "$@" ; exit $?
+//JAVA 25+
+//DEPS com.fasterxml.jackson.core:jackson-databind:2.18.2
+
+import module java.base;
+
+import static java.lang.System.getenv;
+import static java.nio.file.Files.*;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+/**
+ * nanocode - minimal claude code alternative. Original:
+ * https://github.com/1rgs/nanocode
+ */
+
+static final ObjectMapper JSON = new ObjectMapper();
+static final String OPENROUTER_KEY = getenv("OPENROUTER_API_KEY");
+static final String API_URL = OPENROUTER_KEY != null
+ ? "https://openrouter.ai/api/v1/messages"
+ : "https://api.anthropic.com/v1/messages";
+static final String MODEL = Optional.ofNullable(getenv("MODEL"))
+ .orElse(OPENROUTER_KEY != null ? "anthropic/claude-opus-4.6" : "claude-opus-4-6");
+
+ static final String RESET = "\033[0m", BOLD = "\033[1m", DIM = "\033[2m";
+ static final String BLUE = "\033[34m", CYAN = "\033[36m", GREEN = "\033[32m", RED = "\033[31m";
+
+// --- Tools ---
+
+static String toolRead(JsonNode args) throws IOException {
+ var lines = readAllLines(Path.of(args.get("path").asText()));
+ int offset = args.path("offset").asInt(0), limit = args.path("limit").asInt(lines.size());
+ var sb = new StringBuilder();
+ for (int i = offset; i < Math.min(offset + limit, lines.size()); i++)
+ sb.append("%4d| %s%n".formatted(i + 1, lines.get(i)));
+ return sb.toString();
+}
+
+static String toolWrite(JsonNode args) throws IOException {
+ writeString(Path.of(args.get("path").asText()), args.get("content").asText());
+ return "ok";
+}
+
+static String toolEdit(JsonNode args) throws IOException {
+ var path = Path.of(args.get("path").asText());
+ var text = readString(path);
+ var old = args.get("old").asText();
+ var repl = args.get("new").asText();
+ if (!text.contains(old))
+ return "error: old_string not found";
+ int count = (text.length() - text.replace(old, "").length()) / old.length();
+ if (!args.path("all").asBoolean() && count > 1)
+ return "error: old_string appears " + count + " times, must be unique (use all=true)";
+ writeString(path, args.path("all").asBoolean()
+ ? text.replace(old, repl)
+ : text.replaceFirst(Pattern.quote(old), Matcher.quoteReplacement(repl)));
+ return "ok";
+}
+
+static String toolGlob(JsonNode args) throws IOException {
+ var base = Path.of(args.path("path").asText("."));
+ var matcher = FileSystems.getDefault().getPathMatcher("glob:" + base + "/" + args.get("pat").asText());
+ if (!exists(base))
+ return "none";
+ try (var walk = walk(base)) {
+ var files = walk.filter(Files::isRegularFile).filter(matcher::matches)
+ .sorted((a, b) -> {
+ try {
+ return getLastModifiedTime(b).compareTo(getLastModifiedTime(a));
+ } catch (IOException e) {
+ return 0;
+ }
+ })
+ .map(Path::toString).toList();
+ return files.isEmpty() ? "none" : String.join("\n", files);
+ }
+}
+
+static String toolGrep(JsonNode args) throws IOException {
+ var pattern = Pattern.compile(args.get("pat").asText());
+ var base = Path.of(args.path("path").asText("."));
+ var hits = new ArrayList();
+ try (var walk = walk(base)) {
+ walk.filter(Files::isRegularFile).takeWhile(_ -> hits.size() < 50).forEach(file -> {
+ try {
+ var lines = readAllLines(file);
+ for (int i = 0; i < lines.size() && hits.size() < 50; i++)
+ if (pattern.matcher(lines.get(i)).find())
+ hits.add(file + ":" + (i + 1) + ":" + lines.get(i));
+ } catch (Exception e) {
+ /* skip */ }
+ });
+ }
+ return hits.isEmpty() ? "none" : String.join("\n", hits);
+}
+
+static String toolBash(JsonNode args) throws Exception {
+ var proc = new ProcessBuilder("sh", "-c", args.get("cmd").asText()).redirectErrorStream(true).start();
+ var out = new ArrayList();
+ try (var r = new BufferedReader(new InputStreamReader(proc.getInputStream()))) {
+ String line;
+ while ((line = r.readLine()) != null) {
+ System.out.println(" " + DIM + "│ " + line + RESET);
+ out.add(line);
+ }
+ }
+ if (!proc.waitFor(30, TimeUnit.SECONDS)) {
+ proc.destroyForcibly();
+ out.add("(timed out after 30s)");
+ }
+ return out.isEmpty() ? "(empty)" : String.join("\n", out);
+}
+
+static String runTool(String name, JsonNode args) {
+ try {
+ return switch (name) {
+ case "read" -> toolRead(args);
+ case "write" -> toolWrite(args);
+ case "edit" -> toolEdit(args);
+ case "glob" -> toolGlob(args);
+ case "grep" -> toolGrep(args);
+ case "bash" -> toolBash(args);
+ default -> "error: unknown tool " + name;
+ };
+ } catch (Exception e) {
+ return "error: " + e.getMessage();
+ }
+}
+
+// --- Schema ---
+
+static final String SCHEMA = """
+ [{"name":"read","description":"Read file with line numbers (file path, not directory)","input_schema":{"type":"object","properties":{"path":{"type":"string"},"offset":{"type":"integer"},"limit":{"type":"integer"}},"required":["path"]}},
+ {"name":"write","description":"Write content to file","input_schema":{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}},
+ {"name":"edit","description":"Replace old with new in file (old must be unique unless all=true)","input_schema":{"type":"object","properties":{"path":{"type":"string"},"old":{"type":"string"},"new":{"type":"string"},"all":{"type":"boolean"}},"required":["path","old","new"]}},
+ {"name":"glob","description":"Find files by pattern, sorted by mtime","input_schema":{"type":"object","properties":{"pat":{"type":"string"},"path":{"type":"string"}},"required":["pat"]}},
+ {"name":"grep","description":"Search files for regex pattern","input_schema":{"type":"object","properties":{"pat":{"type":"string"},"path":{"type":"string"}},"required":["pat"]}},
+ {"name":"bash","description":"Run shell command","input_schema":{"type":"object","properties":{"cmd":{"type":"string"}},"required":["cmd"]}}]""";
+
+// --- API ---
+
+static JsonNode callApi(ArrayNode messages, String systemPrompt) throws IOException {
+ var body = JSON.createObjectNode().put("model", MODEL).put("max_tokens", 8192).put("system", systemPrompt);
+ body.set("messages", messages);
+ body.set("tools", JSON.readTree(SCHEMA));
+
+ var conn = (HttpURLConnection) URI.create(API_URL).toURL().openConnection();
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type", "application/json");
+ conn.setRequestProperty("anthropic-version", "2023-06-01");
+ conn.setRequestProperty(OPENROUTER_KEY != null ? "Authorization" : "x-api-key",
+ OPENROUTER_KEY != null ? "Bearer " + OPENROUTER_KEY
+ : Optional.ofNullable(getenv("ANTHROPIC_API_KEY")).orElse(""));
+
+ try (var os = conn.getOutputStream()) {
+ os.write(JSON.writeValueAsBytes(body));
+ }
+ int status = conn.getResponseCode();
+ var response = JSON.readTree(status >= 400 ? conn.getErrorStream() : conn.getInputStream());
+ if (status >= 400)
+ throw new IOException("API error " + status + ": " + response);
+ return response;
+}
+
+// --- UI ---
+
+static String sep() {
+ try {
+ var p = new ProcessBuilder("tput", "cols").redirectErrorStream(true).start();
+ return DIM + "─".repeat(Math.min(Integer.parseInt(new String(p.getInputStream().readAllBytes()).trim()), 80))
+ + RESET;
+ } catch (Exception e) {
+ return DIM + "─".repeat(80) + RESET;
+ }
+}
+
+static String preview(String s, int max) {
+ var lines = s.split("\n");
+ var p = lines[0].substring(0, Math.min(lines[0].length(), max));
+ return lines.length > 1 ? p + " ... +" + (lines.length - 1) + " lines" : (lines[0].length() > max ? p + "..." : p);
+}
+
+// --- Main ---
+
+void main(String[] args) throws Exception {
+ var cwd = System.getProperty("user.dir");
+ System.out.println(BOLD + "nanocode" + RESET + " | " + DIM + MODEL + " ("
+ + (OPENROUTER_KEY != null ? "OpenRouter" : "Anthropic") + ") | " + cwd + RESET + "\n");
+
+ var messages = JSON.createArrayNode();
+ var systemPrompt = "Concise coding assistant. cwd: " + cwd;
+ var stdin = new BufferedReader(new InputStreamReader(System.in));
+
+ while (true) {
+ try {
+ System.out.println(sep());
+ System.out.print(BOLD + BLUE + "❯" + RESET + " ");
+ System.out.flush();
+ var input = stdin.readLine();
+ if (input == null)
+ break;
+ input = input.strip();
+ System.out.println(sep());
+ if (input.isEmpty())
+ continue;
+ if (input.equals("/q") || input.equals("exit"))
+ break;
+ if (input.equals("/c")) {
+ messages = JSON.createArrayNode();
+ System.out.println(GREEN + "⏺ Cleared" + RESET);
+ continue;
+ }
+
+ messages.add(JSON.createObjectNode().put("role", "user").put("content", input));
+
+ while (true) {
+ var response = callApi(messages, systemPrompt);
+ var content = response.get("content");
+ var toolResults = JSON.createArrayNode();
+
+ for (var block : content) {
+ if ("text".equals(block.get("type").asText()))
+ System.out.println("\n" + CYAN + "⏺" + RESET + " "
+ + block.get("text").asText().replaceAll("\\*\\*(.+?)\\*\\*", BOLD + "$1" + RESET));
+
+ if ("tool_use".equals(block.get("type").asText())) {
+ var name = block.get("name").asText();
+ var toolArgs = block.get("input");
+ var argPreview = toolArgs.fields().hasNext() ? toolArgs.fields().next().getValue().asText()
+ : "";
+ System.out
+ .println("\n" + GREEN + "⏺ " + Character.toUpperCase(name.charAt(0)) + name.substring(1)
+ + RESET + "(" + DIM + argPreview.substring(0, Math.min(50, argPreview.length()))
+ + RESET + ")");
+
+ var result = runTool(name, toolArgs);
+ System.out.println(" " + DIM + "⎿ " + preview(result, 60) + RESET);
+
+ toolResults.add(JSON.createObjectNode().put("type", "tool_result")
+ .put("tool_use_id", block.get("id").asText()).put("content", result));
+ }
+ }
+
+ messages.add(JSON.createObjectNode().put("role", "assistant").set("content", content));
+ if (toolResults.isEmpty())
+ break;
+ messages.add(JSON.createObjectNode().put("role", "user").set("content", toolResults));
+ }
+ System.out.println();
+ } catch (Exception e) {
+ if (e instanceof EOFException)
+ break;
+ System.out.println(RED + "⏺ Error: " + e.getMessage() + RESET);
+ }
+ }
+}
diff --git a/ai-providers/ai-skills/src/main/java/Assistant.java b/ai-providers/ai-skills/src/main/java/Assistant.java
new file mode 100644
index 00000000..2e75abaa
--- /dev/null
+++ b/ai-providers/ai-skills/src/main/java/Assistant.java
@@ -0,0 +1,3 @@
+public interface Assistant {
+ String chat(String message);
+}
\ No newline at end of file
diff --git a/ai-providers/ai-skills/src/main/java/ScannerTool.java b/ai-providers/ai-skills/src/main/java/ScannerTool.java
new file mode 100644
index 00000000..f6ffcbf4
--- /dev/null
+++ b/ai-providers/ai-skills/src/main/java/ScannerTool.java
@@ -0,0 +1,160 @@
+import dev.langchain4j.agent.tool.P;
+import dev.langchain4j.agent.tool.Tool;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.PathMatcher;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public class ScannerTool {
+
+ public ScannerTool() {}
+
+ @Tool("Read file with line numbers (file path, not directory)")
+ public String readFile(
+ @P("The relative path to the file from the project root") String path,
+ @P("The line offset to start reading from (0-based). Defaults to 0") String offset,
+ @P("The number of lines to read. Defaults to all lines") String limit) throws IOException {
+
+ Path target = pathNormalize(path);
+ List lines = Files.readAllLines(target);
+ int off = (offset != null && !offset.isBlank()) ? Integer.parseInt(offset) : 0;
+ int lim = (limit != null && !limit.isBlank()) ? Integer.parseInt(limit) : lines.size();
+ StringBuilder sb = new StringBuilder();
+ for (int i = off; i < Math.min(off + lim, lines.size()); i++)
+ sb.append(String.format("%4d| %s%n", i + 1, lines.get(i)));
+ return sb.toString();
+ }
+
+ @Tool("Write content to file")
+ public String writeFile(
+ @P("The relative path to the file from the project root") String path,
+ @P("The content to write to the file") String content) throws IOException {
+
+ Path target = pathNormalize(path);
+ Files.writeString(target, content);
+ return "ok";
+ }
+
+ @Tool("Replace old with new in file (old must be unique unless all=true)")
+ public String editFile(
+ @P("The relative path to the file from the project root") String path,
+ @P("The text to find and replace") String old,
+ @P("The replacement text") String replacement,
+ @P("If 'true', replace all occurrences. Defaults to false") String all) throws IOException {
+
+ Path target = pathNormalize(path);
+ String text = Files.readString(target);
+ if (!text.contains(old))
+ return "error: old_string not found";
+ int count = (text.length() - text.replace(old, "").length()) / old.length();
+ boolean replaceAll = "true".equalsIgnoreCase(all);
+ if (!replaceAll && count > 1)
+ return "error: old_string appears " + count + " times, must be unique (use all=true)";
+ Files.writeString(target, replaceAll
+ ? text.replace(old, replacement)
+ : text.replaceFirst(Pattern.quote(old), Matcher.quoteReplacement(replacement)));
+ return "ok";
+ }
+
+ @Tool("Find files by glob pattern, sorted by modification time")
+ public String globFiles(
+ @P("The glob pattern (e.g., '**/*.java', '*.xml')") String pattern,
+ @P("The relative directory to search from (use '.' for root). Defaults to '.'") String path)
+ throws IOException {
+
+ String dir = (path != null && !path.isBlank()) ? path : ".";
+ Path base = pathNormalize(dir);
+ PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + base + "/" + pattern);
+ if (!Files.exists(base))
+ return "none";
+ try (Stream walk = Files.walk(base)) {
+ List files = walk.filter(Files::isRegularFile)
+ .filter(matcher::matches)
+ .sorted((Path a, Path b) -> {
+ try {
+ return Files.getLastModifiedTime(b).compareTo(Files.getLastModifiedTime(a));
+ } catch (IOException e) {
+ return 0;
+ }
+ })
+ .map(p -> base.relativize(p).toString())
+ .collect(Collectors.toList());
+ return files.isEmpty() ? "none" : String.join("\n", files);
+ }
+ }
+
+ @Tool("Recursively searches for a regex pattern within the project files")
+ public String grepProject(
+ @P("The text or regex pattern to search for (e.g., '@SpringBootApplication')") String pattern,
+ @P("The relative directory to start the search from (use '.' for everywhere)") String startDir)
+ throws IOException {
+
+ Path startPath = pathNormalize(startDir);
+ Pattern compiled = Pattern.compile(pattern);
+ List hits = new ArrayList<>();
+ try (Stream walk = Files.walk(startPath)) {
+ List files = walk.filter(Files::isRegularFile).collect(Collectors.toList());
+ for (Path file : files) {
+ if (isBlacklisted(file))
+ continue;
+ if (hits.size() >= 50)
+ break;
+ try {
+ List lines = Files.readAllLines(file);
+ for (int i = 0; i < lines.size() && hits.size() < 50; i++)
+ if (compiled.matcher(lines.get(i)).find())
+ hits.add(startPath.relativize(file) + ":" + (i + 1) + ": " + lines.get(i).trim());
+ } catch (Exception e) {
+ /* skip binary or unreadable files */
+ }
+ }
+ }
+ return hits.isEmpty() ? "none" : String.join("\n", hits);
+ }
+
+ @Tool("Run shell command")
+ public String bash(
+ @P("The shell command to execute") String command) throws Exception {
+
+ Process proc = new ProcessBuilder("sh", "-c", command)
+ .redirectErrorStream(true)
+ .start();
+ List out = new ArrayList<>();
+ try (BufferedReader r = new BufferedReader(new InputStreamReader(proc.getInputStream()))) {
+ String line;
+ while ((line = r.readLine()) != null)
+ out.add(line);
+ }
+ if (!proc.waitFor(30, TimeUnit.SECONDS)) {
+ proc.destroyForcibly();
+ out.add("(timed out after 30s)");
+ }
+ return out.isEmpty() ? "(empty)" : String.join("\n", out);
+ }
+
+ /**
+ * Normalize the path of the AI's tool request
+ */
+ private Path pathNormalize(String path) {
+ return Path.of(path).normalize();
+ }
+
+ /**
+ * Prevents the AI from wasting cycles on target/ or .git/ folders.
+ */
+ private boolean isBlacklisted(Path path) {
+ String p = path.toString();
+ return p.contains("/target/") || p.contains("/.git/") || p.contains("/.idea/") || p.endsWith(".class");
+ }
+}
diff --git a/ai-providers/ai-skills/src/main/java/SkillsAgent.java b/ai-providers/ai-skills/src/main/java/SkillsAgent.java
new file mode 100644
index 00000000..c52a3f51
--- /dev/null
+++ b/ai-providers/ai-skills/src/main/java/SkillsAgent.java
@@ -0,0 +1,127 @@
+
+///usr/bin/env jbang "$0" "$@" ; exit $?
+//DEPS dev.langchain4j:langchain4j:1.13.1
+//DEPS dev.langchain4j:langchain4j-anthropic:1.13.1
+//DEPS dev.langchain4j:langchain4j-skills:1.13.0-beta23
+//DEPS dev.snowdrop.mtool.ai:vertex-ai-anthropic:1.0.5-SNAPSHOT
+////DEPS dev.langchain4j:langchain4j-vertex-ai-anthropic:1.13.1-beta23
+//SOURCES Assistant.java
+//SOURCES ScannerTool.java
+//DEPS org.slf4j:slf4j-simple:2.0.17
+//RUNTIME_OPTIONS -Dorg.slf4j.simpleLogger.defaultLogLevel=info -Dorg.slf4j.simpleLogger.log.dev.langchain4j=debug
+
+import dev.langchain4j.memory.chat.MessageWindowChatMemory;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.service.AiServices;
+import dev.langchain4j.skills.FileSystemSkillLoader;
+import dev.langchain4j.skills.Skills;
+//import dev.langchain4j.model.vertexai.anthropic.VertexAiAnthropicChatModel;
+import io.quarkiverse.langchain4j.vertexai.runtime.anthropic.VertexAiAnthropicChatModel;
+
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Scanner;
+import java.util.logging.Logger;
+
+public class SkillsAgent {
+
+ private static Logger logger = Logger.getLogger(SkillsAgent.class.getName());
+
+ public static void main(String[] args) {
+ /*
+ String projectId = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PROJECT_ID", "dummy");
+ String location = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOCATION", "dummy");
+ String modelId = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_MODEL_ID", "claude-opus-4-6");
+
+ validateRequired(projectId, "QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PROJECT_ID");
+ validateRequired(location, "QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOCATION");
+
+ ChatModel model = VertexAiAnthropicChatModel.builder()
+ .project(projectId)
+ .location(location)
+ .modelName(modelId)
+ .maxTokens(100000)
+ .logRequests(true)
+ .logResponses(true)
+ .build();
+ */
+
+ String projectId = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PROJECT_ID", "dummy");
+ String location = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOCATION", "dummy");
+ String modelId = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_MODEL_ID", "claude-opus-4-6");
+ String publisher = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PUBLISHER", "anthropic");
+ int duration = Integer.parseInt(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_DURATION", "30"));
+ int maxTokens = Integer.parseInt(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_MAX_TOKENS", "1000"));
+ boolean logRequests = Boolean.parseBoolean(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOG_REQUESTS", "false"));
+ boolean logResponses = Boolean.parseBoolean(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOG_RESPONSES", "false"));
+
+ validateRequired(projectId, "QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PROJECT_ID");
+ validateRequired(location, "QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOCATION");
+
+ ChatModel model = VertexAiAnthropicChatModel.builder()
+ .projectId(projectId)
+ .location(location)
+ .modelId(modelId)
+ .publisher(publisher)
+ .maxOutputTokens(maxTokens)
+ .timeout(Duration.ofSeconds(duration))
+ .logRequests(logRequests)
+ .logResponses(logResponses)
+ .logCurl(false)
+ .build();
+
+ String skillsPath = (args.length > 0 && !args[0].isBlank()) ? args[0] : getEnv("SKILLS_PATH", null);
+ if (skillsPath == null || skillsPath.isBlank()) {
+ throw new IllegalStateException(
+ "CRITICAL ERROR: Skills path must be provided as the first argument or via the 'SKILLS_PATH' environment variable.");
+ }
+
+ Skills skills = Skills.from(FileSystemSkillLoader.loadSkill(Path.of(skillsPath)));
+
+ Assistant agent = AiServices.builder(Assistant.class)
+ .chatModel(model)
+ .chatMemory(MessageWindowChatMemory.withMaxMessages(20))
+ .tools(new ScannerTool())
+ .toolProvider(skills.toolProvider())
+ .maxSequentialToolsInvocations(100)
+ .systemMessage("You have access to the following skills:\n" + skills.formatAvailableSkills()
+ + "\nWhen the user's request relates to one of these skills, activate it first.")
+ .build();
+
+ Scanner scanner = new Scanner(System.in);
+ // Migrate Spring Boot application to Quarkus. Use the automated_responses.yml to get answers using the match_query and answers. The question/answer are defined in the YAML file under intent_responses
+ logger.info("=== Migration Agent Online ===");
+ while (true) {
+ System.out.print("User: ");
+ String input = scanner.nextLine();
+ if ("exit".equalsIgnoreCase(input))
+ break;
+
+ String response = agent.chat(input);
+ System.out.println("AI: " + response);
+ }
+
+
+ //System.out.println("=== Migration Agent Online ===");
+ //String response = agent.chat("Migrate Spring Boot application to Quarkus. Use the automated_responses.yml to get answers using the match_query and answer within this file");
+ //logger.info("Agent: " + response);
+ }
+
+ /**
+ * Helper to get Env Var or return a default.
+ */
+ private static String getEnv(String name, String defaultValue) {
+ String val = System.getenv(name);
+ return (val != null && !val.isBlank()) ? val : defaultValue;
+ }
+
+ /**
+ * Helper to enforce required fields.
+ */
+ private static void validateRequired(String value, String envName) {
+ if (value == null || value.isBlank() || value.equals("dummy")) {
+ throw new IllegalStateException(
+ "CRITICAL ERROR: The environment variable '" + envName + "' is required but not set.");
+ }
+ }
+}
\ No newline at end of file
diff --git a/ai-providers/ai-skills/src/main/java/SkillsQuarkusAgent.java b/ai-providers/ai-skills/src/main/java/SkillsQuarkusAgent.java
new file mode 100644
index 00000000..c0657d0f
--- /dev/null
+++ b/ai-providers/ai-skills/src/main/java/SkillsQuarkusAgent.java
@@ -0,0 +1,92 @@
+///usr/bin/env jbang "$0" "$@" ; exit $?
+//DEPS io.quarkus:quarkus-picocli:3.31.3
+//DEPS io.quarkiverse.langchain4j:quarkus-langchain4j-core:999-SNAPSHOT
+//DEPS io.quarkiverse.langchain4j:quarkus-langchain4j-skills:999-SNAPSHOT
+//DEPS dev.snowdrop.mtool.ai:vertex-ai-anthropic:1.0.5-SNAPSHOT
+//SOURCES ScannerTool.java
+
+import dev.langchain4j.memory.chat.MessageWindowChatMemory;
+import dev.langchain4j.model.chat.ChatModel;
+import dev.langchain4j.service.AiServices;
+import dev.langchain4j.skills.FileSystemSkillLoader;
+import dev.langchain4j.skills.Skills;
+import io.quarkiverse.langchain4j.vertexai.runtime.anthropic.VertexAiAnthropicChatModel;
+import picocli.CommandLine;
+
+import java.nio.file.Path;
+import java.time.Duration;
+
+@CommandLine.Command
+public class SkillsQuarkusAgent implements Runnable {
+
+ @CommandLine.Option(
+ names = {"-s", "--skill"},
+ description = "Path directory of the skill to be executed",
+ required = true
+ )
+ String skillsPath;
+
+ @Override
+ public void run() {
+ String projectId = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PROJECT_ID", "dummy");
+ String location = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOCATION", "dummy");
+ String modelId = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_MODEL_ID", "claude-opus-4-6");
+ String publisher = getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PUBLISHER", "anthropic");
+ int duration = Integer.parseInt(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_DURATION", "300"));
+ int maxTokens = Integer.parseInt(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_MAX_TOKENS", "1000"));
+ boolean logRequests = Boolean.parseBoolean(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOG_REQUESTS", "false"));
+ boolean logResponses = Boolean.parseBoolean(getEnv("QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOG_RESPONSES", "false"));
+
+ validateRequired(projectId, "QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_PROJECT_ID");
+ validateRequired(location, "QUARKUS_LANGCHAIN4J_VERTEXAI_ANTHROPIC_LOCATION");
+
+ ChatModel model = VertexAiAnthropicChatModel.builder()
+ .projectId(projectId)
+ .location(location)
+ .modelId(modelId)
+ .publisher(publisher)
+ .maxOutputTokens(maxTokens)
+ .timeout(Duration.ofSeconds(duration))
+ .logRequests(logRequests)
+ .logResponses(logResponses)
+ .logCurl(false)
+ .build();
+
+ Skills skills = Skills.from(FileSystemSkillLoader.loadSkill(Path.of(skillsPath)));
+
+ Assistant agent = AiServices.builder(Assistant.class)
+ .chatModel(model)
+ .chatMemory(MessageWindowChatMemory.withMaxMessages(20))
+ .tools(new ScannerTool())
+ .maxSequentialToolsInvocations(100)
+ .systemMessage("You have access to the following skills:\n" + skills.formatAvailableSkills()
+ + "\nWhen the user's request relates to one of these skills, activate it first.")
+ .build();
+
+ System.out.println("=== Migration Agent Online ===");
+ String response = agent.chat("Migrate Spring Boot applications to Quarkus. Use the automated_responses.yml to get answers");
+ System.out.println("Agent: " + response);
+ }
+
+ /**
+ * Helper to get Env Var or return a default.
+ */
+ private static String getEnv(String name, String defaultValue) {
+ String val = System.getenv(name);
+ return (val != null && !val.isBlank()) ? val : defaultValue;
+ }
+
+ /**
+ * Helper to enforce required fields.
+ */
+ private static void validateRequired(String value, String envName) {
+ if (value == null || value.isBlank() || value.equals("dummy")) {
+ throw new IllegalStateException(
+ "CRITICAL ERROR: The environment variable '" + envName + "' is required but not set.");
+ }
+ }
+
+ interface Assistant {
+ String chat(String message);
+ }
+}
\ No newline at end of file
diff --git a/ai-providers/ai-skills/src/main/resources/application.properties b/ai-providers/ai-skills/src/main/resources/application.properties
new file mode 100644
index 00000000..14cff3fe
--- /dev/null
+++ b/ai-providers/ai-skills/src/main/resources/application.properties
@@ -0,0 +1,6 @@
+# LLM and client enabled
+quarkus.langchain4j.timeout=60s
+quarkus.langchain4j.log-requests=false
+quarkus.langchain4j.log-responses=false
+quarkus.langchain4j.chat-model.provider=vertexai-anthropic
+quarkus.langchain4j.vertexai.anthropic.model-id=claude-opus-4-6
\ No newline at end of file