From 79736cf16f7da85978fafcd23c2efe6c3c5312c8 Mon Sep 17 00:00:00 2001 From: liuzh Date: Wed, 1 Apr 2026 22:56:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20P2=E5=85=A8=E9=83=A8=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=20-=20MCP=E5=8D=8F=E8=AE=AE+=E6=8F=92=E4=BB=B6=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F+=E4=BB=BB=E5=8A=A1=E7=AE=A1=E7=90=86+CLI=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A组 CLI增强: - DiffRenderer: 彩色diff渲染(行号/stat摘要/unified格式) - /hooks: 查看已注册Hook列表 - /review: AI代码审查(支持--staged/文件路径) - /stats: 会话使用统计(tokens/费用/API调用/运行时长) - /branch: 对话分支管理(save/load/list/delete) - /rewind: 回退对话历史 - /tag: 对话位置标签 - /security-review: AI安全审查 B组 任务系统: - TaskManager: 后台任务管理(线程安全/自动执行/手动管理) - TaskCreate/TaskGet/TaskList/TaskUpdate 4个工具 - ConfigTool: 配置读写工具 C组 MCP协议: - McpClient: MCP客户端(JSON-RPC 2.0/工具发现/资源读取) - McpTransport: 传输层接口 - StdioTransport: StdIO传输实现(子进程/异步读写) - McpManager: 多MCP服务器管理(配置加载/生命周期) - McpToolBridge: MCP工具桥接为本地Tool - /mcp: MCP服务器管理命令 D组 插件系统: - Plugin接口 + PluginContext + PluginManager - JAR插件加载(ClassLoader隔离/Manifest读取) - OutputStylePlugin: 内置输出样式插件 - /plugin: 插件管理命令 集成: AppConfig注册全部18工具+28命令 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../command/impl/BranchCommand.java | 198 ++++++++ .../claudecode/command/impl/HooksCommand.java | 95 ++++ .../claudecode/command/impl/McpCommand.java | 361 ++++++++++++++ .../command/impl/PluginCommand.java | 255 ++++++++++ .../command/impl/ReviewCommand.java | 171 +++++++ .../command/impl/RewindCommand.java | 102 ++++ .../command/impl/SecurityReviewCommand.java | 174 +++++++ .../claudecode/command/impl/StatsCommand.java | 157 +++++++ .../claudecode/command/impl/TagCommand.java | 196 ++++++++ .../java/com/claudecode/config/AppConfig.java | 88 +++- .../com/claudecode/console/DiffRenderer.java | 257 ++++++++++ .../java/com/claudecode/core/TaskManager.java | 390 +++++++++++++++ .../java/com/claudecode/mcp/McpClient.java | 443 ++++++++++++++++++ .../java/com/claudecode/mcp/McpException.java | 46 ++ .../java/com/claudecode/mcp/McpManager.java | 409 ++++++++++++++++ .../java/com/claudecode/mcp/McpTransport.java | 43 ++ .../com/claudecode/mcp/StdioTransport.java | 324 +++++++++++++ .../claudecode/plugin/OutputStylePlugin.java | 215 +++++++++ .../java/com/claudecode/plugin/Plugin.java | 117 +++++ .../com/claudecode/plugin/PluginContext.java | 74 +++ .../com/claudecode/plugin/PluginManager.java | 353 ++++++++++++++ .../com/claudecode/tool/impl/ConfigTool.java | 235 ++++++++++ .../claudecode/tool/impl/McpToolBridge.java | 187 ++++++++ .../claudecode/tool/impl/TaskCreateTool.java | 183 ++++++++ .../com/claudecode/tool/impl/TaskGetTool.java | 152 ++++++ .../claudecode/tool/impl/TaskListTool.java | 189 ++++++++ .../claudecode/tool/impl/TaskUpdateTool.java | 187 ++++++++ 27 files changed, 5592 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/claudecode/command/impl/BranchCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/HooksCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/McpCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/PluginCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/ReviewCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/RewindCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/SecurityReviewCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/StatsCommand.java create mode 100644 src/main/java/com/claudecode/command/impl/TagCommand.java create mode 100644 src/main/java/com/claudecode/console/DiffRenderer.java create mode 100644 src/main/java/com/claudecode/core/TaskManager.java create mode 100644 src/main/java/com/claudecode/mcp/McpClient.java create mode 100644 src/main/java/com/claudecode/mcp/McpException.java create mode 100644 src/main/java/com/claudecode/mcp/McpManager.java create mode 100644 src/main/java/com/claudecode/mcp/McpTransport.java create mode 100644 src/main/java/com/claudecode/mcp/StdioTransport.java create mode 100644 src/main/java/com/claudecode/plugin/OutputStylePlugin.java create mode 100644 src/main/java/com/claudecode/plugin/Plugin.java create mode 100644 src/main/java/com/claudecode/plugin/PluginContext.java create mode 100644 src/main/java/com/claudecode/plugin/PluginManager.java create mode 100644 src/main/java/com/claudecode/tool/impl/ConfigTool.java create mode 100644 src/main/java/com/claudecode/tool/impl/McpToolBridge.java create mode 100644 src/main/java/com/claudecode/tool/impl/TaskCreateTool.java create mode 100644 src/main/java/com/claudecode/tool/impl/TaskGetTool.java create mode 100644 src/main/java/com/claudecode/tool/impl/TaskListTool.java create mode 100644 src/main/java/com/claudecode/tool/impl/TaskUpdateTool.java diff --git a/src/main/java/com/claudecode/command/impl/BranchCommand.java b/src/main/java/com/claudecode/command/impl/BranchCommand.java new file mode 100644 index 0000000..cd7fadc --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/BranchCommand.java @@ -0,0 +1,198 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; +import org.springframework.ai.chat.messages.Message; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * /branch 命令 —— 管理对话分支。 + *

+ * 允许用户保存当前对话状态为命名分支,并在不同分支之间切换。 + * 分支数据存储在静态 Map 中,在 JVM 生命周期内持久化。 + *

+ * 支持的子命令: + *

+ */ +public class BranchCommand implements SlashCommand { + + /** 静态分支存储:分支名称 -> 消息快照 */ + private static final Map branches = new LinkedHashMap<>(); + + @Override + public String name() { + return "branch"; + } + + @Override + public String description() { + return "Manage conversation branches"; + } + + @Override + public String execute(String args, CommandContext context) { + if (context.agentLoop() == null) { + return AnsiStyle.red(" ✗ AgentLoop 不可用。"); + } + + String trimmedArgs = args != null ? args.trim() : ""; + + if (trimmedArgs.isEmpty()) { + return showUsage(); + } + + // 解析子命令和参数 + String[] parts = trimmedArgs.split("\\s+", 2); + String subCommand = parts[0].toLowerCase(); + String branchName = parts.length > 1 ? parts[1].trim() : ""; + + return switch (subCommand) { + case "save" -> saveBranch(branchName, context); + case "load" -> loadBranch(branchName, context); + case "list" -> listBranches(context); + case "delete" -> deleteBranch(branchName); + default -> AnsiStyle.red(" ✗ 未知子命令: " + subCommand) + "\n" + showUsage(); + }; + } + + /** + * 保存当前对话为命名分支。 + * + * @param branchName 分支名称 + * @param context 命令上下文 + * @return 操作结果信息 + */ + private String saveBranch(String branchName, CommandContext context) { + if (branchName.isEmpty()) { + return AnsiStyle.red(" ✗ 请指定分支名称。") + "\n" + + AnsiStyle.dim(" 用法: /branch save "); + } + + List currentHistory = context.agentLoop().getMessageHistory(); + + // 创建消息快照(深拷贝列表引用) + List snapshot = new ArrayList<>(currentHistory); + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + branches.put(branchName, new BranchSnapshot(snapshot, timestamp)); + + return AnsiStyle.green(" ✓ 分支已保存: ") + AnsiStyle.bold(branchName) + "\n" + + AnsiStyle.dim(" 消息数: " + snapshot.size() + " 时间: " + timestamp); + } + + /** + * 恢复到指定分支的对话状态。 + * + * @param branchName 分支名称 + * @param context 命令上下文 + * @return 操作结果信息 + */ + private String loadBranch(String branchName, CommandContext context) { + if (branchName.isEmpty()) { + return AnsiStyle.red(" ✗ 请指定分支名称。") + "\n" + + AnsiStyle.dim(" 用法: /branch load "); + } + + BranchSnapshot snapshot = branches.get(branchName); + if (snapshot == null) { + return AnsiStyle.red(" ✗ 分支不存在: " + branchName) + "\n" + + AnsiStyle.dim(" 使用 /branch list 查看所有可用分支。"); + } + + // 恢复对话历史 + context.agentLoop().replaceHistory(new ArrayList<>(snapshot.messages())); + + return AnsiStyle.green(" ✓ 已恢复到分支: ") + AnsiStyle.bold(branchName) + "\n" + + AnsiStyle.dim(" 已加载 " + snapshot.messages().size() + " 条消息 (保存于 " + snapshot.timestamp() + ")"); + } + + /** + * 列出所有已保存的分支。 + * + * @param context 命令上下文 + * @return 分支列表信息 + */ + private String listBranches(CommandContext context) { + if (branches.isEmpty()) { + return AnsiStyle.dim(" 没有保存的分支。") + "\n" + + AnsiStyle.dim(" 使用 /branch save 保存当前对话。"); + } + + int currentSize = context.agentLoop().getMessageHistory().size(); + + StringBuilder sb = new StringBuilder(); + sb.append(AnsiStyle.bold("\n 🌿 Conversation Branches\n\n")); + + for (Map.Entry entry : branches.entrySet()) { + String name = entry.getKey(); + BranchSnapshot snapshot = entry.getValue(); + int msgCount = snapshot.messages().size(); + + // 标记与当前对话大小相同的分支 + String marker = (msgCount == currentSize) ? AnsiStyle.green(" ◀ current size") : ""; + + sb.append(" • ") + .append(AnsiStyle.bold(name)) + .append(AnsiStyle.dim(" (" + msgCount + " messages, " + snapshot.timestamp() + ")")) + .append(marker) + .append("\n"); + } + + sb.append("\n").append(AnsiStyle.dim(" 共 " + branches.size() + " 个分支。")).append("\n"); + return sb.toString(); + } + + /** + * 删除指定分支。 + * + * @param branchName 分支名称 + * @return 操作结果信息 + */ + private String deleteBranch(String branchName) { + if (branchName.isEmpty()) { + return AnsiStyle.red(" ✗ 请指定分支名称。") + "\n" + + AnsiStyle.dim(" 用法: /branch delete "); + } + + BranchSnapshot removed = branches.remove(branchName); + if (removed == null) { + return AnsiStyle.red(" ✗ 分支不存在: " + branchName); + } + + return AnsiStyle.green(" ✓ 分支已删除: ") + AnsiStyle.bold(branchName); + } + + /** + * 显示用法帮助信息。 + * + * @return 用法说明文本 + */ + private String showUsage() { + StringBuilder sb = new StringBuilder(); + sb.append(AnsiStyle.bold("\n 🌿 Branch — 对话分支管理\n\n")); + sb.append(" ").append(AnsiStyle.cyan("/branch save ")).append(" 保存当前对话为分支\n"); + sb.append(" ").append(AnsiStyle.cyan("/branch load ")).append(" 恢复到指定分支\n"); + sb.append(" ").append(AnsiStyle.cyan("/branch list")).append(" 列出所有分支\n"); + sb.append(" ").append(AnsiStyle.cyan("/branch delete ")).append(" 删除指定分支\n"); + return sb.toString(); + } + + /** + * 分支快照记录 —— 保存消息列表和创建时间。 + * + * @param messages 消息快照 + * @param timestamp 创建时间戳 + */ + private record BranchSnapshot(List messages, String timestamp) {} +} diff --git a/src/main/java/com/claudecode/command/impl/HooksCommand.java b/src/main/java/com/claudecode/command/impl/HooksCommand.java new file mode 100644 index 0000000..af6d1f8 --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/HooksCommand.java @@ -0,0 +1,95 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; +import com.claudecode.core.HookManager; +import com.claudecode.core.HookManager.HookRegistration; +import com.claudecode.core.HookManager.HookType; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * /hooks 命令 —— 显示所有已注册的 Hook 信息。 + *

+ * 按 Hook 类型分组展示,包含每个 Hook 的名称和优先级。 + * 如果没有注册任何 Hook,则显示提示信息。 + */ +public class HooksCommand implements SlashCommand { + + @Override + public String name() { + return "hooks"; + } + + @Override + public String description() { + return "Show all registered hooks"; + } + + @Override + public String execute(String args, CommandContext context) { + if (context.agentLoop() == null || context.agentLoop().getHookManager() == null) { + return AnsiStyle.yellow(" ⚠ Hook 管理器不可用。"); + } + + HookManager hookManager = context.agentLoop().getHookManager(); + List allHooks = hookManager.getHooks(); + + // 无已注册的 Hook + if (allHooks.isEmpty()) { + return AnsiStyle.dim(" No hooks registered."); + } + + // 按类型分组 + Map> grouped = allHooks.stream() + .collect(Collectors.groupingBy(HookRegistration::type)); + + StringBuilder sb = new StringBuilder(); + sb.append(AnsiStyle.bold("\n 📎 Registered Hooks\n")); + + // 遍历所有 HookType,保持固定顺序 + for (HookType type : HookType.values()) { + List hooks = grouped.get(type); + sb.append("\n"); + sb.append(" ").append(AnsiStyle.cyan(formatTypeName(type))).append("\n"); + + if (hooks == null || hooks.isEmpty()) { + sb.append(" ").append(AnsiStyle.dim("(none)")).append("\n"); + } else { + // 按优先级排序后展示 + hooks.stream() + .sorted((a, b) -> Integer.compare(a.priority(), b.priority())) + .forEach(hook -> { + String priorityStr = AnsiStyle.dim("[priority=" + hook.priority() + "]"); + sb.append(" • ") + .append(AnsiStyle.bold(hook.name())) + .append(" ") + .append(priorityStr) + .append("\n"); + }); + } + } + + // 统计总数 + sb.append("\n ").append(AnsiStyle.dim("Total: " + allHooks.size() + " hook(s) registered.")).append("\n"); + return sb.toString(); + } + + /** + * 将 HookType 枚举格式化为可读名称。 + * + * @param type Hook 类型枚举 + * @return 格式化后的类型名称 + */ + private String formatTypeName(HookType type) { + return switch (type) { + case PRE_TOOL_USE -> "PRE_TOOL_USE (工具执行前)"; + case POST_TOOL_USE -> "POST_TOOL_USE (工具执行后)"; + case PRE_PROMPT -> "PRE_PROMPT (发送提示前)"; + case POST_RESPONSE -> "POST_RESPONSE (收到响应后)"; + }; + } +} diff --git a/src/main/java/com/claudecode/command/impl/McpCommand.java b/src/main/java/com/claudecode/command/impl/McpCommand.java new file mode 100644 index 0000000..97c9bdf --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/McpCommand.java @@ -0,0 +1,361 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; +import com.claudecode.mcp.McpClient; +import com.claudecode.mcp.McpException; +import com.claudecode.mcp.McpManager; +import com.claudecode.tool.ToolRegistry; +import com.claudecode.tool.impl.McpToolBridge; + +import java.util.*; + +/** + * /mcp 命令 —— 管理 MCP(Model Context Protocol)服务器连接。 + *

+ * 子命令: + *

    + *
  • {@code /mcp} —— 列出所有 MCP 服务器及状态
  • + *
  • {@code /mcp connect [args...]} —— 连接到 MCP 服务器
  • + *
  • {@code /mcp disconnect } —— 断开 MCP 服务器
  • + *
  • {@code /mcp tools [server]} —— 列出 MCP 工具
  • + *
  • {@code /mcp resources [server]} —— 列出 MCP 资源
  • + *
  • {@code /mcp reload} —— 从配置文件重新加载
  • + *
+ */ +public class McpCommand implements SlashCommand { + + @Override + public String name() { + return "mcp"; + } + + @Override + public String description() { + return "Manage MCP server connections"; + } + + @Override + public String execute(String args, CommandContext context) { + // 从工具注册表的上下文中获取 McpManager 不可行, + // 因此使用 CommandContext。这里通过反射或约定获取。 + // 实际实现中,McpManager 应作为 CommandContext 的扩展字段提供。 + // 暂时通过静态持有者或类似机制获取。 + McpManager manager = McpManagerHolder.getInstance(); + if (manager == null) { + return AnsiStyle.red(" ❌ MCP 管理器未初始化"); + } + + String trimmed = args.strip(); + if (trimmed.isEmpty()) { + return showStatus(manager); + } + + String[] parts = trimmed.split("\\s+", 2); + String subCommand = parts[0].toLowerCase(); + String subArgs = parts.length > 1 ? parts[1].strip() : ""; + + return switch (subCommand) { + case "connect" -> handleConnect(manager, subArgs, context); + case "disconnect" -> handleDisconnect(manager, subArgs); + case "tools" -> handleTools(manager, subArgs); + case "resources" -> handleResources(manager, subArgs); + case "reload" -> handleReload(manager, context); + case "help" -> showHelp(); + default -> AnsiStyle.red(" 未知子命令: " + subCommand) + "\n" + showHelp(); + }; + } + + /** + * 显示所有 MCP 服务器状态。 + */ + private String showStatus(McpManager manager) { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(AnsiStyle.bold(" 🔌 MCP 服务器状态\n")); + sb.append(" ").append("─".repeat(50)).append("\n\n"); + + Map clients = manager.getClients(); + if (clients.isEmpty()) { + sb.append(" 无已连接的 MCP 服务器\n\n"); + sb.append(AnsiStyle.dim(" 提示: 使用 /mcp connect [args] 连接服务器\n")); + sb.append(AnsiStyle.dim(" 或在 .mcp.json 配置文件中定义服务器\n")); + return sb.toString(); + } + + for (Map.Entry entry : clients.entrySet()) { + String name = entry.getKey(); + McpClient client = entry.getValue(); + + String statusIcon; + String statusText; + if (client.isConnected() && client.isInitialized()) { + statusIcon = "✅"; + statusText = AnsiStyle.green("已连接"); + } else if (client.isConnected()) { + statusIcon = "🔄"; + statusText = AnsiStyle.yellow("连接中"); + } else { + statusIcon = "❌"; + statusText = AnsiStyle.red("已断开"); + } + + sb.append(String.format(" %s %-18s %s%n", statusIcon, AnsiStyle.bold(name), statusText)); + + // 显示工具和资源数量 + int toolCount = client.getTools().size(); + int resCount = client.getResources().size(); + sb.append(String.format(" %s%n", + AnsiStyle.dim(toolCount + " 工具, " + resCount + " 资源"))); + + // 显示服务器信息 + if (client.getServerInfo() != null) { + sb.append(String.format(" %s%n", + AnsiStyle.dim("Info: " + client.getServerInfo().toString()))); + } + } + + sb.append("\n"); + sb.append(AnsiStyle.dim(" 共 " + clients.size() + " 个服务器, " + + manager.getAllTools().size() + " 个工具\n")); + + return sb.toString(); + } + + /** + * 处理 /mcp connect 子命令。 + */ + private String handleConnect(McpManager manager, String args, CommandContext context) { + if (args.isEmpty()) { + return AnsiStyle.red(" 用法: /mcp connect [args...]"); + } + + String[] parts = args.split("\\s+"); + if (parts.length < 2) { + return AnsiStyle.red(" 用法: /mcp connect [args...]"); + } + + String name = parts[0]; + String command = parts[1]; + List cmdArgs = parts.length > 2 + ? List.of(Arrays.copyOfRange(parts, 2, parts.length)) + : List.of(); + + try { + McpClient client = manager.connect(name, command, cmdArgs, null); + + // 将 MCP 工具桥接到工具注册表 + registerBridgedTools(client, name, context); + + StringBuilder sb = new StringBuilder(); + sb.append(AnsiStyle.green(" ✅ 已连接 MCP 服务器: " + name)).append("\n"); + sb.append(AnsiStyle.dim(" " + client.getTools().size() + " 个工具, " + + client.getResources().size() + " 个资源")).append("\n"); + + // 列出发现的工具 + if (!client.getTools().isEmpty()) { + sb.append("\n 工具:\n"); + for (McpClient.McpTool tool : client.getTools()) { + sb.append(" • ").append(tool.name()); + if (!tool.description().isEmpty()) { + sb.append(AnsiStyle.dim(" - " + truncate(tool.description(), 60))); + } + sb.append("\n"); + } + } + + return sb.toString(); + } catch (McpException e) { + return AnsiStyle.red(" ❌ 连接失败: " + e.getMessage()); + } + } + + /** + * 处理 /mcp disconnect 子命令。 + */ + private String handleDisconnect(McpManager manager, String args) { + if (args.isEmpty()) { + return AnsiStyle.red(" 用法: /mcp disconnect "); + } + + String name = args.split("\\s+")[0]; + try { + manager.disconnect(name); + return AnsiStyle.green(" ✅ 已断开 MCP 服务器: " + name); + } catch (McpException e) { + return AnsiStyle.red(" ❌ 断开失败: " + e.getMessage()); + } + } + + /** + * 处理 /mcp tools 子命令。 + */ + private String handleTools(McpManager manager, String args) { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(AnsiStyle.bold(" 🛠️ MCP 工具列表\n")); + sb.append(" ").append("─".repeat(50)).append("\n\n"); + + String serverFilter = args.isEmpty() ? null : args.split("\\s+")[0]; + + List tools; + if (serverFilter != null) { + tools = manager.getServerTools(serverFilter); + if (tools.isEmpty()) { + return sb + " 服务器 '" + serverFilter + "' 无工具或不存在\n"; + } + sb.append(AnsiStyle.dim(" 服务器: " + serverFilter)).append("\n\n"); + } else { + tools = manager.getAllTools(); + if (tools.isEmpty()) { + return sb + " 无可用的 MCP 工具\n"; + } + } + + for (McpClient.McpTool tool : tools) { + sb.append(" • ").append(AnsiStyle.bold(tool.name())).append("\n"); + if (!tool.description().isEmpty()) { + sb.append(" ").append(AnsiStyle.dim(tool.description())).append("\n"); + } + if (tool.inputSchema() != null) { + sb.append(" ").append(AnsiStyle.dim("Schema: " + + truncate(tool.inputSchema().toString(), 80))).append("\n"); + } + sb.append("\n"); + } + + sb.append(AnsiStyle.dim(" 共 " + tools.size() + " 个工具")).append("\n"); + return sb.toString(); + } + + /** + * 处理 /mcp resources 子命令。 + */ + private String handleResources(McpManager manager, String args) { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(AnsiStyle.bold(" 📦 MCP 资源列表\n")); + sb.append(" ").append("─".repeat(50)).append("\n\n"); + + String serverFilter = args.isEmpty() ? null : args.split("\\s+")[0]; + + List resourceList; + if (serverFilter != null) { + resourceList = manager.getServerResources(serverFilter); + if (resourceList.isEmpty()) { + return sb + " 服务器 '" + serverFilter + "' 无资源或不存在\n"; + } + sb.append(AnsiStyle.dim(" 服务器: " + serverFilter)).append("\n\n"); + } else { + resourceList = manager.getAllResources(); + if (resourceList.isEmpty()) { + return sb + " 无可用的 MCP 资源\n"; + } + } + + for (McpClient.McpResource resource : resourceList) { + sb.append(" • ").append(AnsiStyle.bold(resource.name())).append("\n"); + sb.append(" URI: ").append(AnsiStyle.cyan(resource.uri())).append("\n"); + if (!resource.description().isEmpty()) { + sb.append(" ").append(AnsiStyle.dim(resource.description())).append("\n"); + } + sb.append(" MIME: ").append(AnsiStyle.dim(resource.mimeType())).append("\n\n"); + } + + sb.append(AnsiStyle.dim(" 共 " + resourceList.size() + " 个资源")).append("\n"); + return sb.toString(); + } + + /** + * 处理 /mcp reload 子命令。 + */ + private String handleReload(McpManager manager, CommandContext context) { + try { + manager.reload(); + + // 重新桥接所有工具 + for (Map.Entry entry : manager.getClients().entrySet()) { + registerBridgedTools(entry.getValue(), entry.getKey(), context); + } + + return AnsiStyle.green(" ✅ MCP 配置已重新加载: " + + manager.getClients().size() + " 个服务器, " + + manager.getAllTools().size() + " 个工具"); + } catch (Exception e) { + return AnsiStyle.red(" ❌ 重载失败: " + e.getMessage()); + } + } + + /** + * 将 MCP 工具桥接注册到工具注册表。 + */ + private void registerBridgedTools(McpClient client, String serverName, CommandContext context) { + if (context.toolRegistry() == null) { + return; + } + + ToolRegistry registry = context.toolRegistry(); + List bridges = McpToolBridge.createBridges(serverName, client.getTools()); + for (McpToolBridge bridge : bridges) { + registry.register(bridge); + } + } + + /** + * 显示帮助信息。 + */ + private String showHelp() { + return """ + + \033[1m🔌 MCP 命令帮助\033[0m + ────────────────────────────────────── + + /mcp 列出所有 MCP 服务器状态 + /mcp connect [args] 连接到 MCP 服务器 + /mcp disconnect 断开 MCP 服务器 + /mcp tools [server] 列出 MCP 工具 + /mcp resources [server] 列出 MCP 资源 + /mcp reload 从配置文件重新加载 + /mcp help 显示此帮助信息 + + 配置文件: + 项目级: .mcp.json + 全局: ~/.claude-code-java/mcp.json + """; + } + + /** + * 截断字符串。 + */ + private static String truncate(String s, int maxLen) { + if (s == null) return ""; + return s.length() <= maxLen ? s : s.substring(0, maxLen - 3) + "..."; + } + + // ========== McpManager 持有者(简单单例,供命令和其他组件访问) ========== + + /** + * MCP 管理器全局持有者 —— 用于在命令和工具间共享 McpManager 实例。 + *

+ * 在应用启动时通过 {@link #setInstance(McpManager)} 注入。 + * 这是一种简单的服务定位器模式,后续可迁移到 Spring DI。 + */ + public static final class McpManagerHolder { + + private static volatile McpManager instance; + + private McpManagerHolder() { + } + + /** 设置全局 MCP 管理器实例(应用启动时调用) */ + public static void setInstance(McpManager manager) { + instance = manager; + } + + /** 获取全局 MCP 管理器实例 */ + public static McpManager getInstance() { + return instance; + } + } +} diff --git a/src/main/java/com/claudecode/command/impl/PluginCommand.java b/src/main/java/com/claudecode/command/impl/PluginCommand.java new file mode 100644 index 0000000..ea3266d --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/PluginCommand.java @@ -0,0 +1,255 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; +import com.claudecode.plugin.Plugin; +import com.claudecode.plugin.PluginManager; +import com.claudecode.plugin.PluginManager.PluginInfo; +import com.claudecode.tool.Tool; + +import java.nio.file.Path; +import java.util.List; + +/** + * /plugin 命令 —— 管理已加载的插件。 + *

+ * 子命令: + *

    + *
  • {@code /plugin} —— 列出所有已加载插件
  • + *
  • {@code /plugin load } —— 从 JAR 路径加载插件
  • + *
  • {@code /plugin unload } —— 卸载指定插件
  • + *
  • {@code /plugin reload} —— 重载所有插件
  • + *
  • {@code /plugin info } —— 显示插件详细信息
  • + *
+ *

+ * 通过 {@link com.claudecode.tool.ToolContext} 中 key 为 + * {@code "PLUGIN_MANAGER"} 的共享状态获取 {@link PluginManager} 实例。 + */ +public class PluginCommand implements SlashCommand { + + @Override + public String name() { + return "plugin"; + } + + @Override + public String description() { + return "Manage loaded plugins"; + } + + @Override + public List aliases() { + return List.of("plugins"); + } + + @Override + public String execute(String args, CommandContext context) { + PluginManager manager = getPluginManager(context); + if (manager == null) { + return AnsiStyle.red(" ✗ 插件系统未初始化"); + } + + String trimmed = (args == null) ? "" : args.trim(); + + // 无参数:列出所有插件 + if (trimmed.isEmpty()) { + return listPlugins(manager); + } + + // 解析子命令 + String[] parts = trimmed.split("\\s+", 2); + String subCommand = parts[0].toLowerCase(); + String subArgs = (parts.length > 1) ? parts[1].trim() : ""; + + return switch (subCommand) { + case "load" -> loadPlugin(manager, subArgs); + case "unload" -> unloadPlugin(manager, subArgs); + case "reload" -> reloadPlugins(manager); + case "info" -> pluginInfo(manager, subArgs); + default -> AnsiStyle.yellow(" 未知子命令: " + subCommand) + "\n" + + usageHelp(); + }; + } + + /** + * 列出所有已加载的插件。 + */ + private String listPlugins(PluginManager manager) { + List plugins = manager.getPlugins(); + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(AnsiStyle.bold(" 🔌 Loaded Plugins")).append("\n"); + sb.append(" ").append("─".repeat(50)).append("\n\n"); + + if (plugins.isEmpty()) { + sb.append(AnsiStyle.dim(" No plugins loaded.")).append("\n"); + sb.append(AnsiStyle.dim(" Place JAR files in ~/.claude-code-java/plugins/ to load them.")).append("\n"); + } else { + for (PluginInfo info : plugins) { + Plugin p = info.plugin(); + String scopeBadge = scopeColor(info.scope()); + sb.append(String.format(" %s %s %s%n", + AnsiStyle.bold(p.name()), + AnsiStyle.dim("v" + p.version()), + scopeBadge)); + sb.append(String.format(" ID: %s | %s%n", + AnsiStyle.cyan(p.id()), + p.description())); + sb.append(String.format(" 工具: %d | 命令: %d%n", + p.getTools().size(), + p.getCommands().size())); + sb.append("\n"); + } + sb.append(AnsiStyle.dim(String.format(" 共 %d 个插件", plugins.size()))).append("\n"); + } + return sb.toString(); + } + + /** + * 从 JAR 路径加载插件。 + */ + private String loadPlugin(PluginManager manager, String pathStr) { + if (pathStr.isEmpty()) { + return AnsiStyle.yellow(" 用法: /plugin load "); + } + Path jarPath = Path.of(pathStr); + boolean success = manager.loadPlugin(jarPath); + if (success) { + return AnsiStyle.green(" ✓ 插件加载成功: " + jarPath.getFileName()); + } else { + return AnsiStyle.red(" ✗ 插件加载失败: " + jarPath.getFileName()) + + "\n" + AnsiStyle.dim(" 请检查 JAR 是否包含有效的 Plugin-Class 属性"); + } + } + + /** + * 卸载指定 ID 的插件。 + */ + private String unloadPlugin(PluginManager manager, String pluginId) { + if (pluginId.isEmpty()) { + return AnsiStyle.yellow(" 用法: /plugin unload "); + } + boolean success = manager.unload(pluginId); + if (success) { + return AnsiStyle.green(" ✓ 插件已卸载: " + pluginId); + } else { + return AnsiStyle.red(" ✗ 未找到插件: " + pluginId); + } + } + + /** + * 重载所有插件(先全部卸载,再重新扫描加载)。 + */ + private String reloadPlugins(PluginManager manager) { + int beforeCount = manager.getPlugins().size(); + manager.shutdown(); + manager.loadAll(); + int afterCount = manager.getPlugins().size(); + return AnsiStyle.green( + String.format(" ✓ 插件已重载(之前: %d,现在: %d)", beforeCount, afterCount)); + } + + /** + * 显示指定插件的详细信息。 + */ + private String pluginInfo(PluginManager manager, String pluginId) { + if (pluginId.isEmpty()) { + return AnsiStyle.yellow(" 用法: /plugin info "); + } + + PluginInfo info = manager.findPlugin(pluginId); + if (info == null) { + return AnsiStyle.red(" ✗ 未找到插件: " + pluginId); + } + + Plugin p = info.plugin(); + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(AnsiStyle.bold(" 🔌 Plugin Details")).append("\n"); + sb.append(" ").append("─".repeat(40)).append("\n\n"); + + sb.append(" ").append(AnsiStyle.bold("Name: ")).append(p.name()).append("\n"); + sb.append(" ").append(AnsiStyle.bold("ID: ")).append(AnsiStyle.cyan(p.id())).append("\n"); + sb.append(" ").append(AnsiStyle.bold("Version: ")).append(p.version()).append("\n"); + sb.append(" ").append(AnsiStyle.bold("Description: ")).append(p.description()).append("\n"); + sb.append(" ").append(AnsiStyle.bold("Scope: ")).append(scopeColor(info.scope())).append("\n"); + sb.append(" ").append(AnsiStyle.bold("JAR: ")) + .append(AnsiStyle.dim(info.jarPath() != null ? info.jarPath().toString() : "built-in")) + .append("\n"); + + // 工具列表 + List tools = p.getTools(); + sb.append("\n ").append(AnsiStyle.bold("Tools (" + tools.size() + "):")).append("\n"); + if (tools.isEmpty()) { + sb.append(AnsiStyle.dim(" (none)")).append("\n"); + } else { + for (Tool tool : tools) { + sb.append(" • ").append(AnsiStyle.cyan(tool.name())) + .append(" - ").append(tool.description()).append("\n"); + } + } + + // 命令列表 + List commands = p.getCommands(); + sb.append("\n ").append(AnsiStyle.bold("Commands (" + commands.size() + "):")).append("\n"); + if (commands.isEmpty()) { + sb.append(AnsiStyle.dim(" (none)")).append("\n"); + } else { + for (SlashCommand cmd : commands) { + sb.append(" • ").append(AnsiStyle.green("/" + cmd.name())) + .append(" - ").append(cmd.description()).append("\n"); + } + } + + return sb.toString(); + } + + /** + * 从 CommandContext 获取 PluginManager 实例。 + *

+ * 通过 AgentLoop → ToolContext → 共享状态(key: "PLUGIN_MANAGER")获取。 + * + * @param context 命令执行上下文 + * @return PluginManager 实例,未找到时返回 null + */ + private PluginManager getPluginManager(CommandContext context) { + if (context.agentLoop() == null) { + return null; + } + try { + Object manager = context.agentLoop().getToolContext().get("PLUGIN_MANAGER"); + if (manager instanceof PluginManager pm) { + return pm; + } + } catch (Exception ignored) { + // ToolContext 中可能未注册 PLUGIN_MANAGER + } + return null; + } + + /** + * 为作用域标签着色。 + */ + private String scopeColor(String scope) { + return switch (scope) { + case "global" -> AnsiStyle.blue("[global]"); + case "project" -> AnsiStyle.green("[project]"); + case "dynamic" -> AnsiStyle.magenta("[dynamic]"); + default -> AnsiStyle.dim("[" + scope + "]"); + }; + } + + /** + * 使用帮助文本。 + */ + private String usageHelp() { + return AnsiStyle.dim(""" + 用法: + /plugin 列出所有插件 + /plugin load 加载 JAR 插件 + /plugin unload 卸载插件 + /plugin reload 重载所有插件 + /plugin info 查看插件详情"""); + } +} diff --git a/src/main/java/com/claudecode/command/impl/ReviewCommand.java b/src/main/java/com/claudecode/command/impl/ReviewCommand.java new file mode 100644 index 0000000..d49fc9a --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/ReviewCommand.java @@ -0,0 +1,171 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * /review 命令 —— 通过 AI 进行代码审查。 + *

+ * 支持三种模式: + *

    + *
  • 无参数:审查当前未暂存的变更({@code git diff})
  • + *
  • {@code --staged}:审查已暂存的变更({@code git diff --staged})
  • + *
  • 指定文件路径:审查特定文件的变更
  • + *
+ *

+ * 获取 diff 内容后,发送给 AI 模型进行代码审查。 + */ +public class ReviewCommand implements SlashCommand { + + @Override + public String name() { + return "review"; + } + + @Override + public String description() { + return "Review code changes using AI"; + } + + @Override + public List aliases() { + return List.of("rev"); + } + + @Override + public String execute(String args, CommandContext context) { + if (context.agentLoop() == null) { + return AnsiStyle.red(" ✗ AgentLoop 不可用,无法执行代码审查。"); + } + + String trimmedArgs = args != null ? args.trim() : ""; + + try { + // 构建 git diff 命令 + List command = buildGitDiffCommand(trimmedArgs); + String diffOutput = executeGitCommand(command); + + // 检查 diff 是否为空 + if (diffOutput.isBlank()) { + return AnsiStyle.yellow(" ⚠ 没有检测到代码变更。") + "\n" + + AnsiStyle.dim(" 提示: 使用 --staged 审查已暂存的变更,或指定文件路径。"); + } + + // 构建审查提示 + String reviewPrompt = buildReviewPrompt(trimmedArgs, diffOutput); + + // 输出审查进行中的提示 + context.out().println(AnsiStyle.cyan(" 🔍 正在审查代码变更...")); + context.out().println(AnsiStyle.dim(" diff 大小: " + diffOutput.lines().count() + " 行")); + context.out().println(); + + // 发送给 AI 进行审查 + String result = context.agentLoop().run(reviewPrompt); + return result; + + } catch (Exception e) { + return AnsiStyle.red(" ✗ 代码审查失败: " + e.getMessage()) + "\n" + + AnsiStyle.dim(" 请确保当前目录是一个 Git 仓库。"); + } + } + + /** + * 根据参数构建 git diff 命令。 + * + * @param args 用户输入的参数 + * @return git diff 命令列表 + */ + private List buildGitDiffCommand(String args) { + List command = new ArrayList<>(); + command.add("git"); + command.add("diff"); + + if (args.contains("--staged")) { + // 审查已暂存的变更 + command.add("--staged"); + } else if (!args.isEmpty() && !args.startsWith("-")) { + // 审查指定文件 + command.add("--"); + command.add(args); + } + // 默认:审查未暂存的变更(无额外参数) + + return command; + } + + /** + * 执行 git 命令并返回输出。 + * + * @param command 命令列表 + * @return 命令的标准输出 + * @throws Exception 执行失败时抛出异常 + */ + private String executeGitCommand(List command) throws Exception { + ProcessBuilder pb = new ProcessBuilder(command); + pb.redirectErrorStream(false); + + Process process = pb.start(); + + String output; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + output = reader.lines().collect(Collectors.joining("\n")); + } + + // 读取错误输出 + String errorOutput; + try (BufferedReader errorReader = new BufferedReader( + new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { + errorOutput = errorReader.lines().collect(Collectors.joining("\n")); + } + + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new RuntimeException("git diff 执行失败 (exit=" + exitCode + "): " + errorOutput); + } + + return output; + } + + /** + * 构建代码审查提示词。 + * + * @param args 用户输入的参数 + * @param diffOutput git diff 的输出内容 + * @return 完整的审查提示词 + */ + private String buildReviewPrompt(String args, String diffOutput) { + StringBuilder prompt = new StringBuilder(); + prompt.append("Please review these code changes:\n\n"); + + // 描述审查范围 + if (args.contains("--staged")) { + prompt.append("(Staged changes)\n\n"); + } else if (!args.isEmpty() && !args.startsWith("-")) { + prompt.append("(Changes in file: ").append(args).append(")\n\n"); + } else { + prompt.append("(Unstaged changes)\n\n"); + } + + prompt.append("```diff\n"); + prompt.append(diffOutput); + prompt.append("\n```\n\n"); + prompt.append("Please provide a thorough code review covering:\n"); + prompt.append("1. **Correctness** — Are there any bugs or logic errors?\n"); + prompt.append("2. **Code Quality** — Is the code clean, readable, and well-structured?\n"); + prompt.append("3. **Performance** — Are there any performance concerns?\n"); + prompt.append("4. **Security** — Are there any security vulnerabilities?\n"); + prompt.append("5. **Best Practices** — Does the code follow established patterns and conventions?\n"); + prompt.append("6. **Suggestions** — What improvements would you recommend?\n"); + + return prompt.toString(); + } +} diff --git a/src/main/java/com/claudecode/command/impl/RewindCommand.java b/src/main/java/com/claudecode/command/impl/RewindCommand.java new file mode 100644 index 0000000..e0515ba --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/RewindCommand.java @@ -0,0 +1,102 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; +import org.springframework.ai.chat.messages.Message; + +import java.util.ArrayList; +import java.util.List; + +/** + * /rewind 命令 —— 回滚对话到之前的某个位置。 + *

+ * 按消息对(用户消息 + 助手消息)为单位进行回滚。 + *

    + *
  • {@code /rewind} —— 移除最后 1 个消息对
  • + *
  • {@code /rewind } —— 移除最后 n 个消息对
  • + *
+ *

+ * 使用 {@code agentLoop.getMessageHistory()} 获取当前消息历史, + * 然后通过 {@code agentLoop.replaceHistory()} 用截断后的列表替换。 + */ +public class RewindCommand implements SlashCommand { + + /** 每个消息对包含的消息数(用户消息 + 助手消息) */ + private static final int MESSAGES_PER_PAIR = 2; + + @Override + public String name() { + return "rewind"; + } + + @Override + public String description() { + return "Roll back conversation to a previous point"; + } + + @Override + public String execute(String args, CommandContext context) { + if (context.agentLoop() == null) { + return AnsiStyle.red(" ✗ AgentLoop 不可用。"); + } + + // 解析要回滚的消息对数量 + int pairsToRemove = parseRewindCount(args); + if (pairsToRemove < 0) { + return AnsiStyle.red(" ✗ 无效的回滚数量。请输入一个正整数。") + "\n" + + AnsiStyle.dim(" 用法: /rewind [n] (n 为要移除的消息对数,默认为 1)"); + } + + List currentHistory = context.agentLoop().getMessageHistory(); + int currentSize = currentHistory.size(); + + if (currentSize == 0) { + return AnsiStyle.yellow(" ⚠ 对话历史为空,无法回滚。"); + } + + // 计算需要移除的消息数量 + int messagesToRemove = pairsToRemove * MESSAGES_PER_PAIR; + + // 如果要移除的消息数超过总消息数,则清除所有消息 + if (messagesToRemove >= currentSize) { + context.agentLoop().replaceHistory(new ArrayList<>()); + return AnsiStyle.green(" ✓ 已清除全部 " + currentSize + " 条消息。") + "\n" + + AnsiStyle.dim(" (请求移除 " + pairsToRemove + " 对,实际清除全部消息)"); + } + + // 截断消息历史 + int newSize = currentSize - messagesToRemove; + List truncatedHistory = new ArrayList<>(currentHistory.subList(0, newSize)); + context.agentLoop().replaceHistory(truncatedHistory); + + // 构建结果输出 + StringBuilder sb = new StringBuilder(); + sb.append(AnsiStyle.green(" ✓ 已回滚 " + pairsToRemove + " 个消息对")).append("\n"); + sb.append(AnsiStyle.dim(" 移除: " + messagesToRemove + " 条消息")).append("\n"); + sb.append(AnsiStyle.dim(" 剩余: " + newSize + " 条消息")).append("\n"); + + return sb.toString(); + } + + /** + * 解析回滚数量参数。 + * + * @param args 命令参数 + * @return 要回滚的消息对数量,默认为 1;解析失败返回 -1 + */ + private int parseRewindCount(String args) { + String trimmed = args != null ? args.trim() : ""; + + if (trimmed.isEmpty()) { + return 1; // 默认回滚 1 个消息对 + } + + try { + int count = Integer.parseInt(trimmed); + return count > 0 ? count : -1; + } catch (NumberFormatException e) { + return -1; + } + } +} diff --git a/src/main/java/com/claudecode/command/impl/SecurityReviewCommand.java b/src/main/java/com/claudecode/command/impl/SecurityReviewCommand.java new file mode 100644 index 0000000..1f7f51e --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/SecurityReviewCommand.java @@ -0,0 +1,174 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Collectors; + +/** + * /security-review 命令 —— 通过 AI 进行安全审查。 + *

+ * 获取当前项目的最新代码变更({@code git diff HEAD}), + * 发送给 AI 模型进行安全方面的专项审查。 + *

+ * 审查重点包括: + *

    + *
  • SQL 注入漏洞
  • + *
  • 跨站脚本攻击(XSS)
  • + *
  • 身份认证和授权问题
  • + *
  • 敏感信息泄露(密钥、密码等)
  • + *
  • 依赖安全问题
  • + *
+ */ +public class SecurityReviewCommand implements SlashCommand { + + @Override + public String name() { + return "security-review"; + } + + @Override + public String description() { + return "Review code changes for security vulnerabilities"; + } + + @Override + public List aliases() { + return List.of("sec"); + } + + @Override + public String execute(String args, CommandContext context) { + if (context.agentLoop() == null) { + return AnsiStyle.red(" ✗ AgentLoop 不可用,无法执行安全审查。"); + } + + try { + // 获取最近的代码变更 + String diffOutput = executeGitDiff(); + + if (diffOutput.isBlank()) { + return AnsiStyle.yellow(" ⚠ 没有检测到代码变更。") + "\n" + + AnsiStyle.dim(" git diff HEAD 未返回任何内容。请确认是否有提交记录。"); + } + + // 输出审查进行中的提示 + context.out().println(AnsiStyle.magenta(" 🔒 正在进行安全审查...")); + context.out().println(AnsiStyle.dim(" diff 大小: " + diffOutput.lines().count() + " 行")); + context.out().println(); + + // 构建安全审查提示词 + String securityPrompt = buildSecurityPrompt(diffOutput); + + // 发送给 AI 进行安全审查 + String result = context.agentLoop().run(securityPrompt); + return result; + + } catch (Exception e) { + return AnsiStyle.red(" ✗ 安全审查失败: " + e.getMessage()) + "\n" + + AnsiStyle.dim(" 请确保当前目录是一个 Git 仓库且有提交历史。"); + } + } + + /** + * 执行 {@code git diff HEAD} 获取最近的代码变更。 + * + * @return diff 输出内容 + * @throws Exception 命令执行失败时抛出异常 + */ + private String executeGitDiff() throws Exception { + ProcessBuilder pb = new ProcessBuilder("git", "diff", "HEAD"); + pb.redirectErrorStream(false); + + Process process = pb.start(); + + String output; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + output = reader.lines().collect(Collectors.joining("\n")); + } + + // 读取错误输出 + String errorOutput; + try (BufferedReader errorReader = new BufferedReader( + new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { + errorOutput = errorReader.lines().collect(Collectors.joining("\n")); + } + + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new RuntimeException("git diff HEAD 执行失败 (exit=" + exitCode + "): " + errorOutput); + } + + return output; + } + + /** + * 构建安全审查提示词。 + *

+ * 要求 AI 从多个安全维度对代码变更进行全面审查, + * 并按严重程度分类报告发现的问题。 + * + * @param diffOutput git diff 的输出内容 + * @return 完整的安全审查提示词 + */ + private String buildSecurityPrompt(String diffOutput) { + return """ + Please perform a comprehensive security review of the following code changes. + + ```diff + %s + ``` + + Analyze the code changes for the following security concerns: + + ## 1. SQL Injection + - Are there any raw SQL queries with string concatenation? + - Are parameterized queries/prepared statements used properly? + - Is user input sanitized before database operations? + + ## 2. Cross-Site Scripting (XSS) + - Is user input properly escaped before rendering in HTML? + - Are there any unsafe innerHTML or DOM manipulation patterns? + - Is output encoding applied correctly? + + ## 3. Authentication & Authorization + - Are authentication checks properly implemented? + - Are authorization boundaries enforced correctly? + - Are session tokens handled securely? + - Are passwords hashed with strong algorithms (bcrypt, Argon2)? + + ## 4. Secrets & Sensitive Data + - Are any API keys, passwords, or tokens hardcoded? + - Are sensitive data properly encrypted at rest and in transit? + - Are credentials stored in environment variables or secure vaults? + - Are there any logging statements that might expose sensitive information? + + ## 5. Dependency & Configuration Security + - Are there any known vulnerable dependencies? + - Are security headers properly configured? + - Are CORS policies appropriately restrictive? + - Is TLS/SSL properly configured? + + ## 6. Other Security Concerns + - Path traversal vulnerabilities + - Command injection risks + - Insecure deserialization + - Race conditions + - Improper error handling that leaks information + + For each issue found, please provide: + - **Severity**: Critical / High / Medium / Low + - **Location**: File and line reference + - **Description**: What the vulnerability is + - **Recommendation**: How to fix it + + If no security issues are found, explicitly state that the changes look secure. + """.formatted(diffOutput); + } +} diff --git a/src/main/java/com/claudecode/command/impl/StatsCommand.java b/src/main/java/com/claudecode/command/impl/StatsCommand.java new file mode 100644 index 0000000..7e6532b --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/StatsCommand.java @@ -0,0 +1,157 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; +import com.claudecode.core.TokenTracker; + +import java.lang.management.ManagementFactory; +import java.lang.management.RuntimeMXBean; +import java.util.concurrent.TimeUnit; + +/** + * /stats 命令 —— 显示当前会话的使用统计信息。 + *

+ * 展示内容包括: + *

    + *
  • 近似对话轮数(消息历史大小 / 2)
  • + *
  • API 调用总次数
  • + *
  • Token 使用量(输入/输出/总计)
  • + *
  • 估算费用(美元)
  • + *
  • 每次调用平均 Token 数
  • + *
  • 当前使用的模型名称
  • + *
  • JVM 运行时长
  • + *
+ */ +public class StatsCommand implements SlashCommand { + + @Override + public String name() { + return "stats"; + } + + @Override + public String description() { + return "Show usage statistics"; + } + + @Override + public String execute(String args, CommandContext context) { + if (context.agentLoop() == null) { + return AnsiStyle.red(" ✗ AgentLoop 不可用。"); + } + + TokenTracker tracker = context.agentLoop().getTokenTracker(); + if (tracker == null) { + return AnsiStyle.yellow(" ⚠ Token 追踪器不可用。"); + } + + // 收集统计数据 + int messageCount = context.agentLoop().getMessageHistory().size(); + int conversationRounds = messageCount / 2; // 近似对话轮数 + long apiCalls = tracker.getApiCallCount(); + long inputTokens = tracker.getInputTokens(); + long outputTokens = tracker.getOutputTokens(); + long totalTokens = tracker.getTotalTokens(); + double estimatedCost = tracker.estimateCost(); + String modelName = tracker.getModelName(); + + // 计算每次调用平均 Token 数 + long avgTokensPerCall = apiCalls > 0 ? totalTokens / apiCalls : 0; + + // 计算 JVM 运行时长 + String uptime = formatUptime(); + + // 构建输出 + StringBuilder sb = new StringBuilder(); + sb.append(AnsiStyle.bold("\n 📊 Session Statistics\n")); + sb.append("\n"); + + // 模型信息 + sb.append(formatRow("Model", AnsiStyle.cyan(modelName))); + sb.append(formatRow("Uptime", uptime)); + sb.append("\n"); + + // 对话统计 + sb.append(AnsiStyle.bold(" ── Conversation ──\n")); + sb.append(formatRow("Messages", String.valueOf(messageCount))); + sb.append(formatRow("Conversations (approx)", String.valueOf(conversationRounds))); + sb.append(formatRow("API Calls", String.valueOf(apiCalls))); + sb.append("\n"); + + // Token 统计 + sb.append(AnsiStyle.bold(" ── Token Usage ──\n")); + sb.append(formatRow("Input Tokens", TokenTracker.formatTokens(inputTokens))); + sb.append(formatRow("Output Tokens", TokenTracker.formatTokens(outputTokens))); + sb.append(formatRow("Total Tokens", AnsiStyle.bold(TokenTracker.formatTokens(totalTokens)))); + sb.append(formatRow("Avg Tokens/Call", TokenTracker.formatTokens(avgTokensPerCall))); + sb.append("\n"); + + // 费用统计 + sb.append(AnsiStyle.bold(" ── Cost ──\n")); + sb.append(formatRow("Estimated Cost", formatCost(estimatedCost))); + sb.append("\n"); + + return sb.toString(); + } + + /** + * 格式化表格行(左对齐标签 + 右对齐值)。 + * + * @param label 标签名 + * @param value 值 + * @return 格式化的行字符串 + */ + private String formatRow(String label, String value) { + return String.format(" %-24s %s%n", AnsiStyle.dim(label), value); + } + + /** + * 格式化费用值(保留 4 位小数)。 + *

+ * 根据费用高低使用不同颜色: + *

    + *
  • $0 以下(即免费):绿色
  • + *
  • $0.01 ~ $1.00:黄色
  • + *
  • $1.00 以上:红色
  • + *
+ * + * @param cost 费用(美元) + * @return 带颜色的费用字符串 + */ + private String formatCost(double cost) { + String formatted = String.format("$%.4f", cost); + if (cost < 0.01) { + return AnsiStyle.green(formatted); + } else if (cost < 1.0) { + return AnsiStyle.yellow(formatted); + } else { + return AnsiStyle.red(formatted); + } + } + + /** + * 格式化 JVM 运行时长。 + *

+ * 使用 {@link ManagementFactory#getRuntimeMXBean()} 获取 JVM 启动时间, + * 并计算至今的运行时长,格式为 "Xh Ym Zs"。 + * + * @return 格式化的运行时长字符串 + */ + private String formatUptime() { + RuntimeMXBean runtimeMXBean = ManagementFactory.getRuntimeMXBean(); + long uptimeMillis = runtimeMXBean.getUptime(); + + long hours = TimeUnit.MILLISECONDS.toHours(uptimeMillis); + long minutes = TimeUnit.MILLISECONDS.toMinutes(uptimeMillis) % 60; + long seconds = TimeUnit.MILLISECONDS.toSeconds(uptimeMillis) % 60; + + if (hours > 0) { + return String.format("%dh %dm %ds", hours, minutes, seconds); + } else if (minutes > 0) { + return String.format("%dm %ds", minutes, seconds); + } else { + return String.format("%ds", seconds); + } + } +} diff --git a/src/main/java/com/claudecode/command/impl/TagCommand.java b/src/main/java/com/claudecode/command/impl/TagCommand.java new file mode 100644 index 0000000..d7b5fff --- /dev/null +++ b/src/main/java/com/claudecode/command/impl/TagCommand.java @@ -0,0 +1,196 @@ +package com.claudecode.command.impl; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; +import org.springframework.ai.chat.messages.Message; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * /tag 命令 —— 为当前对话位置打标签。 + *

+ * 标签记录当前消息历史的大小,可用于后续快速回溯到该位置。 + * 标签数据存储在静态 Map 中,在 JVM 生命周期内持久化。 + *

+ * 支持的子命令: + *

    + *
  • {@code /tag } —— 为当前位置打标签
  • + *
  • {@code /tag list} —— 列出所有标签
  • + *
  • {@code /tag goto } —— 回溯到指定标签位置
  • + *
+ */ +public class TagCommand implements SlashCommand { + + /** 静态标签存储:标签名称 -> 标签信息 */ + private static final Map tags = new LinkedHashMap<>(); + + @Override + public String name() { + return "tag"; + } + + @Override + public String description() { + return "Tag current conversation point with a label"; + } + + @Override + public String execute(String args, CommandContext context) { + if (context.agentLoop() == null) { + return AnsiStyle.red(" ✗ AgentLoop 不可用。"); + } + + String trimmedArgs = args != null ? args.trim() : ""; + + if (trimmedArgs.isEmpty()) { + return showUsage(); + } + + // 解析子命令 + String[] parts = trimmedArgs.split("\\s+", 2); + String firstArg = parts[0].toLowerCase(); + + return switch (firstArg) { + case "list" -> listTags(context); + case "goto" -> { + String tagName = parts.length > 1 ? parts[1].trim() : ""; + yield gotoTag(tagName, context); + } + default -> { + // 第一个参数不是子命令,视为标签名称 + yield createTag(trimmedArgs, context); + } + }; + } + + /** + * 创建标签,记录当前消息历史的大小。 + * + * @param tagName 标签名称 + * @param context 命令上下文 + * @return 操作结果信息 + */ + private String createTag(String tagName, CommandContext context) { + if (tagName.isEmpty()) { + return AnsiStyle.red(" ✗ 请指定标签名称。"); + } + + // 标签名称不能与子命令冲突(虽然 list/goto 已在 switch 中处理) + int position = context.agentLoop().getMessageHistory().size(); + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + + boolean isOverwrite = tags.containsKey(tagName); + tags.put(tagName, new TagInfo(position, timestamp)); + + String action = isOverwrite ? "已更新" : "已创建"; + return AnsiStyle.green(" ✓ 标签" + action + ": ") + AnsiStyle.bold(tagName) + "\n" + + AnsiStyle.dim(" 位置: 第 " + position + " 条消息 时间: " + timestamp); + } + + /** + * 列出所有已保存的标签。 + * + * @param context 命令上下文 + * @return 标签列表信息 + */ + private String listTags(CommandContext context) { + if (tags.isEmpty()) { + return AnsiStyle.dim(" 没有保存的标签。") + "\n" + + AnsiStyle.dim(" 使用 /tag 为当前位置打标签。"); + } + + int currentPosition = context.agentLoop().getMessageHistory().size(); + + StringBuilder sb = new StringBuilder(); + sb.append(AnsiStyle.bold("\n 🏷️ Conversation Tags\n\n")); + + for (Map.Entry entry : tags.entrySet()) { + String name = entry.getKey(); + TagInfo info = entry.getValue(); + + // 高亮当前位置匹配的标签 + String marker = (info.position() == currentPosition) + ? AnsiStyle.green(" ◀ current") + : ""; + + sb.append(" • ") + .append(AnsiStyle.bold(name)) + .append(AnsiStyle.dim(" (position=" + info.position() + ", " + info.timestamp() + ")")) + .append(marker) + .append("\n"); + } + + sb.append("\n") + .append(AnsiStyle.dim(" 当前位置: " + currentPosition + " 条消息 | 共 " + tags.size() + " 个标签")) + .append("\n"); + return sb.toString(); + } + + /** + * 回溯到指定标签对应的对话位置。 + *

+ * 将消息历史截断到标签记录的位置。如果当前消息数少于标签位置, + * 则不做截断(标签位置可能超出当前对话长度)。 + * + * @param tagName 标签名称 + * @param context 命令上下文 + * @return 操作结果信息 + */ + private String gotoTag(String tagName, CommandContext context) { + if (tagName.isEmpty()) { + return AnsiStyle.red(" ✗ 请指定标签名称。") + "\n" + + AnsiStyle.dim(" 用法: /tag goto "); + } + + TagInfo info = tags.get(tagName); + if (info == null) { + return AnsiStyle.red(" ✗ 标签不存在: " + tagName) + "\n" + + AnsiStyle.dim(" 使用 /tag list 查看所有可用标签。"); + } + + List currentHistory = context.agentLoop().getMessageHistory(); + int currentSize = currentHistory.size(); + int targetPosition = info.position(); + + if (targetPosition >= currentSize) { + return AnsiStyle.yellow(" ⚠ 标签位置 (" + targetPosition + ") 不小于当前消息数 (" + + currentSize + "),无需回溯。"); + } + + // 截断到标签位置 + List truncated = new ArrayList<>(currentHistory.subList(0, targetPosition)); + context.agentLoop().replaceHistory(truncated); + + int removedCount = currentSize - targetPosition; + return AnsiStyle.green(" ✓ 已回溯到标签: ") + AnsiStyle.bold(tagName) + "\n" + + AnsiStyle.dim(" 移除了 " + removedCount + " 条消息,当前消息数: " + targetPosition); + } + + /** + * 显示用法帮助信息。 + * + * @return 用法说明文本 + */ + private String showUsage() { + StringBuilder sb = new StringBuilder(); + sb.append(AnsiStyle.bold("\n 🏷️ Tag — 对话位置标签\n\n")); + sb.append(" ").append(AnsiStyle.cyan("/tag ")).append(" 为当前位置打标签\n"); + sb.append(" ").append(AnsiStyle.cyan("/tag list")).append(" 列出所有标签\n"); + sb.append(" ").append(AnsiStyle.cyan("/tag goto ")).append(" 回溯到指定标签位置\n"); + return sb.toString(); + } + + /** + * 标签信息记录 —— 保存标签的消息位置和创建时间。 + * + * @param position 消息历史中的位置(消息数量) + * @param timestamp 创建时间戳 + */ + private record TagInfo(int position, String timestamp) {} +} diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index 619ee49..da73340 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -7,7 +7,11 @@ import com.claudecode.context.GitContext; import com.claudecode.context.SkillLoader; import com.claudecode.context.SystemPromptBuilder; import com.claudecode.core.AgentLoop; +import com.claudecode.core.TaskManager; import com.claudecode.core.TokenTracker; +import com.claudecode.mcp.McpManager; +import com.claudecode.plugin.OutputStylePlugin; +import com.claudecode.plugin.PluginManager; import com.claudecode.repl.ReplSession; import com.claudecode.tool.ToolContext; import com.claudecode.tool.ToolRegistry; @@ -21,7 +25,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.nio.file.Path; -import java.util.Map; /** * 应用配置类 —— Spring Bean 装配。 @@ -43,7 +46,40 @@ public class AppConfig { } @Bean - public ToolRegistry toolRegistry() { + public TaskManager taskManager() { + return new TaskManager(); + } + + @Bean + public McpManager mcpManager() { + McpManager manager = new McpManager(); + try { + manager.loadFromConfig(); + } catch (Exception e) { + log.warn("MCP 配置加载失败(可忽略): {}", e.getMessage()); + } + return manager; + } + + @Bean + public PluginManager pluginManager(ToolContext toolContext) { + PluginManager manager = new PluginManager(toolContext); + // 注册内置插件 + var stylePlugin = new OutputStylePlugin(); + stylePlugin.initialize(new com.claudecode.plugin.PluginContext( + toolContext, System.getProperty("user.dir"), stylePlugin.id())); + // 加载外部插件 + manager.loadAll(); + return manager; + } + + @Bean + public ToolRegistry toolRegistry(TaskManager taskManager, McpManager mcpManager, + ToolContext toolContext) { + // 将 TaskManager 和 McpManager 注册到 ToolContext 供工具使用 + toolContext.set("TASK_MANAGER", taskManager); + toolContext.set("MCP_MANAGER", mcpManager); + ToolRegistry registry = new ToolRegistry(); registry.registerAll( new BashTool(), @@ -58,15 +94,31 @@ public class AppConfig { new AgentTool(), new NotebookEditTool(), new WebSearchTool(), - new AskUserQuestionTool() + new AskUserQuestionTool(), + // P2: 任务管理工具 + new TaskCreateTool(), + new TaskGetTool(), + new TaskListTool(), + new TaskUpdateTool(), + // P2: 配置工具 + new ConfigTool() ); + + // P2: 注册 MCP 工具桥接(将远程 MCP 工具映射为本地工具) + for (var client : mcpManager.getClients().values()) { + for (var mcpTool : client.getTools()) { + registry.register(new McpToolBridge(client.getServerName(), mcpTool)); + } + } + return registry; } @Bean - public CommandRegistry commandRegistry() { + public CommandRegistry commandRegistry(PluginManager pluginManager) { CommandRegistry registry = new CommandRegistry(); registry.registerAll( + // 基础命令 new HelpCommand(), new ClearCommand(), new CompactCommand(), @@ -77,23 +129,38 @@ public class AppConfig { new InitCommand(), new ConfigCommand(), new HistoryCommand(), + // P0 命令 new DiffCommand(), new VersionCommand(), new SkillsCommand(), new MemoryCommand(), new CopyCommand(), + // P1 命令 new ResumeCommand(), new ExportCommand(), new CommitCommand(), + // P2 命令 + new HooksCommand(), + new ReviewCommand(), + new StatsCommand(), + new BranchCommand(), + new RewindCommand(), + new TagCommand(), + new SecurityReviewCommand(), + new McpCommand(), + new PluginCommand(), + // Exit 放最后 new ExitCommand() ); + + // P2: 注册插件提供的命令 + pluginManager.registerCommands(registry); + return registry; } /** * 根据 claude-code.provider 配置选择 ChatModel。 - * - "anthropic" → 使用 anthropicChatModel - * - "openai"(默认)→ 使用 openAiChatModel */ @Bean public ChatModel activeChatModel( @@ -111,7 +178,6 @@ public class AppConfig { @Bean public ProviderInfo providerInfo() { - // 统一使用 AI_BASE_URL / AI_MODEL 环境变量,按 Provider 给不同默认值 String baseUrl; String model; @@ -156,7 +222,8 @@ public class AppConfig { @Bean public AgentLoop agentLoop(ChatModel activeChatModel, ToolRegistry toolRegistry, - ToolContext toolContext, String systemPrompt, TokenTracker tokenTracker) { + ToolContext toolContext, String systemPrompt, TokenTracker tokenTracker, + PluginManager pluginManager) { AgentLoop mainLoop = new AgentLoop(activeChatModel, toolRegistry, toolContext, systemPrompt, tokenTracker); // 注册子 Agent 工厂 @@ -166,6 +233,9 @@ public class AppConfig { return subLoop.run(prompt); }); + // 注册 PluginManager 到 ToolContext + toolContext.set("PLUGIN_MANAGER", pluginManager); + return mainLoop; } @@ -175,7 +245,7 @@ public class AppConfig { return new ReplSession(agentLoop, toolRegistry, commandRegistry, providerInfo); } - /** API 提供者信息,供 Banner 和命令显示 */ + /** API 提供者信息 */ public record ProviderInfo(String provider, String baseUrl, String model) { } } diff --git a/src/main/java/com/claudecode/console/DiffRenderer.java b/src/main/java/com/claudecode/console/DiffRenderer.java new file mode 100644 index 0000000..9bcf3da --- /dev/null +++ b/src/main/java/com/claudecode/console/DiffRenderer.java @@ -0,0 +1,257 @@ +package com.claudecode.console; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 差异视图渲染器 —— 对应 claude-code 中的 diff/ 组件。 + * 将 unified diff 格式的文本渲染为带颜色的终端输出。 + */ +public class DiffRenderer { + + private static final Pattern DIFF_HEADER = Pattern.compile("^diff --git a/(.*) b/(.*)$"); + private static final Pattern HUNK_HEADER = Pattern.compile("^@@ -(\\d+)(?:,(\\d+))? \\+(\\d+)(?:,(\\d+))? @@(.*)$"); + private static final Pattern STAT_LINE = Pattern.compile("^\\s*(.+?)\\s*\\|\\s*(\\d+)\\s*([+\\-]*)\\s*$"); + private static final Pattern BINARY_FILE = Pattern.compile("^Binary files .* differ$"); + private static final Pattern RENAME_FROM = Pattern.compile("^rename from (.+)$"); + private static final Pattern RENAME_TO = Pattern.compile("^rename to (.+)$"); + + /** + * 渲染 unified diff 为彩色终端输出。 + */ + public static String render(String diffText) { + if (diffText == null || diffText.isBlank()) { + return AnsiStyle.dim(" (no changes)"); + } + + StringBuilder sb = new StringBuilder(); + for (String line : diffText.split("\n")) { + if (line.startsWith("diff --git")) { + sb.append(AnsiStyle.bold(line)).append("\n"); + } else if (line.startsWith("---") || line.startsWith("+++")) { + sb.append(AnsiStyle.bold(line)).append("\n"); + } else if (line.startsWith("@@")) { + sb.append(AnsiStyle.cyan(line)).append("\n"); + } else if (line.startsWith("+")) { + sb.append(AnsiStyle.green(line)).append("\n"); + } else if (line.startsWith("-")) { + sb.append(AnsiStyle.red(line)).append("\n"); + } else if (line.startsWith("index ") || line.startsWith("new file") || line.startsWith("deleted file")) { + sb.append(AnsiStyle.dim(line)).append("\n"); + } else if (BINARY_FILE.matcher(line).matches()) { + sb.append(AnsiStyle.yellow(line)).append("\n"); + } else if (line.startsWith("rename ")) { + sb.append(AnsiStyle.magenta(line)).append("\n"); + } else { + sb.append(line).append("\n"); + } + } + return sb.toString(); + } + + /** + * 渲染 diff 并附带行号。 + */ + public static String renderWithLineNumbers(String diffText) { + if (diffText == null || diffText.isBlank()) { + return AnsiStyle.dim(" (no changes)"); + } + + List files = parse(diffText); + StringBuilder sb = new StringBuilder(); + + for (DiffFile file : files) { + sb.append(AnsiStyle.bold("━━━ " + file.newPath() + " ━━━")).append("\n"); + + if (file.binary()) { + sb.append(AnsiStyle.yellow(" Binary file")).append("\n\n"); + continue; + } + + for (DiffHunk hunk : file.hunks()) { + sb.append(AnsiStyle.cyan(hunk.header())).append("\n"); + + for (DiffLine dl : hunk.lines()) { + String oldNum = dl.oldLineNum() > 0 ? String.format("%4d", dl.oldLineNum()) : " "; + String newNum = dl.newLineNum() > 0 ? String.format("%4d", dl.newLineNum()) : " "; + String lineNums = AnsiStyle.dim(oldNum + " " + newNum + " │ "); + + switch (dl.type()) { + case ADD -> sb.append(lineNums).append(AnsiStyle.green("+ " + dl.content())).append("\n"); + case REMOVE -> sb.append(lineNums).append(AnsiStyle.red("- " + dl.content())).append("\n"); + case CONTEXT -> sb.append(lineNums).append(" " + dl.content()).append("\n"); + case HEADER -> sb.append(AnsiStyle.cyan(dl.content())).append("\n"); + } + } + } + sb.append("\n"); + } + return sb.toString(); + } + + /** + * 渲染 stat 摘要(类似 git diff --stat)。 + */ + public static String renderStat(String diffText) { + if (diffText == null || diffText.isBlank()) { + return AnsiStyle.dim(" (no changes)"); + } + + List files = parse(diffText); + if (files.isEmpty()) { + return AnsiStyle.dim(" (no changes)"); + } + + int totalAdded = 0, totalRemoved = 0; + int maxNameLen = 0; + List stats = new ArrayList<>(); + + for (DiffFile file : files) { + int added = 0, removed = 0; + for (DiffHunk hunk : file.hunks()) { + for (DiffLine line : hunk.lines()) { + if (line.type() == DiffLine.Type.ADD) added++; + else if (line.type() == DiffLine.Type.REMOVE) removed++; + } + } + String name = file.newPath(); + if (name.length() > maxNameLen) maxNameLen = name.length(); + stats.add(new FileStat(name, added, removed, file.binary())); + totalAdded += added; + totalRemoved += removed; + } + + StringBuilder sb = new StringBuilder(); + int barWidth = 40; + + for (FileStat fs : stats) { + String name = String.format(" %-" + (maxNameLen + 2) + "s", fs.name()); + + if (fs.binary()) { + sb.append(name).append(AnsiStyle.dim(" | ")).append(AnsiStyle.yellow("Bin")).append("\n"); + continue; + } + + int total = fs.added() + fs.removed(); + sb.append(name).append(AnsiStyle.dim(" | ")).append(String.format("%4d ", total)); + + // 条形图 + int maxBar = Math.min(total, barWidth); + int addBar = total > 0 ? (int) Math.round((double) fs.added() / total * maxBar) : 0; + int remBar = maxBar - addBar; + sb.append(AnsiStyle.green("+".repeat(addBar))); + sb.append(AnsiStyle.red("-".repeat(remBar))); + sb.append("\n"); + } + + sb.append(AnsiStyle.dim(String.format(" %d file%s changed", files.size(), files.size() == 1 ? "" : "s"))); + if (totalAdded > 0) sb.append(AnsiStyle.green(String.format(", %d insertion%s(+)", totalAdded, totalAdded == 1 ? "" : "s"))); + if (totalRemoved > 0) sb.append(AnsiStyle.red(String.format(", %d deletion%s(-)", totalRemoved, totalRemoved == 1 ? "" : "s"))); + sb.append("\n"); + + return sb.toString(); + } + + /** + * 解析 unified diff 为结构化对象。 + */ + public static List parse(String diffText) { + if (diffText == null || diffText.isBlank()) return List.of(); + + List files = new ArrayList<>(); + String[] lines = diffText.split("\n"); + int i = 0; + + while (i < lines.length) { + Matcher headerMatch = DIFF_HEADER.matcher(lines[i]); + if (!headerMatch.matches()) { + i++; + continue; + } + + String oldPath = headerMatch.group(1); + String newPath = headerMatch.group(2); + i++; + + // 跳过扩展头(index, mode, rename 等) + boolean binary = false; + while (i < lines.length && !lines[i].startsWith("---") && !lines[i].startsWith("diff --git") && !lines[i].startsWith("@@")) { + if (BINARY_FILE.matcher(lines[i]).matches()) binary = true; + if (lines[i].startsWith("rename to ")) { + Matcher rm = RENAME_TO.matcher(lines[i]); + if (rm.matches()) newPath = rm.group(1); + } + i++; + } + + if (binary) { + files.add(new DiffFile(oldPath, newPath, List.of(), true)); + continue; + } + + // 跳过 --- 和 +++ 行 + if (i < lines.length && lines[i].startsWith("---")) i++; + if (i < lines.length && lines[i].startsWith("+++")) i++; + + // 解析 hunks + List hunks = new ArrayList<>(); + while (i < lines.length && !lines[i].startsWith("diff --git")) { + Matcher hunkMatch = HUNK_HEADER.matcher(lines[i]); + if (!hunkMatch.matches()) { + i++; + continue; + } + + int oldStart = Integer.parseInt(hunkMatch.group(1)); + int oldCount = hunkMatch.group(2) != null ? Integer.parseInt(hunkMatch.group(2)) : 1; + int newStart = Integer.parseInt(hunkMatch.group(3)); + int newCount = hunkMatch.group(4) != null ? Integer.parseInt(hunkMatch.group(4)) : 1; + String hunkHeader = lines[i]; + i++; + + List diffLines = new ArrayList<>(); + int oldLine = oldStart; + int newLine = newStart; + + while (i < lines.length && !lines[i].startsWith("diff --git") && !lines[i].startsWith("@@")) { + String line = lines[i]; + if (line.startsWith("+")) { + diffLines.add(new DiffLine(DiffLine.Type.ADD, line.substring(1), -1, newLine++)); + } else if (line.startsWith("-")) { + diffLines.add(new DiffLine(DiffLine.Type.REMOVE, line.substring(1), oldLine++, -1)); + } else if (line.startsWith(" ")) { + diffLines.add(new DiffLine(DiffLine.Type.CONTEXT, line.substring(1), oldLine++, newLine++)); + } else if (line.startsWith("\\")) { + // "\ No newline at end of file" — 跳过 + } else { + break; + } + i++; + } + + hunks.add(new DiffHunk(oldStart, oldCount, newStart, newCount, hunkHeader, List.copyOf(diffLines))); + } + + files.add(new DiffFile(oldPath, newPath, List.copyOf(hunks), false)); + } + + return List.copyOf(files); + } + + // ==================== 结构化类型 ==================== + + /** 差异文件 */ + public record DiffFile(String oldPath, String newPath, List hunks, boolean binary) {} + + /** 差异区块 */ + public record DiffHunk(int oldStart, int oldCount, int newStart, int newCount, String header, List lines) {} + + /** 差异行 */ + public record DiffLine(Type type, String content, int oldLineNum, int newLineNum) { + public enum Type { CONTEXT, ADD, REMOVE, HEADER } + } + + /** 文件统计(内部使用) */ + private record FileStat(String name, int added, int removed, boolean binary) {} +} diff --git a/src/main/java/com/claudecode/core/TaskManager.java b/src/main/java/com/claudecode/core/TaskManager.java new file mode 100644 index 0000000..e0b0b04 --- /dev/null +++ b/src/main/java/com/claudecode/core/TaskManager.java @@ -0,0 +1,390 @@ +package com.claudecode.core; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; +import java.util.stream.Collectors; + +/** + * 任务管理器 —— 对应 claude-code 中的 TaskCreate / TaskGet / TaskList / TaskUpdate 功能。 + *

+ * 管理后台任务的创建、执行、查询和更新。 + * 所有公共方法均线程安全,内部使用 {@link ConcurrentHashMap} 存储任务、 + * 使用守护线程池异步执行带有 {@link Callable} 工作体的任务。 + *

+ * + *

两种创建模式

+ *
    + *
  • {@link #createTask(String, Callable)} —— 自动执行模式:提交后立即在后台线程池中运行
  • + *
  • {@link #createManualTask(String)} —— 手动管理模式:仅创建 PENDING 状态的记录, + * 由外部通过 {@link #updateTask} 驱动状态流转
  • + *
+ */ +public class TaskManager { + + /* ------------------------------------------------------------------ */ + /* 任务状态枚举 */ + /* ------------------------------------------------------------------ */ + + /** + * 任务生命周期状态。 + */ + public enum TaskStatus { + /** 已创建,等待执行 */ + PENDING, + /** 正在执行 */ + RUNNING, + /** 执行成功完成 */ + COMPLETED, + /** 执行失败 */ + FAILED, + /** 已被取消 */ + CANCELLED + } + + /* ------------------------------------------------------------------ */ + /* 任务信息记录 */ + /* ------------------------------------------------------------------ */ + + /** + * 不可变的任务快照。每次状态变更都会创建新的 {@code TaskInfo} 实例写入映射表, + * 从而保证读取端永远拿到一致的快照。 + * + * @param id 任务唯一 ID(UUID 前 8 位) + * @param description 任务描述 + * @param status 当前状态 + * @param result 执行结果(可为 {@code null}) + * @param createdAt 创建时间 + * @param updatedAt 最后更新时间 + * @param metadata 附加元数据(不可变视图) + */ + public record TaskInfo( + String id, + String description, + TaskStatus status, + String result, + Instant createdAt, + Instant updatedAt, + Map metadata + ) { + /** + * 创建一个更新了状态、结果和时间戳的新快照。 + */ + public TaskInfo withStatusAndResult(TaskStatus newStatus, String newResult) { + return new TaskInfo( + id, description, newStatus, newResult, + createdAt, Instant.now(), + metadata + ); + } + + /** + * 创建一个仅更新了状态和时间戳的新快照。 + */ + public TaskInfo withStatus(TaskStatus newStatus) { + return withStatusAndResult(newStatus, result); + } + } + + /* ------------------------------------------------------------------ */ + /* 内部状态 */ + /* ------------------------------------------------------------------ */ + + /** 任务存储:taskId → TaskInfo(不可变快照) */ + private final ConcurrentHashMap tasks = new ConcurrentHashMap<>(); + + /** 自动执行模式下对应的 Future,用于取消 */ + private final ConcurrentHashMap> futures = new ConcurrentHashMap<>(); + + /** 守护线程池,用于执行自动任务 */ + private final ExecutorService executor = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "task-worker-" + Thread.currentThread().threadId()); + t.setDaemon(true); + return t; + }); + + /* ------------------------------------------------------------------ */ + /* 创建任务 */ + /* ------------------------------------------------------------------ */ + + /** + * 创建并自动执行一个后台任务。 + *

+ * 任务会立即被提交到线程池执行;执行完成后状态自动变为 COMPLETED 或 FAILED。 + *

+ * + * @param description 任务描述 + * @param work 要执行的工作体 + * @return 生成的任务 ID(UUID 前 8 位) + * @throws NullPointerException 若 description 或 work 为 null + */ + public String createTask(String description, Callable work) { + Objects.requireNonNull(description, "任务描述不能为 null"); + Objects.requireNonNull(work, "任务工作体不能为 null"); + + String taskId = generateId(); + Instant now = Instant.now(); + + TaskInfo info = new TaskInfo( + taskId, description, TaskStatus.PENDING, null, + now, now, Collections.emptyMap() + ); + tasks.put(taskId, info); + + // 提交到线程池异步执行 + Future future = executor.submit(() -> { + // 标记为 RUNNING + tasks.computeIfPresent(taskId, (id, old) -> old.withStatus(TaskStatus.RUNNING)); + try { + String result = work.call(); + // 标记为 COMPLETED + tasks.computeIfPresent(taskId, (id, old) -> + old.withStatusAndResult(TaskStatus.COMPLETED, result)); + } catch (Exception e) { + // 标记为 FAILED,记录异常信息 + String errorMsg = e.getClass().getSimpleName() + ": " + e.getMessage(); + tasks.computeIfPresent(taskId, (id, old) -> + old.withStatusAndResult(TaskStatus.FAILED, errorMsg)); + } + }); + futures.put(taskId, future); + + return taskId; + } + + /** + * 创建并自动执行一个带元数据的后台任务。 + * + * @param description 任务描述 + * @param work 要执行的工作体 + * @param metadata 附加元数据 + * @return 生成的任务 ID + */ + public String createTask(String description, Callable work, + Map metadata) { + Objects.requireNonNull(metadata, "元数据不能为 null"); + String taskId = createTask(description, work); + // 把元数据补充进去(创建时尚处于 PENDING/RUNNING 初期阶段) + tasks.computeIfPresent(taskId, (id, old) -> new TaskInfo( + old.id(), old.description(), old.status(), old.result(), + old.createdAt(), old.updatedAt(), + Collections.unmodifiableMap(new LinkedHashMap<>(metadata)) + )); + return taskId; + } + + /** + * 创建一个手动管理的任务(不自动执行)。 + *

+ * 初始状态为 PENDING,需要外部通过 {@link #updateTask} 手动驱动状态变化。 + *

+ * + * @param description 任务描述 + * @return 生成的任务 ID + */ + public String createManualTask(String description) { + return createManualTask(description, Collections.emptyMap()); + } + + /** + * 创建一个带元数据的手动管理任务。 + * + * @param description 任务描述 + * @param metadata 附加元数据 + * @return 生成的任务 ID + */ + public String createManualTask(String description, Map metadata) { + Objects.requireNonNull(description, "任务描述不能为 null"); + + String taskId = generateId(); + Instant now = Instant.now(); + + Map metaCopy = (metadata == null || metadata.isEmpty()) + ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(metadata)); + + TaskInfo info = new TaskInfo( + taskId, description, TaskStatus.PENDING, null, + now, now, metaCopy + ); + tasks.put(taskId, info); + return taskId; + } + + /* ------------------------------------------------------------------ */ + /* 查询任务 */ + /* ------------------------------------------------------------------ */ + + /** + * 获取指定任务的信息快照。 + * + * @param taskId 任务 ID + * @return 包含任务信息的 Optional,不存在时返回 empty + */ + public Optional getTask(String taskId) { + if (taskId == null || taskId.isBlank()) { + return Optional.empty(); + } + return Optional.ofNullable(tasks.get(taskId)); + } + + /** + * 列出所有任务。 + * + * @return 任务列表(按创建时间升序) + */ + public List listTasks() { + return listTasks(null); + } + + /** + * 列出任务,可按状态过滤。 + * + * @param statusFilter 状态过滤器,{@code null} 表示不过滤 + * @return 符合条件的任务列表(按创建时间升序) + */ + public List listTasks(TaskStatus statusFilter) { + return tasks.values().stream() + .filter(t -> statusFilter == null || t.status() == statusFilter) + .sorted(Comparator.comparing(TaskInfo::createdAt)) + .collect(Collectors.toUnmodifiableList()); + } + + /* ------------------------------------------------------------------ */ + /* 更新任务 */ + /* ------------------------------------------------------------------ */ + + /** + * 更新任务的状态和结果。 + * + * @param taskId 任务 ID + * @param newStatus 新状态 + * @param result 执行结果(可为 null) + * @return 如果任务存在且更新成功返回 {@code true};否则 {@code false} + */ + public boolean updateTask(String taskId, TaskStatus newStatus, String result) { + if (taskId == null || newStatus == null) { + return false; + } + + TaskInfo existing = tasks.get(taskId); + if (existing == null) { + return false; + } + + // 不允许对已终态(COMPLETED / FAILED / CANCELLED)的任务再次更新 + if (isTerminal(existing.status())) { + return false; + } + + tasks.computeIfPresent(taskId, (id, old) -> + old.withStatusAndResult(newStatus, result)); + return true; + } + + /* ------------------------------------------------------------------ */ + /* 取消任务 */ + /* ------------------------------------------------------------------ */ + + /** + * 取消指定任务。 + *

+ * 如果任务由线程池执行且尚未完成,会尝试中断执行线程。 + *

+ * + * @param taskId 任务 ID + * @return 如果任务存在且成功取消返回 {@code true} + */ + public boolean cancelTask(String taskId) { + if (taskId == null) { + return false; + } + + TaskInfo existing = tasks.get(taskId); + if (existing == null) { + return false; + } + + // 已处于终态则无需取消 + if (isTerminal(existing.status())) { + return false; + } + + // 尝试中断线程池中正在运行的 Future + Future future = futures.remove(taskId); + if (future != null && !future.isDone()) { + future.cancel(true); + } + + tasks.computeIfPresent(taskId, (id, old) -> + old.withStatus(TaskStatus.CANCELLED)); + return true; + } + + /* ------------------------------------------------------------------ */ + /* 汇总信息 */ + /* ------------------------------------------------------------------ */ + + /** + * 返回当前所有任务的汇总字符串,适合用于 UI 展示。 + * + * @return 格式化的汇总信息 + */ + public String getSummary() { + if (tasks.isEmpty()) { + return "当前没有任何任务。"; + } + + Map counts = tasks.values().stream() + .collect(Collectors.groupingBy(TaskInfo::status, Collectors.counting())); + + StringBuilder sb = new StringBuilder(); + sb.append("任务汇总 (共 ").append(tasks.size()).append(" 个):\n"); + for (TaskStatus status : TaskStatus.values()) { + long count = counts.getOrDefault(status, 0L); + if (count > 0) { + sb.append(" ").append(status.name()).append(": ").append(count).append('\n'); + } + } + return sb.toString().stripTrailing(); + } + + /* ------------------------------------------------------------------ */ + /* 生命周期管理 */ + /* ------------------------------------------------------------------ */ + + /** + * 关闭执行器。等待最多 5 秒让正在运行的任务完成,超时后强制关闭。 + */ + public void shutdown() { + executor.shutdown(); + try { + if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + /* ------------------------------------------------------------------ */ + /* 内部辅助方法 */ + /* ------------------------------------------------------------------ */ + + /** + * 生成任务 ID:取 UUID 前 8 位。 + */ + private String generateId() { + return UUID.randomUUID().toString().substring(0, 8); + } + + /** + * 判断状态是否为终态。 + */ + private boolean isTerminal(TaskStatus status) { + return status == TaskStatus.COMPLETED + || status == TaskStatus.FAILED + || status == TaskStatus.CANCELLED; + } +} diff --git a/src/main/java/com/claudecode/mcp/McpClient.java b/src/main/java/com/claudecode/mcp/McpClient.java new file mode 100644 index 0000000..97c15de --- /dev/null +++ b/src/main/java/com/claudecode/mcp/McpClient.java @@ -0,0 +1,443 @@ +package com.claudecode.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * MCP 客户端 —— 对应 claude-code 中的 mcp/ 模块。 + *

+ * 负责与单个 MCP 服务器的完整生命周期管理: + *

    + *
  1. 通过 {@link McpTransport} 建立连接
  2. + *
  3. 发送 {@code initialize} 握手请求
  4. + *
  5. 发现服务器提供的工具({@code tools/list})和资源({@code resources/list})
  6. + *
  7. 调用工具({@code tools/call})和读取资源({@code resources/read})
  8. + *
+ *

+ * MCP 协议使用 JSON-RPC 2.0 格式通信。 + * + * @see McpTransport + * @see McpManager + */ +public class McpClient implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(McpClient.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** JSON-RPC 请求 ID 生成器 */ + private final AtomicInteger idCounter = new AtomicInteger(1); + + /** 服务器名称标识 */ + private final String serverName; + + /** 底层传输层 */ + private final McpTransport transport; + + /** 已发现的工具集合:toolName -> McpTool */ + private final Map tools = new ConcurrentHashMap<>(); + + /** 已发现的资源集合:uri -> McpResource */ + private final Map resources = new ConcurrentHashMap<>(); + + /** 服务器能力信息 */ + private JsonNode serverCapabilities; + + /** 服务器信息 */ + private JsonNode serverInfo; + + /** 是否已完成初始化 */ + private volatile boolean initialized = false; + + /** + * 创建 MCP 客户端。 + * + * @param serverName 服务器标识名称 + * @param transport 传输层实现 + */ + public McpClient(String serverName, McpTransport transport) { + this.serverName = Objects.requireNonNull(serverName, "服务器名称不能为空"); + this.transport = Objects.requireNonNull(transport, "传输层不能为空"); + } + + /** + * 初始化连接 —— MCP 协议握手流程。 + *

+ * 步骤: + *

    + *
  1. 发送 {@code initialize} 请求,声明客户端能力和协议版本
  2. + *
  3. 解析服务器返回的能力信息
  4. + *
  5. 发送 {@code notifications/initialized} 通知
  6. + *
  7. 发现服务器提供的工具和资源
  8. + *
+ * + * @throws McpException 初始化失败 + */ + public void initialize() throws McpException { + log.info("正在初始化 MCP 服务器 '{}'...", serverName); + + // 1. 发送 initialize 请求 + int initId = nextId(); + var initRequest = Map.of( + "jsonrpc", "2.0", + "id", initId, + "method", "initialize", + "params", Map.of( + "protocolVersion", "2024-11-05", + "capabilities", Map.of(), + "clientInfo", Map.of( + "name", "claude-code-java", + "version", "1.0.0" + ) + ) + ); + + JsonNode response; + try { + response = transport.sendRequest(MAPPER.writeValueAsString(initRequest)); + } catch (Exception e) { + throw new McpException("MCP initialize 请求失败: " + e.getMessage(), e); + } + + // 2. 解析服务器能力 + JsonNode result = response.get("result"); + if (result != null) { + serverCapabilities = result.get("capabilities"); + serverInfo = result.get("serverInfo"); + String serverVersion = result.has("protocolVersion") + ? result.get("protocolVersion").asText() : "unknown"; + log.info("MCP 服务器 '{}' 协议版本: {}", serverName, serverVersion); + if (serverInfo != null) { + log.info("MCP 服务器信息: {}", serverInfo); + } + } + + // 3. 发送 initialized 通知 + var initializedNotif = Map.of( + "jsonrpc", "2.0", + "method", "notifications/initialized" + ); + try { + transport.sendNotification(MAPPER.writeValueAsString(initializedNotif)); + } catch (Exception e) { + throw new McpException("发送 initialized 通知失败: " + e.getMessage(), e); + } + + // 4. 发现工具 + discoverTools(); + + // 5. 发现资源 + discoverResources(); + + initialized = true; + log.info("MCP 服务器 '{}' 初始化完成: {} 个工具, {} 个资源", + serverName, tools.size(), resources.size()); + } + + /** + * 发现服务器提供的工具 —— 发送 {@code tools/list} 请求。 + */ + private void discoverTools() throws McpException { + // 检查服务器是否支持 tools 能力 + if (serverCapabilities != null + && serverCapabilities.has("tools") + && serverCapabilities.get("tools").isObject()) { + // 服务器声明支持工具 + } else if (serverCapabilities != null && !serverCapabilities.has("tools")) { + log.debug("MCP 服务器 '{}' 未声明 tools 能力,尝试发现工具", serverName); + } + + int id = nextId(); + var request = Map.of( + "jsonrpc", "2.0", + "id", id, + "method", "tools/list", + "params", Map.of() + ); + + try { + JsonNode response = transport.sendRequest(MAPPER.writeValueAsString(request)); + JsonNode result = response.get("result"); + if (result != null && result.has("tools")) { + ArrayNode toolsArray = (ArrayNode) result.get("tools"); + for (JsonNode toolNode : toolsArray) { + String name = toolNode.get("name").asText(); + String description = toolNode.has("description") + ? toolNode.get("description").asText() : ""; + JsonNode inputSchema = toolNode.get("inputSchema"); + + tools.put(name, new McpTool(name, description, inputSchema)); + log.debug("发现 MCP 工具: {} - {}", name, description); + } + } + } catch (McpException e) { + // tools/list 可能不被支持,记录警告但不中断初始化 + if (e.isJsonRpcError() && e.getErrorCode() == -32601) { + log.debug("MCP 服务器 '{}' 不支持 tools/list", serverName); + } else { + log.warn("发现 MCP 工具失败: {}", e.getMessage()); + } + } catch (Exception e) { + log.warn("发现 MCP 工具时序列化异常: {}", e.getMessage()); + } + } + + /** + * 发现服务器提供的资源 —— 发送 {@code resources/list} 请求。 + */ + private void discoverResources() throws McpException { + // 检查服务器是否支持 resources 能力 + if (serverCapabilities != null + && serverCapabilities.has("resources") + && serverCapabilities.get("resources").isObject()) { + // 服务器声明支持资源 + } else if (serverCapabilities != null && !serverCapabilities.has("resources")) { + log.debug("MCP 服务器 '{}' 未声明 resources 能力,跳过资源发现", serverName); + return; + } + + int id = nextId(); + var request = Map.of( + "jsonrpc", "2.0", + "id", id, + "method", "resources/list", + "params", Map.of() + ); + + try { + JsonNode response = transport.sendRequest(MAPPER.writeValueAsString(request)); + JsonNode result = response.get("result"); + if (result != null && result.has("resources")) { + ArrayNode resourcesArray = (ArrayNode) result.get("resources"); + for (JsonNode resNode : resourcesArray) { + String uri = resNode.get("uri").asText(); + String name = resNode.has("name") ? resNode.get("name").asText() : uri; + String description = resNode.has("description") + ? resNode.get("description").asText() : ""; + String mimeType = resNode.has("mimeType") + ? resNode.get("mimeType").asText() : "text/plain"; + + resources.put(uri, new McpResource(uri, name, description, mimeType)); + log.debug("发现 MCP 资源: {} ({})", name, uri); + } + } + } catch (McpException e) { + if (e.isJsonRpcError() && e.getErrorCode() == -32601) { + log.debug("MCP 服务器 '{}' 不支持 resources/list", serverName); + } else { + log.warn("发现 MCP 资源失败: {}", e.getMessage()); + } + } catch (Exception e) { + log.warn("发现 MCP 资源时序列化异常: {}", e.getMessage()); + } + } + + /** + * 调用 MCP 工具 —— 发送 {@code tools/call} 请求。 + * + * @param toolName 工具名称 + * @param arguments 工具参数(键值对) + * @return 工具执行结果文本 + * @throws McpException 调用失败或工具不存在 + */ + public String callTool(String toolName, Map arguments) throws McpException { + if (!initialized) { + throw new McpException("MCP 客户端尚未初始化"); + } + if (!tools.containsKey(toolName)) { + throw new McpException("MCP 工具不存在: " + toolName); + } + + int id = nextId(); + var request = Map.of( + "jsonrpc", "2.0", + "id", id, + "method", "tools/call", + "params", Map.of( + "name", toolName, + "arguments", arguments != null ? arguments : Map.of() + ) + ); + + try { + log.debug("调用 MCP 工具: {} (参数: {})", toolName, arguments); + JsonNode response = transport.sendRequest(MAPPER.writeValueAsString(request)); + JsonNode result = response.get("result"); + + if (result == null) { + return ""; + } + + // MCP tools/call 返回 { content: [{ type: "text", text: "..." }, ...] } + if (result.has("content")) { + StringBuilder sb = new StringBuilder(); + for (JsonNode contentItem : result.get("content")) { + String type = contentItem.has("type") ? contentItem.get("type").asText() : "text"; + if ("text".equals(type) && contentItem.has("text")) { + if (!sb.isEmpty()) { + sb.append("\n"); + } + sb.append(contentItem.get("text").asText()); + } + } + + // 检查 isError 标志 + if (result.has("isError") && result.get("isError").asBoolean()) { + throw new McpException("MCP 工具 '" + toolName + "' 执行出错: " + sb); + } + + return sb.toString(); + } + + // 兜底:直接返回 result 的文本形式 + return result.toString(); + + } catch (McpException e) { + throw e; + } catch (Exception e) { + throw new McpException("调用 MCP 工具 '" + toolName + "' 失败: " + e.getMessage(), e); + } + } + + /** + * 读取 MCP 资源 —— 发送 {@code resources/read} 请求。 + * + * @param uri 资源 URI + * @return 资源内容文本 + * @throws McpException 读取失败或资源不存在 + */ + public String readResource(String uri) throws McpException { + if (!initialized) { + throw new McpException("MCP 客户端尚未初始化"); + } + + int id = nextId(); + var request = Map.of( + "jsonrpc", "2.0", + "id", id, + "method", "resources/read", + "params", Map.of("uri", uri) + ); + + try { + log.debug("读取 MCP 资源: {}", uri); + JsonNode response = transport.sendRequest(MAPPER.writeValueAsString(request)); + JsonNode result = response.get("result"); + + if (result == null) { + return ""; + } + + // MCP resources/read 返回 { contents: [{ uri, text/blob }] } + if (result.has("contents")) { + StringBuilder sb = new StringBuilder(); + for (JsonNode contentItem : result.get("contents")) { + if (contentItem.has("text")) { + if (!sb.isEmpty()) { + sb.append("\n"); + } + sb.append(contentItem.get("text").asText()); + } + } + return sb.toString(); + } + + return result.toString(); + + } catch (McpException e) { + throw e; + } catch (Exception e) { + throw new McpException("读取 MCP 资源 '" + uri + "' 失败: " + e.getMessage(), e); + } + } + + /** + * 获取所有已发现的工具(不可变视图)。 + */ + public Collection getTools() { + return Collections.unmodifiableCollection(tools.values()); + } + + /** + * 获取所有已发现的资源(不可变视图)。 + */ + public Collection getResources() { + return Collections.unmodifiableCollection(resources.values()); + } + + /** + * 按名称查找工具。 + * + * @param toolName 工具名称 + * @return 工具定义,若不存在则返回 {@link Optional#empty()} + */ + public Optional findTool(String toolName) { + return Optional.ofNullable(tools.get(toolName)); + } + + /** 获取服务器名称标识 */ + public String getServerName() { + return serverName; + } + + /** 是否已完成初始化 */ + public boolean isInitialized() { + return initialized; + } + + /** 传输层是否仍然连接 */ + public boolean isConnected() { + return transport.isConnected(); + } + + /** 获取服务器能力信息 */ + public JsonNode getServerCapabilities() { + return serverCapabilities; + } + + /** 获取服务器信息 */ + public JsonNode getServerInfo() { + return serverInfo; + } + + @Override + public void close() throws Exception { + initialized = false; + transport.close(); + log.info("MCP 客户端 '{}' 已关闭", serverName); + } + + /** 生成下一个 JSON-RPC 请求 ID */ + private int nextId() { + return idCounter.getAndIncrement(); + } + + // ========== 内部记录类型 ========== + + /** + * MCP 工具定义 —— 服务器暴露的可调用工具。 + * + * @param name 工具名称 + * @param description 工具描述 + * @param inputSchema 输入参数的 JSON Schema + */ + public record McpTool(String name, String description, JsonNode inputSchema) { + } + + /** + * MCP 资源定义 —— 服务器暴露的可读取资源。 + * + * @param uri 资源 URI + * @param name 资源名称 + * @param description 资源描述 + * @param mimeType MIME 类型 + */ + public record McpResource(String uri, String name, String description, String mimeType) { + } +} diff --git a/src/main/java/com/claudecode/mcp/McpException.java b/src/main/java/com/claudecode/mcp/McpException.java new file mode 100644 index 0000000..d444323 --- /dev/null +++ b/src/main/java/com/claudecode/mcp/McpException.java @@ -0,0 +1,46 @@ +package com.claudecode.mcp; + +/** + * MCP 相关异常 —— 统一封装 MCP 通信、协议解析和工具调用中的错误。 + */ +public class McpException extends Exception { + + /** JSON-RPC 错误码(若源自 JSON-RPC error 响应) */ + private final int errorCode; + + public McpException(String message) { + super(message); + this.errorCode = -1; + } + + public McpException(String message, Throwable cause) { + super(message, cause); + this.errorCode = -1; + } + + public McpException(String message, int errorCode) { + super(message); + this.errorCode = errorCode; + } + + public McpException(String message, int errorCode, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + /** + * 获取 JSON-RPC 错误码。 + * + * @return 错误码,若非 JSON-RPC 错误则返回 {@code -1} + */ + public int getErrorCode() { + return errorCode; + } + + /** + * 是否为 JSON-RPC 协议级错误。 + */ + public boolean isJsonRpcError() { + return errorCode != -1; + } +} diff --git a/src/main/java/com/claudecode/mcp/McpManager.java b/src/main/java/com/claudecode/mcp/McpManager.java new file mode 100644 index 0000000..dfdb882 --- /dev/null +++ b/src/main/java/com/claudecode/mcp/McpManager.java @@ -0,0 +1,409 @@ +package com.claudecode.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * MCP 管理器 —— 管理多个 MCP 服务器连接的统一入口。 + *

+ * 职责: + *

    + *
  • 从配置文件加载 MCP 服务器定义
  • + *
  • 管理服务器连接的生命周期(连接、断开、重连)
  • + *
  • 聚合所有服务器的工具和资源供上层使用
  • + *
  • 路由工具调用到正确的服务器
  • + *
+ *

+ * 配置文件格式({@code mcp.json}): + *

{@code
+ * {
+ *   "servers": {
+ *     "server-name": {
+ *       "command": "npx",
+ *       "args": ["-y", "@modelcontextprotocol/server-filesystem"],
+ *       "env": { "KEY": "VALUE" }
+ *     }
+ *   }
+ * }
+ * }
+ * + * @see McpClient + * @see StdioTransport + */ +public class McpManager implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(McpManager.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** 全局配置文件路径:~/.claude-code-java/mcp.json */ + private static final String GLOBAL_CONFIG = ".claude-code-java/mcp.json"; + + /** 项目级配置文件名 */ + private static final String PROJECT_CONFIG = ".mcp.json"; + + /** 已连接的 MCP 客户端:serverName -> McpClient */ + private final Map clients = new ConcurrentHashMap<>(); + + /** 工具名称到服务器名称的映射:toolName -> serverName(用于路由调用) */ + private final Map toolToServer = new ConcurrentHashMap<>(); + + /** + * 从配置文件加载并连接所有 MCP 服务器。 + *

+ * 优先级: + *

    + *
  1. 项目级配置文件:当前工作目录下的 {@code .mcp.json}
  2. + *
  3. 全局配置文件:{@code ~/.claude-code-java/mcp.json}
  4. + *
+ * 两个配置文件中的服务器会合并加载。 + */ + public void loadFromConfig() { + // 项目级配置 + Path projectConfig = Path.of(System.getProperty("user.dir"), PROJECT_CONFIG); + if (Files.exists(projectConfig)) { + loadConfigFile(projectConfig, "项目级"); + } + + // 全局配置 + Path globalConfig = Path.of(System.getProperty("user.home"), GLOBAL_CONFIG); + if (Files.exists(globalConfig)) { + loadConfigFile(globalConfig, "全局"); + } + + if (clients.isEmpty()) { + log.debug("未找到 MCP 配置文件或无服务器定义"); + } + } + + /** + * 加载单个配置文件中的 MCP 服务器定义。 + */ + private void loadConfigFile(Path configPath, String label) { + log.info("加载 {} MCP 配置: {}", label, configPath); + + try { + String content = Files.readString(configPath); + JsonNode root = MAPPER.readTree(content); + + JsonNode serversNode = root.get("servers"); + if (serversNode == null || !serversNode.isObject()) { + log.warn("{} 配置文件缺少 'servers' 字段: {}", label, configPath); + return; + } + + Iterator> fields = serversNode.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + String name = entry.getKey(); + JsonNode serverDef = entry.getValue(); + + // 跳过已存在的服务器(项目级优先于全局) + if (clients.containsKey(name)) { + log.debug("MCP 服务器 '{}' 已连接,跳过 {} 配置中的重复定义", name, label); + continue; + } + + try { + String command = serverDef.get("command").asText(); + + List args = new ArrayList<>(); + if (serverDef.has("args") && serverDef.get("args").isArray()) { + for (JsonNode arg : serverDef.get("args")) { + args.add(arg.asText()); + } + } + + Map env = new HashMap<>(); + if (serverDef.has("env") && serverDef.get("env").isObject()) { + Iterator> envFields = serverDef.get("env").fields(); + while (envFields.hasNext()) { + Map.Entry envEntry = envFields.next(); + env.put(envEntry.getKey(), envEntry.getValue().asText()); + } + } + + connect(name, command, args, env); + } catch (Exception e) { + log.error("从配置连接 MCP 服务器 '{}' 失败: {}", name, e.getMessage()); + } + } + } catch (IOException e) { + log.error("读取 MCP 配置文件失败: {}", configPath, e); + } + } + + /** + * 连接单个 MCP 服务器。 + * + * @param name 服务器名称标识 + * @param command 服务器可执行命令 + * @param args 命令参数列表 + * @param env 环境变量(可为 {@code null}) + * @return 已初始化的 MCP 客户端 + * @throws McpException 连接或初始化失败 + */ + public McpClient connect(String name, String command, List args, Map env) + throws McpException { + // 如果已存在,先断开 + if (clients.containsKey(name)) { + log.info("MCP 服务器 '{}' 已存在,先断开旧连接", name); + try { + disconnect(name); + } catch (Exception e) { + log.warn("断开旧 MCP 连接 '{}' 时异常: {}", name, e.getMessage()); + } + } + + log.info("连接 MCP 服务器 '{}': {} {}", name, command, String.join(" ", args)); + + // 创建传输层并启动 + StdioTransport transport = new StdioTransport(command, args, env); + transport.start(); + + // 创建客户端并初始化 + McpClient client = new McpClient(name, transport); + client.initialize(); + + // 注册客户端 + clients.put(name, client); + + // 建立工具 -> 服务器的映射 + for (McpClient.McpTool tool : client.getTools()) { + String existingServer = toolToServer.get(tool.name()); + if (existingServer != null) { + log.warn("MCP 工具名称冲突: '{}' 同时存在于服务器 '{}' 和 '{}',使用后者", + tool.name(), existingServer, name); + } + toolToServer.put(tool.name(), name); + } + + log.info("MCP 服务器 '{}' 连接成功", name); + return client; + } + + /** + * 断开 MCP 服务器连接。 + * + * @param name 服务器名称 + * @throws McpException 断开失败 + */ + public void disconnect(String name) throws McpException { + McpClient client = clients.remove(name); + if (client == null) { + throw new McpException("MCP 服务器 '" + name + "' 不存在"); + } + + // 清理工具映射 + toolToServer.entrySet().removeIf(entry -> entry.getValue().equals(name)); + + try { + client.close(); + log.info("MCP 服务器 '{}' 已断开", name); + } catch (Exception e) { + throw new McpException("断开 MCP 服务器 '" + name + "' 时异常: " + e.getMessage(), e); + } + } + + /** + * 获取所有已连接的客户端(不可变视图)。 + */ + public Map getClients() { + return Collections.unmodifiableMap(clients); + } + + /** + * 获取指定服务器的客户端。 + * + * @param name 服务器名称 + * @return 客户端实例,若不存在则返回 {@link Optional#empty()} + */ + public Optional getClient(String name) { + return Optional.ofNullable(clients.get(name)); + } + + /** + * 获取所有 MCP 工具(合并所有服务器的工具)。 + * + * @return 所有已发现的工具列表 + */ + public List getAllTools() { + return clients.values().stream() + .filter(McpClient::isInitialized) + .flatMap(client -> client.getTools().stream()) + .collect(Collectors.toList()); + } + + /** + * 获取指定服务器的工具。 + * + * @param serverName 服务器名称 + * @return 工具列表,若服务器不存在则返回空列表 + */ + public List getServerTools(String serverName) { + McpClient client = clients.get(serverName); + if (client == null || !client.isInitialized()) { + return List.of(); + } + return List.copyOf(client.getTools()); + } + + /** + * 获取所有 MCP 资源(合并所有服务器的资源)。 + * + * @return 所有已发现的资源列表 + */ + public List getAllResources() { + return clients.values().stream() + .filter(McpClient::isInitialized) + .flatMap(client -> client.getResources().stream()) + .collect(Collectors.toList()); + } + + /** + * 获取指定服务器的资源。 + * + * @param serverName 服务器名称 + * @return 资源列表,若服务器不存在则返回空列表 + */ + public List getServerResources(String serverName) { + McpClient client = clients.get(serverName); + if (client == null || !client.isInitialized()) { + return List.of(); + } + return List.copyOf(client.getResources()); + } + + /** + * 调用 MCP 工具 —— 自动路由到拥有该工具的服务器。 + * + * @param toolName 工具名称 + * @param args 工具参数 + * @return 工具执行结果 + * @throws McpException 工具不存在或调用失败 + */ + public String callTool(String toolName, Map args) throws McpException { + String serverName = toolToServer.get(toolName); + if (serverName == null) { + throw new McpException("未找到 MCP 工具: " + toolName); + } + return callTool(serverName, toolName, args); + } + + /** + * 调用指定服务器的 MCP 工具。 + * + * @param serverName 服务器名称 + * @param toolName 工具名称 + * @param args 工具参数 + * @return 工具执行结果 + * @throws McpException 服务器不存在或调用失败 + */ + public String callTool(String serverName, String toolName, Map args) + throws McpException { + McpClient client = clients.get(serverName); + if (client == null) { + throw new McpException("MCP 服务器 '" + serverName + "' 不存在"); + } + if (!client.isInitialized()) { + throw new McpException("MCP 服务器 '" + serverName + "' 尚未初始化"); + } + return client.callTool(toolName, args); + } + + /** + * 查找工具所属的服务器名称。 + * + * @param toolName 工具名称 + * @return 服务器名称,若不存在则返回 {@link Optional#empty()} + */ + public Optional findServerForTool(String toolName) { + return Optional.ofNullable(toolToServer.get(toolName)); + } + + /** + * 重新加载配置文件并重连所有服务器。 + *

+ * 先断开所有已有连接,再重新加载配置。 + */ + public void reload() { + log.info("重新加载 MCP 配置..."); + + // 断开所有现有连接 + List serverNames = new ArrayList<>(clients.keySet()); + for (String name : serverNames) { + try { + disconnect(name); + } catch (Exception e) { + log.warn("重载时断开 MCP 服务器 '{}' 失败: {}", name, e.getMessage()); + } + } + + // 重新加载 + loadFromConfig(); + log.info("MCP 配置重载完成: {} 个服务器已连接", clients.size()); + } + + /** + * 获取状态摘要(用于 /mcp 命令或状态显示)。 + * + * @return 格式化的状态摘要文本 + */ + public String getSummary() { + if (clients.isEmpty()) { + return " 无已连接的 MCP 服务器"; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : clients.entrySet()) { + String name = entry.getKey(); + McpClient client = entry.getValue(); + + String status; + if (client.isConnected() && client.isInitialized()) { + status = "✅ 已连接"; + } else if (client.isConnected()) { + status = "🔄 连接中"; + } else { + status = "❌ 已断开"; + } + + sb.append(String.format(" %-20s %s (%d 工具, %d 资源)%n", + name, status, client.getTools().size(), client.getResources().size())); + } + return sb.toString().stripTrailing(); + } + + @Override + public void close() throws Exception { + log.info("关闭所有 MCP 连接..."); + List errors = new ArrayList<>(); + + for (Map.Entry entry : clients.entrySet()) { + try { + entry.getValue().close(); + } catch (Exception e) { + errors.add(e); + log.error("关闭 MCP 服务器 '{}' 时异常: {}", entry.getKey(), e.getMessage()); + } + } + clients.clear(); + toolToServer.clear(); + + if (!errors.isEmpty()) { + McpException ex = new McpException("关闭 MCP 管理器时有 " + errors.size() + " 个错误"); + errors.forEach(ex::addSuppressed); + throw ex; + } + + log.info("所有 MCP 连接已关闭"); + } +} diff --git a/src/main/java/com/claudecode/mcp/McpTransport.java b/src/main/java/com/claudecode/mcp/McpTransport.java new file mode 100644 index 0000000..2f70015 --- /dev/null +++ b/src/main/java/com/claudecode/mcp/McpTransport.java @@ -0,0 +1,43 @@ +package com.claudecode.mcp; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * MCP 传输层接口 —— 抽象不同的通信方式(StdIO / SSE / HTTP)。 + *

+ * MCP 协议底层基于 JSON-RPC 2.0,传输层负责将 JSON-RPC 消息 + * 发送给 MCP 服务器并接收响应。不同传输实现(如 StdIO 子进程、 + * HTTP+SSE 远程连接)均通过此接口统一暴露。 + * + * @see StdioTransport + */ +public interface McpTransport extends AutoCloseable { + + /** + * 发送 JSON-RPC 请求并等待响应。 + *

+ * 实现应根据请求中的 {@code id} 字段将响应与请求关联。 + * + * @param jsonRpcRequest 完整的 JSON-RPC 2.0 请求字符串 + * @return 服务器返回的 JSON-RPC 响应节点 + * @throws McpException 通信异常或超时 + */ + JsonNode sendRequest(String jsonRpcRequest) throws McpException; + + /** + * 发送 JSON-RPC 通知(无需响应)。 + *

+ * 通知消息不包含 {@code id} 字段,服务器无需回复。 + * + * @param jsonRpcNotification 完整的 JSON-RPC 2.0 通知字符串 + * @throws McpException 通信异常 + */ + void sendNotification(String jsonRpcNotification) throws McpException; + + /** + * 传输层是否已连接且可用。 + * + * @return 如果底层连接仍然活跃则返回 {@code true} + */ + boolean isConnected(); +} diff --git a/src/main/java/com/claudecode/mcp/StdioTransport.java b/src/main/java/com/claudecode/mcp/StdioTransport.java new file mode 100644 index 0000000..91d08a3 --- /dev/null +++ b/src/main/java/com/claudecode/mcp/StdioTransport.java @@ -0,0 +1,324 @@ +package com.claudecode.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.*; + +/** + * 基于 StdIO 的 MCP 传输实现 —— 通过子进程的 stdin/stdout 进行 JSON-RPC 通信。 + *

+ * 工作原理: + *

    + *
  1. 启动外部 MCP 服务器进程(如 {@code npx -y @modelcontextprotocol/server-filesystem})
  2. + *
  3. 通过进程的 stdin 发送 JSON-RPC 消息(每行一条 JSON)
  4. + *
  5. 通过独立读线程从 stdout 异步读取响应
  6. + *
  7. 使用 {@link CompletableFuture} 按 {@code id} 字段进行请求-响应关联
  8. + *
+ *

+ * 消息分隔:每条 JSON-RPC 消息占一行,以 {@code \n} 分隔。 + */ +public class StdioTransport implements McpTransport { + + private static final Logger log = LoggerFactory.getLogger(StdioTransport.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** 请求默认超时时间(秒) */ + private static final int DEFAULT_TIMEOUT_SECONDS = 30; + + /** MCP 服务器子进程 */ + private Process process; + + /** 子进程 stdin 写入流 */ + private BufferedWriter processStdin; + + /** 异步读取线程 */ + private Thread readerThread; + + /** 待匹配的请求:id -> CompletableFuture */ + private final ConcurrentHashMap> pendingRequests = + new ConcurrentHashMap<>(); + + /** 启动命令 */ + private final String command; + + /** 启动参数 */ + private final List args; + + /** 环境变量(可选) */ + private final Map env; + + /** 连接状态 */ + private volatile boolean connected = false; + + /** + * 创建 StdIO 传输实例。 + * + * @param command 服务器可执行命令(如 "npx") + * @param args 命令参数列表 + * @param env 额外的环境变量,可为 {@code null} + */ + public StdioTransport(String command, List args, Map env) { + this.command = command; + this.args = args != null ? List.copyOf(args) : List.of(); + this.env = env != null ? Map.copyOf(env) : Map.of(); + } + + /** + * 启动 MCP 服务器子进程并开始监听 stdout。 + * + * @throws McpException 进程启动失败 + */ + public void start() throws McpException { + try { + // 构建进程命令行 + var cmdList = new java.util.ArrayList(); + cmdList.add(command); + cmdList.addAll(args); + + log.info("启动 MCP 服务器进程: {}", String.join(" ", cmdList)); + + ProcessBuilder pb = new ProcessBuilder(cmdList); + pb.redirectErrorStream(false); // stderr 单独处理 + + // 设置环境变量 + if (!env.isEmpty()) { + pb.environment().putAll(env); + } + + process = pb.start(); + + // 初始化 stdin 写入器 + processStdin = new BufferedWriter( + new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); + + // 启动 stdout 异步读取线程 + readerThread = Thread.ofVirtual().name("mcp-stdio-reader").start(this::readLoop); + + // 启动 stderr 日志线程(仅记录日志,不参与协议通信) + Thread.ofVirtual().name("mcp-stdio-stderr").start(this::stderrLoop); + + connected = true; + log.info("MCP 服务器进程已启动 (PID: {})", process.pid()); + + } catch (IOException e) { + throw new McpException("启动 MCP 服务器进程失败: " + e.getMessage(), e); + } + } + + /** + * stdout 读取循环 —— 逐行读取 JSON-RPC 响应并分发到对应的 Future。 + */ + private void readLoop() { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) { + continue; + } + + try { + JsonNode message = MAPPER.readTree(line); + handleMessage(message); + } catch (Exception e) { + log.warn("解析 MCP 响应失败: {}", line, e); + } + } + } catch (IOException e) { + if (connected) { + log.warn("MCP stdout 读取中断: {}", e.getMessage()); + } + } finally { + connected = false; + // 清理所有等待中的请求 + pendingRequests.forEach((id, future) -> + future.completeExceptionally(new McpException("MCP 连接已断开"))); + pendingRequests.clear(); + } + } + + /** + * stderr 读取循环 —— 将服务器 stderr 输出记录为日志。 + */ + private void stderrLoop() { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { + + String line; + while ((line = reader.readLine()) != null) { + log.debug("[MCP stderr] {}", line); + } + } catch (IOException e) { + // stderr 读取结束,忽略 + } + } + + /** + * 处理从 stdout 读取的 JSON-RPC 消息。 + *

+ * 若消息包含 {@code id} 字段,则匹配到对应的待处理请求; + * 若消息为通知(无 {@code id}),则记录日志。 + */ + private void handleMessage(JsonNode message) { + // 检查是否有 id 字段(响应消息) + JsonNode idNode = message.get("id"); + if (idNode != null && !idNode.isNull()) { + Object id; + if (idNode.isNumber()) { + id = idNode.asInt(); + } else { + id = idNode.asText(); + } + + CompletableFuture future = pendingRequests.remove(id); + if (future != null) { + future.complete(message); + } else { + log.warn("收到未匹配的 MCP 响应 (id={}): {}", id, message); + } + } else { + // 服务器主动通知(如 notifications/tools/list_changed) + String method = message.has("method") ? message.get("method").asText() : "unknown"; + log.debug("收到 MCP 服务器通知: {}", method); + } + } + + @Override + public JsonNode sendRequest(String jsonRpcRequest) throws McpException { + if (!connected) { + throw new McpException("MCP 传输层未连接"); + } + + try { + // 解析出请求 id + JsonNode requestNode = MAPPER.readTree(jsonRpcRequest); + JsonNode idNode = requestNode.get("id"); + if (idNode == null || idNode.isNull()) { + throw new McpException("JSON-RPC 请求缺少 id 字段"); + } + + Object id; + if (idNode.isNumber()) { + id = idNode.asInt(); + } else { + id = idNode.asText(); + } + + // 注册 Future + CompletableFuture future = new CompletableFuture<>(); + pendingRequests.put(id, future); + + // 写入 stdin(一行 JSON + 换行符) + synchronized (processStdin) { + processStdin.write(jsonRpcRequest); + processStdin.newLine(); + processStdin.flush(); + } + + log.debug("发送 MCP 请求 (id={}): {}", id, truncate(jsonRpcRequest, 200)); + + // 等待响应 + JsonNode response = future.get(DEFAULT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + // 检查 JSON-RPC error + JsonNode errorNode = response.get("error"); + if (errorNode != null && !errorNode.isNull()) { + int code = errorNode.has("code") ? errorNode.get("code").asInt() : -1; + String msg = errorNode.has("message") ? errorNode.get("message").asText() : "未知错误"; + throw new McpException("MCP 服务器返回错误: " + msg, code); + } + + return response; + + } catch (McpException e) { + throw e; + } catch (TimeoutException e) { + throw new McpException("MCP 请求超时 (" + DEFAULT_TIMEOUT_SECONDS + "s)", e); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof McpException mcp) { + throw mcp; + } + throw new McpException("MCP 请求执行异常: " + cause.getMessage(), cause); + } catch (Exception e) { + throw new McpException("MCP 请求发送失败: " + e.getMessage(), e); + } + } + + @Override + public void sendNotification(String jsonRpcNotification) throws McpException { + if (!connected) { + throw new McpException("MCP 传输层未连接"); + } + + try { + synchronized (processStdin) { + processStdin.write(jsonRpcNotification); + processStdin.newLine(); + processStdin.flush(); + } + log.debug("发送 MCP 通知: {}", truncate(jsonRpcNotification, 200)); + } catch (IOException e) { + throw new McpException("MCP 通知发送失败: " + e.getMessage(), e); + } + } + + @Override + public boolean isConnected() { + return connected && process != null && process.isAlive(); + } + + @Override + public void close() throws Exception { + connected = false; + log.info("关闭 MCP StdIO 传输..."); + + // 关闭 stdin(通知服务器退出) + if (processStdin != null) { + try { + processStdin.close(); + } catch (IOException e) { + log.debug("关闭 stdin 时异常: {}", e.getMessage()); + } + } + + // 等待进程退出 + if (process != null && process.isAlive()) { + boolean exited = process.waitFor(5, TimeUnit.SECONDS); + if (!exited) { + log.warn("MCP 服务器进程未在 5s 内退出,强制终止"); + process.destroyForcibly(); + process.waitFor(3, TimeUnit.SECONDS); + } + } + + // 中断读取线程 + if (readerThread != null && readerThread.isAlive()) { + readerThread.interrupt(); + } + + // 清理待处理请求 + pendingRequests.forEach((id, future) -> + future.completeExceptionally(new McpException("MCP 传输已关闭"))); + pendingRequests.clear(); + + log.info("MCP StdIO 传输已关闭"); + } + + /** + * 截断字符串用于日志输出。 + */ + private static String truncate(String s, int maxLen) { + if (s == null) return "null"; + return s.length() <= maxLen ? s : s.substring(0, maxLen) + "..."; + } +} diff --git a/src/main/java/com/claudecode/plugin/OutputStylePlugin.java b/src/main/java/com/claudecode/plugin/OutputStylePlugin.java new file mode 100644 index 0000000..621fb05 --- /dev/null +++ b/src/main/java/com/claudecode/plugin/OutputStylePlugin.java @@ -0,0 +1,215 @@ +package com.claudecode.plugin; + +import com.claudecode.command.CommandContext; +import com.claudecode.command.SlashCommand; +import com.claudecode.console.AnsiStyle; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +/** + * 输出样式插件 —— 提供 /style 命令来切换输出样式。 + *

+ * 这是一个内建的示例插件,演示了插件系统的使用方式。 + * 用户可以通过 /style 命令在不同的输出样式之间切换。 + * + *

可用样式

+ *
    + *
  • default —— 默认彩色输出,包含 ANSI 颜色和格式
  • + *
  • minimal —— 精简输出,无颜色无装饰
  • + *
  • verbose —— 详细输出,包含额外的调试和时间戳信息
  • + *
  • markdown —— 纯 Markdown 输出,适合导出和分享
  • + *
+ * + *

线程安全

+ *

当前样式使用 {@link AtomicReference} 存储,支持并发读写。

+ */ +public class OutputStylePlugin implements Plugin { + + /** 所有支持的样式名称 */ + private static final Set SUPPORTED_STYLES = Set.of( + "default", "minimal", "verbose", "markdown" + ); + + /** 当前激活的输出样式,使用原子引用保证线程安全 */ + private final AtomicReference currentStyle = new AtomicReference<>("default"); + + /** 插件上下文引用 */ + private PluginContext context; + + @Override + public String id() { + return "output-style"; + } + + @Override + public String name() { + return "Output Style"; + } + + @Override + public String version() { + return "1.0.0"; + } + + @Override + public String description() { + return "自定义输出样式"; + } + + @Override + public void initialize(PluginContext context) { + this.context = context; + context.getLogger().info("输出样式插件已初始化,当前样式: {}", currentStyle.get()); + } + + @Override + public List getCommands() { + return List.of(new StyleCommand()); + } + + /** + * 获取当前激活的输出样式名称。 + * + * @return 样式名称("default"、"minimal"、"verbose" 或 "markdown") + */ + public String getCurrentStyle() { + return currentStyle.get(); + } + + /** + * 以编程方式设置输出样式。 + * + * @param style 样式名称 + * @return 是否设置成功(样式名称有效) + */ + public boolean setStyle(String style) { + if (style != null && SUPPORTED_STYLES.contains(style.toLowerCase())) { + currentStyle.set(style.toLowerCase()); + return true; + } + return false; + } + + @Override + public void destroy() { + if (context != null) { + context.getLogger().info("输出样式插件已销毁"); + } + } + + // ======================================================================== + // 内部类:/style 命令 + // ======================================================================== + + /** + * /style 命令 —— 查看或切换输出样式。 + *

+ * 用法: + *

    + *
  • {@code /style} —— 显示当前样式和所有可用样式
  • + *
  • {@code /style } —— 切换到指定样式
  • + *
+ */ + private class StyleCommand implements SlashCommand { + + @Override + public String name() { + return "style"; + } + + @Override + public String description() { + return "Switch output style (default/minimal/verbose/markdown)"; + } + + @Override + public String execute(String args, CommandContext commandContext) { + String trimmed = (args == null) ? "" : args.trim().toLowerCase(); + + // 无参数:显示当前样式和所有可用选项 + if (trimmed.isEmpty()) { + return showStyles(); + } + + // 切换样式 + if (!SUPPORTED_STYLES.contains(trimmed)) { + return AnsiStyle.red(" ✗ 未知样式: " + trimmed) + "\n" + + AnsiStyle.dim(" 可用样式: default, minimal, verbose, markdown"); + } + + String oldStyle = currentStyle.getAndSet(trimmed); + + // 将当前样式存入 ToolContext 共享状态,供其他组件读取 + if (commandContext.agentLoop() != null) { + try { + commandContext.agentLoop().getToolContext().set("OUTPUT_STYLE", trimmed); + } catch (Exception ignored) { + // 兼容性处理 + } + } + + if (context != null) { + context.getLogger().info("输出样式切换: {} → {}", oldStyle, trimmed); + } + + return AnsiStyle.green(" ✓ 输出样式已切换: ") + + AnsiStyle.bold(oldStyle) + + " → " + + AnsiStyle.bold(AnsiStyle.cyan(trimmed)) + + "\n" + getStyleDescription(trimmed); + } + + /** + * 显示当前样式和所有可用样式。 + */ + private String showStyles() { + String active = currentStyle.get(); + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(AnsiStyle.bold(" 🎨 Output Styles")).append("\n"); + sb.append(" ").append("─".repeat(40)).append("\n\n"); + + for (String style : List.of("default", "minimal", "verbose", "markdown")) { + boolean isActive = style.equals(active); + String indicator = isActive ? AnsiStyle.green("● ") : AnsiStyle.dim("○ "); + String styleName = isActive + ? AnsiStyle.bold(AnsiStyle.cyan(style)) + : style; + String desc = getStyleBrief(style); + sb.append(" ").append(indicator).append(styleName) + .append(AnsiStyle.dim(" - " + desc)).append("\n"); + } + + sb.append("\n").append(AnsiStyle.dim(" 用法: /style ")).append("\n"); + return sb.toString(); + } + + /** + * 获取样式的简短描述。 + */ + private String getStyleBrief(String style) { + return switch (style) { + case "default" -> "默认彩色输出"; + case "minimal" -> "精简输出,无颜色"; + case "verbose" -> "详细输出,含调试信息"; + case "markdown" -> "纯 Markdown 输出"; + default -> "未知样式"; + }; + } + + /** + * 获取切换后的样式说明。 + */ + private String getStyleDescription(String style) { + return switch (style) { + case "default" -> AnsiStyle.dim(" 使用 ANSI 颜色和格式的标准输出模式"); + case "minimal" -> AnsiStyle.dim(" 无颜色无装饰的精简输出,适合管道和日志"); + case "verbose" -> AnsiStyle.dim(" 包含时间戳、调试信息的详细输出模式"); + case "markdown" -> AnsiStyle.dim(" 纯 Markdown 格式,适合导出到文档"); + default -> ""; + }; + } + } +} diff --git a/src/main/java/com/claudecode/plugin/Plugin.java b/src/main/java/com/claudecode/plugin/Plugin.java new file mode 100644 index 0000000..108223c --- /dev/null +++ b/src/main/java/com/claudecode/plugin/Plugin.java @@ -0,0 +1,117 @@ +package com.claudecode.plugin; + +import com.claudecode.tool.Tool; +import com.claudecode.command.SlashCommand; + +import java.util.List; + +/** + * 插件接口 —— 对应 claude-code 中的 plugins/ 模块。 + *

+ * 插件可以提供额外的工具和命令,扩展核心功能。 + * 每个插件有独立的生命周期:初始化 → 运行 → 销毁。 + * + *

实现指南

+ *
    + *
  • 每个插件必须有唯一的 {@link #id()},推荐使用 kebab-case 格式(如 "my-plugin")
  • + *
  • {@link #initialize(PluginContext)} 在加载时调用一次,用于初始化资源
  • + *
  • {@link #getTools()} 和 {@link #getCommands()} 返回插件提供的扩展
  • + *
  • {@link #destroy()} 在卸载时调用,释放资源
  • + *
+ * + *

JAR 插件打包

+ *

+ * 打包为 JAR 时需要在 {@code META-INF/MANIFEST.MF} 中指定: + *

+ * Plugin-Class: com.example.MyPlugin
+ * 
+ */ +public interface Plugin { + + /** + * 插件唯一标识。 + *

+ * 推荐使用 kebab-case 格式,如 "output-style"、"git-helper"。 + * 标识在整个应用生命周期内必须唯一。 + * + * @return 非空的插件标识字符串 + */ + String id(); + + /** + * 插件显示名称。 + * + * @return 人类可读的插件名称 + */ + String name(); + + /** + * 插件版本号。 + *

+ * 推荐使用语义化版本号(SemVer),如 "1.0.0"。 + * + * @return 版本号字符串 + */ + String version(); + + /** + * 插件功能描述。 + * + * @return 简短的功能描述 + */ + String description(); + + /** + * 初始化插件。 + *

+ * 在插件加载后立即调用,用于: + *

    + *
  • 初始化内部状态和资源
  • + *
  • 读取配置
  • + *
  • 建立外部连接
  • + *
+ * 如果初始化失败应抛出异常,插件将不会被注册。 + * + * @param context 插件上下文,提供访问应用核心功能的接口 + * @throws RuntimeException 初始化失败时抛出 + */ + void initialize(PluginContext context); + + /** + * 获取插件提供的工具列表。 + *

+ * 返回的工具将被注册到 {@link com.claudecode.tool.ToolRegistry}, + * 可供 LLM 调用。 + * + * @return 工具列表,默认为空列表 + */ + default List getTools() { + return List.of(); + } + + /** + * 获取插件提供的斜杠命令列表。 + *

+ * 返回的命令将被注册到 {@link com.claudecode.command.CommandRegistry}, + * 用户可通过 /{@code name} 调用。 + * + * @return 命令列表,默认为空列表 + */ + default List getCommands() { + return List.of(); + } + + /** + * 销毁插件,释放资源。 + *

+ * 在插件卸载或应用关闭时调用。实现应: + *

    + *
  • 关闭打开的连接和流
  • + *
  • 停止后台线程
  • + *
  • 释放外部资源
  • + *
+ * 此方法不应抛出异常。 + */ + default void destroy() { + } +} diff --git a/src/main/java/com/claudecode/plugin/PluginContext.java b/src/main/java/com/claudecode/plugin/PluginContext.java new file mode 100644 index 0000000..0805443 --- /dev/null +++ b/src/main/java/com/claudecode/plugin/PluginContext.java @@ -0,0 +1,74 @@ +package com.claudecode.plugin; + +import com.claudecode.tool.ToolContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Objects; + +/** + * 插件上下文 —— 为插件提供访问应用核心功能的接口。 + *

+ * 每个插件在初始化时会收到一个专属的 {@code PluginContext} 实例, + * 包含: + *

    + *
  • {@link ToolContext} —— 工具执行上下文(工作目录、共享状态)
  • + *
  • 工作目录路径
  • + *
  • 插件专属日志器 —— 日志前缀为 "plugin.{pluginId}"
  • + *
+ * + *

此类的引用字段在构造后不可变(shallowly immutable),但持有的 + * {@link ToolContext} 本身是可变的,多个插件共享同一实例。

+ */ +public class PluginContext { + + private final ToolContext toolContext; + private final String workDir; + private final Logger pluginLogger; + + /** + * 创建插件上下文。 + * + * @param toolContext 工具执行上下文,不能为 null + * @param workDir 当前工作目录路径,不能为 null + * @param pluginId 插件标识,用于创建专属日志器,不能为 null + * @throws NullPointerException 如果任何参数为 null + */ + public PluginContext(ToolContext toolContext, String workDir, String pluginId) { + this.toolContext = Objects.requireNonNull(toolContext, "toolContext 不能为 null"); + this.workDir = Objects.requireNonNull(workDir, "workDir 不能为 null"); + Objects.requireNonNull(pluginId, "pluginId 不能为 null"); + this.pluginLogger = LoggerFactory.getLogger("plugin." + pluginId); + } + + /** + * 获取工具执行上下文。 + *

+ * 通过 ToolContext 可以访问工作目录、模型信息和共享状态。 + * + * @return 工具执行上下文 + */ + public ToolContext getToolContext() { + return toolContext; + } + + /** + * 获取当前工作目录路径。 + * + * @return 工作目录的绝对路径字符串 + */ + public String getWorkDir() { + return workDir; + } + + /** + * 获取插件专属日志器。 + *

+ * 日志器名称格式为 "plugin.{pluginId}",方便在日志中区分不同插件的输出。 + * + * @return SLF4J Logger 实例 + */ + public Logger getLogger() { + return pluginLogger; + } +} diff --git a/src/main/java/com/claudecode/plugin/PluginManager.java b/src/main/java/com/claudecode/plugin/PluginManager.java new file mode 100644 index 0000000..9bb8c48 --- /dev/null +++ b/src/main/java/com/claudecode/plugin/PluginManager.java @@ -0,0 +1,353 @@ +package com.claudecode.plugin; + +import com.claudecode.command.CommandRegistry; +import com.claudecode.command.SlashCommand; +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; +import com.claudecode.tool.ToolRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.jar.JarFile; +import java.util.jar.Manifest; + +/** + * 插件管理器 —— 负责插件的加载、注册和生命周期管理。 + *

+ * 对应 claude-code 中的插件加载机制,支持从外部 JAR 文件动态加载插件。 + * + *

插件加载方式

+ *
    + *
  1. 全局插件:从 {@code ~/.claude-code-java/plugins/} 目录加载 JAR 文件
  2. + *
  3. 项目插件:从项目 {@code .claude-code/plugins/} 目录加载 JAR 文件
  4. + *
+ * + *

JAR 插件要求

+ *
    + *
  • {@code META-INF/MANIFEST.MF} 中必须包含 {@code Plugin-Class} 属性
  • + *
  • 指定的类必须实现 {@link Plugin} 接口
  • + *
  • 类必须有无参公共构造器
  • + *
+ * + *

线程安全

+ *

+ * 插件列表使用 {@link CopyOnWriteArrayList} 存储,支持并发读取。 + * 加载和卸载操作本身不是原子的,建议在应用启动阶段或由单一线程执行。 + *

+ * + * @see Plugin + * @see PluginContext + */ +public class PluginManager { + + private static final Logger log = LoggerFactory.getLogger(PluginManager.class); + + /** 已加载的插件信息列表,使用 COW 列表保证并发读安全 */ + private final List plugins = new CopyOnWriteArrayList<>(); + + /** 工具执行上下文,传递给每个插件 */ + private final ToolContext toolContext; + + /** 全局插件目录:~/.claude-code-java/plugins/ */ + private final Path globalPluginDir; + + /** 项目插件目录:{project}/.claude-code/plugins/ */ + private final Path projectPluginDir; + + /** + * 创建插件管理器。 + * + * @param toolContext 工具执行上下文,不能为 null + * @throws NullPointerException 如果 toolContext 为 null + */ + public PluginManager(ToolContext toolContext) { + this.toolContext = Objects.requireNonNull(toolContext, "toolContext 不能为 null"); + this.globalPluginDir = Path.of( + System.getProperty("user.home"), ".claude-code-java", "plugins"); + this.projectPluginDir = toolContext.getWorkDir().resolve(".claude-code").resolve("plugins"); + } + + /** + * 扫描并加载所有插件目录中的插件。 + *

+ * 依次扫描全局插件目录和项目插件目录。 + * 加载失败的单个插件不会影响其他插件的加载。 + */ + public void loadAll() { + loadFromDirectory(globalPluginDir, "global"); + loadFromDirectory(projectPluginDir, "project"); + log.info("共加载 {} 个插件", plugins.size()); + } + + /** + * 从指定目录扫描并加载所有 JAR 插件。 + * + * @param dir 插件目录路径 + * @param scope 插件作用域标识("global" 或 "project") + */ + private void loadFromDirectory(Path dir, String scope) { + if (!Files.isDirectory(dir)) { + log.debug("插件目录不存在,跳过: {}", dir); + return; + } + try (var stream = Files.list(dir)) { + stream.filter(p -> p.toString().endsWith(".jar")) + .forEach(jar -> loadJarPlugin(jar, scope)); + } catch (IOException e) { + log.warn("扫描插件目录失败: {}", dir, e); + } + } + + /** + * 加载单个 JAR 插件。 + *

+ * 流程: + *

    + *
  1. 读取 JAR 的 MANIFEST.MF,获取 Plugin-Class 属性
  2. + *
  3. 使用 URLClassLoader 加载插件类
  4. + *
  5. 验证插件类实现了 {@link Plugin} 接口
  6. + *
  7. 实例化并初始化插件
  8. + *
  9. 将插件信息添加到已加载列表
  10. + *
+ * + * @param jarPath JAR 文件路径 + * @param scope 插件作用域 + */ + private void loadJarPlugin(Path jarPath, String scope) { + // 第一步:从 JAR 清单中读取 Plugin-Class 属性(使用 try-with-resources 确保关闭) + String pluginClassName; + try (JarFile jar = new JarFile(jarPath.toFile())) { + Manifest manifest = jar.getManifest(); + pluginClassName = (manifest != null) + ? manifest.getMainAttributes().getValue("Plugin-Class") + : null; + } catch (IOException e) { + log.error("读取 JAR 清单失败: {}", jarPath.getFileName(), e); + return; + } + + if (pluginClassName == null) { + log.warn("JAR {} 缺少 Plugin-Class 属性,跳过", jarPath.getFileName()); + return; + } + + // 第二步:加载并实例化插件(确保失败时关闭 ClassLoader) + URLClassLoader loader = null; + boolean success = false; + try { + // 创建隔离的类加载器,以当前类加载器为父加载器 + loader = new URLClassLoader( + new URL[]{jarPath.toUri().toURL()}, + getClass().getClassLoader() + ); + + Class clazz = loader.loadClass(pluginClassName); + if (!Plugin.class.isAssignableFrom(clazz)) { + log.warn("{} 未实现 Plugin 接口,跳过", pluginClassName); + return; + } + + Plugin plugin = (Plugin) clazz.getDeclaredConstructor().newInstance(); + + // 检查插件 ID 是否重复 + if (findPlugin(plugin.id()) != null) { + log.warn("插件 ID '{}' 已存在,跳过重复加载: {}", plugin.id(), jarPath.getFileName()); + return; + } + + // 创建插件上下文并初始化 + PluginContext ctx = new PluginContext( + toolContext, toolContext.getWorkDir().toString(), plugin.id()); + plugin.initialize(ctx); + + plugins.add(new PluginInfo(plugin, scope, jarPath, loader)); + log.info("加载插件: {} v{} [{}] ({})", plugin.name(), plugin.version(), plugin.id(), scope); + success = true; + + } catch (Exception e) { + log.error("加载插件失败: {}", jarPath.getFileName(), e); + } finally { + // 仅在加载失败时关闭类加载器;成功时由 PluginInfo 持有 + if (!success) { + safeClose(loader); + } + } + } + + /** + * 从指定 JAR 路径加载单个插件(用于运行时动态加载)。 + * + * @param jarPath JAR 文件路径 + * @return 加载成功返回 true,否则返回 false + */ + public boolean loadPlugin(Path jarPath) { + if (!Files.isRegularFile(jarPath) || !jarPath.toString().endsWith(".jar")) { + log.warn("无效的插件路径: {}", jarPath); + return false; + } + loadJarPlugin(jarPath, "dynamic"); + // 通过检查是否有任何插件关联此 JAR 路径来判断是否加载成功 + return plugins.stream().anyMatch(info -> jarPath.equals(info.jarPath())); + } + + /** + * 将所有已加载插件的工具注册到 ToolRegistry。 + * + * @param toolRegistry 工具注册中心 + */ + public void registerTools(ToolRegistry toolRegistry) { + for (PluginInfo info : plugins) { + for (Tool tool : info.plugin().getTools()) { + toolRegistry.register(tool); + log.debug("注册插件工具: {} (来自 {})", tool.name(), info.plugin().name()); + } + } + } + + /** + * 将所有已加载插件的命令注册到 CommandRegistry。 + * + * @param commandRegistry 命令注册中心 + */ + public void registerCommands(CommandRegistry commandRegistry) { + for (PluginInfo info : plugins) { + for (SlashCommand cmd : info.plugin().getCommands()) { + commandRegistry.register(cmd); + log.debug("注册插件命令: /{} (来自 {})", cmd.name(), info.plugin().name()); + } + } + } + + /** + * 卸载指定 ID 的插件。 + *

+ * 调用插件的 {@link Plugin#destroy()} 方法,并关闭其类加载器。 + * 注意:已注册到 ToolRegistry / CommandRegistry 的工具和命令不会自动移除。 + *

+ * 使用 {@link CopyOnWriteArrayList#remove(Object)} 而非迭代器删除, + * 因为 CopyOnWriteArrayList 的迭代器不支持 remove 操作。 + * + * @param pluginId 插件唯一标识 + * @return 卸载成功返回 true,未找到返回 false + */ + public boolean unload(String pluginId) { + for (PluginInfo info : plugins) { + if (info.plugin().id().equals(pluginId)) { + try { + info.plugin().destroy(); + } catch (Exception e) { + log.warn("插件 {} 销毁时异常", pluginId, e); + } + safeClose(info.classLoader()); + plugins.remove(info); // CopyOnWriteArrayList.remove(Object) 是安全的 + log.info("已卸载插件: {} ({})", info.plugin().name(), pluginId); + return true; + } + } + log.warn("未找到插件: {}", pluginId); + return false; + } + + /** + * 获取所有已加载的插件信息(只读快照)。 + * + * @return 不可变的插件信息列表 + */ + public List getPlugins() { + return List.copyOf(plugins); + } + + /** + * 根据 ID 查找已加载的插件信息。 + * + * @param pluginId 插件唯一标识 + * @return 插件信息,未找到返回 null + */ + public PluginInfo findPlugin(String pluginId) { + for (PluginInfo info : plugins) { + if (info.plugin().id().equals(pluginId)) { + return info; + } + } + return null; + } + + /** + * 获取已加载插件的摘要信息。 + * + * @return 格式化的插件列表字符串 + */ + public String getSummary() { + if (plugins.isEmpty()) { + return "No plugins loaded"; + } + StringBuilder sb = new StringBuilder(); + for (PluginInfo info : plugins) { + Plugin p = info.plugin(); + sb.append(String.format(" %s v%s [%s] - %s (tools: %d, commands: %d)%n", + p.name(), p.version(), info.scope(), p.description(), + p.getTools().size(), p.getCommands().size())); + } + return sb.toString(); + } + + /** + * 关闭所有插件并清理资源。 + *

+ * 依次调用每个插件的 {@link Plugin#destroy()} 方法, + * 然后关闭对应的类加载器。此方法应在应用关闭时调用。 + */ + public void shutdown() { + log.info("正在关闭 {} 个插件...", plugins.size()); + for (PluginInfo info : plugins) { + try { + info.plugin().destroy(); + } catch (Exception e) { + log.warn("插件 {} 销毁异常", info.plugin().id(), e); + } + safeClose(info.classLoader()); + } + plugins.clear(); + log.info("所有插件已关闭"); + } + + /** + * 安全关闭 AutoCloseable 资源,异常仅记录 DEBUG 日志。 + * + * @param closeable 要关闭的资源,可以为 null + */ + private void safeClose(AutoCloseable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (Exception e) { + log.debug("关闭资源时异常 ({}): {}", + closeable.getClass().getSimpleName(), e.getMessage()); + } + } + } + + /** + * 插件信息记录 —— 封装已加载插件的元数据。 + * + * @param plugin 插件实例 + * @param scope 插件作用域("global"、"project" 或 "dynamic") + * @param jarPath JAR 文件路径 + * @param classLoader 插件的类加载器,内建插件为 null + */ + public record PluginInfo( + Plugin plugin, + String scope, + Path jarPath, + URLClassLoader classLoader + ) { + } +} diff --git a/src/main/java/com/claudecode/tool/impl/ConfigTool.java b/src/main/java/com/claudecode/tool/impl/ConfigTool.java new file mode 100644 index 0000000..339e42f --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/ConfigTool.java @@ -0,0 +1,235 @@ +package com.claudecode.tool.impl; + +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Config 工具 —— 获取或设置配置值。 + *

+ * 属于 P2 优先级的辅助工具。支持两种存储后端: + *

+ *
    + *
  1. 首选从 ToolContext 中的配置映射(键 "CONFIG_STORE")读写
  2. + *
  3. 回退到 {@link System#getProperty} / {@link System#setProperty}
  4. + *
+ * + *

参数

+ *
    + *
  • action(必填)—— "get" 或 "set"
  • + *
  • key(必填)—— 配置键名
  • + *
  • value(可选,set 时必填)—— 配置值
  • + *
+ * + *

返回

+ *

JSON 格式:get 返回当前值,set 返回确认信息。

+ */ +public class ConfigTool implements Tool { + + /** ToolContext 中配置存储的键名 */ + private static final String CONFIG_STORE_KEY = "CONFIG_STORE"; + + @Override + public String name() { + return "Config"; + } + + @Override + public String description() { + return "Get or set configuration values"; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "description": "操作类型:get(获取)或 set(设置)", + "enum": ["get", "set"] + }, + "key": { + "type": "string", + "description": "配置项的键名" + }, + "value": { + "type": "string", + "description": "配置项的值(仅 set 操作时需要)" + } + }, + "required": ["action", "key"] + }"""; + } + + /** + * Config 工具不是纯只读的(set 操作会修改状态), + * 但出于安全考虑仍标记为 false。 + */ + @Override + public boolean isReadOnly() { + return false; + } + + @Override + public String execute(Map input, ToolContext context) { + // 解析必填参数: action + String action = (String) input.get("action"); + if (action == null || action.isBlank()) { + return errorJson("参数 'action' 是必填项,可选值: get, set"); + } + action = action.trim().toLowerCase(); + + // 解析必填参数: key + String key = (String) input.get("key"); + if (key == null || key.isBlank()) { + return errorJson("参数 'key' 是必填项且不能为空"); + } + + // 获取或初始化配置存储 + @SuppressWarnings("unchecked") + ConcurrentHashMap configStore = + context.getOrDefault(CONFIG_STORE_KEY, null); + + if (configStore == null) { + configStore = new ConcurrentHashMap<>(); + context.set(CONFIG_STORE_KEY, configStore); + } + + return switch (action) { + case "get" -> executeGet(key, configStore); + case "set" -> executeSet(key, input, configStore); + default -> errorJson("无效的 action 值: '" + action + "'。可选值: get, set"); + }; + } + + @Override + public String activityDescription(Map input) { + String action = (String) input.getOrDefault("action", "?"); + String key = (String) input.getOrDefault("key", "?"); + if ("set".equalsIgnoreCase(action)) { + return "⚙️ Setting config: " + key; + } + return "⚙️ Getting config: " + key; + } + + /* ------------------------------------------------------------------ */ + /* get / set 具体实现 */ + /* ------------------------------------------------------------------ */ + + /** + * 执行 get 操作:优先从配置映射读取,回退到系统属性。 + * + * @param key 配置键 + * @param configStore 配置映射 + * @return JSON 格式的结果 + */ + private String executeGet(String key, ConcurrentHashMap configStore) { + // 优先从上下文配置映射获取 + String value = configStore.get(key); + + // 回退到系统属性 + if (value == null) { + value = System.getProperty(key); + } + + if (value == null) { + return """ + { + "action": "get", + "key": "%s", + "value": null, + "found": false, + "message": "配置项 '%s' 未找到" + }""".formatted(escapeJson(key), escapeJson(key)); + } + + return """ + { + "action": "get", + "key": "%s", + "value": "%s", + "found": true + }""".formatted(escapeJson(key), escapeJson(value)); + } + + /** + * 执行 set 操作:同时写入配置映射和系统属性。 + * + * @param key 配置键 + * @param input 输入参数映射 + * @param configStore 配置映射 + * @return JSON 格式的确认 + */ + private String executeSet(String key, Map input, + ConcurrentHashMap configStore) { + String value = (String) input.get("value"); + if (value == null) { + return errorJson("set 操作需要提供 'value' 参数"); + } + + // 获取旧值(用于返回信息) + String oldValue = configStore.get(key); + if (oldValue == null) { + oldValue = System.getProperty(key); + } + + // 写入配置映射 + configStore.put(key, value); + + // 同步写入系统属性(简单实现,生产环境应使用专门的配置管理) + try { + System.setProperty(key, value); + } catch (SecurityException e) { + // 如果没有权限设置系统属性,只使用配置映射即可 + } + + StringBuilder sb = new StringBuilder(); + sb.append("{\n"); + sb.append(" \"action\": \"set\",\n"); + sb.append(" \"key\": \"").append(escapeJson(key)).append("\",\n"); + sb.append(" \"value\": \"").append(escapeJson(value)).append("\",\n"); + + if (oldValue != null) { + sb.append(" \"previous_value\": \"").append(escapeJson(oldValue)).append("\",\n"); + } else { + sb.append(" \"previous_value\": null,\n"); + } + + sb.append(" \"success\": true,\n"); + sb.append(" \"message\": \"配置项 '").append(escapeJson(key)).append("' 已设置\"\n"); + sb.append("}"); + + return sb.toString(); + } + + /* ------------------------------------------------------------------ */ + /* 辅助方法 */ + /* ------------------------------------------------------------------ */ + + /** + * 转义 JSON 特殊字符。 + */ + private String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + /** + * 构建错误 JSON 响应。 + */ + private String errorJson(String message) { + return """ + { + "error": true, + "message": "%s" + }""".formatted(escapeJson(message)); + } +} diff --git a/src/main/java/com/claudecode/tool/impl/McpToolBridge.java b/src/main/java/com/claudecode/tool/impl/McpToolBridge.java new file mode 100644 index 0000000..b10af28 --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/McpToolBridge.java @@ -0,0 +1,187 @@ +package com.claudecode.tool.impl; + +import com.claudecode.mcp.McpClient; +import com.claudecode.mcp.McpException; +import com.claudecode.mcp.McpManager; +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.*; + +/** + * MCP 工具桥接 —— 将 MCP 服务器暴露的远程工具桥接为本地 {@link Tool} 实例。 + *

+ * 使 AI 模型可以像调用本地工具一样调用 MCP 服务器上的工具。 + * 每个 {@code McpToolBridge} 实例对应一个 MCP 服务器上的一个工具。 + *

+ * 桥接流程: + *

    + *
  1. 从 {@link McpClient.McpTool} 提取工具定义(名称、描述、参数 schema)
  2. + *
  3. 实现 {@link Tool} 接口,使其可注册到 {@link com.claudecode.tool.ToolRegistry}
  4. + *
  5. 执行时通过 {@link ToolContext} 中的 {@link McpManager} 路由到对应 MCP 服务器
  6. + *
+ * + * @see McpManager + * @see McpClient.McpTool + */ +public class McpToolBridge implements Tool { + + /** MCP 管理器在 ToolContext 中的存储键 */ + public static final String MCP_MANAGER_KEY = "MCP_MANAGER"; + + /** 工具所属的 MCP 服务器名称 */ + private final String serverName; + + /** MCP 工具名称(在 MCP 服务器上的名称) */ + private final String mcpToolName; + + /** 桥接后的本地工具名称(格式:mcp__{serverName}__{toolName}) */ + private final String bridgedName; + + /** MCP 工具描述 */ + private final String mcpDescription; + + /** MCP 工具的输入参数 JSON Schema(原始 JsonNode) */ + private final JsonNode mcpInputSchema; + + /** 缓存的 inputSchema JSON 字符串 */ + private final String inputSchemaString; + + /** + * 创建 MCP 工具桥接实例。 + * + * @param serverName MCP 服务器名称 + * @param mcpTool MCP 工具定义 + */ + public McpToolBridge(String serverName, McpClient.McpTool mcpTool) { + this.serverName = Objects.requireNonNull(serverName, "服务器名称不能为空"); + Objects.requireNonNull(mcpTool, "MCP 工具定义不能为空"); + + this.mcpToolName = mcpTool.name(); + this.mcpDescription = mcpTool.description(); + this.mcpInputSchema = mcpTool.inputSchema(); + + // 生成桥接名称:mcp__{serverName}__{toolName} + // 使用双下划线分隔,避免与本地工具名称冲突 + this.bridgedName = "mcp__" + sanitizeName(serverName) + "__" + sanitizeName(mcpToolName); + + // 序列化 inputSchema + this.inputSchemaString = buildInputSchema(); + } + + @Override + public String name() { + return bridgedName; + } + + @Override + public String description() { + return String.format("[MCP:%s] %s", serverName, mcpDescription); + } + + @Override + public String inputSchema() { + return inputSchemaString; + } + + /** + * 执行 MCP 远程工具调用。 + *

+ * 通过 {@link ToolContext} 中存储的 {@link McpManager} 实例 + * 路由调用到对应的 MCP 服务器。 + * + * @param input 解析后的输入参数 + * @param context 工具执行上下文(必须包含 {@code MCP_MANAGER} 键) + * @return MCP 工具的执行结果 + */ + @Override + public String execute(Map input, ToolContext context) { + // 从上下文获取 McpManager + McpManager mcpManager = context.get(MCP_MANAGER_KEY); + if (mcpManager == null) { + return "错误: MCP 管理器未在上下文中注册 (key=" + MCP_MANAGER_KEY + ")"; + } + + try { + return mcpManager.callTool(serverName, mcpToolName, input); + } catch (McpException e) { + return "MCP 工具调用失败 [" + serverName + "/" + mcpToolName + "]: " + e.getMessage(); + } + } + + @Override + public boolean isReadOnly() { + // MCP 工具的读写属性未知,保守地标记为非只读 + return false; + } + + @Override + public String activityDescription(Map input) { + return "🔌 [MCP:" + serverName + "] " + mcpToolName; + } + + /** + * 获取 MCP 服务器名称。 + */ + public String getServerName() { + return serverName; + } + + /** + * 获取 MCP 工具原始名称。 + */ + public String getMcpToolName() { + return mcpToolName; + } + + /** + * 从 MCP 工具定义批量创建桥接工具。 + * + * @param serverName 服务器名称 + * @param mcpTools MCP 工具集合 + * @return 桥接工具列表 + */ + public static List createBridges(String serverName, + Collection mcpTools) { + return mcpTools.stream() + .map(tool -> new McpToolBridge(serverName, tool)) + .toList(); + } + + /** + * 构建 inputSchema JSON 字符串。 + *

+ * 如果 MCP 工具提供了 inputSchema,直接使用; + * 否则生成一个接受任意参数的兜底 schema。 + */ + private String buildInputSchema() { + if (mcpInputSchema != null && !mcpInputSchema.isNull()) { + return mcpInputSchema.toString(); + } + // 兜底:接受任意 JSON 对象 + return """ + { + "type": "object", + "properties": {}, + "additionalProperties": true + }"""; + } + + /** + * 清理名称,替换非法字符为下划线。 + * 保留字母、数字、连字符和下划线。 + */ + private static String sanitizeName(String name) { + return name.replaceAll("[^a-zA-Z0-9_-]", "_"); + } + + @Override + public String toString() { + return "McpToolBridge{" + + "server='" + serverName + '\'' + + ", tool='" + mcpToolName + '\'' + + ", bridgedAs='" + bridgedName + '\'' + + '}'; + } +} diff --git a/src/main/java/com/claudecode/tool/impl/TaskCreateTool.java b/src/main/java/com/claudecode/tool/impl/TaskCreateTool.java new file mode 100644 index 0000000..bb2f5b7 --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/TaskCreateTool.java @@ -0,0 +1,183 @@ +package com.claudecode.tool.impl; + +import com.claudecode.core.TaskManager; +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * TaskCreate 工具 —— 创建一个新的后台任务(手动管理模式)。 + *

+ * 对应 claude-code 中的 TaskCreate 命令。创建后任务处于 PENDING 状态, + * 需要通过 TaskUpdate 工具推动状态流转。 + *

+ * + *

参数

+ *
    + *
  • description(必填)—— 任务描述
  • + *
  • metadata(可选)—— JSON 格式的附加元数据字符串
  • + *
+ * + *

返回

+ *

JSON 格式,包含 task_id 与 status 字段。

+ */ +public class TaskCreateTool implements Tool { + + /** ToolContext 中 TaskManager 的存储键 */ + private static final String TASK_MANAGER_KEY = "TASK_MANAGER"; + + @Override + public String name() { + return "TaskCreate"; + } + + @Override + public String description() { + return "Create a new background task for tracking work items"; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "任务描述,说明这个任务要做什么" + }, + "metadata": { + "type": "string", + "description": "可选的 JSON 格式元数据字符串,例如 {\\"priority\\":\\"high\\"}" + } + }, + "required": ["description"] + }"""; + } + + @Override + public boolean isReadOnly() { + return false; + } + + @Override + public String execute(Map input, ToolContext context) { + // 获取 TaskManager 实例 + TaskManager manager = context.get(TASK_MANAGER_KEY); + if (manager == null) { + return errorJson("TaskManager 未初始化,请检查上下文配置"); + } + + // 解析必填参数: description + String desc = (String) input.get("description"); + if (desc == null || desc.isBlank()) { + return errorJson("参数 'description' 是必填项且不能为空"); + } + + // 解析可选参数: metadata + Map metadata = parseMetadata((String) input.get("metadata")); + + // 创建手动管理的任务 + String taskId; + if (metadata.isEmpty()) { + taskId = manager.createManualTask(desc); + } else { + taskId = manager.createManualTask(desc, metadata); + } + + // 返回 JSON 结果 + return """ + { + "task_id": "%s", + "description": "%s", + "status": "PENDING", + "message": "任务已创建" + }""".formatted(escapeJson(taskId), escapeJson(desc)); + } + + @Override + public String activityDescription(Map input) { + return "📋 Creating task: " + input.getOrDefault("description", "unnamed"); + } + + /* ------------------------------------------------------------------ */ + /* 辅助方法 */ + /* ------------------------------------------------------------------ */ + + /** + * 解析 metadata JSON 字符串为 Map。 + * 简易解析:支持 key:value 对的平面 JSON 对象。 + * 解析失败时返回空 Map 而不抛异常。 + */ + private Map parseMetadata(String metadataJson) { + if (metadataJson == null || metadataJson.isBlank()) { + return Collections.emptyMap(); + } + + try { + // 简易解析:去掉花括号、按逗号分割、按冒号取 key-value + String trimmed = metadataJson.trim(); + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + trimmed = trimmed.substring(1, trimmed.length() - 1).trim(); + } + + if (trimmed.isEmpty()) { + return Collections.emptyMap(); + } + + Map result = new LinkedHashMap<>(); + for (String pair : trimmed.split(",")) { + String[] kv = pair.split(":", 2); + if (kv.length == 2) { + String key = stripQuotes(kv[0].trim()); + String value = stripQuotes(kv[1].trim()); + if (!key.isEmpty()) { + result.put(key, value); + } + } + } + return Collections.unmodifiableMap(result); + } catch (Exception e) { + // 解析失败,返回空 Map + return Collections.emptyMap(); + } + } + + /** + * 去除字符串两端的引号。 + */ + private String stripQuotes(String s) { + if (s.length() >= 2 + && ((s.startsWith("\"") && s.endsWith("\"")) + || (s.startsWith("'") && s.endsWith("'")))) { + return s.substring(1, s.length() - 1); + } + return s; + } + + /** + * 转义 JSON 特殊字符。 + */ + private String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + /** + * 构建错误 JSON 响应。 + */ + private String errorJson(String message) { + return """ + { + "error": true, + "message": "%s" + }""".formatted(escapeJson(message)); + } +} diff --git a/src/main/java/com/claudecode/tool/impl/TaskGetTool.java b/src/main/java/com/claudecode/tool/impl/TaskGetTool.java new file mode 100644 index 0000000..90412f9 --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/TaskGetTool.java @@ -0,0 +1,152 @@ +package com.claudecode.tool.impl; + +import com.claudecode.core.TaskManager; +import com.claudecode.core.TaskManager.TaskInfo; +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; + +import java.util.Map; +import java.util.Optional; + +/** + * TaskGet 工具 —— 查询指定任务的详细信息。 + *

+ * 对应 claude-code 中的 TaskGet 命令。根据 task_id 返回任务的完整快照, + * 包括状态、结果、时间戳和元数据。 + *

+ * + *

参数

+ *
    + *
  • task_id(必填)—— 要查询的任务 ID
  • + *
+ * + *

返回

+ *

JSON 格式的任务详情,或错误信息。

+ */ +public class TaskGetTool implements Tool { + + /** ToolContext 中 TaskManager 的存储键 */ + private static final String TASK_MANAGER_KEY = "TASK_MANAGER"; + + @Override + public String name() { + return "TaskGet"; + } + + @Override + public String description() { + return "Get information about a specific task"; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "要查询的任务 ID" + } + }, + "required": ["task_id"] + }"""; + } + + @Override + public boolean isReadOnly() { + return true; + } + + @Override + public String execute(Map input, ToolContext context) { + // 获取 TaskManager 实例 + TaskManager manager = context.get(TASK_MANAGER_KEY); + if (manager == null) { + return errorJson("TaskManager 未初始化,请检查上下文配置"); + } + + // 解析必填参数: task_id + String taskId = (String) input.get("task_id"); + if (taskId == null || taskId.isBlank()) { + return errorJson("参数 'task_id' 是必填项且不能为空"); + } + + // 查询任务 + Optional taskOpt = manager.getTask(taskId); + if (taskOpt.isEmpty()) { + return errorJson("未找到 ID 为 '" + taskId + "' 的任务"); + } + + // 返回任务详情 JSON + return taskInfoToJson(taskOpt.get()); + } + + @Override + public String activityDescription(Map input) { + return "🔍 Getting task: " + input.getOrDefault("task_id", "unknown"); + } + + /* ------------------------------------------------------------------ */ + /* 辅助方法 */ + /* ------------------------------------------------------------------ */ + + /** + * 将 TaskInfo 转换为 JSON 字符串。 + */ + private String taskInfoToJson(TaskInfo task) { + StringBuilder sb = new StringBuilder(); + sb.append("{\n"); + sb.append(" \"task_id\": \"").append(escapeJson(task.id())).append("\",\n"); + sb.append(" \"description\": \"").append(escapeJson(task.description())).append("\",\n"); + sb.append(" \"status\": \"").append(task.status().name()).append("\",\n"); + + if (task.result() != null) { + sb.append(" \"result\": \"").append(escapeJson(task.result())).append("\",\n"); + } else { + sb.append(" \"result\": null,\n"); + } + + sb.append(" \"created_at\": \"").append(task.createdAt()).append("\",\n"); + sb.append(" \"updated_at\": \"").append(task.updatedAt()).append("\""); + + // 输出元数据 + if (task.metadata() != null && !task.metadata().isEmpty()) { + sb.append(",\n \"metadata\": {"); + boolean first = true; + for (Map.Entry entry : task.metadata().entrySet()) { + if (!first) sb.append(","); + sb.append("\n \"").append(escapeJson(entry.getKey())) + .append("\": \"").append(escapeJson(entry.getValue())).append("\""); + first = false; + } + sb.append("\n }"); + } + + sb.append("\n}"); + return sb.toString(); + } + + /** + * 转义 JSON 特殊字符。 + */ + private String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + /** + * 构建错误 JSON 响应。 + */ + private String errorJson(String message) { + return """ + { + "error": true, + "message": "%s" + }""".formatted(escapeJson(message)); + } +} diff --git a/src/main/java/com/claudecode/tool/impl/TaskListTool.java b/src/main/java/com/claudecode/tool/impl/TaskListTool.java new file mode 100644 index 0000000..8670389 --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/TaskListTool.java @@ -0,0 +1,189 @@ +package com.claudecode.tool.impl; + +import com.claudecode.core.TaskManager; +import com.claudecode.core.TaskManager.TaskInfo; +import com.claudecode.core.TaskManager.TaskStatus; +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; + +import java.util.List; +import java.util.Map; + +/** + * TaskList 工具 —— 列出所有任务,支持按状态过滤。 + *

+ * 对应 claude-code 中的 TaskList 命令。返回任务列表的 JSON 数组, + * 每个元素包含任务 ID、描述和当前状态。 + *

+ * + *

参数

+ *
    + *
  • status(可选)—— 状态过滤器:PENDING / RUNNING / COMPLETED / FAILED / CANCELLED
  • + *
+ * + *

返回

+ *

JSON 格式的任务列表。

+ */ +public class TaskListTool implements Tool { + + /** ToolContext 中 TaskManager 的存储键 */ + private static final String TASK_MANAGER_KEY = "TASK_MANAGER"; + + @Override + public String name() { + return "TaskList"; + } + + @Override + public String description() { + return "List all tasks, optionally filtered by status"; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "按状态过滤:PENDING / RUNNING / COMPLETED / FAILED / CANCELLED", + "enum": ["PENDING", "RUNNING", "COMPLETED", "FAILED", "CANCELLED"] + } + }, + "required": [] + }"""; + } + + @Override + public boolean isReadOnly() { + return true; + } + + @Override + public String execute(Map input, ToolContext context) { + // 获取 TaskManager 实例 + TaskManager manager = context.get(TASK_MANAGER_KEY); + if (manager == null) { + return errorJson("TaskManager 未初始化,请检查上下文配置"); + } + + // 解析可选参数: status + TaskStatus statusFilter = null; + String statusStr = (String) input.get("status"); + if (statusStr != null && !statusStr.isBlank()) { + try { + statusFilter = TaskStatus.valueOf(statusStr.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return errorJson("无效的状态值: '" + statusStr + + "'。可选值: PENDING, RUNNING, COMPLETED, FAILED, CANCELLED"); + } + } + + // 查询任务列表 + List taskList = manager.listTasks(statusFilter); + + // 构建 JSON 响应 + return buildListJson(taskList, statusFilter); + } + + @Override + public String activityDescription(Map input) { + String status = (String) input.get("status"); + if (status != null && !status.isBlank()) { + return "📋 Listing tasks [" + status + "]"; + } + return "📋 Listing all tasks"; + } + + /* ------------------------------------------------------------------ */ + /* 辅助方法 */ + /* ------------------------------------------------------------------ */ + + /** + * 将任务列表构建为 JSON 响应。 + * + * @param taskList 任务列表 + * @param statusFilter 当前使用的过滤条件(用于信息展示),可为 null + * @return JSON 字符串 + */ + private String buildListJson(List taskList, TaskStatus statusFilter) { + StringBuilder sb = new StringBuilder(); + sb.append("{\n"); + sb.append(" \"total\": ").append(taskList.size()).append(",\n"); + + if (statusFilter != null) { + sb.append(" \"filter\": \"").append(statusFilter.name()).append("\",\n"); + } + + sb.append(" \"tasks\": ["); + + if (taskList.isEmpty()) { + sb.append("]\n}"); + return sb.toString(); + } + + sb.append('\n'); + for (int i = 0; i < taskList.size(); i++) { + TaskInfo task = taskList.get(i); + sb.append(" {\n"); + sb.append(" \"task_id\": \"").append(escapeJson(task.id())).append("\",\n"); + sb.append(" \"description\": \"").append(escapeJson(task.description())).append("\",\n"); + sb.append(" \"status\": \"").append(task.status().name()).append("\",\n"); + + if (task.result() != null) { + sb.append(" \"result\": \"").append(escapeJson(task.result())).append("\",\n"); + } else { + sb.append(" \"result\": null,\n"); + } + + sb.append(" \"created_at\": \"").append(task.createdAt()).append("\",\n"); + sb.append(" \"updated_at\": \"").append(task.updatedAt()).append("\""); + + // 输出元数据 + if (task.metadata() != null && !task.metadata().isEmpty()) { + sb.append(",\n \"metadata\": {"); + boolean first = true; + for (Map.Entry entry : task.metadata().entrySet()) { + if (!first) sb.append(","); + sb.append("\n \"").append(escapeJson(entry.getKey())) + .append("\": \"").append(escapeJson(entry.getValue())).append("\""); + first = false; + } + sb.append("\n }"); + } + + sb.append("\n }"); + if (i < taskList.size() - 1) { + sb.append(','); + } + sb.append('\n'); + } + + sb.append(" ]\n}"); + return sb.toString(); + } + + /** + * 转义 JSON 特殊字符。 + */ + private String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + /** + * 构建错误 JSON 响应。 + */ + private String errorJson(String message) { + return """ + { + "error": true, + "message": "%s" + }""".formatted(escapeJson(message)); + } +} diff --git a/src/main/java/com/claudecode/tool/impl/TaskUpdateTool.java b/src/main/java/com/claudecode/tool/impl/TaskUpdateTool.java new file mode 100644 index 0000000..90c395d --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/TaskUpdateTool.java @@ -0,0 +1,187 @@ +package com.claudecode.tool.impl; + +import com.claudecode.core.TaskManager; +import com.claudecode.core.TaskManager.TaskInfo; +import com.claudecode.core.TaskManager.TaskStatus; +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; + +import java.util.Map; +import java.util.Optional; + +/** + * TaskUpdate 工具 —— 更新指定任务的状态和结果。 + *

+ * 对应 claude-code 中的 TaskUpdate 命令。用于推动手动管理任务的状态流转, + * 例如从 PENDING → RUNNING → COMPLETED。 + *

+ * + *

参数

+ *
    + *
  • task_id(必填)—— 要更新的任务 ID
  • + *
  • status(必填)—— 新状态:PENDING / RUNNING / COMPLETED / FAILED / CANCELLED
  • + *
  • result(可选)—— 任务执行结果或附加信息
  • + *
+ * + *

返回

+ *

JSON 格式的更新确认,包含更新后的任务信息。

+ * + *

状态约束

+ *

已处于终态(COMPLETED / FAILED / CANCELLED)的任务不允许再次更新。

+ */ +public class TaskUpdateTool implements Tool { + + /** ToolContext 中 TaskManager 的存储键 */ + private static final String TASK_MANAGER_KEY = "TASK_MANAGER"; + + @Override + public String name() { + return "TaskUpdate"; + } + + @Override + public String description() { + return "Update a task's status and optional result"; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "要更新的任务 ID" + }, + "status": { + "type": "string", + "description": "新状态:PENDING / RUNNING / COMPLETED / FAILED / CANCELLED", + "enum": ["PENDING", "RUNNING", "COMPLETED", "FAILED", "CANCELLED"] + }, + "result": { + "type": "string", + "description": "任务执行结果或附加信息(可选)" + } + }, + "required": ["task_id", "status"] + }"""; + } + + @Override + public boolean isReadOnly() { + return false; + } + + @Override + public String execute(Map input, ToolContext context) { + // 获取 TaskManager 实例 + TaskManager manager = context.get(TASK_MANAGER_KEY); + if (manager == null) { + return errorJson("TaskManager 未初始化,请检查上下文配置"); + } + + // 解析必填参数: task_id + String taskId = (String) input.get("task_id"); + if (taskId == null || taskId.isBlank()) { + return errorJson("参数 'task_id' 是必填项且不能为空"); + } + + // 解析必填参数: status + String statusStr = (String) input.get("status"); + if (statusStr == null || statusStr.isBlank()) { + return errorJson("参数 'status' 是必填项且不能为空"); + } + + TaskStatus newStatus; + try { + newStatus = TaskStatus.valueOf(statusStr.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return errorJson("无效的状态值: '" + statusStr + + "'。可选值: PENDING, RUNNING, COMPLETED, FAILED, CANCELLED"); + } + + // 解析可选参数: result + String result = (String) input.get("result"); + + // 在更新前先获取旧状态(用于返回信息) + Optional beforeOpt = manager.getTask(taskId); + if (beforeOpt.isEmpty()) { + return errorJson("未找到 ID 为 '" + taskId + "' 的任务"); + } + + TaskInfo before = beforeOpt.get(); + String oldStatus = before.status().name(); + + // 执行更新 + boolean success = manager.updateTask(taskId, newStatus, result); + if (!success) { + return errorJson("更新失败:任务 '" + taskId + "' 当前状态为 " + + oldStatus + ",可能已处于终态,无法再次更新"); + } + + // 获取更新后的任务信息 + Optional afterOpt = manager.getTask(taskId); + if (afterOpt.isEmpty()) { + // 理论上不会出现,防御性编程 + return errorJson("更新后未能获取任务信息"); + } + + TaskInfo after = afterOpt.get(); + + // 返回更新确认 JSON + StringBuilder sb = new StringBuilder(); + sb.append("{\n"); + sb.append(" \"success\": true,\n"); + sb.append(" \"task_id\": \"").append(escapeJson(after.id())).append("\",\n"); + sb.append(" \"previous_status\": \"").append(oldStatus).append("\",\n"); + sb.append(" \"current_status\": \"").append(after.status().name()).append("\",\n"); + + if (after.result() != null) { + sb.append(" \"result\": \"").append(escapeJson(after.result())).append("\",\n"); + } else { + sb.append(" \"result\": null,\n"); + } + + sb.append(" \"updated_at\": \"").append(after.updatedAt()).append("\",\n"); + sb.append(" \"message\": \"任务状态已从 ").append(oldStatus) + .append(" 更新为 ").append(after.status().name()).append("\"\n"); + sb.append("}"); + + return sb.toString(); + } + + @Override + public String activityDescription(Map input) { + String taskId = (String) input.getOrDefault("task_id", "unknown"); + String status = (String) input.getOrDefault("status", "?"); + return "✏️ Updating task " + taskId + " → " + status; + } + + /* ------------------------------------------------------------------ */ + /* 辅助方法 */ + /* ------------------------------------------------------------------ */ + + /** + * 转义 JSON 特殊字符。 + */ + private String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + /** + * 构建错误 JSON 响应。 + */ + private String errorJson(String message) { + return """ + { + "error": true, + "message": "%s" + }""".formatted(escapeJson(message)); + } +}