diff --git a/src/main/java/com/claudecode/context/SkillLoader.java b/src/main/java/com/claudecode/context/SkillLoader.java index 2658609..975de64 100644 --- a/src/main/java/com/claudecode/context/SkillLoader.java +++ b/src/main/java/com/claudecode/context/SkillLoader.java @@ -10,15 +10,32 @@ import java.nio.file.Path; import java.util.*; /** - * Skills 技能加载器 —— 对应 claude-code/src/skills/ 模块。 + * Skills 技能加载器 —— 对应 claude-code/src/skills/loadSkillsDir.ts。 *
- * 从多个来源扫描和加载 .md 格式的技能文件: + * 从多个来源扫描和加载技能: *
+ * /skills/ 目录仅支持目录格式(与原版 claude-code 一致): + *
+ * .claude/skills/ + * └── verify-tests/ + * └── SKILL.md ← 技能名 = "verify-tests" + *+ *
+ * /commands/ 目录支持两种格式: + *
+ * .claude/commands/ + * ├── my-cmd.md ← 命令名 = "my-cmd"(单文件格式) + * ├── my-skill/ + * │ └── SKILL.md ← 命令名 = "my-skill"(目录格式,优先) + * └── sub/ + * └── nested-cmd.md ← 命令名 = "sub:nested-cmd"(命名空间) + *+ *
* 每个技能文件支持 YAML frontmatter 元数据: *
* ---
@@ -50,56 +67,146 @@ public class SkillLoader {
skills.addAll(BundledSkills.getAll());
log.debug("Loaded {} bundled skills", BundledSkills.getAll().size());
- // 1. 用户级技能
+ // 1. 用户级技能(目录格式: skill-name/SKILL.md)
Path userSkillsDir = Path.of(System.getProperty("user.home"), ".claude", "skills");
- loadFromDirectory(userSkillsDir, "user");
+ loadFromSkillsDirectory(userSkillsDir, "user");
- // 2. 项目级技能
+ // 2. 项目级技能(目录格式: skill-name/SKILL.md)
Path projectSkillsDir = projectDir.resolve(".claude").resolve("skills");
- loadFromDirectory(projectSkillsDir, "project");
+ loadFromSkillsDirectory(projectSkillsDir, "project");
- // 3. 命令目录(自动转换为技能)
+ // 3. 命令目录(支持目录格式 + 单文件格式 + 递归子目录)
Path commandsDir = projectDir.resolve(".claude").resolve("commands");
- loadFromDirectory(commandsDir, "command");
+ loadFromCommandsDirectory(commandsDir, "command");
log.debug("Loaded {} skills in total", skills.size());
return Collections.unmodifiableList(skills);
}
/**
- * 从指定目录加载 .md 技能文件
+ * 从 skills 目录加载技能 —— 仅支持目录格式: skill-name/SKILL.md
+ *
+ * 对应 TS loadSkillsFromSkillsDir():
+ * - 每个技能是一个子目录,内含 SKILL.md
+ * - 单独的 .md 文件不被加载(与原版一致)
+ * - 目录名即技能名
*/
- private void loadFromDirectory(Path dir, String source) {
+ private void loadFromSkillsDirectory(Path dir, String source) {
if (!Files.isDirectory(dir)) {
return;
}
try (var stream = Files.list(dir)) {
- stream.filter(p -> p.toString().endsWith(".md"))
+ stream.filter(Files::isDirectory)
.sorted()
- .forEach(p -> {
- try {
- Skill skill = parseSkillFile(p, source);
- skills.add(skill);
- log.debug("Loaded skill: {} [{}] from {}", skill.name(), source, p.getFileName());
- } catch (IOException e) {
- log.warn("Failed to load skill file: {}: {}", p, e.getMessage());
+ .forEach(subDir -> {
+ Path skillFile = subDir.resolve("SKILL.md");
+ if (Files.isRegularFile(skillFile)) {
+ 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 skill directory: {}: {}", dir, e.getMessage());
+ 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) {
+ try (var stream = Files.list(currentDir)) {
+ stream.sorted().forEach(entry -> {
+ try {
+ if (Files.isDirectory(entry)) {
+ // 检查目录内是否有 SKILL.md(目录格式技能)
+ Path skillFile = entry.resolve("SKILL.md");
+ if (Files.isRegularFile(skillFile)) {
+ 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")) {
+ // 单文件格式
+ 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 和内容
+ * 解析单个技能文件,提取 frontmatter 和内容。
+ * 当 overrideName 非 null 时,用它作为默认技能名(目录名或命令名)。
*/
- private Skill parseSkillFile(Path path, String source) throws IOException {
+ 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", "");
- // 尝试提取 YAML frontmatter
- String name = fileName;
+ // 默认名称:优先使用 overrideName(目录名/命名空间名),否则用文件名
+ String name = overrideName != null ? overrideName : fileName;
String description = "";
String whenToUse = "";
String content = raw;