> listener : skillChangeListeners) {
try {
listener.accept(current);
} catch (Exception e) {
log.warn("Skill change listener failed: {}", e.getMessage());
}
}
}
// ==================== Setting Source Helpers ====================
/**
* Check if an environment variable is truthy (1, true, yes).
* Corresponds to TS isEnvTruthy().
*/
private static boolean isEnvTruthy(String name) {
String val = System.getenv(name);
if (val == null) val = System.getProperty(name);
if (val == null) return false;
return "1".equals(val) || "true".equalsIgnoreCase(val) || "yes".equalsIgnoreCase(val);
}
/**
* Check if a setting source is enabled.
* Default: all sources enabled unless explicitly disabled via environment.
* Corresponds to TS isSettingSourceEnabled().
*/
private static boolean isSettingSourceEnabled(String source) {
// CLAUDE_CODE_DISABLE_{SOURCE} → disables that source
String envKey = "CLAUDE_CODE_DISABLE_" + source.toUpperCase().replace("SETTINGS", "_SETTINGS");
return !isEnvTruthy(envKey);
}
/**
* Get managed/policy skills directory path (if configured).
*/
private static Path getManagedSkillsPath() {
String managedPath = System.getenv("CLAUDE_CODE_MANAGED_PATH");
if (managedPath == null) managedPath = System.getProperty("claude.managed.path");
if (managedPath == null) return null;
return Path.of(managedPath, ".claude", "skills");
}
/**
* 从 skills 目录加载技能 —— 仅支持目录格式: skill-name/SKILL.md
*
* 对应 TS loadSkillsFromSkillsDir():
* - 每个技能是一个子目录,内含 SKILL.md
* - 单独的 .md 文件不被加载(与原版一致)
* - 目录名即技能名
*/
private void loadFromSkillsDirectory(Path dir, String source) {
if (!Files.isDirectory(dir)) {
return;
}
try (var stream = Files.list(dir)) {
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);
log.debug("Loaded skill: {} [{}] from {}/SKILL.md", skill.name(), source, subDir.getFileName());
} catch (IOException e) {
log.warn("Failed to load skill file: {}: {}", skillFile, e.getMessage());
}
} else {
log.debug("Skipping skill directory without SKILL.md: {}", subDir.getFileName());
}
});
} catch (IOException e) {
log.debug("Failed to scan skills directory: {}: {}", dir, e.getMessage());
}
}
/**
* 从 commands 目录加载技能 —— 支持两种格式:
*
* - 目录格式: command-name/SKILL.md(优先)
* - 单文件格式: command-name.md
* - 递归子目录: sub/command-name.md → 名称 "sub:command-name"
*
*
* 对应 TS loadSkillsFromCommandsDir()
*/
private void loadFromCommandsDirectory(Path dir, String source) {
if (!Files.isDirectory(dir)) {
return;
}
loadCommandsRecursive(dir, dir, source);
}
/**
* 递归加载 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) || 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);
log.debug("Loaded command skill: {} [{}] from {}/SKILL.md", name, source, entry.getFileName());
} else {
// 递归进入子目录
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);
skills.add(skill);
log.debug("Loaded command: {} [{}] from {}", name, source, entry.getFileName());
}
} catch (IOException e) {
log.warn("Failed to load command file: {}: {}", entry, e.getMessage());
}
});
} catch (IOException e) {
log.debug("Failed to scan commands directory: {}: {}", currentDir, e.getMessage());
}
}
/**
* 构建命令名称,支持命名空间(子目录用 : 分隔)。
*
* 例: baseDir=commands, entry=commands/sub/my-cmd.md → "sub:my-cmd"
* baseDir=commands, entry=commands/my-skill/SKILL.md (isDir=true) → "my-skill"
*/
private String buildCommandName(Path entry, Path baseDir, boolean isDirectory) {
Path relative;
if (isDirectory) {
// 目录格式:取目录名相对于 baseDir 的路径
relative = baseDir.relativize(entry);
} else {
// 文件格式:取文件路径(去掉 .md)相对于 baseDir
relative = baseDir.relativize(entry);
}
// 用 : 替换路径分隔符,去掉 .md 后缀
String name = relative.toString()
.replace('\\', ':')
.replace('/', ':');
if (!isDirectory && name.endsWith(".md")) {
name = name.substring(0, name.length() - 3);
}
return name;
}
/**
* 解析单个技能文件,提取 frontmatter 和内容。
* 使用 SnakeYAML 解析 frontmatter,支持所有 YAML 数据类型。
* 当 overrideName 非 null 时,用它作为默认技能名(目录名或命令名)。
*/
private Skill parseSkillFile(Path path, String source, String overrideName) throws IOException {
String raw = Files.readString(path, StandardCharsets.UTF_8).strip();
String fileName = path.getFileName().toString().replace(".md", "");
// 默认名称:优先使用 overrideName(目录名/命名空间名),否则用文件名
String name = overrideName != null ? overrideName : fileName;
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();
fm = parseFrontmatterYaml(fmRaw, path);
}
}
// 解析所有 frontmatter 字段
String displayName = fmString(fm, "display-name", fmString(fm, "name", null));
String fmName = fmString(fm, "name", null);
if (fmName != null && !fmName.isBlank() && overrideName == null) {
name = fmName;
}
// Description with tracking
String rawDescription = fmString(fm, "description", null);
boolean hasUserSpecifiedDescription = rawDescription != null && !rawDescription.isBlank();
String description = hasUserSpecifiedDescription ? rawDescription : "";
if (description.isEmpty() && !content.isEmpty()) {
description = extractDescriptionFromMarkdown(content);
}
String whenToUse = fmString(fm, "when-to-use", fmString(fm, "whenToUse", fmString(fm, "when_to_use", "")));
List allowedTools = fmStringList(fm, "allowed-tools");
List disallowedTools = fmStringList(fm, "disallowed-tools");
// disable-model-invocation: separate boolean flag (NOT alias for disallowedTools)
boolean disableModelInvocation = fmBoolean(fm, "disable-model-invocation", false);
String model = fmString(fm, "model", null);
// Model resolution: aliases (haiku→full ID), "inherit"→null
model = ModelResolver.resolveSkillModelOverride(model);
// Effort validation (only accept valid values)
String rawEffort = fmString(fm, "effort", null);
String effort = parseEffortValue(rawEffort);
if (rawEffort != null && effort == null) {
log.debug("Skill {} has invalid effort '{}'. Valid: low, medium, high, max, or positive integer",
name, rawEffort);
}
boolean userInvocable = fmBoolean(fm, "user-invocable", true);
boolean hideFromSlashCommandTool = fmBoolean(fm, "hide-from-slash-command-tool", false);
boolean isSensitive = fmBoolean(fm, "is-sensitive", false);
String context = fmString(fm, "context", "inline");
String agent = fmString(fm, "agent", null);
String shell = fmString(fm, "shell", null);
if (shell != null && !"bash".equals(shell) && !"powershell".equals(shell)) {
log.warn("Invalid shell '{}' in {}, falling back to bash", shell, path);
shell = null;
}
List paths = parseSkillPaths(fm);
String argumentHint = fmString(fm, "argument-hint", null);
List arguments = fmStringList(fm, "arguments");
String version = fmString(fm, "version", null);
// Hooks parsing (corresponds to TS parseHooksFromFrontmatter + HooksSchema)
Map hooks = parseHooksFromFrontmatter(fm, name);
// Determine loadedFrom based on source
String loadedFrom = Skill.sourceToLoadedFrom(source);
// skillRoot: the directory containing the skill (for SKILL.md, it's parent; for single .md, null)
Path skillRoot = null;
if (path.getFileName().toString().equalsIgnoreCase("SKILL.md")) {
skillRoot = path.getParent();
}
return new Skill(name, displayName, description, hasUserSpecifiedDescription,
whenToUse, content, source, loadedFrom, path, skillRoot,
allowedTools, disallowedTools, disableModelInvocation,
model, effort, userInvocable, hideFromSlashCommandTool, isSensitive,
context, agent, shell, paths, argumentHint, arguments, version,
content.length(), "running", hooks);
}
// ==================== 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