From c66d58426fd1dbcc9e73da4f8e6468c4bbe1b236 Mon Sep 17 00:00:00 2001 From: abel533 Date: Thu, 9 Apr 2026 23:26:24 +0800 Subject: [PATCH] feat: align SkillLoader with original claude-code (7 critical fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. YAML parsing: SnakeYAML instead of naive string splitting - Handles arrays, nested objects, glob patterns, special chars - quoteProblematicValues() fallback on parse failure - extractDescriptionFromMarkdown() fallback for missing description 2. Frontmatter fields: 3 → 19 fields - Added: display-name, allowed-tools, disallowed-tools, model, effort, user-invocable, context, agent, shell, paths, argument-hint, arguments, version - Skill record expanded with convenience methods (isConditional, isForked, userFacingName) 3. Gitignore filtering: git check-ignore integration - Skills/commands from gitignored directories are skipped - Prevents loading malicious skills from node_modules etc. 4. Symlink resolution & deduplication - toRealPath() resolves symlinks to canonical paths - Set tracking prevents duplicate loading - Handles broken symlinks gracefully 5. AgentLoader: new .claude/agents/ directory support - Supports AGENT.md directory format and single .md files - Agent-specific frontmatter: tools, disallowed-tools, max-turns, memory, isolation, background, model, effort - User-level (~/.claude/agents/) and project-level loading 6. Conditional skills (paths) - Parse paths frontmatter field (glob patterns) - getConditionalSkillsForPaths() with glob matching - getUnconditionalSkills() for always-active skills 7. Enhanced SkillTool execution - Inline vs fork execution modes - Argument substitution: \, \, \ - Named argument support from skill definition - Tool restrictions in prompt (allowed-tools, disallowed-tools) - Model and effort hints in prompt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../com/claudecode/context/AgentLoader.java | 206 ++++++++++ .../com/claudecode/context/SkillLoader.java | 367 ++++++++++++++++-- .../com/claudecode/tool/impl/SkillTool.java | 70 +++- 3 files changed, 613 insertions(+), 30 deletions(-) create mode 100644 src/main/java/com/claudecode/context/AgentLoader.java diff --git a/src/main/java/com/claudecode/context/AgentLoader.java b/src/main/java/com/claudecode/context/AgentLoader.java new file mode 100644 index 0000000..b7bd1b8 --- /dev/null +++ b/src/main/java/com/claudecode/context/AgentLoader.java @@ -0,0 +1,206 @@ +package com.claudecode.context; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.yaml.snakeyaml.Yaml; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +/** + * Agent 定义加载器 —— 对应 claude-code/src/tools/AgentTool/loadAgentsDir.ts。 + *

+ * 从 .claude/agents/ 目录加载 Agent 定义文件(AGENT.md 或 .md)。 + * Agent 定义支持特殊的 frontmatter 字段(tools, maxTurns, memory, isolation 等)。 + *

+ * 目录结构: + *

+ * .claude/agents/
+ * ├── reviewer/
+ * │   └── AGENT.md        ← Agent 名 = "reviewer"
+ * └── code-generator.md   ← Agent 名 = "code-generator"
+ * 
+ */ +public class AgentLoader { + + private static final Logger log = LoggerFactory.getLogger(AgentLoader.class); + + private final Path projectDir; + private final List agents = new ArrayList<>(); + + public AgentLoader(Path projectDir) { + this.projectDir = projectDir; + } + + /** + * 扫描并加载所有 Agent 定义 + */ + public List loadAll() { + agents.clear(); + + // 1. 用户级 agents + Path userAgentsDir = Path.of(System.getProperty("user.home"), ".claude", "agents"); + loadFromDirectory(userAgentsDir, "user"); + + // 2. 项目级 agents + Path projectAgentsDir = projectDir.resolve(".claude").resolve("agents"); + loadFromDirectory(projectAgentsDir, "project"); + + log.debug("Loaded {} agent definitions in total", agents.size()); + return Collections.unmodifiableList(agents); + } + + /** + * 从目录加载 Agent 定义。 + * 支持两种格式: + * - 目录格式: agent-name/AGENT.md + * - 单文件格式: agent-name.md + */ + private void loadFromDirectory(Path dir, String source) { + if (!Files.isDirectory(dir)) return; + + try (var stream = Files.list(dir)) { + stream.sorted().forEach(entry -> { + try { + if (Files.isDirectory(entry)) { + // 目录格式: agent-name/AGENT.md + Path agentFile = entry.resolve("AGENT.md"); + if (Files.isRegularFile(agentFile)) { + AgentDefinition agent = parseAgentFile(agentFile, source, entry.getFileName().toString()); + agents.add(agent); + log.debug("Loaded agent: {} [{}] from {}/AGENT.md", agent.name(), source, entry.getFileName()); + } + } else if (entry.toString().endsWith(".md")) { + // 单文件格式: agent-name.md + String name = entry.getFileName().toString().replace(".md", ""); + AgentDefinition agent = parseAgentFile(entry, source, name); + agents.add(agent); + log.debug("Loaded agent: {} [{}] from {}", agent.name(), source, entry.getFileName()); + } + } catch (IOException e) { + log.warn("Failed to load agent file: {}: {}", entry, e.getMessage()); + } + }); + } catch (IOException e) { + log.debug("Failed to scan agents directory: {}: {}", dir, e.getMessage()); + } + } + + /** + * 解析 Agent 定义文件 + */ + @SuppressWarnings("unchecked") + private AgentDefinition parseAgentFile(Path path, String source, String defaultName) throws IOException { + String raw = Files.readString(path, StandardCharsets.UTF_8).strip(); + + String name = defaultName; + String description = ""; + String content = raw; + Map fm = Collections.emptyMap(); + + // YAML frontmatter + if (raw.startsWith("---")) { + int endIdx = raw.indexOf("---", 3); + if (endIdx > 0) { + String fmRaw = raw.substring(3, endIdx).strip(); + content = raw.substring(endIdx + 3).strip(); + try { + Yaml yaml = new Yaml(); + Object result = yaml.load(fmRaw); + if (result instanceof Map) { + fm = (Map) result; + } + } catch (Exception e) { + log.warn("Failed to parse agent frontmatter in {}: {}", path, e.getMessage()); + } + } + } + + // 解析字段 + if (fm.containsKey("name")) name = fm.get("name").toString(); + if (fm.containsKey("description")) description = fm.get("description").toString(); + + List tools = getStringList(fm, "tools"); + List disallowedTools = getStringList(fm, "disallowed-tools"); + int maxTurns = getInt(fm, "max-turns", 25); + boolean memory = getBoolean(fm, "memory", false); + String isolation = getString(fm, "isolation", "fork"); + boolean background = getBoolean(fm, "background", false); + String model = getString(fm, "model", null); + String effort = getString(fm, "effort", null); + + return new AgentDefinition(name, description, content, source, path, + tools, disallowedTools, maxTurns, memory, isolation, background, model, effort); + } + + public List getAgents() { + return Collections.unmodifiableList(agents); + } + + public Optional findByName(String name) { + return agents.stream() + .filter(a -> a.name().equalsIgnoreCase(name)) + .findFirst(); + } + + // ==================== 辅助方法 ==================== + + private String getString(Map fm, String key, String defaultValue) { + Object val = fm.get(key); + return val != null ? val.toString().strip() : defaultValue; + } + + private int getInt(Map fm, String key, int defaultValue) { + Object val = fm.get(key); + if (val instanceof Number n) return n.intValue(); + if (val != null) { + try { return Integer.parseInt(val.toString().strip()); } + catch (NumberFormatException e) { /* ignore */ } + } + return defaultValue; + } + + private boolean getBoolean(Map fm, String key, boolean defaultValue) { + Object val = fm.get(key); + if (val instanceof Boolean b) return b; + if (val != null) { + String s = val.toString().strip().toLowerCase(); + return "true".equals(s) || "yes".equals(s) || "1".equals(s); + } + return defaultValue; + } + + @SuppressWarnings("unchecked") + private List getStringList(Map fm, String key) { + Object val = fm.get(key); + if (val == null) return null; + if (val instanceof List) { + return ((List) val).stream().map(Object::toString).toList(); + } + String s = val.toString().strip(); + if (s.isEmpty()) return null; + return Arrays.stream(s.split(",")).map(String::strip).filter(v -> !v.isEmpty()).toList(); + } + + /** + * Agent 定义数据记录 + */ + public record AgentDefinition( + String name, + String description, + String content, + String source, + Path filePath, + List tools, + List disallowedTools, + int maxTurns, + boolean memory, + String isolation, + boolean background, + String model, + String effort + ) {} +} diff --git a/src/main/java/com/claudecode/context/SkillLoader.java b/src/main/java/com/claudecode/context/SkillLoader.java index 975de64..cba2b4e 100644 --- a/src/main/java/com/claudecode/context/SkillLoader.java +++ b/src/main/java/com/claudecode/context/SkillLoader.java @@ -2,6 +2,7 @@ package com.claudecode.context; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.yaml.snakeyaml.Yaml; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -52,6 +53,8 @@ public class SkillLoader { private final Path projectDir; private final List skills = new ArrayList<>(); + /** 已加载文件的规范路径集合,用于 symlink 去重 */ + private final Set loadedCanonicalPaths = new HashSet<>(); public SkillLoader(Path projectDir) { this.projectDir = projectDir; @@ -62,6 +65,7 @@ public class SkillLoader { */ public List loadAll() { skills.clear(); + loadedCanonicalPaths.clear(); // 0. 内置技能 skills.addAll(BundledSkills.getAll()); @@ -97,11 +101,21 @@ public class SkillLoader { } try (var stream = Files.list(dir)) { - stream.filter(Files::isDirectory) + stream.filter(p -> Files.isDirectory(p) || Files.isSymbolicLink(p)) .sorted() .forEach(subDir -> { + // Gitignore 过滤 + if (isGitignored(subDir)) { + log.debug("Skipping gitignored skill directory: {}", subDir); + return; + } Path skillFile = subDir.resolve("SKILL.md"); if (Files.isRegularFile(skillFile)) { + // Symlink 去重 + if (!trackAndCheckDuplicate(skillFile)) { + log.debug("Skipping duplicate skill (symlink): {}", skillFile); + return; + } try { Skill skill = parseSkillFile(skillFile, source, subDir.getFileName().toString()); skills.add(skill); @@ -140,13 +154,20 @@ public class SkillLoader { * 递归加载 commands 目录 */ private void loadCommandsRecursive(Path currentDir, Path baseDir, String source) { + // Gitignore 过滤(对整个目录) + if (!currentDir.equals(baseDir) && isGitignored(currentDir)) { + log.debug("Skipping gitignored commands directory: {}", currentDir); + return; + } + try (var stream = Files.list(currentDir)) { stream.sorted().forEach(entry -> { try { - if (Files.isDirectory(entry)) { + if (Files.isDirectory(entry) || Files.isSymbolicLink(entry)) { // 检查目录内是否有 SKILL.md(目录格式技能) Path skillFile = entry.resolve("SKILL.md"); if (Files.isRegularFile(skillFile)) { + if (!trackAndCheckDuplicate(skillFile)) return; String name = buildCommandName(entry, baseDir, true); Skill skill = parseSkillFile(skillFile, source, name); skills.add(skill); @@ -156,6 +177,7 @@ public class SkillLoader { loadCommandsRecursive(entry, baseDir, source); } } else if (entry.toString().endsWith(".md")) { + if (!trackAndCheckDuplicate(entry)) return; // 单文件格式 String name = buildCommandName(entry, baseDir, false); Skill skill = parseSkillFile(entry, source, name); @@ -199,6 +221,7 @@ public class SkillLoader { /** * 解析单个技能文件,提取 frontmatter 和内容。 + * 使用 SnakeYAML 解析 frontmatter,支持所有 YAML 数据类型。 * 当 overrideName 非 null 时,用它作为默认技能名(目录名或命令名)。 */ private Skill parseSkillFile(Path path, String source, String overrideName) throws IOException { @@ -207,38 +230,220 @@ public class SkillLoader { // 默认名称:优先使用 overrideName(目录名/命名空间名),否则用文件名 String name = overrideName != null ? overrideName : fileName; - String description = ""; - String whenToUse = ""; String content = raw; + Map fm = Collections.emptyMap(); + // 提取 YAML frontmatter if (raw.startsWith("---")) { int endIdx = raw.indexOf("---", 3); if (endIdx > 0) { - String frontmatter = raw.substring(3, endIdx).strip(); + String fmRaw = raw.substring(3, endIdx).strip(); content = raw.substring(endIdx + 3).strip(); + fm = parseFrontmatterYaml(fmRaw, path); + } + } - // 简单的 YAML 解析(key: value 格式) - for (String line : frontmatter.split("\n")) { - line = line.strip(); - int colonIdx = line.indexOf(':'); - if (colonIdx > 0) { - String key = line.substring(0, colonIdx).strip(); - String value = line.substring(colonIdx + 1).strip(); - // 去掉引号 - if (value.startsWith("\"") && value.endsWith("\"")) { - value = value.substring(1, value.length() - 1); - } - switch (key) { - case "name" -> name = value; - case "description" -> description = value; - case "whenToUse" -> whenToUse = value; + // 解析所有 frontmatter 字段 + String displayName = fmString(fm, "display-name", fmString(fm, "name", null)); + if (fm.containsKey("name") && displayName != null && !displayName.isBlank()) { + // display-name 优先于 name(与 TS 一致) + } + String fmName = fmString(fm, "name", null); + if (fmName != null && !fmName.isBlank() && overrideName == null) { + name = fmName; + } + + String description = fmString(fm, "description", ""); + // 描述 fallback:从 markdown 第一行提取 + if (description.isEmpty() && !content.isEmpty()) { + description = extractDescriptionFromMarkdown(content); + } + + String whenToUse = fmString(fm, "when-to-use", fmString(fm, "whenToUse", "")); + List allowedTools = fmStringList(fm, "allowed-tools"); + List disallowedTools = fmStringList(fm, "disallowed-tools"); + if (disallowedTools == null) { + // TS 也支持 disable-model-invocation 作为别名 + disallowedTools = fmStringList(fm, "disable-model-invocation"); + } + String model = fmString(fm, "model", null); + String effort = fmString(fm, "effort", null); + boolean userInvocable = fmBoolean(fm, "user-invocable", true); + String context = fmString(fm, "context", "inline"); + String agent = fmString(fm, "agent", null); + String shell = fmString(fm, "shell", null); + // shell 校验(仅 bash / powershell) + if (shell != null && !"bash".equals(shell) && !"powershell".equals(shell)) { + log.warn("Invalid shell '{}' in {}, ignoring (use 'bash' or 'powershell')", shell, path); + shell = null; + } + List paths = fmStringList(fm, "paths"); + String argumentHint = fmString(fm, "argument-hint", null); + List arguments = fmStringList(fm, "arguments"); + String version = fmString(fm, "version", null); + + return new Skill(name, displayName, description, whenToUse, content, source, path, + allowedTools, disallowedTools, model, effort, userInvocable, + context, agent, shell, paths, argumentHint, arguments, version); + } + + // ==================== Frontmatter YAML 解析工具方法 ==================== + + /** + * 使用 SnakeYAML 解析 frontmatter 文本。 + * 处理特殊字符自动引号包裹(对应 TS quoteProblematicValues)。 + */ + @SuppressWarnings("unchecked") + private Map parseFrontmatterYaml(String yamlText, Path path) { + Yaml yaml = new Yaml(); + try { + Object result = yaml.load(yamlText); + if (result instanceof Map) { + return (Map) result; + } + return Collections.emptyMap(); + } catch (Exception e) { + // 首次解析失败 → 尝试自动引号处理后重试(对应 TS quoteProblematicValues) + log.debug("YAML parse failed for {}, retrying with quoted values: {}", path, e.getMessage()); + try { + String quoted = quoteProblematicValues(yamlText); + Object result = yaml.load(quoted); + if (result instanceof Map) { + return (Map) result; + } + } catch (Exception e2) { + log.warn("Failed to parse frontmatter in {}: {}", path, e2.getMessage()); + } + return Collections.emptyMap(); + } + } + + /** + * 对应 TS quoteProblematicValues():为包含 YAML 特殊字符的值自动加引号。 + * 处理 glob 模式、特殊符号等会导致 YAML 解析失败的值。 + */ + private String quoteProblematicValues(String yamlText) { + String[] specialChars = {"{", "}", "[", "]", "*", " &", "#", "!", "|", ">", "%", "@", "\"", "`"}; + StringBuilder result = new StringBuilder(); + for (String line : yamlText.split("\n")) { + int colonIdx = line.indexOf(':'); + if (colonIdx > 0 && colonIdx < line.length() - 1) { + String key = line.substring(0, colonIdx + 1); + String value = line.substring(colonIdx + 1).strip(); + // 如果值未被引号包裹且包含特殊字符 + if (!value.isEmpty() && !value.startsWith("\"") && !value.startsWith("'")) { + boolean hasSpecial = false; + for (String sc : specialChars) { + if (value.contains(sc)) { + hasSpecial = true; + break; } } + if (hasSpecial) { + value = "\"" + value.replace("\"", "\\\"") + "\""; + result.append(key).append(" ").append(value).append("\n"); + continue; + } + } + } + result.append(line).append("\n"); + } + return result.toString(); + } + + /** 从 frontmatter map 中安全获取字符串值 */ + private String fmString(Map fm, String key, String defaultValue) { + Object val = fm.get(key); + if (val == null) return defaultValue; + return val.toString().strip(); + } + + /** 从 frontmatter map 中获取字符串列表(支持逗号分隔字符串或 YAML 数组) */ + @SuppressWarnings("unchecked") + private List fmStringList(Map fm, String key) { + Object val = fm.get(key); + if (val == null) return null; + if (val instanceof List) { + return ((List) val).stream() + .map(Object::toString) + .map(String::strip) + .toList(); + } + // 逗号分隔字符串 + String s = val.toString().strip(); + if (s.isEmpty()) return null; + return Arrays.stream(s.split(",")) + .map(String::strip) + .filter(v -> !v.isEmpty()) + .toList(); + } + + /** 从 frontmatter map 中获取布尔值 */ + private boolean fmBoolean(Map fm, String key, boolean defaultValue) { + Object val = fm.get(key); + if (val == null) return defaultValue; + if (val instanceof Boolean b) return b; + String s = val.toString().strip().toLowerCase(); + return "true".equals(s) || "yes".equals(s) || "1".equals(s); + } + + /** + * 从 Markdown 内容提取描述(第一个非空行,去掉 # 前缀)。 + * 对应 TS extractDescriptionFromMarkdown()。 + */ + private String extractDescriptionFromMarkdown(String content) { + for (String line : content.split("\n")) { + String trimmed = line.strip(); + if (!trimmed.isEmpty()) { + // 去掉 markdown 标题前缀 + if (trimmed.startsWith("#")) { + trimmed = trimmed.replaceFirst("^#+\\s*", ""); } + return trimmed.length() > 120 ? trimmed.substring(0, 120) + "..." : trimmed; } } + return ""; + } + + // ==================== Gitignore 过滤 & Symlink 去重 ==================== - return new Skill(name, description, whenToUse, content, source, path); + /** + * 检查路径是否被 gitignore 忽略。 + * 对应 TS isPathGitignored(),使用 git check-ignore 命令。 + */ + private boolean isGitignored(Path path) { + try { + ProcessBuilder pb = new ProcessBuilder("git", "check-ignore", "-q", path.toString()); + pb.directory(projectDir.toFile()); + pb.redirectErrorStream(true); + Process process = pb.start(); + boolean finished = process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + return false; + } + // exit code 0 = ignored, 1 = not ignored, 128 = not a git repo + return process.exitValue() == 0; + } catch (Exception e) { + log.debug("git check-ignore failed for {}: {}", path, e.getMessage()); + return false; + } + } + + /** + * 检查文件是否已通过 symlink 或其他路径加载过(去重)。 + * 对应 TS deduplication by resolved canonical path。 + * + * @return true 如果是新文件(未加载过),false 如果已加载过 + */ + private boolean trackAndCheckDuplicate(Path path) { + try { + Path canonical = path.toRealPath(); + return loadedCanonicalPaths.add(canonical); + } catch (IOException e) { + // toRealPath 失败(如断开的 symlink)→ 用原始路径 + return loadedCanonicalPaths.add(path.toAbsolutePath().normalize()); + } } /** @@ -249,7 +454,7 @@ public class SkillLoader { } /** - * 按名称查找技能 + * 按名称查找技能(精确匹配,不区分大小写) */ public Optional findByName(String name) { return skills.stream() @@ -257,6 +462,58 @@ public class SkillLoader { .findFirst(); } + /** + * 获取非条件技能(始终激活的技能) + */ + public List getUnconditionalSkills() { + return skills.stream() + .filter(s -> !s.isConditional()) + .toList(); + } + + /** + * 获取匹配指定文件路径的条件技能。 + * 对应 TS discoverSkillDirsForPaths()。 + * + * @param filePaths 当前编辑的文件路径列表 + * @return 匹配的条件技能 + */ + public List getConditionalSkillsForPaths(List filePaths) { + if (filePaths == null || filePaths.isEmpty()) return List.of(); + + return skills.stream() + .filter(Skill::isConditional) + .filter(skill -> { + for (String pattern : skill.paths()) { + for (String filePath : filePaths) { + if (matchGlob(pattern, filePath)) { + return true; + } + } + } + return false; + }) + .toList(); + } + + /** + * 简单 glob 匹配(支持 * 和 **) + */ + private boolean matchGlob(String pattern, String path) { + // 将 glob 转换为正则 + String regex = pattern + .replace(".", "\\.") + .replace("**/", "(.+/)?") + .replace("**", ".*") + .replace("*", "[^/]*") + .replace("?", "[^/]"); + try { + return path.matches(regex) || path.replace('\\', '/').matches(regex); + } catch (Exception e) { + return false; + } + } + /** * 构建技能上下文摘要(注入系统提示词)。 * 支持预算控制,确保不超过上下文窗口的 1%。 @@ -314,9 +571,69 @@ public class SkillLoader { } /** - * 技能数据记录 + * 技能数据记录 —— 对应 TS Command 类型中与 Skill 相关的字段。 + * + * @param name 技能名称(目录名或 frontmatter name) + * @param displayName 显示名称(frontmatter display-name,可为 null) + * @param description 技能描述 + * @param whenToUse 何时使用提示 + * @param content Markdown 内容体 + * @param source 来源(user/project/command/bundled) + * @param filePath 文件路径 + * @param allowedTools 允许使用的工具列表(null = 不限制) + * @param disallowedTools 禁止使用的工具列表(null = 不限制) + * @param model 模型覆盖(null = 使用默认,"inherit" = 继承父级) + * @param effort Effort 级别(low/medium/high/max 或整数) + * @param userInvocable 是否可由用户通过 /name 调用(默认 true) + * @param context 执行上下文("inline" = 当前上下文, "fork" = 子 Agent) + * @param agent 子 Agent 类型(当 context=fork 时使用) + * @param shell Shell 类型("bash" / "powershell",可为 null) + * @param paths 条件激活路径(glob 模式列表,null = 始终激活) + * @param argumentHint 参数提示文本 + * @param arguments 参数名列表 + * @param version 技能版本 */ - public record Skill(String name, String description, String whenToUse, - String content, String source, Path filePath) { + public record Skill( + String name, + String displayName, + String description, + String whenToUse, + String content, + String source, + Path filePath, + List allowedTools, + List disallowedTools, + String model, + String effort, + boolean userInvocable, + String context, + String agent, + String shell, + List paths, + String argumentHint, + List arguments, + String version + ) { + /** 便捷构造(向后兼容旧代码) */ + public Skill(String name, String description, String whenToUse, + String content, String source, Path filePath) { + this(name, null, description, whenToUse, content, source, filePath, + null, null, null, null, true, "inline", null, null, null, null, null, null); + } + + /** 是否为条件技能(仅在匹配路径时激活) */ + public boolean isConditional() { + return paths != null && !paths.isEmpty(); + } + + /** 是否应在子 Agent 中执行 */ + public boolean isForked() { + return "fork".equalsIgnoreCase(context); + } + + /** 用户可见名称(displayName 优先,否则 name) */ + public String userFacingName() { + return displayName != null && !displayName.isBlank() ? displayName : name; + } } } diff --git a/src/main/java/com/claudecode/tool/impl/SkillTool.java b/src/main/java/com/claudecode/tool/impl/SkillTool.java index 683417c..fc25aaa 100644 --- a/src/main/java/com/claudecode/tool/impl/SkillTool.java +++ b/src/main/java/com/claudecode/tool/impl/SkillTool.java @@ -7,6 +7,7 @@ import com.claudecode.tool.ToolContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Optional; @@ -112,12 +113,19 @@ public class SkillTool implements Tool { } Skill skill = skillOpt.get(); - log.info("Executing skill: {} [{}]", skill.name(), skill.source()); + log.info("Executing skill: {} [{}] context={}", skill.name(), skill.source(), skill.context()); // Build skill execution prompt String skillPrompt = buildSkillPrompt(skill, arguments); - // Execute via agent factory (same as AgentTool) + // Check if skill should be forked (sub-agent) or inline + if (!skill.isForked()) { + // Inline execution: return the skill prompt for the current agent to follow + return "📋 Skill '" + skill.userFacingName() + "' loaded (inline mode).\n\n" + + "Follow these instructions:\n\n" + skill.content(); + } + + // Forked execution: execute via agent factory (same as AgentTool) @SuppressWarnings("unchecked") java.util.function.Function agentFactory = context.getOrDefault(AgentTool.AGENT_FACTORY_KEY, null); @@ -152,10 +160,11 @@ public class SkillTool implements Tool { /** * Build the full prompt for skill execution. + * Supports argument substitution (${ARGUMENT_NAME}, ${CLAUDE_SKILL_DIR}, ${CLAUDE_SESSION_ID}). */ private String buildSkillPrompt(Skill skill, String arguments) { StringBuilder sb = new StringBuilder(); - sb.append("You are executing a skill: ").append(skill.name()).append("\n\n"); + sb.append("You are executing a skill: ").append(skill.userFacingName()).append("\n\n"); if (!skill.description().isEmpty()) { sb.append("Description: ").append(skill.description()).append("\n"); @@ -163,11 +172,27 @@ public class SkillTool implements Tool { if (!skill.whenToUse().isEmpty()) { sb.append("When to use: ").append(skill.whenToUse()).append("\n"); } + if (skill.model() != null) { + sb.append("Preferred model: ").append(skill.model()).append("\n"); + } + if (skill.effort() != null) { + sb.append("Effort level: ").append(skill.effort()).append("\n"); + } sb.append("\n"); - // Inject skill content as instructions + // Tool restrictions + if (skill.allowedTools() != null && !skill.allowedTools().isEmpty()) { + sb.append("Allowed tools: ").append(String.join(", ", skill.allowedTools())).append("\n"); + } + if (skill.disallowedTools() != null && !skill.disallowedTools().isEmpty()) { + sb.append("Disallowed tools: ").append(String.join(", ", skill.disallowedTools())).append("\n"); + } + + // Inject skill content with argument substitution + String content = skill.content(); + content = substituteArguments(content, arguments, skill); sb.append("## Skill Instructions\n\n"); - sb.append(skill.content()).append("\n\n"); + sb.append(content).append("\n\n"); // Inject arguments if (arguments != null && !arguments.isBlank()) { @@ -186,6 +211,41 @@ public class SkillTool implements Tool { return sb.toString(); } + /** + * Argument substitution — replaces ${ARGUMENT_NAME}, ${CLAUDE_SKILL_DIR}, ${CLAUDE_SESSION_ID}. + * Corresponds to TS substituteArguments() + template variables. + */ + private String substituteArguments(String content, String arguments, Skill skill) { + if (content == null) return ""; + + // Replace ${CLAUDE_SKILL_DIR} with the skill's directory + if (skill.filePath() != null) { + Path skillDir = skill.filePath().getParent(); + if (skillDir != null) { + content = content.replace("${CLAUDE_SKILL_DIR}", skillDir.toString()); + } + } + + // Replace ${CLAUDE_SESSION_ID} with a session identifier + content = content.replace("${CLAUDE_SESSION_ID}", + System.getProperty("claude.session.id", "default-session")); + + // Replace generic ${ARGUMENTS} or positional args + if (arguments != null && !arguments.isBlank()) { + content = content.replace("${ARGUMENTS}", arguments); + + // Parse named arguments from skill definition + if (skill.arguments() != null) { + String[] argValues = arguments.split("\\s+", skill.arguments().size()); + for (int i = 0; i < skill.arguments().size() && i < argValues.length; i++) { + content = content.replace("${" + skill.arguments().get(i) + "}", argValues[i]); + } + } + } + + return content; + } + /** * Partial name match for skills. */