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 字段
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 第一行提取
// 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", ""));
String whenToUse = fmString(fm, "when-to-use", fmString(fm, "whenToUse", fmString(fm, "when_to_use", "")));
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");
}
// disable-model-invocation: separate boolean flag (NOT alias for disallowedTools)
boolean disableModelInvocation = fmBoolean(fm, "disable-model-invocation", false);
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 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);
// shell 校验(仅 bash / powershell)
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;
}
List<String> paths = parseSkillPaths(fm);
@ -424,9 +437,21 @@ public class SkillLoader {
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);
// 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");
}
// ==================== Frontmatter YAML 解析工具方法 ====================
@ -828,7 +853,13 @@ public class SkillLoader {
* @param 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 "";
}
@ -839,9 +870,9 @@ public class SkillLoader {
int budgetUsed = sb.length();
int perEntryMax = 250; // Per-entry cap for cache efficiency
for (Skill skill : skills) {
for (Skill skill : visibleSkills) {
StringBuilder entry = new StringBuilder();
entry.append("- **").append(skill.name()).append("**");
entry.append("- **").append(skill.userFacingName()).append("**");
if (!skill.description().isEmpty()) {
String desc = skill.description();
if (desc.length() > perEntryMax - skill.name().length() - 10) {
@ -857,7 +888,7 @@ public class SkillLoader {
// Check budget
if (budgetUsed + entry.length() > charBudget) {
// 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");
break;
}
@ -870,20 +901,26 @@ public class SkillLoader {
}
/**
* 技能数据记录 对应 TS Command 类型中与 Skill 相关的字段
* 技能数据记录 对应 TS Command/CommandBase/PromptCommand 类型
*
* @param name 技能名称目录名或 frontmatter name
* @param displayName 显示名称frontmatter display-name可为 null
* @param description 技能描述
* @param hasUserSpecifiedDescription 描述是否来自 frontmattervs 自动提取
* @param whenToUse 何时使用提示
* @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 skillRoot 技能基础目录用于 CLAUDE_PLUGIN_ROOT 等环境变量
* @param allowedTools 允许使用的工具列表null = 不限制
* @param disallowedTools 禁止使用的工具列表null = 不限制
* @param disableModelInvocation 是否禁止模型通过 SkillTool 调用此技能
* @param model 模型覆盖null = 使用默认"inherit" = 继承父级
* @param effort Effort 级别low/medium/high/max 或整数
* @param effort Effort 级别low/medium/high/max 或整数已验证
* @param userInvocable 是否可由用户通过 /name 调用默认 true
* @param hideFromSlashCommandTool SkillTool 隐藏hide-from-slash-command-tool frontmatter
* @param isSensitive 参数是否应从会话历史中脱敏
* @param context 执行上下文"inline" = 当前上下文, "fork" = Agent
* @param agent Agent 类型 context=fork 时使用
* @param shell Shell 类型"bash" / "powershell"可为 null
@ -891,29 +928,55 @@ public class SkillLoader {
* @param argumentHint 参数提示文本
* @param arguments 参数名列表
* @param version 技能版本
* @param contentLength Markdown 内容长度用于 token 预估
* @param progressMessage 执行时显示的进度消息
*/
public record Skill(
String name,
String displayName,
String description,
boolean hasUserSpecifiedDescription,
String whenToUse,
String content,
String source,
String loadedFrom,
Path filePath,
Path skillRoot,
List<String> allowedTools,
List<String> disallowedTools,
boolean disableModelInvocation,
String model,
String effort,
boolean userInvocable,
boolean hideFromSlashCommandTool,
boolean isSensitive,
String context,
String agent,
String shell,
List<String> paths,
String argumentHint,
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,
String content, String source, Path filePath) {
this(name, null, description, whenToUse, content, source, filePath,
@ -934,5 +997,59 @@ public class SkillLoader {
public String userFacingName() {
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("Available skills:\n");
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()) {
msg.append(": ").append(s.description());
}
@ -115,6 +116,13 @@ public class SkillTool implements Tool {
}
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());
// Build skill execution prompt
@ -195,7 +203,10 @@ public class SkillTool implements Tool {
String content = skill.content();
// 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) {
content = "Base directory for this skill: " + skillDir + "\n\n" + content;
}

Loading…
Cancel
Save