From 6088678c4f9acd25135c40443a53d0cfd30d444c Mon Sep 17 00:00:00 2001 From: abel533 Date: Sun, 5 Apr 2026 09:39:29 +0800 Subject: [PATCH] feat: Skills execution system (Phase 2B) - SkillTool: execute skills by name via forked sub-agent - BundledSkills: 4 built-in skills (verify, debug, review, commit) - SkillLoader: bundled skills auto-loaded + budget-aware prompt listing - /skills command: detail view with /skills - Budget management: 8K char budget, 250 char per-entry cap Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../command/impl/SkillsCommand.java | 53 ++++- .../java/com/claudecode/config/AppConfig.java | 8 +- .../com/claudecode/context/BundledSkills.java | 200 ++++++++++++++++++ .../com/claudecode/context/SkillLoader.java | 47 +++- .../com/claudecode/tool/impl/SkillTool.java | 198 +++++++++++++++++ 5 files changed, 497 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/claudecode/context/BundledSkills.java create mode 100644 src/main/java/com/claudecode/tool/impl/SkillTool.java diff --git a/src/main/java/com/claudecode/command/impl/SkillsCommand.java b/src/main/java/com/claudecode/command/impl/SkillsCommand.java index bf4b9ea..4f71aae 100644 --- a/src/main/java/com/claudecode/command/impl/SkillsCommand.java +++ b/src/main/java/com/claudecode/command/impl/SkillsCommand.java @@ -31,6 +31,24 @@ public class SkillsCommand implements SlashCommand { SkillLoader loader = new SkillLoader(projectDir); List skills = loader.loadAll(); + // If args provided, show specific skill detail + if (args != null && !args.isBlank()) { + String query = args.strip().toLowerCase(); + var match = skills.stream() + .filter(s -> s.name().equalsIgnoreCase(query)) + .findFirst(); + if (match.isEmpty()) { + match = skills.stream() + .filter(s -> s.name().toLowerCase().contains(query)) + .findFirst(); + } + if (match.isPresent()) { + return formatSkillDetail(match.get()); + } + return AnsiStyle.red(" Skill not found: " + args.strip()) + "\n" + + AnsiStyle.dim(" Use /skills to list all available skills."); + } + StringBuilder sb = new StringBuilder(); sb.append("\n"); sb.append(AnsiStyle.bold(" 🎯 Available Skills\n")); @@ -61,12 +79,43 @@ public class SkillsCommand implements SlashCommand { if (!skill.whenToUse().isEmpty()) { sb.append(" ").append(AnsiStyle.dim("When: " + skill.whenToUse())).append("\n"); } - sb.append(" ").append(AnsiStyle.dim("File: " + skill.filePath())).append("\n"); sb.append("\n"); } - sb.append(AnsiStyle.dim(" Total " + skills.size() + " skills\n")); + sb.append(AnsiStyle.dim(" Total " + skills.size() + " skills")); + sb.append(AnsiStyle.dim(" • Use /skills for details\n")); } return sb.toString(); } + + private String formatSkillDetail(SkillLoader.Skill skill) { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(AnsiStyle.bold(" 🎯 Skill: " + skill.name())).append("\n"); + sb.append(" ").append("─".repeat(50)).append("\n\n"); + + sb.append(" ").append(AnsiStyle.bold("Source: ")).append(skill.source()).append("\n"); + if (!skill.description().isEmpty()) { + sb.append(" ").append(AnsiStyle.bold("Description: ")).append(skill.description()).append("\n"); + } + if (!skill.whenToUse().isEmpty()) { + sb.append(" ").append(AnsiStyle.bold("When to use: ")).append(skill.whenToUse()).append("\n"); + } + sb.append(" ").append(AnsiStyle.bold("File: ")).append(skill.filePath()).append("\n"); + sb.append("\n"); + + // Show content preview + String content = skill.content(); + if (content.length() > 500) { + content = content.substring(0, 497) + "..."; + } + sb.append(AnsiStyle.dim(" Content:\n")); + for (String line : content.lines().toList()) { + sb.append(AnsiStyle.dim(" │ " + line)).append("\n"); + } + sb.append("\n"); + sb.append(AnsiStyle.dim(" Tip: Ask AI to execute this skill or type: /verify, /debug, etc.\n")); + + return sb.toString(); + } } diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index d57f0e5..a8756e8 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -113,7 +113,8 @@ public class AppConfig { new SleepTool(), new ToolSearchTool(), new EnterPlanModeTool(), - new ExitPlanModeTool() + new ExitPlanModeTool(), + new SkillTool() ); // P2: 注册 MCP 工具桥接(将远程 MCP 工具映射为本地工具) @@ -238,7 +239,7 @@ public class AppConfig { } @Bean - public String systemPrompt() { + public String systemPrompt(ToolContext toolContext) { Path projectDir = Path.of(System.getProperty("user.dir")); ClaudeMdLoader claudeLoader = new ClaudeMdLoader(projectDir); @@ -248,6 +249,9 @@ public class AppConfig { skillLoader.loadAll(); String skillsSummary = skillLoader.buildSkillsSummary(); + // Inject SkillLoader into ToolContext for SkillTool + toolContext.set(SkillTool.SKILL_LOADER_KEY, skillLoader); + GitContext gitContext = new GitContext(projectDir).collect(); String gitSummary = gitContext.buildSummary(); diff --git a/src/main/java/com/claudecode/context/BundledSkills.java b/src/main/java/com/claudecode/context/BundledSkills.java new file mode 100644 index 0000000..8f885ff --- /dev/null +++ b/src/main/java/com/claudecode/context/BundledSkills.java @@ -0,0 +1,200 @@ +package com.claudecode.context; + +import com.claudecode.context.SkillLoader.Skill; + +import java.nio.file.Path; +import java.util.List; + +/** + * 内置 Skills 注册 —— 对应 claude-code/src/skills/bundled/ 目录。 + *

+ * 提供默认可用的 Skills,无需用户手动创建 .md 文件。 + */ +public final class BundledSkills { + + private BundledSkills() {} + + /** + * 获取所有内置 Skills。 + */ + public static List getAll() { + return List.of( + verifySkill(), + debugSkill(), + reviewSkill(), + commitSkill() + ); + } + + /** + * verify — 代码验证 Skill。 + * 运行项目测试、lint、类型检查,确保代码变更正确。 + */ + static Skill verifySkill() { + return new Skill( + "verify", + "Run tests and checks to verify code changes are correct", + "After making code changes that need verification", + """ + # Verify Changes + + You are verifying that recent code changes are correct and don't break anything. + + ## Steps + + 1. **Identify the project type** by reading build/config files: + - Look for: package.json, pom.xml, build.gradle, Cargo.toml, go.mod, etc. + + 2. **Run the appropriate test command**: + - Node.js: `npm test` or `npx jest` or `npx vitest` + - Java/Maven: `mvn test -q` + - Java/Gradle: `./gradlew test` + - Python: `pytest` or `python -m pytest` + - Rust: `cargo test` + - Go: `go test ./...` + + 3. **Run linting if available**: + - Node.js: `npm run lint` or `npx eslint .` + - Python: `ruff check .` or `flake8` + - Java: `mvn checkstyle:check -q` + + 4. **Run type checking if available**: + - TypeScript: `npx tsc --noEmit` + - Python: `mypy .` + + 5. **Report results**: + - ✅ All checks passed + - ❌ Failures found (include specific errors) + - ⚠️ Warnings (include details) + + If a check fails, analyze the error and suggest a fix. + """, + "bundled", + Path.of("bundled://verify") + ); + } + + /** + * debug — 调试辅助 Skill。 + * 分析错误信息,定位问题根因。 + */ + static Skill debugSkill() { + return new Skill( + "debug", + "Analyze errors and help debug issues", + "When encountering an error or unexpected behavior", + """ + # Debug Issue + + You are helping debug an issue. Follow this systematic approach: + + ## Steps + + 1. **Understand the error**: + - Read the error message carefully + - Identify the error type (compile, runtime, logic, etc.) + - Note the file and line number if available + + 2. **Reproduce the issue**: + - Try to reproduce the error with the minimal command + - Check if the error is consistent + + 3. **Trace the root cause**: + - Read the file(s) mentioned in the error + - Check imports, dependencies, and configuration + - Look for recent changes that might have caused it + - Use Grep to find related code patterns + + 4. **Analyze and diagnose**: + - Identify the root cause (not just symptoms) + - Consider edge cases and dependencies + - Check if similar patterns exist elsewhere + + 5. **Suggest fix**: + - Propose a specific, minimal fix + - Explain why the fix works + - Note any side effects or risks + + If the user provides an error message, start from Step 1. + If the user describes unexpected behavior, start from Step 2. + """, + "bundled", + Path.of("bundled://debug") + ); + } + + /** + * review — 代码审查 Skill。 + */ + static Skill reviewSkill() { + return new Skill( + "review", + "Review code changes for quality, bugs, and best practices", + "When the user wants feedback on code changes", + """ + # Code Review + + You are reviewing code changes. Focus on: + + ## Checklist + + 1. **Correctness**: Does the code do what it's supposed to? + 2. **Edge cases**: Are boundary conditions handled? + 3. **Error handling**: Are errors caught and handled gracefully? + 4. **Security**: Any injection, auth, or data exposure risks? + 5. **Performance**: Any obvious performance issues? + 6. **Readability**: Is the code clear and well-structured? + 7. **Tests**: Are there tests? Do they cover key cases? + + ## Process + + 1. Run `git diff` to see what changed + 2. Read each changed file + 3. For each issue found, note: + - Severity: 🔴 Critical / 🟡 Warning / 🔵 Suggestion + - File and line number + - What's wrong and how to fix it + 4. Summarize findings + """, + "bundled", + Path.of("bundled://review") + ); + } + + /** + * commit — 提交 Skill。 + */ + static Skill commitSkill() { + return new Skill( + "commit", + "Create a well-structured git commit with conventional commit message", + "When the user wants to commit changes", + """ + # Git Commit + + Create a well-structured git commit. + + ## Steps + + 1. Run `git status` and `git diff --stat` to see changes + 2. Analyze the changes to determine: + - Type: feat, fix, refactor, docs, test, chore, etc. + - Scope: which module/area is affected + - Breaking changes + 3. Generate commit message in Conventional Commits format: + ``` + type(scope): brief description + + Detailed body explaining what and why (not how). + + Breaking changes (if any). + ``` + 4. Stage relevant files with `git add` + 5. Create the commit with `git commit -m "..."` + 6. Report the commit hash + """, + "bundled", + Path.of("bundled://commit") + ); + } +} diff --git a/src/main/java/com/claudecode/context/SkillLoader.java b/src/main/java/com/claudecode/context/SkillLoader.java index c33f42d..2658609 100644 --- a/src/main/java/com/claudecode/context/SkillLoader.java +++ b/src/main/java/com/claudecode/context/SkillLoader.java @@ -46,6 +46,10 @@ public class SkillLoader { public List loadAll() { skills.clear(); + // 0. 内置技能 + skills.addAll(BundledSkills.getAll()); + log.debug("Loaded {} bundled skills", BundledSkills.getAll().size()); + // 1. 用户级技能 Path userSkillsDir = Path.of(System.getProperty("user.home"), ".claude", "skills"); loadFromDirectory(userSkillsDir, "user"); @@ -147,25 +151,58 @@ public class SkillLoader { } /** - * 构建技能上下文摘要(注入系统提示词) + * 构建技能上下文摘要(注入系统提示词)。 + * 支持预算控制,确保不超过上下文窗口的 1%。 */ public String buildSkillsSummary() { + return buildSkillsSummary(8000); // Default 8K chars budget + } + + /** + * 构建技能上下文摘要(带预算控制)。 + * 对应 TS formatCommandsWithinBudget()。 + * + * @param charBudget 最大字符预算 + */ + public String buildSkillsSummary(int charBudget) { if (skills.isEmpty()) { return ""; } StringBuilder sb = new StringBuilder(); sb.append("# Available Skills\n\n"); + sb.append("Skills can be invoked by name using the Skill tool or by typing /.\n\n"); + + int budgetUsed = sb.length(); + int perEntryMax = 250; // Per-entry cap for cache efficiency + for (Skill skill : skills) { - sb.append("- **").append(skill.name()).append("**"); + StringBuilder entry = new StringBuilder(); + entry.append("- **").append(skill.name()).append("**"); if (!skill.description().isEmpty()) { - sb.append(": ").append(skill.description()); + String desc = skill.description(); + if (desc.length() > perEntryMax - skill.name().length() - 10) { + desc = desc.substring(0, perEntryMax - skill.name().length() - 13) + "..."; + } + entry.append(": ").append(desc); } if (!skill.whenToUse().isEmpty()) { - sb.append(" (use when: ").append(skill.whenToUse()).append(")"); + entry.append(" (use when: ").append(skill.whenToUse()).append(")"); + } + entry.append("\n"); + + // Check budget + if (budgetUsed + entry.length() > charBudget) { + // Add truncation notice + sb.append("- ... and ").append(skills.size() - skills.indexOf(skill)) + .append(" more skills (use /skills to see all)\n"); + break; } - sb.append("\n"); + + sb.append(entry); + budgetUsed += entry.length(); } + return sb.toString(); } diff --git a/src/main/java/com/claudecode/tool/impl/SkillTool.java b/src/main/java/com/claudecode/tool/impl/SkillTool.java new file mode 100644 index 0000000..683417c --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/SkillTool.java @@ -0,0 +1,198 @@ +package com.claudecode.tool.impl; + +import com.claudecode.context.SkillLoader; +import com.claudecode.context.SkillLoader.Skill; +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Skill 执行工具 —— 对应 claude-code/src/tools/SkillTool/SkillTool.ts。 + *

+ * 通过名称调用已加载的 Skill。Skill 会作为 forked sub-agent 执行, + * 注入 Skill 的 markdown 内容作为上下文指导。 + *

+ * Skills 来源: + *

    + *
  • 用户级: ~/.claude/skills/
  • + *
  • 项目级: ./.claude/skills/
  • + *
  • 命令目录: ./.claude/commands/
  • + *
  • 内置 Skills: verify, debug 等
  • + *
+ */ +public class SkillTool implements Tool { + + private static final Logger log = LoggerFactory.getLogger(SkillTool.class); + + /** ToolContext key for SkillLoader */ + public static final String SKILL_LOADER_KEY = "SKILL_LOADER"; + + @Override + public String name() { + return "Skill"; + } + + @Override + public String description() { + return """ + Execute a registered skill by name. Skills are reusable, structured workflows \ + defined in markdown files that guide a specialized sub-agent. + + When to use: + - User invokes a skill by name (e.g., /verify, /debug) + - You identify a task that matches a registered skill's "whenToUse" criteria + - Complex workflows that benefit from structured guidance + + Available skills are listed in the system prompt under "Available Skills". + + The skill runs as an isolated sub-agent with its own context. Provide any relevant \ + arguments or context from the current conversation. + """; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "skill_name": { + "type": "string", + "description": "Name of the skill to execute (case-insensitive)" + }, + "arguments": { + "type": "string", + "description": "Arguments or context to pass to the skill" + } + }, + "required": ["skill_name"] + } + """; + } + + @Override + public String execute(Map input, ToolContext context) { + String skillName = (String) input.get("skill_name"); + String arguments = (String) input.getOrDefault("arguments", ""); + + if (skillName == null || skillName.isBlank()) { + return "Error: 'skill_name' is required"; + } + + // Get SkillLoader from context + SkillLoader skillLoader = context.get(SKILL_LOADER_KEY); + if (skillLoader == null) { + return "Error: SkillLoader not configured. No skills available."; + } + + // Find skill by name + Optional skillOpt = skillLoader.findByName(skillName); + if (skillOpt.isEmpty()) { + // Try partial match + skillOpt = findByPartialName(skillLoader.getSkills(), skillName); + } + + if (skillOpt.isEmpty()) { + StringBuilder msg = new StringBuilder(); + msg.append("Skill '").append(skillName).append("' not found.\n\n"); + msg.append("Available skills:\n"); + for (Skill s : skillLoader.getSkills()) { + msg.append(" - ").append(s.name()); + if (!s.description().isEmpty()) { + msg.append(": ").append(s.description()); + } + msg.append("\n"); + } + return msg.toString(); + } + + Skill skill = skillOpt.get(); + log.info("Executing skill: {} [{}]", skill.name(), skill.source()); + + // Build skill execution prompt + String skillPrompt = buildSkillPrompt(skill, arguments); + + // Execute via agent factory (same as AgentTool) + @SuppressWarnings("unchecked") + java.util.function.Function agentFactory = + context.getOrDefault(AgentTool.AGENT_FACTORY_KEY, null); + + if (agentFactory == null) { + // Fallback: return skill content for manual execution guidance + return "⚠️ Sub-agent not available. Skill content for manual execution:\n\n" + + "# Skill: " + skill.name() + "\n" + + skill.content(); + } + + try { + String result = agentFactory.apply(skillPrompt); + log.info("Skill '{}' completed, result: {} chars", skill.name(), result.length()); + return result; + } catch (Exception e) { + log.debug("Skill execution failed", e); + return "Error executing skill '" + skill.name() + "': " + e.getMessage(); + } + } + + @Override + public boolean isReadOnly() { + return false; // Skills may modify files + } + + @Override + public String activityDescription(Map input) { + String name = input != null ? (String) input.get("skill_name") : null; + return name != null ? "Running skill: " + name + "..." : "Running skill..."; + } + + /** + * Build the full prompt for skill execution. + */ + private String buildSkillPrompt(Skill skill, String arguments) { + StringBuilder sb = new StringBuilder(); + sb.append("You are executing a skill: ").append(skill.name()).append("\n\n"); + + if (!skill.description().isEmpty()) { + sb.append("Description: ").append(skill.description()).append("\n"); + } + if (!skill.whenToUse().isEmpty()) { + sb.append("When to use: ").append(skill.whenToUse()).append("\n"); + } + sb.append("\n"); + + // Inject skill content as instructions + sb.append("## Skill Instructions\n\n"); + sb.append(skill.content()).append("\n\n"); + + // Inject arguments + if (arguments != null && !arguments.isBlank()) { + sb.append("## User Arguments\n\n"); + sb.append(arguments).append("\n\n"); + } + + sb.append(""" + ## Execution Guidelines + - Follow the skill instructions above carefully + - Use the available tools to complete the task + - Report results concisely when done + - If the skill requires user input, use AskUserQuestion + """); + + return sb.toString(); + } + + /** + * Partial name match for skills. + */ + private Optional findByPartialName(List skills, String name) { + String lower = name.toLowerCase(); + return skills.stream() + .filter(s -> s.name().toLowerCase().contains(lower)) + .findFirst(); + } +}