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>
This commit is contained in:
@@ -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 描述是否来自 frontmatter(vs 自动提取)
|
||||||
* @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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user