OFFICIAL_MARKETPLACE_PREFIXES = Set.of(
+ "anthropic/", "claude/", "official/"
+ );
+
+ private SkillFilters() {}
+
+ // ==================== Core Filter Functions ====================
+
+ /**
+ * Get skills visible to the SkillTool (model-invocable skills).
+ * Corresponds to TS getSkillToolCommands().
+ *
+ * Filter logic (matches TS exactly):
+ * - Must be from loadedFrom: bundled, skills, commands_DEPRECATED
+ * - OR has hasUserSpecifiedDescription=true
+ * - OR has non-empty whenToUse
+ * - AND NOT disableModelInvocation
+ * - AND NOT isHidden
+ *
+ * @param skills all loaded skills
+ * @return filtered skills visible to model
+ */
+ public static List getSkillToolCommands(List skills) {
+ return skills.stream()
+ .filter(s -> !s.disableModelInvocation())
+ .filter(s -> !s.isHidden())
+ .filter(s -> isSkillToolVisible(s))
+ .toList();
+ }
+
+ /**
+ * Get skills visible to the slash command tool (user-facing / commands).
+ * Corresponds to TS getSlashCommandToolSkills().
+ *
+ * Filter logic:
+ * - Must be from loadedFrom: skills, plugin, bundled
+ * - OR has disableModelInvocation=true (user-only skills always show in slash)
+ * - MUST have description or whenToUse
+ * - AND NOT isHidden (unless disableModelInvocation gives visibility)
+ *
+ * @param skills all loaded skills
+ * @return filtered skills visible as slash commands
+ */
+ public static List getSlashCommandToolSkills(List skills) {
+ return skills.stream()
+ .filter(s -> isSlashCommandVisible(s))
+ .filter(s -> hasVisibleMetadata(s))
+ .toList();
+ }
+
+ /**
+ * Format a skill description with its source label.
+ * Corresponds to TS formatDescriptionWithSource().
+ *
+ * Examples:
+ * - "Run tests" → "Run tests"
+ * - "Run tests" (from plugin) → "Run tests [plugin]"
+ * - "Run tests" (from managed) → "Run tests [managed]"
+ *
+ * @param skill the skill
+ * @return formatted description with optional source tag
+ */
+ public static String formatDescriptionWithSource(Skill skill) {
+ String desc = skill.description();
+ if (desc == null || desc.isBlank()) {
+ desc = skill.whenToUse();
+ }
+ if (desc == null) desc = "";
+
+ String loadedFrom = skill.loadedFrom();
+ if (loadedFrom != null && !Set.of("bundled", "skills", "commands_DEPRECATED").contains(loadedFrom)) {
+ return desc.isBlank() ? "[" + loadedFrom + "]" : desc + " [" + loadedFrom + "]";
+ }
+ return desc;
+ }
+
+ // ==================== Command Lookup Functions ====================
+
+ /**
+ * Get the canonical command name for a skill.
+ * Corresponds to TS getCommandName().
+ */
+ public static String getCommandName(Skill skill) {
+ return skill.name();
+ }
+
+ /**
+ * Find a command/skill by name (case-insensitive).
+ * Corresponds to TS findCommand().
+ */
+ public static Optional findCommand(List skills, String name) {
+ if (name == null || name.isBlank()) return Optional.empty();
+ return skills.stream()
+ .filter(s -> s.name().equalsIgnoreCase(name))
+ .findFirst();
+ }
+
+ /**
+ * Check if a command/skill exists by name.
+ * Corresponds to TS hasCommand().
+ */
+ public static boolean hasCommand(List skills, String name) {
+ return findCommand(skills, name).isPresent();
+ }
+
+ /**
+ * Get a command/skill by name, or null.
+ * Corresponds to TS getCommand().
+ */
+ public static Skill getCommand(List skills, String name) {
+ return findCommand(skills, name).orElse(null);
+ }
+
+ /**
+ * Get all skill names (for typeahead/autocomplete).
+ */
+ public static List getAllSkillNames(List skills) {
+ return skills.stream()
+ .filter(s -> !s.isHidden())
+ .map(Skill::name)
+ .sorted()
+ .toList();
+ }
+
+ /**
+ * Get skills grouped by source for display.
+ */
+ public static Map> groupBySource(List skills) {
+ return skills.stream()
+ .collect(Collectors.groupingBy(
+ s -> s.loadedFrom() != null ? s.loadedFrom() : "unknown",
+ LinkedHashMap::new,
+ Collectors.toList()
+ ));
+ }
+
+ // ==================== Marketplace ====================
+
+ /**
+ * Check if a skill name belongs to the official marketplace.
+ * Corresponds to TS isOfficialMarketplaceName().
+ *
+ * @param skillName the skill name to check
+ * @return true if the skill is from the official marketplace
+ */
+ public static boolean isOfficialMarketplaceName(String skillName) {
+ if (skillName == null) return false;
+ String lower = skillName.toLowerCase();
+ return OFFICIAL_MARKETPLACE_PREFIXES.stream().anyMatch(lower::startsWith);
+ }
+
+ /**
+ * Check if a skill is from the official marketplace.
+ */
+ public static boolean isOfficialMarketplace(Skill skill) {
+ return isOfficialMarketplaceName(skill.name())
+ || "plugin".equals(skill.loadedFrom())
+ || "managed".equals(skill.loadedFrom());
+ }
+
+ // ==================== Internal Filter Logic ====================
+
+ /**
+ * Check if a skill should be visible to the SkillTool (model).
+ * Matches TS getSkillToolCommands() filter logic.
+ */
+ private static boolean isSkillToolVisible(Skill skill) {
+ // Always show bundled, skills-dir, and commands-dir skills
+ String loadedFrom = skill.loadedFrom();
+ if (loadedFrom != null) {
+ if ("bundled".equals(loadedFrom) || "skills".equals(loadedFrom)
+ || "commands_DEPRECATED".equals(loadedFrom)) {
+ return true;
+ }
+ }
+
+ // Show if user specified a description
+ if (skill.hasUserSpecifiedDescription()) return true;
+
+ // Show if has whenToUse hint
+ return skill.whenToUse() != null && !skill.whenToUse().isBlank();
+ }
+
+ /**
+ * Check if a skill should be visible as a slash command.
+ * Matches TS getSlashCommandToolSkills() filter logic.
+ */
+ private static boolean isSlashCommandVisible(Skill skill) {
+ String loadedFrom = skill.loadedFrom();
+
+ // Skills with disableModelInvocation are user-only → always show in slash
+ if (skill.disableModelInvocation()) return true;
+
+ // Standard visibility sources
+ if (loadedFrom != null) {
+ return "skills".equals(loadedFrom)
+ || "plugin".equals(loadedFrom)
+ || "bundled".equals(loadedFrom);
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if a skill has enough metadata to be displayed.
+ */
+ private static boolean hasVisibleMetadata(Skill skill) {
+ return (skill.description() != null && !skill.description().isBlank())
+ || (skill.whenToUse() != null && !skill.whenToUse().isBlank());
+ }
+}
diff --git a/src/main/java/com/claudecode/context/SkillLoader.java b/src/main/java/com/claudecode/context/SkillLoader.java
index e6c2dc2..4880013 100644
--- a/src/main/java/com/claudecode/context/SkillLoader.java
+++ b/src/main/java/com/claudecode/context/SkillLoader.java
@@ -4,6 +4,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.yaml.snakeyaml.Yaml;
+import com.claudecode.util.ModelResolver;
+
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@@ -133,6 +135,9 @@ public class SkillLoader {
Path commandsDir = projectDir.resolve(".claude").resolve("commands");
loadFromCommandsDirectory(commandsDir, "command");
+ // 5. MCP skills from registered builders
+ loadMcpSkills();
+
// Separate conditional and unconditional skills
List unconditional = new ArrayList<>();
for (Skill skill : skills) {
@@ -408,10 +413,8 @@ public class SkillLoader {
boolean disableModelInvocation = fmBoolean(fm, "disable-model-invocation", false);
String model = fmString(fm, "model", null);
- // "inherit" means use parent model (TS treats as undefined)
- if ("inherit".equalsIgnoreCase(model)) {
- model = null;
- }
+ // Model resolution: aliases (haiku→full ID), "inherit"→null
+ model = ModelResolver.resolveSkillModelOverride(model);
// Effort validation (only accept valid values)
String rawEffort = fmString(fm, "effort", null);
@@ -437,6 +440,9 @@ public class SkillLoader {
List arguments = fmStringList(fm, "arguments");
String version = fmString(fm, "version", null);
+ // Hooks parsing (corresponds to TS parseHooksFromFrontmatter + HooksSchema)
+ Map hooks = parseHooksFromFrontmatter(fm, name);
+
// Determine loadedFrom based on source
String loadedFrom = Skill.sourceToLoadedFrom(source);
@@ -451,7 +457,7 @@ public class SkillLoader {
allowedTools, disallowedTools, disableModelInvocation,
model, effort, userInvocable, hideFromSlashCommandTool, isSensitive,
context, agent, shell, paths, argumentHint, arguments, version,
- content.length(), "running");
+ content.length(), "running", hooks);
}
// ==================== Frontmatter YAML 解析工具方法 ====================
@@ -930,6 +936,7 @@ public class SkillLoader {
* @param version 技能版本
* @param contentLength Markdown 内容长度(用于 token 预估)
* @param progressMessage 执行时显示的进度消息
+ * @param hooks 技能级生命周期钩子(PreToolUse, PostToolUse, Stop 等)
*/
public record Skill(
String name,
@@ -958,8 +965,26 @@ public class SkillLoader {
List arguments,
String version,
int contentLength,
- String progressMessage
+ String progressMessage,
+ Map hooks
) {
+ /** 向后兼容 28 参数构造器(无 hooks) */
+ public Skill(String name, String displayName, String description,
+ boolean hasUserSpecifiedDescription, String whenToUse, String content,
+ String source, String loadedFrom, Path filePath, Path skillRoot,
+ List allowedTools, List disallowedTools,
+ boolean disableModelInvocation, String model, String effort,
+ boolean userInvocable, boolean hideFromSlashCommandTool, boolean isSensitive,
+ String context, String agent, String shell, List paths,
+ String argumentHint, List arguments, String version,
+ int contentLength, String progressMessage) {
+ this(name, displayName, description, hasUserSpecifiedDescription, whenToUse,
+ content, source, loadedFrom, filePath, skillRoot, allowedTools, disallowedTools,
+ disableModelInvocation, model, effort, userInvocable, hideFromSlashCommandTool,
+ isSensitive, context, agent, shell, paths, argumentHint, arguments, version,
+ contentLength, progressMessage, null);
+ }
+
/** 向后兼容 19 参数构造器(从之前的 7 critical fixes 版本) */
public Skill(String name, String displayName, String description, String whenToUse,
String content, String source, Path filePath,
@@ -973,7 +998,7 @@ public class SkillLoader {
allowedTools, disallowedTools, false, model, effort,
userInvocable, false, false, context, agent, shell,
paths, argumentHint, arguments, version,
- content != null ? content.length() : 0, "running");
+ content != null ? content.length() : 0, "running", null);
}
/** 最简便捷构造(BundledSkills 使用) */
@@ -1008,6 +1033,11 @@ public class SkillLoader {
return "mcp".equals(source) || "mcp".equals(loadedFrom);
}
+ /** 是否有生命周期钩子 */
+ public boolean hasHooks() {
+ return hooks != null && !hooks.isEmpty();
+ }
+
/** 预估 frontmatter 部分 token 数 */
public int estimateFrontmatterTokens() {
String text = String.join(" ",
@@ -1032,6 +1062,80 @@ public class SkillLoader {
}
}
+ // ==================== Hooks Parsing ====================
+
+ /** Valid hook event names (matching TS HooksSchema) */
+ private static final Set VALID_HOOK_EVENTS = Set.of(
+ "PreToolUse", "PostToolUse", "Stop", "Notification",
+ "SubAgentStart", "SubAgentEnd", "ConfigChange"
+ );
+
+ /**
+ * Parse hooks from frontmatter, validating against HooksSchema.
+ * Corresponds to TS parseHooksFromFrontmatter() + HooksSchema validation.
+ *
+ * @return validated hooks map, or null if no hooks or invalid
+ */
+ @SuppressWarnings("unchecked")
+ private Map parseHooksFromFrontmatter(Map fm, String skillName) {
+ Object hooksRaw = fm.get("hooks");
+ if (hooksRaw == null) return null;
+
+ if (!(hooksRaw instanceof Map)) {
+ log.debug("Invalid hooks in skill '{}': expected object, got {}", skillName, hooksRaw.getClass().getSimpleName());
+ return null;
+ }
+
+ Map hooks = (Map) hooksRaw;
+ Map validated = new LinkedHashMap<>();
+
+ for (var entry : hooks.entrySet()) {
+ String eventName = entry.getKey();
+ if (!VALID_HOOK_EVENTS.contains(eventName)) {
+ log.debug("Unknown hook event '{}' in skill '{}', skipping", eventName, skillName);
+ continue;
+ }
+
+ Object hookDef = entry.getValue();
+ if (hookDef instanceof List || hookDef instanceof Map || hookDef instanceof String) {
+ validated.put(eventName, hookDef);
+ } else {
+ log.debug("Invalid hook definition for '{}' in skill '{}': {}", eventName, skillName, hookDef);
+ }
+ }
+
+ return validated.isEmpty() ? null : Collections.unmodifiableMap(validated);
+ }
+
+ // ==================== MCP Skill Integration ====================
+
+ /**
+ * Load MCP skills from the McpSkillBuilders registry.
+ * Called during loadAll() to merge MCP-provided skills.
+ */
+ private void loadMcpSkills() {
+ try {
+ List mcpSkills = McpSkillBuilders.getAllMcpSkills();
+ if (!mcpSkills.isEmpty()) {
+ skills.addAll(mcpSkills);
+ log.debug("Loaded {} MCP skills from registered builders", mcpSkills.size());
+ }
+ } catch (Exception e) {
+ log.debug("Failed to load MCP skills: {}", e.getMessage());
+ }
+ }
+
+ // ==================== Experimental Features ====================
+
+ /**
+ * Check if experimental skill search is enabled.
+ * Corresponds to TS feature('EXPERIMENTAL_SKILL_SEARCH').
+ */
+ public static boolean isExperimentalSkillSearchEnabled() {
+ return isEnvTruthy("CLAUDE_CODE_EXPERIMENTAL_SKILL_SEARCH")
+ || isEnvTruthy("EXPERIMENTAL_SKILL_SEARCH");
+ }
+
// ==================== Effort Validation ====================
/** Valid effort level strings (matching TS EFFORT_LEVELS) */
diff --git a/src/main/java/com/claudecode/tool/impl/SkillTool.java b/src/main/java/com/claudecode/tool/impl/SkillTool.java
index a86449a..1697b3d 100644
--- a/src/main/java/com/claudecode/tool/impl/SkillTool.java
+++ b/src/main/java/com/claudecode/tool/impl/SkillTool.java
@@ -1,7 +1,10 @@
package com.claudecode.tool.impl;
+import com.claudecode.context.SkillFilters;
import com.claudecode.context.SkillLoader;
import com.claudecode.context.SkillLoader.Skill;
+import com.claudecode.permission.PermissionRuleEngine;
+import com.claudecode.permission.PermissionTypes.PermissionDecision;
import com.claudecode.tool.Tool;
import com.claudecode.tool.ToolContext;
import com.claudecode.util.ArgumentSubstitution;
@@ -35,6 +38,12 @@ public class SkillTool implements Tool {
/** ToolContext key for SkillLoader */
public static final String SKILL_LOADER_KEY = "SKILL_LOADER";
+ /** ToolContext key for PermissionRuleEngine */
+ public static final String PERMISSION_ENGINE_KEY = "PERMISSION_ENGINE";
+
+ /** Max progress messages to show in non-verbose mode (matches TS) */
+ private static final int MAX_PROGRESS_MESSAGES = 3;
+
@Override
public String name() {
return "Skill";
@@ -87,28 +96,38 @@ public class SkillTool implements Tool {
return "Error: 'skill_name' is required";
}
+ // Normalize leading slash (matches TS behavior)
+ boolean hasLeadingSlash = skillName.startsWith("/");
+ if (hasLeadingSlash) {
+ skillName = skillName.substring(1);
+ logEvent("tengu_skill_tool_slash_prefix", Map.of());
+ }
+
// Get SkillLoader from context
SkillLoader skillLoader = context.get(SKILL_LOADER_KEY);
if (skillLoader == null) {
return "Error: SkillLoader not configured. No skills available.";
}
- // Find skill by name
- Optional skillOpt = skillLoader.findByName(skillName);
+ // Find skill by name (using filtered list for model-invocable skills)
+ List allSkills = skillLoader.getSkills();
+ List visibleSkills = SkillFilters.getSkillToolCommands(allSkills);
+
+ Optional skillOpt = SkillFilters.findCommand(allSkills, skillName);
if (skillOpt.isEmpty()) {
- // Try partial match
- skillOpt = findByPartialName(skillLoader.getSkills(), skillName);
+ // Try partial match across all skills
+ skillOpt = findByPartialName(allSkills, skillName);
}
if (skillOpt.isEmpty()) {
StringBuilder msg = new StringBuilder();
msg.append("Skill '").append(skillName).append("' not found.\n\n");
msg.append("Available skills:\n");
- for (Skill s : skillLoader.getSkills()) {
- if (s.isHidden() || s.disableModelInvocation()) continue;
+ for (Skill s : visibleSkills) {
msg.append(" - ").append(s.userFacingName());
- if (!s.description().isEmpty()) {
- msg.append(": ").append(s.description());
+ String desc = SkillFilters.formatDescriptionWithSource(s);
+ if (!desc.isEmpty()) {
+ msg.append(": ").append(desc);
}
msg.append("\n");
}
@@ -119,10 +138,19 @@ public class SkillTool implements Tool {
// 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.";
+ return renderError(skill, "Skill '" + skill.userFacingName()
+ + "' cannot be invoked by the model. It has disable-model-invocation: true.");
+ }
+
+ // Permission check (corresponds to TS checkPermissions)
+ String permissionError = checkPermissions(skill, skillName, context);
+ if (permissionError != null) {
+ return renderRejected(skill, permissionError);
}
+ // Analytics: log skill invocation
+ logSkillInvocation(skill, skillName, arguments);
+
log.info("Executing skill: {} [{}] context={}", skill.name(), skill.source(), skill.context());
// Build skill execution prompt
@@ -131,8 +159,7 @@ public class SkillTool implements Tool {
// 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" + skillPrompt;
+ return renderInlineResult(skill, skillPrompt);
}
// Forked execution: execute via agent factory (same as AgentTool)
@@ -150,10 +177,10 @@ public class SkillTool implements Tool {
try {
String result = agentFactory.apply(skillPrompt);
log.info("Skill '{}' completed, result: {} chars", skill.name(), result.length());
- return result;
+ return renderForkedResult(skill, result);
} catch (Exception e) {
log.debug("Skill execution failed", e);
- return "Error executing skill '" + skill.name() + "': " + e.getMessage();
+ return renderError(skill, "Error executing skill '" + skill.name() + "': " + e.getMessage());
}
}
@@ -168,6 +195,139 @@ public class SkillTool implements Tool {
return name != null ? "Running skill: " + name + "..." : "Running skill...";
}
+ // ==================== Permission Checking ====================
+
+ /**
+ * Check permissions for skill execution.
+ * Corresponds to TS SkillTool.checkPermissions().
+ *
+ * @return error message if denied, null if allowed
+ */
+ private String checkPermissions(Skill skill, String commandName, ToolContext context) {
+ PermissionRuleEngine engine = context.get(PERMISSION_ENGINE_KEY);
+ if (engine == null) return null; // No permission engine → allow
+
+ // Check permission using the tool name "Skill" and skill name as command
+ PermissionDecision decision = engine.evaluate("Skill",
+ Map.of("skill_name", commandName), false, context);
+
+ return switch (decision.behavior()) {
+ case DENY -> decision.reason() != null ? decision.reason() : "Skill execution blocked by permission rules";
+ case ASK -> null; // ASK = let it through (interactive confirm handled elsewhere)
+ default -> null; // ALLOW
+ };
+ }
+
+ // ==================== UI Rendering ====================
+
+ /**
+ * Render result for inline skill execution.
+ * Corresponds to TS renderToolResultMessage() for inline skills.
+ */
+ private String renderInlineResult(Skill skill, String prompt) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("📋 Skill '").append(skill.userFacingName()).append("' loaded (inline mode).");
+
+ // Show tools count if restricted
+ if (skill.allowedTools() != null && !skill.allowedTools().isEmpty()) {
+ sb.append(" [").append(skill.allowedTools().size()).append(" tools allowed]");
+ }
+
+ // Show model if non-default
+ if (skill.model() != null) {
+ sb.append(" [model: ").append(skill.model()).append("]");
+ }
+
+ sb.append("\n\nFollow these instructions:\n\n").append(prompt);
+ return sb.toString();
+ }
+
+ /**
+ * Render result for forked skill execution.
+ * Corresponds to TS renderToolResultMessage() for forked skills — shows "Done".
+ */
+ private String renderForkedResult(Skill skill, String result) {
+ return result;
+ }
+
+ /**
+ * Render rejection message.
+ * Corresponds to TS renderToolUseRejectedMessage().
+ */
+ private String renderRejected(Skill skill, String reason) {
+ return "⛔ Skill '" + skill.userFacingName() + "' rejected: " + reason;
+ }
+
+ /**
+ * Render error message.
+ * Corresponds to TS renderToolUseErrorMessage().
+ */
+ private String renderError(Skill skill, String error) {
+ return "❌ " + error;
+ }
+
+ /**
+ * Render tool use message (for display during execution).
+ * Corresponds to TS renderToolUseMessage() — shows legacy /commands/ marker.
+ */
+ public static String renderToolUseMessage(Skill skill) {
+ if ("commands_DEPRECATED".equals(skill.loadedFrom())) {
+ return "/" + skill.name();
+ }
+ return skill.userFacingName();
+ }
+
+ /**
+ * Render progress message during skill execution.
+ * Corresponds to TS renderToolUseProgressMessage().
+ */
+ public static String renderProgressMessage(Skill skill) {
+ String msg = skill.progressMessage();
+ return msg != null ? msg : "running";
+ }
+
+ // ==================== Analytics ====================
+
+ /**
+ * Log skill invocation telemetry event.
+ * Corresponds to TS logEvent('tengu_skill_tool_invocation', ...).
+ */
+ private void logSkillInvocation(Skill skill, String commandName, String arguments) {
+ boolean isOfficial = SkillFilters.isOfficialMarketplace(skill);
+ String executionContext = skill.isForked() ? "fork" : "inline";
+
+ log.info("SKILL_INVOKED: name={}, source={}, loadedFrom={}, context={}, official={}, argsLen={}",
+ commandName, skill.source(), skill.loadedFrom(), executionContext,
+ isOfficial, arguments != null ? arguments.length() : 0);
+
+ logEvent("tengu_skill_tool_invocation", Map.of(
+ "command_name", sanitizeSkillName(commandName),
+ "execution_context", executionContext,
+ "skill_source", skill.source() != null ? skill.source() : "",
+ "skill_loaded_from", skill.loadedFrom() != null ? skill.loadedFrom() : "",
+ "is_official_marketplace", String.valueOf(isOfficial)
+ ));
+ }
+
+ /**
+ * Sanitize skill name for telemetry (remove PII).
+ */
+ private String sanitizeSkillName(String name) {
+ if (name == null) return "unknown";
+ // Replace user-specific paths with generic markers
+ return name.replaceAll("[^a-zA-Z0-9_:-]", "_");
+ }
+
+ /**
+ * Log a telemetry event (stub — integrates with existing TelemetryService if available).
+ */
+ private void logEvent(String eventName, Map properties) {
+ // Log to SLF4J for now; when TelemetryService is wired, delegate there
+ log.debug("TELEMETRY: {} {}", eventName, properties);
+ }
+
+ // ==================== Prompt Building ====================
+
/**
* Build the full prompt for skill execution.
* Supports argument substitution ($ARGUMENTS, $n, $name, ${CLAUDE_SKILL_DIR}, ${CLAUDE_SESSION_ID}).
diff --git a/src/main/java/com/claudecode/util/ModelResolver.java b/src/main/java/com/claudecode/util/ModelResolver.java
new file mode 100644
index 0000000..e1abf11
--- /dev/null
+++ b/src/main/java/com/claudecode/util/ModelResolver.java
@@ -0,0 +1,109 @@
+package com.claudecode.util;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Map;
+
+/**
+ * 模型别名解析器 —— 对应 TS resolveSkillModelOverride()。
+ *
+ * 解析技能 frontmatter 中的 model 字段,支持:
+ *
+ * - 别名解析:haiku → claude-3-haiku, sonnet → claude-sonnet-4, opus → claude-opus-4
+ * - 特殊值:inherit → null(继承父级模型)
+ * - 完整模型 ID:直接透传
+ *
+ */
+public final class ModelResolver {
+
+ private static final Logger log = LoggerFactory.getLogger(ModelResolver.class);
+
+ /** Model alias mapping (short name → full model ID) */
+ private static final Map MODEL_ALIASES = Map.ofEntries(
+ // Claude 3 family
+ Map.entry("haiku", "claude-3-haiku-20240307"),
+ Map.entry("claude-3-haiku", "claude-3-haiku-20240307"),
+ Map.entry("haiku-3", "claude-3-haiku-20240307"),
+
+ // Claude 3.5 family
+ Map.entry("sonnet-3.5", "claude-3-5-sonnet-20241022"),
+ Map.entry("claude-3.5-sonnet", "claude-3-5-sonnet-20241022"),
+ Map.entry("haiku-3.5", "claude-3-5-haiku-20241022"),
+ Map.entry("claude-3.5-haiku", "claude-3-5-haiku-20241022"),
+
+ // Claude 4 family (latest)
+ Map.entry("sonnet", "claude-sonnet-4-20250514"),
+ Map.entry("claude-sonnet", "claude-sonnet-4-20250514"),
+ Map.entry("sonnet-4", "claude-sonnet-4-20250514"),
+ Map.entry("claude-sonnet-4", "claude-sonnet-4-20250514"),
+
+ Map.entry("opus", "claude-opus-4-20250514"),
+ Map.entry("claude-opus", "claude-opus-4-20250514"),
+ Map.entry("opus-4", "claude-opus-4-20250514"),
+ Map.entry("claude-opus-4", "claude-opus-4-20250514"),
+
+ // OpenAI aliases (for OpenAI-compatible providers)
+ Map.entry("gpt-4", "gpt-4"),
+ Map.entry("gpt-4o", "gpt-4o"),
+ Map.entry("gpt-4o-mini", "gpt-4o-mini"),
+ Map.entry("o1", "o1"),
+ Map.entry("o1-mini", "o1-mini"),
+ Map.entry("o3", "o3"),
+ Map.entry("o3-mini", "o3-mini"),
+ Map.entry("o4-mini", "o4-mini")
+ );
+
+ private ModelResolver() {}
+
+ /**
+ * Resolve a model override from skill frontmatter.
+ * Corresponds to TS resolveSkillModelOverride().
+ *
+ * @param modelValue raw model value from frontmatter (may be alias, "inherit", or full ID)
+ * @return resolved model ID, or null if "inherit" or invalid
+ */
+ public static String resolveSkillModelOverride(String modelValue) {
+ if (modelValue == null || modelValue.isBlank()) {
+ return null;
+ }
+
+ String normalized = modelValue.strip().toLowerCase();
+
+ // "inherit" means use parent model
+ if ("inherit".equals(normalized)) {
+ return null;
+ }
+
+ // Check aliases
+ String resolved = MODEL_ALIASES.get(normalized);
+ if (resolved != null) {
+ log.debug("Resolved model alias '{}' → '{}'", modelValue, resolved);
+ return resolved;
+ }
+
+ // If it looks like a full model ID (contains a dash and has enough chars), pass through
+ if (modelValue.contains("-") || modelValue.contains("/") || modelValue.length() > 10) {
+ return modelValue.strip();
+ }
+
+ // Unknown short name — log warning but still pass through
+ log.debug("Unknown model alias '{}', passing through as-is", modelValue);
+ return modelValue.strip();
+ }
+
+ /**
+ * Check if a model value is a known alias.
+ */
+ public static boolean isKnownAlias(String modelValue) {
+ if (modelValue == null) return false;
+ return MODEL_ALIASES.containsKey(modelValue.strip().toLowerCase());
+ }
+
+ /**
+ * Get all known model aliases.
+ */
+ public static Map getAllAliases() {
+ return MODEL_ALIASES;
+ }
+}
diff --git a/src/test/java/com/claudecode/context/McpSkillBuildersTest.java b/src/test/java/com/claudecode/context/McpSkillBuildersTest.java
new file mode 100644
index 0000000..fdf892c
--- /dev/null
+++ b/src/test/java/com/claudecode/context/McpSkillBuildersTest.java
@@ -0,0 +1,94 @@
+package com.claudecode.context;
+
+import com.claudecode.context.SkillLoader.Skill;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for McpSkillBuilders registry.
+ */
+class McpSkillBuildersTest {
+
+ @BeforeEach
+ @AfterEach
+ void cleanup() {
+ McpSkillBuilders.clearAll();
+ }
+
+ @Test
+ void register_and_getAllMcpSkills() {
+ Skill s1 = new Skill("mcp-lint", "Lint code", "", "content1", "mcp", null);
+ Skill s2 = new Skill("mcp-format", "Format code", "", "content2", "mcp", null);
+
+ McpSkillBuilders.register("server1", () -> List.of(s1));
+ McpSkillBuilders.register("server2", () -> List.of(s2));
+
+ List result = McpSkillBuilders.getAllMcpSkills();
+ assertEquals(2, result.size());
+ }
+
+ @Test
+ void getAllMcpSkills_cachesResult() {
+ McpSkillBuilders.register("server", () -> List.of(
+ new Skill("mcp-test", "test", "", "content", "mcp", null)
+ ));
+
+ List first = McpSkillBuilders.getAllMcpSkills();
+ List second = McpSkillBuilders.getAllMcpSkills();
+ assertSame(first, second); // Same cached instance
+ }
+
+ @Test
+ void register_invalidatesCache() {
+ McpSkillBuilders.register("server1", () -> List.of(
+ new Skill("s1", "test", "", "content", "mcp", null)
+ ));
+ McpSkillBuilders.getAllMcpSkills(); // populate cache
+
+ McpSkillBuilders.register("server2", () -> List.of(
+ new Skill("s2", "test2", "", "content2", "mcp", null)
+ ));
+ List result = McpSkillBuilders.getAllMcpSkills();
+ assertEquals(2, result.size()); // Cache was invalidated
+ }
+
+ @Test
+ void unregister_removesServer() {
+ McpSkillBuilders.register("server", () -> List.of(
+ new Skill("s1", "test", "", "content", "mcp", null)
+ ));
+ McpSkillBuilders.unregister("server");
+ assertTrue(McpSkillBuilders.getAllMcpSkills().isEmpty());
+ }
+
+ @Test
+ void getRegisteredServers() {
+ McpSkillBuilders.register("server1", List::of);
+ McpSkillBuilders.register("server2", List::of);
+ assertEquals(2, McpSkillBuilders.getRegisteredServers().size());
+ }
+
+ @Test
+ void hasBuilders_returnsCorrectly() {
+ assertFalse(McpSkillBuilders.hasBuilders());
+ McpSkillBuilders.register("s", List::of);
+ assertTrue(McpSkillBuilders.hasBuilders());
+ }
+
+ @Test
+ void builderException_isHandledGracefully() {
+ McpSkillBuilders.register("bad-server", () -> { throw new RuntimeException("fail"); });
+ McpSkillBuilders.register("good-server", () -> List.of(
+ new Skill("ok", "ok", "", "ok", "mcp", null)
+ ));
+
+ List result = McpSkillBuilders.getAllMcpSkills();
+ assertEquals(1, result.size());
+ assertEquals("ok", result.getFirst().name());
+ }
+}
diff --git a/src/test/java/com/claudecode/context/SkillFiltersTest.java b/src/test/java/com/claudecode/context/SkillFiltersTest.java
new file mode 100644
index 0000000..8395b66
--- /dev/null
+++ b/src/test/java/com/claudecode/context/SkillFiltersTest.java
@@ -0,0 +1,137 @@
+package com.claudecode.context;
+
+import com.claudecode.context.SkillLoader.Skill;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for SkillFilters utility class.
+ */
+class SkillFiltersTest {
+
+ private Skill makeSkill(String name, String source, String loadedFrom,
+ boolean disableModelInvocation, boolean hasUserDesc,
+ String whenToUse, boolean userInvocable) {
+ return new Skill(name, null, "desc", hasUserDesc, whenToUse,
+ "content", source, loadedFrom, null, null,
+ null, null, disableModelInvocation, null, null,
+ userInvocable, false, false, "inline", null, null,
+ null, null, null, null, 7, "running", null);
+ }
+
+ @Test
+ void getSkillToolCommands_filtersByLoadedFrom() {
+ List skills = List.of(
+ makeSkill("bundled-skill", "bundled", "bundled", false, false, "", true),
+ makeSkill("skills-skill", "user", "skills", false, false, "", true),
+ makeSkill("commands-skill", "command", "commands_DEPRECATED", false, false, "", true),
+ makeSkill("plugin-skill", "plugin", "plugin", false, false, "", true)
+ );
+
+ List result = SkillFilters.getSkillToolCommands(skills);
+ assertEquals(3, result.size());
+ assertTrue(result.stream().anyMatch(s -> s.name().equals("bundled-skill")));
+ assertTrue(result.stream().anyMatch(s -> s.name().equals("skills-skill")));
+ assertTrue(result.stream().anyMatch(s -> s.name().equals("commands-skill")));
+ }
+
+ @Test
+ void getSkillToolCommands_includesWithUserDescription() {
+ Skill pluginWithDesc = makeSkill("plugin-desc", "plugin", "plugin", false, true, "", true);
+ List result = SkillFilters.getSkillToolCommands(List.of(pluginWithDesc));
+ assertEquals(1, result.size());
+ }
+
+ @Test
+ void getSkillToolCommands_includesWithWhenToUse() {
+ Skill pluginWithWhen = makeSkill("plugin-when", "plugin", "plugin", false, false, "When testing", true);
+ List result = SkillFilters.getSkillToolCommands(List.of(pluginWithWhen));
+ assertEquals(1, result.size());
+ }
+
+ @Test
+ void getSkillToolCommands_excludesDisableModelInvocation() {
+ Skill disabled = makeSkill("no-model", "bundled", "bundled", true, false, "", true);
+ List result = SkillFilters.getSkillToolCommands(List.of(disabled));
+ assertEquals(0, result.size());
+ }
+
+ @Test
+ void getSkillToolCommands_excludesHidden() {
+ Skill hidden = makeSkill("hidden", "bundled", "bundled", false, false, "", false);
+ List result = SkillFilters.getSkillToolCommands(List.of(hidden));
+ assertEquals(0, result.size());
+ }
+
+ @Test
+ void getSlashCommandToolSkills_includesSkillsPluginBundled() {
+ List skills = List.of(
+ makeSkill("s1", "user", "skills", false, true, "", true),
+ makeSkill("s2", "plugin", "plugin", false, true, "", true),
+ makeSkill("s3", "bundled", "bundled", false, true, "", true),
+ makeSkill("s4", "command", "commands_DEPRECATED", false, true, "", true)
+ );
+
+ List result = SkillFilters.getSlashCommandToolSkills(skills);
+ assertEquals(3, result.size());
+ assertFalse(result.stream().anyMatch(s -> s.name().equals("s4")));
+ }
+
+ @Test
+ void getSlashCommandToolSkills_includesDisableModelInvocation() {
+ Skill userOnly = makeSkill("user-only", "command", "commands_DEPRECATED", true, true, "", true);
+ List result = SkillFilters.getSlashCommandToolSkills(List.of(userOnly));
+ assertEquals(1, result.size());
+ }
+
+ @Test
+ void formatDescriptionWithSource_bundledNoTag() {
+ Skill bundled = makeSkill("test", "bundled", "bundled", false, true, "", true);
+ assertEquals("desc", SkillFilters.formatDescriptionWithSource(bundled));
+ }
+
+ @Test
+ void formatDescriptionWithSource_pluginTag() {
+ Skill plugin = makeSkill("test", "plugin", "plugin", false, true, "", true);
+ assertEquals("desc [plugin]", SkillFilters.formatDescriptionWithSource(plugin));
+ }
+
+ @Test
+ void formatDescriptionWithSource_managedTag() {
+ Skill managed = makeSkill("test", "policySettings", "managed", false, true, "", true);
+ assertEquals("desc [managed]", SkillFilters.formatDescriptionWithSource(managed));
+ }
+
+ @Test
+ void findCommand_exactAndCaseInsensitive() {
+ Skill s = makeSkill("my-skill", "user", "skills", false, true, "", true);
+ assertTrue(SkillFilters.findCommand(List.of(s), "my-skill").isPresent());
+ assertTrue(SkillFilters.findCommand(List.of(s), "MY-SKILL").isPresent());
+ assertFalse(SkillFilters.findCommand(List.of(s), "other").isPresent());
+ }
+
+ @Test
+ void isOfficialMarketplaceName_matchesPrefixes() {
+ assertTrue(SkillFilters.isOfficialMarketplaceName("anthropic/lint"));
+ assertTrue(SkillFilters.isOfficialMarketplaceName("claude/verify"));
+ assertTrue(SkillFilters.isOfficialMarketplaceName("official/test"));
+ assertFalse(SkillFilters.isOfficialMarketplaceName("user/my-skill"));
+ assertFalse(SkillFilters.isOfficialMarketplaceName(null));
+ }
+
+ @Test
+ void groupBySource_groupsCorrectly() {
+ List skills = List.of(
+ makeSkill("s1", "user", "skills", false, true, "", true),
+ makeSkill("s2", "user", "skills", false, true, "", true),
+ makeSkill("s3", "bundled", "bundled", false, true, "", true)
+ );
+ var grouped = SkillFilters.groupBySource(skills);
+ assertEquals(2, grouped.size());
+ assertEquals(2, grouped.get("skills").size());
+ assertEquals(1, grouped.get("bundled").size());
+ }
+}
diff --git a/src/test/java/com/claudecode/context/SkillHooksTest.java b/src/test/java/com/claudecode/context/SkillHooksTest.java
new file mode 100644
index 0000000..995aba9
--- /dev/null
+++ b/src/test/java/com/claudecode/context/SkillHooksTest.java
@@ -0,0 +1,90 @@
+package com.claudecode.context;
+
+import com.claudecode.context.SkillLoader.Skill;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for Skill record hooks field and computed methods.
+ */
+class SkillHooksTest {
+
+ @Test
+ void skill_withHooks_hasHooksTrue() {
+ Map hooks = Map.of(
+ "PreToolUse", Map.of("command", "echo pre"),
+ "PostToolUse", Map.of("command", "echo post")
+ );
+ Skill skill = new Skill("test", null, "desc", false, "",
+ "content", "user", "skills", null, null,
+ null, null, false, null, null,
+ true, false, false, "inline", null, null,
+ null, null, null, null, 7, "running", hooks);
+ assertTrue(skill.hasHooks());
+ }
+
+ @Test
+ void skill_withoutHooks_hasHooksFalse() {
+ Skill skill = new Skill("test", null, "desc", false, "",
+ "content", "user", "skills", null, null,
+ null, null, false, null, null,
+ true, false, false, "inline", null, null,
+ null, null, null, null, 7, "running", null);
+ assertFalse(skill.hasHooks());
+ }
+
+ @Test
+ void skill_emptyHooks_hasHooksFalse() {
+ Skill skill = new Skill("test", null, "desc", false, "",
+ "content", "user", "skills", null, null,
+ null, null, false, null, null,
+ true, false, false, "inline", null, null,
+ null, null, null, null, 7, "running", Map.of());
+ assertFalse(skill.hasHooks());
+ }
+
+ @Test
+ void skill_backwardCompat_28arg_noHooks() {
+ Skill skill = new Skill("test", null, "desc", false, "",
+ "content", "user", "skills", null, null,
+ null, null, false, null, null,
+ true, false, false, "inline", null, null,
+ null, null, null, null, 7, "running");
+ assertFalse(skill.hasHooks());
+ assertNull(skill.hooks());
+ }
+
+ @Test
+ void skill_backwardCompat_19arg() {
+ Skill skill = new Skill("test", null, "desc", "",
+ "content", "user", null,
+ null, null, null, null, true,
+ "inline", null, null, null, null, null, null);
+ assertFalse(skill.hasHooks());
+ }
+
+ @Test
+ void skill_backwardCompat_6arg() {
+ Skill skill = new Skill("test", "desc", "", "content", "bundled", null);
+ assertFalse(skill.hasHooks());
+ }
+
+ @Test
+ void skill_isMcp() {
+ Skill mcp1 = new Skill("test", "desc", "", "content", "mcp", null);
+ assertTrue(mcp1.isMcp());
+
+ Skill mcp2 = new Skill("test", null, "desc", false, "",
+ "content", "user", "mcp", null, null,
+ null, null, false, null, null,
+ true, false, false, "inline", null, null,
+ null, null, null, null, 7, "running", null);
+ assertTrue(mcp2.isMcp());
+
+ Skill notMcp = new Skill("test", "desc", "", "content", "user", null);
+ assertFalse(notMcp.isMcp());
+ }
+}
diff --git a/src/test/java/com/claudecode/util/ModelResolverTest.java b/src/test/java/com/claudecode/util/ModelResolverTest.java
new file mode 100644
index 0000000..1b9b573
--- /dev/null
+++ b/src/test/java/com/claudecode/util/ModelResolverTest.java
@@ -0,0 +1,63 @@
+package com.claudecode.util;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for ModelResolver utility.
+ */
+class ModelResolverTest {
+
+ @Test
+ void resolveSkillModelOverride_inherit_returnsNull() {
+ assertNull(ModelResolver.resolveSkillModelOverride("inherit"));
+ assertNull(ModelResolver.resolveSkillModelOverride("INHERIT"));
+ assertNull(ModelResolver.resolveSkillModelOverride("Inherit"));
+ }
+
+ @Test
+ void resolveSkillModelOverride_null_returnsNull() {
+ assertNull(ModelResolver.resolveSkillModelOverride(null));
+ assertNull(ModelResolver.resolveSkillModelOverride(""));
+ assertNull(ModelResolver.resolveSkillModelOverride(" "));
+ }
+
+ @Test
+ void resolveSkillModelOverride_aliases() {
+ assertEquals("claude-sonnet-4-20250514", ModelResolver.resolveSkillModelOverride("sonnet"));
+ assertEquals("claude-sonnet-4-20250514", ModelResolver.resolveSkillModelOverride("Sonnet"));
+ assertEquals("claude-opus-4-20250514", ModelResolver.resolveSkillModelOverride("opus"));
+ assertEquals("claude-3-haiku-20240307", ModelResolver.resolveSkillModelOverride("haiku"));
+ }
+
+ @Test
+ void resolveSkillModelOverride_versionedAliases() {
+ assertEquals("claude-sonnet-4-20250514", ModelResolver.resolveSkillModelOverride("sonnet-4"));
+ assertEquals("claude-opus-4-20250514", ModelResolver.resolveSkillModelOverride("opus-4"));
+ assertEquals("claude-3-haiku-20240307", ModelResolver.resolveSkillModelOverride("haiku-3"));
+ assertEquals("claude-3-5-sonnet-20241022", ModelResolver.resolveSkillModelOverride("sonnet-3.5"));
+ }
+
+ @Test
+ void resolveSkillModelOverride_fullModelIds_passThrough() {
+ assertEquals("claude-sonnet-4-20250514", ModelResolver.resolveSkillModelOverride("claude-sonnet-4-20250514"));
+ assertEquals("gpt-4o", ModelResolver.resolveSkillModelOverride("gpt-4o"));
+ }
+
+ @Test
+ void resolveSkillModelOverride_openAiAliases() {
+ assertEquals("gpt-4", ModelResolver.resolveSkillModelOverride("gpt-4"));
+ assertEquals("gpt-4o", ModelResolver.resolveSkillModelOverride("gpt-4o"));
+ assertEquals("o3-mini", ModelResolver.resolveSkillModelOverride("o3-mini"));
+ }
+
+ @Test
+ void isKnownAlias_works() {
+ assertTrue(ModelResolver.isKnownAlias("sonnet"));
+ assertTrue(ModelResolver.isKnownAlias("haiku"));
+ assertTrue(ModelResolver.isKnownAlias("opus"));
+ assertFalse(ModelResolver.isKnownAlias("unknown-model"));
+ assertFalse(ModelResolver.isKnownAlias(null));
+ }
+}