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>
This commit is contained in:
@@ -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();
|
||||
|
||||
// 简单的 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
fm = parseFrontmatterYaml(fmRaw, path);
|
||||
}
|
||||
}
|
||||
|
||||
return new Skill(name, description, whenToUse, content, source, path);
|
||||
// 解析所有 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 去重 ====================
|
||||
|
||||
/**
|
||||
* 检查路径是否被 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user