feat: align SkillLoader with original claude-code (7 critical fixes)

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<Path> 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>
main
abel533 4 weeks ago
parent af9934d8c6
commit c66d58426f
  1. 206
      src/main/java/com/claudecode/context/AgentLoader.java
  2. 367
      src/main/java/com/claudecode/context/SkillLoader.java
  3. 70
      src/main/java/com/claudecode/tool/impl/SkillTool.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
* <p>
* .claude/agents/ 目录加载 Agent 定义文件AGENT.md .md
* Agent 定义支持特殊的 frontmatter 字段tools, maxTurns, memory, isolation
* <p>
* 目录结构
* <pre>
* .claude/agents/
* reviewer/
* AGENT.md Agent = "reviewer"
* code-generator.md Agent = "code-generator"
* </pre>
*/
public class AgentLoader {
private static final Logger log = LoggerFactory.getLogger(AgentLoader.class);
private final Path projectDir;
private final List<AgentDefinition> agents = new ArrayList<>();
public AgentLoader(Path projectDir) {
this.projectDir = projectDir;
}
/**
* 扫描并加载所有 Agent 定义
*/
public List<AgentDefinition> 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<String, Object> 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<String, Object>) 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<String> tools = getStringList(fm, "tools");
List<String> 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<AgentDefinition> getAgents() {
return Collections.unmodifiableList(agents);
}
public Optional<AgentDefinition> findByName(String name) {
return agents.stream()
.filter(a -> a.name().equalsIgnoreCase(name))
.findFirst();
}
// ==================== 辅助方法 ====================
private String getString(Map<String, Object> fm, String key, String defaultValue) {
Object val = fm.get(key);
return val != null ? val.toString().strip() : defaultValue;
}
private int getInt(Map<String, Object> 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<String, Object> 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<String> getStringList(Map<String, Object> fm, String key) {
Object val = fm.get(key);
if (val == null) return null;
if (val instanceof List) {
return ((List<Object>) 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<String> tools,
List<String> disallowedTools,
int maxTurns,
boolean memory,
String isolation,
boolean background,
String model,
String effort
) {}
}

@ -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<Skill> skills = new ArrayList<>();
/** 已加载文件的规范路径集合,用于 symlink 去重 */
private final Set<Path> loadedCanonicalPaths = new HashSet<>();
public SkillLoader(Path projectDir) {
this.projectDir = projectDir;
@ -62,6 +65,7 @@ public class SkillLoader {
*/
public List<Skill> 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<String, Object> 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<String> allowedTools = fmStringList(fm, "allowed-tools");
List<String> 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<String> paths = fmStringList(fm, "paths");
String argumentHint = fmString(fm, "argument-hint", null);
List<String> 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<String, Object> parseFrontmatterYaml(String yamlText, Path path) {
Yaml yaml = new Yaml();
try {
Object result = yaml.load(yamlText);
if (result instanceof Map) {
return (Map<String, Object>) 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<String, Object>) 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<String, Object> 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<String> fmStringList(Map<String, Object> fm, String key) {
Object val = fm.get(key);
if (val == null) return null;
if (val instanceof List) {
return ((List<Object>) 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<String, Object> 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<Skill> findByName(String name) {
return skills.stream()
@ -257,6 +462,58 @@ public class SkillLoader {
.findFirst();
}
/**
* 获取非条件技能始终激活的技能
*/
public List<Skill> getUnconditionalSkills() {
return skills.stream()
.filter(s -> !s.isConditional())
.toList();
}
/**
* 获取匹配指定文件路径的条件技能
* 对应 TS discoverSkillDirsForPaths()
*
* @param filePaths 当前编辑的文件路径列表
* @return 匹配的条件技能
*/
public List<Skill> getConditionalSkillsForPaths(List<String> 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<String> allowedTools,
List<String> disallowedTools,
String model,
String effort,
boolean userInvocable,
String context,
String agent,
String shell,
List<String> paths,
String argumentHint,
List<String> 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;
}
}
}

@ -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<String, String> 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.
*/

Loading…
Cancel
Save