From fb13919dbc416acac8c407b35e9db4f27481c1d3 Mon Sep 17 00:00:00 2001 From: abel533 Date: Fri, 10 Apr 2026 00:17:32 +0800 Subject: [PATCH] feat: implement 12 SkillLoader audit fixes + wire AgentLoader Audit fixes implemented: 1. SkillTool command filtering via SkillFilters.getSkillToolCommands() 2. getSlashCommandToolSkills() with distinct slash command filters 3. SkillChangeDetector: WatchService-based hot reload with 300ms debounce 4. Skill hooks support: hooks field in Skill record + HooksSchema validation 5. SkillTool UI rendering: renderInlineResult/ForkedResult/Rejected/Error 6. McpSkillBuilders registry: register/unregister/getAllMcpSkills pattern 7. Permission checks in SkillTool via PermissionRuleEngine integration 8. Analytics telemetry: logSkillInvocation + tengu_skill_tool_invocation 9. SkillFilters exports: findCommand/hasCommand/getCommand/groupBySource 10. isOfficialMarketplaceName: marketplace skill provenance check 11. Experimental skill search: isExperimentalSkillSearchEnabled() flag 12. Model alias resolution: ModelResolver with haiku/sonnet/opus aliases Additional: - AgentLoader wired as Spring Bean in AppConfig - PermissionRuleEngine injected into ToolContext for SkillTool - SkillChangeDetector started on app boot for skill hot reload - Skill record: 27 -> 28 fields (added hooks Map) - 34 new tests (138 total, all passing) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../java/com/claudecode/config/AppConfig.java | 26 +- .../claudecode/context/McpSkillBuilders.java | 116 ++++++++ .../context/SkillChangeDetector.java | 267 ++++++++++++++++++ .../com/claudecode/context/SkillFilters.java | 235 +++++++++++++++ .../com/claudecode/context/SkillLoader.java | 118 +++++++- .../com/claudecode/tool/impl/SkillTool.java | 188 +++++++++++- .../com/claudecode/util/ModelResolver.java | 109 +++++++ .../context/McpSkillBuildersTest.java | 94 ++++++ .../claudecode/context/SkillFiltersTest.java | 137 +++++++++ .../claudecode/context/SkillHooksTest.java | 90 ++++++ .../claudecode/util/ModelResolverTest.java | 63 +++++ 11 files changed, 1420 insertions(+), 23 deletions(-) create mode 100644 src/main/java/com/claudecode/context/McpSkillBuilders.java create mode 100644 src/main/java/com/claudecode/context/SkillChangeDetector.java create mode 100644 src/main/java/com/claudecode/context/SkillFilters.java create mode 100644 src/main/java/com/claudecode/util/ModelResolver.java create mode 100644 src/test/java/com/claudecode/context/McpSkillBuildersTest.java create mode 100644 src/test/java/com/claudecode/context/SkillFiltersTest.java create mode 100644 src/test/java/com/claudecode/context/SkillHooksTest.java create mode 100644 src/test/java/com/claudecode/util/ModelResolverTest.java diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index cb72cc1..6a2267b 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -1,8 +1,10 @@ package com.claudecode.config; import com.claudecode.command.CommandRegistry; +import com.claudecode.context.AgentLoader; import com.claudecode.context.ClaudeMdLoader; import com.claudecode.context.GitContext; +import com.claudecode.context.SkillChangeDetector; import com.claudecode.context.SkillLoader; import com.claudecode.context.SystemPromptBuilder; import com.claudecode.core.AgentLoop; @@ -150,7 +152,17 @@ public class AppConfig { } @Bean - public String systemPrompt(ToolContext toolContext, SessionMemoryService sessionMemoryService) { + public AgentLoader agentLoader() { + Path projectDir = Path.of(System.getProperty("user.dir")); + AgentLoader loader = new AgentLoader(projectDir); + loader.loadAll(); + log.info("Loaded {} agent definitions", loader.getAgents().size()); + return loader; + } + + @Bean + public String systemPrompt(ToolContext toolContext, SessionMemoryService sessionMemoryService, + PermissionRuleEngine permissionRuleEngine, AgentLoader agentLoader) { Path projectDir = Path.of(System.getProperty("user.dir")); ClaudeMdLoader claudeLoader = new ClaudeMdLoader(projectDir); @@ -163,9 +175,20 @@ public class AppConfig { // Inject SkillLoader into ToolContext for SkillTool toolContext.set(SkillTool.SKILL_LOADER_KEY, skillLoader); + // Inject PermissionRuleEngine into ToolContext for SkillTool permission checks + toolContext.set(SkillTool.PERMISSION_ENGINE_KEY, permissionRuleEngine); + + // Inject AgentLoader into ToolContext for AgentTool + toolContext.set("AGENT_LOADER", agentLoader); + // Inject SessionMemoryService into ToolContext toolContext.set("SESSION_MEMORY_SERVICE", sessionMemoryService); + // Start skill file watcher for hot reload + SkillChangeDetector changeDetector = new SkillChangeDetector(skillLoader, projectDir); + changeDetector.start(); + toolContext.set("SKILL_CHANGE_DETECTOR", changeDetector); + GitContext gitContext = new GitContext(projectDir).collect(); String gitSummary = gitContext.buildSummary(); @@ -175,7 +198,6 @@ public class AppConfig { // Check if coordinator mode is enabled if (CoordinatorMode.isCoordinatorMode()) { log.info("Coordinator mode enabled via CLAUDE_CODE_COORDINATOR_MODE env var"); - // Coordinator uses a specialized system prompt String coordinatorPrompt = CoordinatorMode.getCoordinatorSystemPrompt(); String userContext = CoordinatorMode.getCoordinatorUserContext(); return coordinatorPrompt + "\n\n" + userContext; diff --git a/src/main/java/com/claudecode/context/McpSkillBuilders.java b/src/main/java/com/claudecode/context/McpSkillBuilders.java new file mode 100644 index 0000000..0b4137e --- /dev/null +++ b/src/main/java/com/claudecode/context/McpSkillBuilders.java @@ -0,0 +1,116 @@ +package com.claudecode.context; + +import com.claudecode.context.SkillLoader.Skill; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * MCP Skill Builders 注册表 —— 对应 claude-code/src/skills/mcpSkillBuilders.ts。 + *

+ * 允许 MCP 服务器注册技能构建器,将 MCP prompts 转换为 Skills。 + * 使用注册表模式打破 MCP ↔ Skills 循环依赖。 + *

+ * 用法: + *

+ * // 在 MCP 模块中注册
+ * McpSkillBuilders.register("my-server", () -> List.of(
+ *     new Skill("mcp-lint", "Lint code", ...)
+ * ));
+ *
+ * // 在 SkillLoader 中获取
+ * List<Skill> mcpSkills = McpSkillBuilders.getAllMcpSkills();
+ * 
+ */ +public final class McpSkillBuilders { + + private static final Logger log = LoggerFactory.getLogger(McpSkillBuilders.class); + + /** Registry of MCP skill builders keyed by server name */ + private static final Map>> builders = new ConcurrentHashMap<>(); + + /** Cache of built skills (invalidated when builders change) */ + private static volatile List cachedSkills = null; + + private McpSkillBuilders() {} + + /** + * Register an MCP skill builder for a given server. + * Corresponds to TS registerMCPSkillBuilders(). + * + * @param serverName unique MCP server identifier + * @param builder supplier that produces skills from MCP prompts + */ + public static void register(String serverName, Supplier> builder) { + builders.put(serverName, builder); + cachedSkills = null; // Invalidate cache + log.debug("Registered MCP skill builder for server: {}", serverName); + } + + /** + * Unregister an MCP skill builder. + */ + public static void unregister(String serverName) { + builders.remove(serverName); + cachedSkills = null; + log.debug("Unregistered MCP skill builder for server: {}", serverName); + } + + /** + * Get all MCP skills from all registered builders. + * Corresponds to TS getMCPSkillBuilders() result aggregation. + * + * @return combined list of skills from all MCP servers + */ + public static List getAllMcpSkills() { + List cached = cachedSkills; + if (cached != null) return cached; + + List result = new ArrayList<>(); + for (var entry : builders.entrySet()) { + try { + List skills = entry.getValue().get(); + if (skills != null) { + result.addAll(skills); + } + } catch (Exception e) { + log.warn("Failed to build MCP skills from server '{}': {}", entry.getKey(), e.getMessage()); + } + } + + cachedSkills = Collections.unmodifiableList(result); + return cachedSkills; + } + + /** + * Get registered server names. + */ + public static Set getRegisteredServers() { + return Collections.unmodifiableSet(builders.keySet()); + } + + /** + * Clear all registered builders and cache. + */ + public static void clearAll() { + builders.clear(); + cachedSkills = null; + } + + /** + * Invalidate the cache (force rebuild on next access). + */ + public static void invalidateCache() { + cachedSkills = null; + } + + /** + * Check if any builders are registered. + */ + public static boolean hasBuilders() { + return !builders.isEmpty(); + } +} diff --git a/src/main/java/com/claudecode/context/SkillChangeDetector.java b/src/main/java/com/claudecode/context/SkillChangeDetector.java new file mode 100644 index 0000000..2426509 --- /dev/null +++ b/src/main/java/com/claudecode/context/SkillChangeDetector.java @@ -0,0 +1,267 @@ +package com.claudecode.context; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.Consumer; + +/** + * Skill 文件变更检测器 —— 对应 claude-code/src/utils/skills/skillChangeDetector.ts。 + *

+ * 使用 Java WatchService 监控 skill 目录文件变更, + * 自动触发 SkillLoader 缓存清除和重新加载。 + *

+ * 特性: + *

    + *
  • 300ms debounce(合并短时间内的多个文件事件)
  • + *
  • 监控 ~/.claude/skills, .claude/skills, .claude/commands
  • + *
  • 监控 .md 文件的创建、修改、删除
  • + *
  • 自动调用 SkillLoader.clearCache() 触发重新加载
  • + *
+ */ +public class SkillChangeDetector implements Closeable { + + private static final Logger log = LoggerFactory.getLogger(SkillChangeDetector.class); + + /** Debounce delay in milliseconds (matches TS 300ms) */ + private static final long DEBOUNCE_MS = 300; + + /** File stability check delay — wait for file writes to finish (matches TS 1s) */ + private static final long STABILITY_MS = 1000; + + private final SkillLoader skillLoader; + private final Path projectDir; + private final List> changeListeners = new CopyOnWriteArrayList<>(); + + private WatchService watchService; + private final Map watchKeyPathMap = new ConcurrentHashMap<>(); + private ScheduledExecutorService scheduler; + private volatile ScheduledFuture pendingDebounce; + private volatile boolean running = false; + + public SkillChangeDetector(SkillLoader skillLoader, Path projectDir) { + this.skillLoader = skillLoader; + this.projectDir = projectDir; + } + + /** + * Start watching skill directories for changes. + * Non-blocking — starts a background thread. + */ + public void start() { + if (running) return; + + try { + watchService = FileSystems.getDefault().newWatchService(); + scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "skill-change-detector"); + t.setDaemon(true); + return t; + }); + + // Register directories to watch + List watchDirs = getWatchDirectories(); + for (Path dir : watchDirs) { + registerDirectory(dir); + } + + if (watchKeyPathMap.isEmpty()) { + log.debug("No skill directories found to watch"); + close(); + return; + } + + running = true; + + // Start polling thread + Thread poller = new Thread(this::pollLoop, "skill-watcher-poll"); + poller.setDaemon(true); + poller.start(); + + log.debug("SkillChangeDetector started, watching {} directories", watchKeyPathMap.size()); + } catch (IOException e) { + log.debug("Failed to start skill file watcher: {}", e.getMessage()); + } + } + + /** + * Get all directories that should be watched. + */ + private List getWatchDirectories() { + List dirs = new ArrayList<>(); + + // User skills directory + Path userSkills = Path.of(System.getProperty("user.home"), ".claude", "skills"); + if (Files.isDirectory(userSkills)) dirs.add(userSkills); + + // Project skills directory + Path projectSkills = projectDir.resolve(".claude").resolve("skills"); + if (Files.isDirectory(projectSkills)) dirs.add(projectSkills); + + // Project commands directory + Path projectCommands = projectDir.resolve(".claude").resolve("commands"); + if (Files.isDirectory(projectCommands)) dirs.add(projectCommands); + + // User agents directory + Path userAgents = Path.of(System.getProperty("user.home"), ".claude", "agents"); + if (Files.isDirectory(userAgents)) dirs.add(userAgents); + + // Project agents directory + Path projectAgents = projectDir.resolve(".claude").resolve("agents"); + if (Files.isDirectory(projectAgents)) dirs.add(projectAgents); + + return dirs; + } + + /** + * Register a directory (and its subdirectories) with the WatchService. + */ + private void registerDirectory(Path dir) { + try { + WatchKey key = dir.register(watchService, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + StandardWatchEventKinds.ENTRY_MODIFY); + watchKeyPathMap.put(key, dir); + log.debug("Watching directory: {}", dir); + + // Also register subdirectories (for skill-name/SKILL.md structure) + try (var stream = Files.list(dir)) { + stream.filter(Files::isDirectory).forEach(subDir -> { + try { + WatchKey subKey = subDir.register(watchService, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_DELETE, + StandardWatchEventKinds.ENTRY_MODIFY); + watchKeyPathMap.put(subKey, subDir); + } catch (IOException e) { + log.debug("Failed to watch subdirectory: {}", subDir); + } + }); + } + } catch (IOException e) { + log.debug("Failed to register directory for watching: {}: {}", dir, e.getMessage()); + } + } + + /** + * Main polling loop — runs on a daemon thread. + */ + private void pollLoop() { + while (running) { + try { + WatchKey key = watchService.poll(2, TimeUnit.SECONDS); + if (key == null) continue; + + Path dir = watchKeyPathMap.get(key); + boolean relevant = false; + + for (WatchEvent event : key.pollEvents()) { + WatchEvent.Kind kind = event.kind(); + if (kind == StandardWatchEventKinds.OVERFLOW) continue; + + @SuppressWarnings("unchecked") + WatchEvent pathEvent = (WatchEvent) event; + Path changed = dir != null ? dir.resolve(pathEvent.context()) : pathEvent.context(); + + // Only care about .md files and directories (new skill dirs) + String name = changed.getFileName().toString(); + if (name.endsWith(".md") || Files.isDirectory(changed) || kind == StandardWatchEventKinds.ENTRY_DELETE) { + relevant = true; + log.debug("Skill file change detected: {} {}", kind.name(), changed); + + // If a new directory was created, register it for watching + if (kind == StandardWatchEventKinds.ENTRY_CREATE && Files.isDirectory(changed)) { + registerDirectory(changed); + } + } + } + + key.reset(); + + if (relevant) { + scheduleDebounce(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } catch (ClosedWatchServiceException e) { + break; + } + } + } + + /** + * Schedule a debounced cache clear + reload. + * Multiple rapid file changes are coalesced into a single reload. + */ + private void scheduleDebounce() { + // Cancel any pending debounce + ScheduledFuture pending = this.pendingDebounce; + if (pending != null) { + pending.cancel(false); + } + + // Schedule new debounce (DEBOUNCE_MS + STABILITY_MS for file stability) + this.pendingDebounce = scheduler.schedule(() -> { + log.info("Skill files changed, reloading..."); + try { + skillLoader.clearCache(); + skillLoader.loadAll(); + notifyListeners(); + log.info("Skills reloaded successfully ({} skills)", skillLoader.getSkills().size()); + } catch (Exception e) { + log.warn("Failed to reload skills after file change: {}", e.getMessage()); + } + }, DEBOUNCE_MS + STABILITY_MS, TimeUnit.MILLISECONDS); + } + + /** + * Register a listener for skill change events. + */ + public void onSkillsChanged(Consumer listener) { + changeListeners.add(listener); + } + + private void notifyListeners() { + for (Consumer listener : changeListeners) { + try { + listener.accept(null); + } catch (Exception e) { + log.debug("Skill change listener error: {}", e.getMessage()); + } + } + } + + /** + * Check if the detector is currently running. + */ + public boolean isRunning() { + return running; + } + + @Override + public void close() { + running = false; + if (pendingDebounce != null) { + pendingDebounce.cancel(true); + } + if (scheduler != null) { + scheduler.shutdownNow(); + } + if (watchService != null) { + try { + watchService.close(); + } catch (IOException e) { + log.debug("Error closing WatchService: {}", e.getMessage()); + } + } + watchKeyPathMap.clear(); + log.debug("SkillChangeDetector stopped"); + } +} diff --git a/src/main/java/com/claudecode/context/SkillFilters.java b/src/main/java/com/claudecode/context/SkillFilters.java new file mode 100644 index 0000000..3c33ea6 --- /dev/null +++ b/src/main/java/com/claudecode/context/SkillFilters.java @@ -0,0 +1,235 @@ +package com.claudecode.context; + +import com.claudecode.context.SkillLoader.Skill; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Skill 过滤与查询工具 —— 对应 TS commands.ts 中的过滤函数。 + *

+ * 提供多种视图用于不同 UI 场景: + *

    + *
  • {@link #getSkillToolCommands} — SkillTool(模型)可见的技能列表
  • + *
  • {@link #getSlashCommandToolSkills} — 斜杠命令可见的技能列表
  • + *
  • {@link #formatDescriptionWithSource} — 带来源标记的描述格式化
  • + *
+ */ +public final class SkillFilters { + + private static final Logger log = LoggerFactory.getLogger(SkillFilters.class); + + /** Known official marketplace skill name prefixes */ + private static final Set 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)); + } +}