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<String,Object>)
- 34 new tests (138 total, all passing)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
main
abel533 4 weeks ago
parent 1ca1543662
commit fb13919dbc
  1. 26
      src/main/java/com/claudecode/config/AppConfig.java
  2. 116
      src/main/java/com/claudecode/context/McpSkillBuilders.java
  3. 267
      src/main/java/com/claudecode/context/SkillChangeDetector.java
  4. 235
      src/main/java/com/claudecode/context/SkillFilters.java
  5. 118
      src/main/java/com/claudecode/context/SkillLoader.java
  6. 188
      src/main/java/com/claudecode/tool/impl/SkillTool.java
  7. 109
      src/main/java/com/claudecode/util/ModelResolver.java
  8. 94
      src/test/java/com/claudecode/context/McpSkillBuildersTest.java
  9. 137
      src/test/java/com/claudecode/context/SkillFiltersTest.java
  10. 90
      src/test/java/com/claudecode/context/SkillHooksTest.java
  11. 63
      src/test/java/com/claudecode/util/ModelResolverTest.java

@ -1,8 +1,10 @@
package com.claudecode.config; package com.claudecode.config;
import com.claudecode.command.CommandRegistry; import com.claudecode.command.CommandRegistry;
import com.claudecode.context.AgentLoader;
import com.claudecode.context.ClaudeMdLoader; import com.claudecode.context.ClaudeMdLoader;
import com.claudecode.context.GitContext; import com.claudecode.context.GitContext;
import com.claudecode.context.SkillChangeDetector;
import com.claudecode.context.SkillLoader; import com.claudecode.context.SkillLoader;
import com.claudecode.context.SystemPromptBuilder; import com.claudecode.context.SystemPromptBuilder;
import com.claudecode.core.AgentLoop; import com.claudecode.core.AgentLoop;
@ -150,7 +152,17 @@ public class AppConfig {
} }
@Bean @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")); Path projectDir = Path.of(System.getProperty("user.dir"));
ClaudeMdLoader claudeLoader = new ClaudeMdLoader(projectDir); ClaudeMdLoader claudeLoader = new ClaudeMdLoader(projectDir);
@ -163,9 +175,20 @@ public class AppConfig {
// Inject SkillLoader into ToolContext for SkillTool // Inject SkillLoader into ToolContext for SkillTool
toolContext.set(SkillTool.SKILL_LOADER_KEY, skillLoader); 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 // Inject SessionMemoryService into ToolContext
toolContext.set("SESSION_MEMORY_SERVICE", sessionMemoryService); 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(); GitContext gitContext = new GitContext(projectDir).collect();
String gitSummary = gitContext.buildSummary(); String gitSummary = gitContext.buildSummary();
@ -175,7 +198,6 @@ public class AppConfig {
// Check if coordinator mode is enabled // Check if coordinator mode is enabled
if (CoordinatorMode.isCoordinatorMode()) { if (CoordinatorMode.isCoordinatorMode()) {
log.info("Coordinator mode enabled via CLAUDE_CODE_COORDINATOR_MODE env var"); log.info("Coordinator mode enabled via CLAUDE_CODE_COORDINATOR_MODE env var");
// Coordinator uses a specialized system prompt
String coordinatorPrompt = CoordinatorMode.getCoordinatorSystemPrompt(); String coordinatorPrompt = CoordinatorMode.getCoordinatorSystemPrompt();
String userContext = CoordinatorMode.getCoordinatorUserContext(); String userContext = CoordinatorMode.getCoordinatorUserContext();
return coordinatorPrompt + "\n\n" + userContext; return coordinatorPrompt + "\n\n" + userContext;

@ -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
* <p>
* 允许 MCP 服务器注册技能构建器 MCP prompts 转换为 Skills
* 使用注册表模式打破 MCP Skills 循环依赖
* <p>
* 用法:
* <pre>
* // 在 MCP 模块中注册
* McpSkillBuilders.register("my-server", () -> List.of(
* new Skill("mcp-lint", "Lint code", ...)
* ));
*
* // 在 SkillLoader 中获取
* List&lt;Skill&gt; mcpSkills = McpSkillBuilders.getAllMcpSkills();
* </pre>
*/
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<String, Supplier<List<Skill>>> builders = new ConcurrentHashMap<>();
/** Cache of built skills (invalidated when builders change) */
private static volatile List<Skill> 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<List<Skill>> 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<Skill> getAllMcpSkills() {
List<Skill> cached = cachedSkills;
if (cached != null) return cached;
List<Skill> result = new ArrayList<>();
for (var entry : builders.entrySet()) {
try {
List<Skill> 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<String> 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();
}
}

@ -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
* <p>
* 使用 Java WatchService 监控 skill 目录文件变更
* 自动触发 SkillLoader 缓存清除和重新加载
* <p>
* 特性
* <ul>
* <li>300ms debounce合并短时间内的多个文件事件</li>
* <li>监控 ~/.claude/skills, .claude/skills, .claude/commands</li>
* <li>监控 .md 文件的创建修改删除</li>
* <li>自动调用 SkillLoader.clearCache() 触发重新加载</li>
* </ul>
*/
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<Consumer<Void>> changeListeners = new CopyOnWriteArrayList<>();
private WatchService watchService;
private final Map<WatchKey, Path> 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<Path> 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<Path> getWatchDirectories() {
List<Path> 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<Path> pathEvent = (WatchEvent<Path>) 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<Void> listener) {
changeListeners.add(listener);
}
private void notifyListeners() {
for (Consumer<Void> 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");
}
}

@ -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 中的过滤函数
* <p>
* 提供多种视图用于不同 UI 场景
* <ul>
* <li>{@link #getSkillToolCommands} SkillTool模型可见的技能列表</li>
* <li>{@link #getSlashCommandToolSkills} 斜杠命令可见的技能列表</li>
* <li>{@link #formatDescriptionWithSource} 带来源标记的描述格式化</li>
* </ul>
*/
public final class SkillFilters {
private static final Logger log = LoggerFactory.getLogger(SkillFilters.class);
/** Known official marketplace skill name prefixes */
private static final Set<String> 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().
* <p>
* 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<Skill> getSkillToolCommands(List<Skill> 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 /<name> commands).
* Corresponds to TS getSlashCommandToolSkills().
* <p>
* 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<Skill> getSlashCommandToolSkills(List<Skill> 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().
* <p>
* 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<Skill> findCommand(List<Skill> 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<Skill> 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<Skill> skills, String name) {
return findCommand(skills, name).orElse(null);
}
/**
* Get all skill names (for typeahead/autocomplete).
*/
public static List<String> getAllSkillNames(List<Skill> skills) {
return skills.stream()
.filter(s -> !s.isHidden())
.map(Skill::name)
.sorted()
.toList();
}
/**
* Get skills grouped by source for display.
*/
public static Map<String, List<Skill>> groupBySource(List<Skill> 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());
}
}

@ -4,6 +4,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.Yaml;
import com.claudecode.util.ModelResolver;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
@ -133,6 +135,9 @@ public class SkillLoader {
Path commandsDir = projectDir.resolve(".claude").resolve("commands"); Path commandsDir = projectDir.resolve(".claude").resolve("commands");
loadFromCommandsDirectory(commandsDir, "command"); loadFromCommandsDirectory(commandsDir, "command");
// 5. MCP skills from registered builders
loadMcpSkills();
// Separate conditional and unconditional skills // Separate conditional and unconditional skills
List<Skill> unconditional = new ArrayList<>(); List<Skill> unconditional = new ArrayList<>();
for (Skill skill : skills) { for (Skill skill : skills) {
@ -408,10 +413,8 @@ public class SkillLoader {
boolean disableModelInvocation = fmBoolean(fm, "disable-model-invocation", false); boolean disableModelInvocation = fmBoolean(fm, "disable-model-invocation", false);
String model = fmString(fm, "model", null); String model = fmString(fm, "model", null);
// "inherit" means use parent model (TS treats as undefined) // Model resolution: aliases (haiku→full ID), "inherit"→null
if ("inherit".equalsIgnoreCase(model)) { model = ModelResolver.resolveSkillModelOverride(model);
model = null;
}
// Effort validation (only accept valid values) // Effort validation (only accept valid values)
String rawEffort = fmString(fm, "effort", null); String rawEffort = fmString(fm, "effort", null);
@ -437,6 +440,9 @@ public class SkillLoader {
List<String> arguments = fmStringList(fm, "arguments"); List<String> arguments = fmStringList(fm, "arguments");
String version = fmString(fm, "version", null); String version = fmString(fm, "version", null);
// Hooks parsing (corresponds to TS parseHooksFromFrontmatter + HooksSchema)
Map<String, Object> hooks = parseHooksFromFrontmatter(fm, name);
// Determine loadedFrom based on source // Determine loadedFrom based on source
String loadedFrom = Skill.sourceToLoadedFrom(source); String loadedFrom = Skill.sourceToLoadedFrom(source);
@ -451,7 +457,7 @@ public class SkillLoader {
allowedTools, disallowedTools, disableModelInvocation, allowedTools, disallowedTools, disableModelInvocation,
model, effort, userInvocable, hideFromSlashCommandTool, isSensitive, model, effort, userInvocable, hideFromSlashCommandTool, isSensitive,
context, agent, shell, paths, argumentHint, arguments, version, context, agent, shell, paths, argumentHint, arguments, version,
content.length(), "running"); content.length(), "running", hooks);
} }
// ==================== Frontmatter YAML 解析工具方法 ==================== // ==================== Frontmatter YAML 解析工具方法 ====================
@ -930,6 +936,7 @@ public class SkillLoader {
* @param version 技能版本 * @param version 技能版本
* @param contentLength Markdown 内容长度用于 token 预估 * @param contentLength Markdown 内容长度用于 token 预估
* @param progressMessage 执行时显示的进度消息 * @param progressMessage 执行时显示的进度消息
* @param hooks 技能级生命周期钩子PreToolUse, PostToolUse, Stop
*/ */
public record Skill( public record Skill(
String name, String name,
@ -958,8 +965,26 @@ public class SkillLoader {
List<String> arguments, List<String> arguments,
String version, String version,
int contentLength, int contentLength,
String progressMessage String progressMessage,
Map<String, Object> 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<String> allowedTools, List<String> disallowedTools,
boolean disableModelInvocation, String model, String effort,
boolean userInvocable, boolean hideFromSlashCommandTool, boolean isSensitive,
String context, String agent, String shell, List<String> paths,
String argumentHint, List<String> arguments, String version,
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 版本) */ /** 向后兼容 19 参数构造器(从之前的 7 critical fixes 版本) */
public Skill(String name, String displayName, String description, String whenToUse, public Skill(String name, String displayName, String description, String whenToUse,
String content, String source, Path filePath, String content, String source, Path filePath,
@ -973,7 +998,7 @@ public class SkillLoader {
allowedTools, disallowedTools, false, model, effort, allowedTools, disallowedTools, false, model, effort,
userInvocable, false, false, context, agent, shell, userInvocable, false, false, context, agent, shell,
paths, argumentHint, arguments, version, paths, argumentHint, arguments, version,
content != null ? content.length() : 0, "running"); content != null ? content.length() : 0, "running", null);
} }
/** 最简便捷构造(BundledSkills 使用) */ /** 最简便捷构造(BundledSkills 使用) */
@ -1008,6 +1033,11 @@ public class SkillLoader {
return "mcp".equals(source) || "mcp".equals(loadedFrom); return "mcp".equals(source) || "mcp".equals(loadedFrom);
} }
/** 是否有生命周期钩子 */
public boolean hasHooks() {
return hooks != null && !hooks.isEmpty();
}
/** 预估 frontmatter 部分 token 数 */ /** 预估 frontmatter 部分 token 数 */
public int estimateFrontmatterTokens() { public int estimateFrontmatterTokens() {
String text = String.join(" ", String text = String.join(" ",
@ -1032,6 +1062,80 @@ public class SkillLoader {
} }
} }
// ==================== Hooks Parsing ====================
/** Valid hook event names (matching TS HooksSchema) */
private static final Set<String> 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<String, Object> parseHooksFromFrontmatter(Map<String, Object> 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<String, Object> hooks = (Map<String, Object>) hooksRaw;
Map<String, Object> 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<Skill> 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 ==================== // ==================== Effort Validation ====================
/** Valid effort level strings (matching TS EFFORT_LEVELS) */ /** Valid effort level strings (matching TS EFFORT_LEVELS) */

@ -1,7 +1,10 @@
package com.claudecode.tool.impl; package com.claudecode.tool.impl;
import com.claudecode.context.SkillFilters;
import com.claudecode.context.SkillLoader; import com.claudecode.context.SkillLoader;
import com.claudecode.context.SkillLoader.Skill; 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.Tool;
import com.claudecode.tool.ToolContext; import com.claudecode.tool.ToolContext;
import com.claudecode.util.ArgumentSubstitution; import com.claudecode.util.ArgumentSubstitution;
@ -35,6 +38,12 @@ public class SkillTool implements Tool {
/** ToolContext key for SkillLoader */ /** ToolContext key for SkillLoader */
public static final String SKILL_LOADER_KEY = "SKILL_LOADER"; 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 @Override
public String name() { public String name() {
return "Skill"; return "Skill";
@ -87,28 +96,38 @@ public class SkillTool implements Tool {
return "Error: 'skill_name' is required"; 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 // Get SkillLoader from context
SkillLoader skillLoader = context.get(SKILL_LOADER_KEY); SkillLoader skillLoader = context.get(SKILL_LOADER_KEY);
if (skillLoader == null) { if (skillLoader == null) {
return "Error: SkillLoader not configured. No skills available."; return "Error: SkillLoader not configured. No skills available.";
} }
// Find skill by name // Find skill by name (using filtered list for model-invocable skills)
Optional<Skill> skillOpt = skillLoader.findByName(skillName); List<Skill> allSkills = skillLoader.getSkills();
List<Skill> visibleSkills = SkillFilters.getSkillToolCommands(allSkills);
Optional<Skill> skillOpt = SkillFilters.findCommand(allSkills, skillName);
if (skillOpt.isEmpty()) { if (skillOpt.isEmpty()) {
// Try partial match // Try partial match across all skills
skillOpt = findByPartialName(skillLoader.getSkills(), skillName); skillOpt = findByPartialName(allSkills, skillName);
} }
if (skillOpt.isEmpty()) { if (skillOpt.isEmpty()) {
StringBuilder msg = new StringBuilder(); StringBuilder msg = new StringBuilder();
msg.append("Skill '").append(skillName).append("' not found.\n\n"); msg.append("Skill '").append(skillName).append("' not found.\n\n");
msg.append("Available skills:\n"); msg.append("Available skills:\n");
for (Skill s : skillLoader.getSkills()) { for (Skill s : visibleSkills) {
if (s.isHidden() || s.disableModelInvocation()) continue;
msg.append(" - ").append(s.userFacingName()); msg.append(" - ").append(s.userFacingName());
if (!s.description().isEmpty()) { String desc = SkillFilters.formatDescriptionWithSource(s);
msg.append(": ").append(s.description()); if (!desc.isEmpty()) {
msg.append(": ").append(desc);
} }
msg.append("\n"); msg.append("\n");
} }
@ -119,10 +138,19 @@ public class SkillTool implements Tool {
// Check if model invocation is disabled for this skill // Check if model invocation is disabled for this skill
if (skill.disableModelInvocation()) { if (skill.disableModelInvocation()) {
return "Error: Skill '" + skill.userFacingName() + "' cannot be invoked by the model. " return renderError(skill, "Skill '" + skill.userFacingName()
+ "It has disable-model-invocation: true in its frontmatter."; + "' 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()); log.info("Executing skill: {} [{}] context={}", skill.name(), skill.source(), skill.context());
// Build skill execution prompt // Build skill execution prompt
@ -131,8 +159,7 @@ public class SkillTool implements Tool {
// Check if skill should be forked (sub-agent) or inline // Check if skill should be forked (sub-agent) or inline
if (!skill.isForked()) { if (!skill.isForked()) {
// Inline execution: return the skill prompt for the current agent to follow // Inline execution: return the skill prompt for the current agent to follow
return "📋 Skill '" + skill.userFacingName() + "' loaded (inline mode).\n\n" return renderInlineResult(skill, skillPrompt);
+ "Follow these instructions:\n\n" + skillPrompt;
} }
// Forked execution: execute via agent factory (same as AgentTool) // Forked execution: execute via agent factory (same as AgentTool)
@ -150,10 +177,10 @@ public class SkillTool implements Tool {
try { try {
String result = agentFactory.apply(skillPrompt); String result = agentFactory.apply(skillPrompt);
log.info("Skill '{}' completed, result: {} chars", skill.name(), result.length()); log.info("Skill '{}' completed, result: {} chars", skill.name(), result.length());
return result; return renderForkedResult(skill, result);
} catch (Exception e) { } catch (Exception e) {
log.debug("Skill execution failed", 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..."; 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<String, String> 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. * Build the full prompt for skill execution.
* Supports argument substitution ($ARGUMENTS, $n, $name, ${CLAUDE_SKILL_DIR}, ${CLAUDE_SESSION_ID}). * Supports argument substitution ($ARGUMENTS, $n, $name, ${CLAUDE_SKILL_DIR}, ${CLAUDE_SESSION_ID}).

@ -0,0 +1,109 @@
package com.claudecode.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
/**
* 模型别名解析器 对应 TS resolveSkillModelOverride()
* <p>
* 解析技能 frontmatter 中的 model 字段支持
* <ul>
* <li>别名解析haiku claude-3-haiku, sonnet claude-sonnet-4, opus claude-opus-4</li>
* <li>特殊值inherit null继承父级模型</li>
* <li>完整模型 ID直接透传</li>
* </ul>
*/
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<String, String> 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<String, String> getAllAliases() {
return MODEL_ALIASES;
}
}

@ -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<Skill> 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<Skill> first = McpSkillBuilders.getAllMcpSkills();
List<Skill> 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<Skill> 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<Skill> result = McpSkillBuilders.getAllMcpSkills();
assertEquals(1, result.size());
assertEquals("ok", result.getFirst().name());
}
}

@ -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<Skill> 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<Skill> 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<Skill> 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<Skill> 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<Skill> result = SkillFilters.getSkillToolCommands(List.of(disabled));
assertEquals(0, result.size());
}
@Test
void getSkillToolCommands_excludesHidden() {
Skill hidden = makeSkill("hidden", "bundled", "bundled", false, false, "", false);
List<Skill> result = SkillFilters.getSkillToolCommands(List.of(hidden));
assertEquals(0, result.size());
}
@Test
void getSlashCommandToolSkills_includesSkillsPluginBundled() {
List<Skill> 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<Skill> 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<Skill> 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<Skill> 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());
}
}

@ -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<String, Object> 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());
}
}

@ -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));
}
}
Loading…
Cancel
Save