feat: implement 13 minor SkillLoader/Skill differences

Skill record expanded from 19 to 27 fields:
1. hasUserSpecifiedDescription — track if description from frontmatter vs auto-extracted
2. loadedFrom — aligned with TS values (commands_DEPRECATED/skills/plugin/managed/bundled/mcp)
3. skillRoot — base directory for CLAUDE_PLUGIN_ROOT env var support
4. disableModelInvocation — separate from disallowedTools, prevents model SkillTool invocation
5. hideFromSlashCommandTool — hide-from-slash-command-tool frontmatter field
6. isSensitive — is-sensitive frontmatter, args redacted from history
7. contentLength — content.length() for token estimation
8. progressMessage — default 'running' for UI progress display

New computed methods:
9. isHidden() — derived from !userInvocable || hideFromSlashCommandTool
10. isMcp() — check source/loadedFrom for MCP origin
11. estimateFrontmatterTokens() — rough token count for context budget

Validation & parsing improvements:
12. Effort validation — only accept low/medium/high/max/positive integer (parseEffortValue)
13. model 'inherit' — treated as null (use parent model, matching TS)

Additional fixes:
- when_to_use key support (TS uses underscore variant)
- buildSkillsSummary filters hidden/model-disabled skills
- SkillTool blocks model invocation when disableModelInvocation=true
- SkillTool uses skillRoot for base directory
- Brace expansion in paths (splitPathInFrontmatter + expandBraces)
- Backward-compatible 19-arg and 6-arg constructors preserved

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
main
abel533 4 weeks ago
parent b7a7d35025
commit 1ca1543662
  1. 167
      src/main/java/com/claudecode/context/SkillLoader.java
  2. 15
      src/main/java/com/claudecode/tool/impl/SkillTool.java

@ -387,36 +387,49 @@ public class SkillLoader {
// 解析所有 frontmatter 字段 // 解析所有 frontmatter 字段
String displayName = fmString(fm, "display-name", fmString(fm, "name", null)); 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); String fmName = fmString(fm, "name", null);
if (fmName != null && !fmName.isBlank() && overrideName == null) { if (fmName != null && !fmName.isBlank() && overrideName == null) {
name = fmName; name = fmName;
} }
String description = fmString(fm, "description", ""); // Description with tracking
// 描述 fallback:从 markdown 第一行提取 String rawDescription = fmString(fm, "description", null);
boolean hasUserSpecifiedDescription = rawDescription != null && !rawDescription.isBlank();
String description = hasUserSpecifiedDescription ? rawDescription : "";
if (description.isEmpty() && !content.isEmpty()) { if (description.isEmpty() && !content.isEmpty()) {
description = extractDescriptionFromMarkdown(content); description = extractDescriptionFromMarkdown(content);
} }
String whenToUse = fmString(fm, "when-to-use", fmString(fm, "whenToUse", "")); String whenToUse = fmString(fm, "when-to-use", fmString(fm, "whenToUse", fmString(fm, "when_to_use", "")));
List<String> allowedTools = fmStringList(fm, "allowed-tools"); List<String> allowedTools = fmStringList(fm, "allowed-tools");
List<String> disallowedTools = fmStringList(fm, "disallowed-tools"); List<String> disallowedTools = fmStringList(fm, "disallowed-tools");
if (disallowedTools == null) {
// TS 也支持 disable-model-invocation 作为别名 // disable-model-invocation: separate boolean flag (NOT alias for disallowedTools)
disallowedTools = fmStringList(fm, "disable-model-invocation"); boolean disableModelInvocation = fmBoolean(fm, "disable-model-invocation", false);
}
String model = fmString(fm, "model", null); String model = fmString(fm, "model", null);
String effort = fmString(fm, "effort", null); // "inherit" means use parent model (TS treats as undefined)
if ("inherit".equalsIgnoreCase(model)) {
model = null;
}
// 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 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 context = fmString(fm, "context", "inline");
String agent = fmString(fm, "agent", null); String agent = fmString(fm, "agent", null);
String shell = fmString(fm, "shell", null); String shell = fmString(fm, "shell", null);
// shell 校验(仅 bash / powershell)
if (shell != null && !"bash".equals(shell) && !"powershell".equals(shell)) { if (shell != null && !"bash".equals(shell) && !"powershell".equals(shell)) {
log.warn("Invalid shell '{}' in {}, ignoring (use 'bash' or 'powershell')", shell, path); log.warn("Invalid shell '{}' in {}, falling back to bash", shell, path);
shell = null; shell = null;
} }
List<String> paths = parseSkillPaths(fm); List<String> paths = parseSkillPaths(fm);
@ -424,9 +437,21 @@ public class SkillLoader {
List<String> arguments = fmStringList(fm, "arguments"); List<String> arguments = fmStringList(fm, "arguments");
String version = fmString(fm, "version", null); String version = fmString(fm, "version", null);
return new Skill(name, displayName, description, whenToUse, content, source, path, // Determine loadedFrom based on source
allowedTools, disallowedTools, model, effort, userInvocable, String loadedFrom = Skill.sourceToLoadedFrom(source);
context, agent, shell, paths, argumentHint, arguments, version);
// 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");
} }
// ==================== Frontmatter YAML 解析工具方法 ==================== // ==================== Frontmatter YAML 解析工具方法 ====================
@ -828,7 +853,13 @@ public class SkillLoader {
* @param charBudget 最大字符预算 * @param charBudget 最大字符预算
*/ */
public String buildSkillsSummary(int charBudget) { public String buildSkillsSummary(int charBudget) {
if (skills.isEmpty()) { List<Skill> allSkills = getSkills();
// Filter out hidden and model-invocation-disabled skills
List<Skill> visibleSkills = allSkills.stream()
.filter(s -> !s.isHidden() && !s.disableModelInvocation())
.toList();
if (visibleSkills.isEmpty()) {
return ""; return "";
} }
@ -839,9 +870,9 @@ public class SkillLoader {
int budgetUsed = sb.length(); int budgetUsed = sb.length();
int perEntryMax = 250; // Per-entry cap for cache efficiency int perEntryMax = 250; // Per-entry cap for cache efficiency
for (Skill skill : skills) { for (Skill skill : visibleSkills) {
StringBuilder entry = new StringBuilder(); StringBuilder entry = new StringBuilder();
entry.append("- **").append(skill.name()).append("**"); entry.append("- **").append(skill.userFacingName()).append("**");
if (!skill.description().isEmpty()) { if (!skill.description().isEmpty()) {
String desc = skill.description(); String desc = skill.description();
if (desc.length() > perEntryMax - skill.name().length() - 10) { if (desc.length() > perEntryMax - skill.name().length() - 10) {
@ -857,7 +888,7 @@ public class SkillLoader {
// Check budget // Check budget
if (budgetUsed + entry.length() > charBudget) { if (budgetUsed + entry.length() > charBudget) {
// Add truncation notice // Add truncation notice
sb.append("- ... and ").append(skills.size() - skills.indexOf(skill)) sb.append("- ... and ").append(visibleSkills.size() - visibleSkills.indexOf(skill))
.append(" more skills (use /skills to see all)\n"); .append(" more skills (use /skills to see all)\n");
break; break;
} }
@ -870,20 +901,26 @@ public class SkillLoader {
} }
/** /**
* 技能数据记录 对应 TS Command 类型中与 Skill 相关的字段 * 技能数据记录 对应 TS Command/CommandBase/PromptCommand 类型
* *
* @param name 技能名称目录名或 frontmatter name * @param name 技能名称目录名或 frontmatter name
* @param displayName 显示名称frontmatter display-name可为 null * @param displayName 显示名称frontmatter display-name可为 null
* @param description 技能描述 * @param description 技能描述
* @param hasUserSpecifiedDescription 描述是否来自 frontmattervs 自动提取
* @param whenToUse 何时使用提示 * @param whenToUse 何时使用提示
* @param content Markdown 内容体 * @param content Markdown 内容体
* @param source 来源user/project/command/bundled * @param source 来源user/project/command/bundled/mcp/plugin/policySettings
* @param loadedFrom 加载来源commands_DEPRECATED/skills/plugin/managed/bundled/mcp
* @param filePath 文件路径 * @param filePath 文件路径
* @param skillRoot 技能基础目录用于 CLAUDE_PLUGIN_ROOT 等环境变量
* @param allowedTools 允许使用的工具列表null = 不限制 * @param allowedTools 允许使用的工具列表null = 不限制
* @param disallowedTools 禁止使用的工具列表null = 不限制 * @param disallowedTools 禁止使用的工具列表null = 不限制
* @param disableModelInvocation 是否禁止模型通过 SkillTool 调用此技能
* @param model 模型覆盖null = 使用默认"inherit" = 继承父级 * @param model 模型覆盖null = 使用默认"inherit" = 继承父级
* @param effort Effort 级别low/medium/high/max 或整数 * @param effort Effort 级别low/medium/high/max 或整数已验证
* @param userInvocable 是否可由用户通过 /name 调用默认 true * @param userInvocable 是否可由用户通过 /name 调用默认 true
* @param hideFromSlashCommandTool SkillTool 隐藏hide-from-slash-command-tool frontmatter
* @param isSensitive 参数是否应从会话历史中脱敏
* @param context 执行上下文"inline" = 当前上下文, "fork" = Agent * @param context 执行上下文"inline" = 当前上下文, "fork" = Agent
* @param agent Agent 类型 context=fork 时使用 * @param agent Agent 类型 context=fork 时使用
* @param shell Shell 类型"bash" / "powershell"可为 null * @param shell Shell 类型"bash" / "powershell"可为 null
@ -891,29 +928,55 @@ public class SkillLoader {
* @param argumentHint 参数提示文本 * @param argumentHint 参数提示文本
* @param arguments 参数名列表 * @param arguments 参数名列表
* @param version 技能版本 * @param version 技能版本
* @param contentLength Markdown 内容长度用于 token 预估
* @param progressMessage 执行时显示的进度消息
*/ */
public record Skill( public record Skill(
String name, String name,
String displayName, String displayName,
String description, String description,
boolean hasUserSpecifiedDescription,
String whenToUse, String whenToUse,
String content, String content,
String source, String source,
String loadedFrom,
Path filePath, Path filePath,
Path skillRoot,
List<String> allowedTools, List<String> allowedTools,
List<String> disallowedTools, List<String> disallowedTools,
boolean disableModelInvocation,
String model, String model,
String effort, String effort,
boolean userInvocable, boolean userInvocable,
boolean hideFromSlashCommandTool,
boolean isSensitive,
String context, String context,
String agent, String agent,
String shell, String shell,
List<String> paths, List<String> paths,
String argumentHint, String argumentHint,
List<String> arguments, List<String> arguments,
String version String version,
int contentLength,
String progressMessage
) { ) {
/** 便捷构造(向后兼容旧代码) */ /** 向后兼容 19 参数构造器(从之前的 7 critical fixes 版本) */
public 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) {
this(name, displayName, description, false, whenToUse, content, source,
sourceToLoadedFrom(source), filePath,
filePath != null ? filePath.getParent() : null,
allowedTools, disallowedTools, false, model, effort,
userInvocable, false, false, context, agent, shell,
paths, argumentHint, arguments, version,
content != null ? content.length() : 0, "running");
}
/** 最简便捷构造(BundledSkills 使用) */
public Skill(String name, String description, String whenToUse, public Skill(String name, String description, String whenToUse,
String content, String source, Path filePath) { String content, String source, Path filePath) {
this(name, null, description, whenToUse, content, source, filePath, this(name, null, description, whenToUse, content, source, filePath,
@ -934,5 +997,59 @@ public class SkillLoader {
public String userFacingName() { public String userFacingName() {
return displayName != null && !displayName.isBlank() ? displayName : name; return displayName != null && !displayName.isBlank() ? displayName : name;
} }
/** 是否对用户隐藏(不在 typeahead/help 中显示) */
public boolean isHidden() {
return !userInvocable || hideFromSlashCommandTool;
}
/** 是否来自 MCP */
public boolean isMcp() {
return "mcp".equals(source) || "mcp".equals(loadedFrom);
}
/** 预估 frontmatter 部分 token 数 */
public int estimateFrontmatterTokens() {
String text = String.join(" ",
name != null ? name : "",
description != null ? description : "",
whenToUse != null ? whenToUse : "");
// Rough estimate: ~4 chars per token
return Math.max(1, text.length() / 4);
}
/** 将 source 映射到 loadedFrom 值 */
private static String sourceToLoadedFrom(String source) {
if (source == null) return "skills";
return switch (source) {
case "bundled" -> "bundled";
case "command" -> "commands_DEPRECATED";
case "mcp" -> "mcp";
case "plugin" -> "plugin";
case "policySettings" -> "managed";
default -> "skills";
};
}
}
// ==================== Effort Validation ====================
/** Valid effort level strings (matching TS EFFORT_LEVELS) */
private static final Set<String> VALID_EFFORT_LEVELS = Set.of("low", "medium", "high", "max");
/**
* Parse and validate an effort value.
* Accepts: "low", "medium", "high", "max", or a positive integer string.
* Returns null for invalid values (matching TS parseEffortValue).
*/
private static String parseEffortValue(String raw) {
if (raw == null || raw.isBlank()) return null;
String normalized = raw.strip().toLowerCase();
if (VALID_EFFORT_LEVELS.contains(normalized)) return normalized;
try {
int val = Integer.parseInt(normalized);
if (val > 0) return String.valueOf(val);
} catch (NumberFormatException ignored) {}
return null;
} }
} }

@ -105,7 +105,8 @@ public class SkillTool implements Tool {
msg.append("Skill '").append(skillName).append("' not found.\n\n"); msg.append("Skill '").append(skillName).append("' not found.\n\n");
msg.append("Available skills:\n"); msg.append("Available skills:\n");
for (Skill s : skillLoader.getSkills()) { for (Skill s : skillLoader.getSkills()) {
msg.append(" - ").append(s.name()); if (s.isHidden() || s.disableModelInvocation()) continue;
msg.append(" - ").append(s.userFacingName());
if (!s.description().isEmpty()) { if (!s.description().isEmpty()) {
msg.append(": ").append(s.description()); msg.append(": ").append(s.description());
} }
@ -115,6 +116,13 @@ public class SkillTool implements Tool {
} }
Skill skill = skillOpt.get(); Skill skill = skillOpt.get();
// Check if model invocation is disabled for this skill
if (skill.disableModelInvocation()) {
return "Error: Skill '" + skill.userFacingName() + "' cannot be invoked by the model. "
+ "It has disable-model-invocation: true in its frontmatter.";
}
log.info("Executing skill: {} [{}] context={}", skill.name(), skill.source(), skill.context()); log.info("Executing skill: {} [{}] context={}", skill.name(), skill.source(), skill.context());
// Build skill execution prompt // Build skill execution prompt
@ -195,7 +203,10 @@ public class SkillTool implements Tool {
String content = skill.content(); String content = skill.content();
// Prepend base directory if available (matches TS behavior) // Prepend base directory if available (matches TS behavior)
Path skillDir = skill.filePath() != null ? skill.filePath().getParent() : null; Path skillDir = skill.skillRoot();
if (skillDir == null && skill.filePath() != null) {
skillDir = skill.filePath().getParent();
}
if (skillDir != null) { if (skillDir != null) {
content = "Base directory for this skill: " + skillDir + "\n\n" + content; content = "Base directory for this skill: " + skillDir + "\n\n" + content;
} }

Loading…
Cancel
Save