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 服务器的完整生命周期管理:
+ *
+ * 通过 {@link McpTransport} 建立连接
+ * 发送 {@code initialize} 握手请求
+ * 发现服务器提供的工具({@code tools/list})和资源({@code resources/list})
+ * 调用工具({@code tools/call})和读取资源({@code resources/read})
+ *
+ *
+ * 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 协议握手流程。
+ *
+ * 步骤:
+ *
+ * 发送 {@code initialize} 请求,声明客户端能力和协议版本
+ * 解析服务器返回的能力信息
+ * 发送 {@code notifications/initialized} 通知
+ * 发现服务器提供的工具和资源
+ *
+ *
+ * @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 服务器。
+ *
+ * 优先级:
+ *
+ * 项目级配置文件:当前工作目录下的 {@code .mcp.json}
+ * 全局配置文件:{@code ~/.claude-code-java/mcp.json}
+ *
+ * 两个配置文件中的服务器会合并加载。
+ */
+ 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 通信。
+ *
+ * 工作原理:
+ *
+ * 启动外部 MCP 服务器进程(如 {@code npx -y @modelcontextprotocol/server-filesystem})
+ * 通过进程的 stdin 发送 JSON-RPC 消息(每行一条 JSON)
+ * 通过独立读线程从 stdout 异步读取响应
+ * 使用 {@link CompletableFuture} 按 {@code id} 字段进行请求-响应关联
+ *
+ *
+ * 消息分隔:每条 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 文件动态加载插件。
+ *
+ *
插件加载方式
+ *
+ * 全局插件 :从 {@code ~/.claude-code-java/plugins/} 目录加载 JAR 文件
+ * 项目插件 :从项目 {@code .claude-code/plugins/} 目录加载 JAR 文件
+ *
+ *
+ * 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 插件。
+ *
+ * 流程:
+ *
+ * 读取 JAR 的 MANIFEST.MF,获取 Plugin-Class 属性
+ * 使用 URLClassLoader 加载插件类
+ * 验证插件类实现了 {@link Plugin} 接口
+ * 实例化并初始化插件
+ * 将插件信息添加到已加载列表
+ *
+ *
+ * @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 优先级的辅助工具。支持两种存储后端:
+ *
+ *
+ * 首选从 ToolContext 中的配置映射(键 "CONFIG_STORE")读写
+ * 回退到 {@link System#getProperty} / {@link System#setProperty}
+ *
+ *
+ * 参数
+ *
+ * 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 服务器上的一个工具。
+ *
+ * 桥接流程:
+ *
+ * 从 {@link McpClient.McpTool} 提取工具定义(名称、描述、参数 schema)
+ * 实现 {@link Tool} 接口,使其可注册到 {@link com.claudecode.tool.ToolRegistry}
+ * 执行时通过 {@link ToolContext} 中的 {@link McpManager} 路由到对应 MCP 服务器
+ *
+ *
+ * @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));
+ }
+}