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>
This commit is contained in:
@@ -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<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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user