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:
@@ -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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.yaml.snakeyaml.Yaml;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -52,6 +53,8 @@ public class SkillLoader {
|
|||||||
|
|
||||||
private final Path projectDir;
|
private final Path projectDir;
|
||||||
private final List<Skill> skills = new ArrayList<>();
|
private final List<Skill> skills = new ArrayList<>();
|
||||||
|
/** 已加载文件的规范路径集合,用于 symlink 去重 */
|
||||||
|
private final Set<Path> loadedCanonicalPaths = new HashSet<>();
|
||||||
|
|
||||||
public SkillLoader(Path projectDir) {
|
public SkillLoader(Path projectDir) {
|
||||||
this.projectDir = projectDir;
|
this.projectDir = projectDir;
|
||||||
@@ -62,6 +65,7 @@ public class SkillLoader {
|
|||||||
*/
|
*/
|
||||||
public List<Skill> loadAll() {
|
public List<Skill> loadAll() {
|
||||||
skills.clear();
|
skills.clear();
|
||||||
|
loadedCanonicalPaths.clear();
|
||||||
|
|
||||||
// 0. 内置技能
|
// 0. 内置技能
|
||||||
skills.addAll(BundledSkills.getAll());
|
skills.addAll(BundledSkills.getAll());
|
||||||
@@ -97,11 +101,21 @@ public class SkillLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try (var stream = Files.list(dir)) {
|
try (var stream = Files.list(dir)) {
|
||||||
stream.filter(Files::isDirectory)
|
stream.filter(p -> Files.isDirectory(p) || Files.isSymbolicLink(p))
|
||||||
.sorted()
|
.sorted()
|
||||||
.forEach(subDir -> {
|
.forEach(subDir -> {
|
||||||
|
// Gitignore 过滤
|
||||||
|
if (isGitignored(subDir)) {
|
||||||
|
log.debug("Skipping gitignored skill directory: {}", subDir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Path skillFile = subDir.resolve("SKILL.md");
|
Path skillFile = subDir.resolve("SKILL.md");
|
||||||
if (Files.isRegularFile(skillFile)) {
|
if (Files.isRegularFile(skillFile)) {
|
||||||
|
// Symlink 去重
|
||||||
|
if (!trackAndCheckDuplicate(skillFile)) {
|
||||||
|
log.debug("Skipping duplicate skill (symlink): {}", skillFile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
Skill skill = parseSkillFile(skillFile, source, subDir.getFileName().toString());
|
Skill skill = parseSkillFile(skillFile, source, subDir.getFileName().toString());
|
||||||
skills.add(skill);
|
skills.add(skill);
|
||||||
@@ -140,13 +154,20 @@ public class SkillLoader {
|
|||||||
* 递归加载 commands 目录
|
* 递归加载 commands 目录
|
||||||
*/
|
*/
|
||||||
private void loadCommandsRecursive(Path currentDir, Path baseDir, String source) {
|
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)) {
|
try (var stream = Files.list(currentDir)) {
|
||||||
stream.sorted().forEach(entry -> {
|
stream.sorted().forEach(entry -> {
|
||||||
try {
|
try {
|
||||||
if (Files.isDirectory(entry)) {
|
if (Files.isDirectory(entry) || Files.isSymbolicLink(entry)) {
|
||||||
// 检查目录内是否有 SKILL.md(目录格式技能)
|
// 检查目录内是否有 SKILL.md(目录格式技能)
|
||||||
Path skillFile = entry.resolve("SKILL.md");
|
Path skillFile = entry.resolve("SKILL.md");
|
||||||
if (Files.isRegularFile(skillFile)) {
|
if (Files.isRegularFile(skillFile)) {
|
||||||
|
if (!trackAndCheckDuplicate(skillFile)) return;
|
||||||
String name = buildCommandName(entry, baseDir, true);
|
String name = buildCommandName(entry, baseDir, true);
|
||||||
Skill skill = parseSkillFile(skillFile, source, name);
|
Skill skill = parseSkillFile(skillFile, source, name);
|
||||||
skills.add(skill);
|
skills.add(skill);
|
||||||
@@ -156,6 +177,7 @@ public class SkillLoader {
|
|||||||
loadCommandsRecursive(entry, baseDir, source);
|
loadCommandsRecursive(entry, baseDir, source);
|
||||||
}
|
}
|
||||||
} else if (entry.toString().endsWith(".md")) {
|
} else if (entry.toString().endsWith(".md")) {
|
||||||
|
if (!trackAndCheckDuplicate(entry)) return;
|
||||||
// 单文件格式
|
// 单文件格式
|
||||||
String name = buildCommandName(entry, baseDir, false);
|
String name = buildCommandName(entry, baseDir, false);
|
||||||
Skill skill = parseSkillFile(entry, source, name);
|
Skill skill = parseSkillFile(entry, source, name);
|
||||||
@@ -199,6 +221,7 @@ public class SkillLoader {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析单个技能文件,提取 frontmatter 和内容。
|
* 解析单个技能文件,提取 frontmatter 和内容。
|
||||||
|
* 使用 SnakeYAML 解析 frontmatter,支持所有 YAML 数据类型。
|
||||||
* 当 overrideName 非 null 时,用它作为默认技能名(目录名或命令名)。
|
* 当 overrideName 非 null 时,用它作为默认技能名(目录名或命令名)。
|
||||||
*/
|
*/
|
||||||
private Skill parseSkillFile(Path path, String source, String overrideName) throws IOException {
|
private Skill parseSkillFile(Path path, String source, String overrideName) throws IOException {
|
||||||
@@ -207,38 +230,220 @@ public class SkillLoader {
|
|||||||
|
|
||||||
// 默认名称:优先使用 overrideName(目录名/命名空间名),否则用文件名
|
// 默认名称:优先使用 overrideName(目录名/命名空间名),否则用文件名
|
||||||
String name = overrideName != null ? overrideName : fileName;
|
String name = overrideName != null ? overrideName : fileName;
|
||||||
String description = "";
|
|
||||||
String whenToUse = "";
|
|
||||||
String content = raw;
|
String content = raw;
|
||||||
|
Map<String, Object> fm = Collections.emptyMap();
|
||||||
|
|
||||||
|
// 提取 YAML frontmatter
|
||||||
if (raw.startsWith("---")) {
|
if (raw.startsWith("---")) {
|
||||||
int endIdx = raw.indexOf("---", 3);
|
int endIdx = raw.indexOf("---", 3);
|
||||||
if (endIdx > 0) {
|
if (endIdx > 0) {
|
||||||
String frontmatter = raw.substring(3, endIdx).strip();
|
String fmRaw = raw.substring(3, endIdx).strip();
|
||||||
content = raw.substring(endIdx + 3).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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
public Optional<Skill> findByName(String name) {
|
||||||
return skills.stream()
|
return skills.stream()
|
||||||
@@ -257,6 +462,58 @@ public class SkillLoader {
|
|||||||
.findFirst();
|
.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%。
|
* 支持预算控制,确保不超过上下文窗口的 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,
|
public record Skill(
|
||||||
String content, String source, Path filePath) {
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -112,12 +113,19 @@ public class SkillTool implements Tool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Skill skill = skillOpt.get();
|
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
|
// Build skill execution prompt
|
||||||
String skillPrompt = buildSkillPrompt(skill, arguments);
|
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")
|
@SuppressWarnings("unchecked")
|
||||||
java.util.function.Function<String, String> agentFactory =
|
java.util.function.Function<String, String> agentFactory =
|
||||||
context.getOrDefault(AgentTool.AGENT_FACTORY_KEY, null);
|
context.getOrDefault(AgentTool.AGENT_FACTORY_KEY, null);
|
||||||
@@ -152,10 +160,11 @@ public class SkillTool implements Tool {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the full prompt for skill execution.
|
* 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) {
|
private String buildSkillPrompt(Skill skill, String arguments) {
|
||||||
StringBuilder sb = new StringBuilder();
|
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()) {
|
if (!skill.description().isEmpty()) {
|
||||||
sb.append("Description: ").append(skill.description()).append("\n");
|
sb.append("Description: ").append(skill.description()).append("\n");
|
||||||
@@ -163,11 +172,27 @@ public class SkillTool implements Tool {
|
|||||||
if (!skill.whenToUse().isEmpty()) {
|
if (!skill.whenToUse().isEmpty()) {
|
||||||
sb.append("When to use: ").append(skill.whenToUse()).append("\n");
|
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");
|
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 Instructions\n\n");
|
||||||
sb.append(skill.content()).append("\n\n");
|
sb.append(content).append("\n\n");
|
||||||
|
|
||||||
// Inject arguments
|
// Inject arguments
|
||||||
if (arguments != null && !arguments.isBlank()) {
|
if (arguments != null && !arguments.isBlank()) {
|
||||||
@@ -186,6 +211,41 @@ public class SkillTool implements Tool {
|
|||||||
return sb.toString();
|
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.
|
* Partial name match for skills.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user