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
parent
1ca1543662
commit
fb13919dbc
@ -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<Skill> 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()); |
||||||
|
} |
||||||
|
} |
||||||
@ -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…
Reference in new issue