feat: P2全部功能 - MCP协议+插件系统+任务管理+CLI增强

A组 CLI增强:
- DiffRenderer: 彩色diff渲染(行号/stat摘要/unified格式)
- /hooks: 查看已注册Hook列表
- /review: AI代码审查(支持--staged/文件路径)
- /stats: 会话使用统计(tokens/费用/API调用/运行时长)
- /branch: 对话分支管理(save/load/list/delete)
- /rewind: 回退对话历史
- /tag: 对话位置标签
- /security-review: AI安全审查

B组 任务系统:
- TaskManager: 后台任务管理(线程安全/自动执行/手动管理)
- TaskCreate/TaskGet/TaskList/TaskUpdate 4个工具
- ConfigTool: 配置读写工具

C组 MCP协议:
- McpClient: MCP客户端(JSON-RPC 2.0/工具发现/资源读取)
- McpTransport: 传输层接口
- StdioTransport: StdIO传输实现(子进程/异步读写)
- McpManager: 多MCP服务器管理(配置加载/生命周期)
- McpToolBridge: MCP工具桥接为本地Tool
- /mcp: MCP服务器管理命令

D组 插件系统:
- Plugin接口 + PluginContext + PluginManager
- JAR插件加载(ClassLoader隔离/Manifest读取)
- OutputStylePlugin: 内置输出样式插件
- /plugin: 插件管理命令

集成: AppConfig注册全部18工具+28命令

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
liuzh
2026-04-01 22:56:35 +08:00
co-authored by Copilot
parent da26f02498
commit 79736cf16f
27 changed files with 5592 additions and 9 deletions
@@ -0,0 +1,198 @@
package com.claudecode.command.impl;
import com.claudecode.command.CommandContext;
import com.claudecode.command.SlashCommand;
import com.claudecode.console.AnsiStyle;
import org.springframework.ai.chat.messages.Message;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* /branch 命令 —— 管理对话分支。
* <p>
* 允许用户保存当前对话状态为命名分支,并在不同分支之间切换。
* 分支数据存储在静态 Map 中,在 JVM 生命周期内持久化。
* <p>
* 支持的子命令:
* <ul>
* <li>{@code /branch save <name>} —— 将当前对话保存为分支</li>
* <li>{@code /branch load <name>} —— 恢复到指定分支的对话状态</li>
* <li>{@code /branch list} —— 列出所有已保存的分支</li>
* <li>{@code /branch delete <name>} —— 删除指定分支</li>
* </ul>
*/
public class BranchCommand implements SlashCommand {
/** 静态分支存储:分支名称 -> 消息快照 */
private static final Map<String, BranchSnapshot> branches = new LinkedHashMap<>();
@Override
public String name() {
return "branch";
}
@Override
public String description() {
return "Manage conversation branches";
}
@Override
public String execute(String args, CommandContext context) {
if (context.agentLoop() == null) {
return AnsiStyle.red(" ✗ AgentLoop 不可用。");
}
String trimmedArgs = args != null ? args.trim() : "";
if (trimmedArgs.isEmpty()) {
return showUsage();
}
// 解析子命令和参数
String[] parts = trimmedArgs.split("\\s+", 2);
String subCommand = parts[0].toLowerCase();
String branchName = parts.length > 1 ? parts[1].trim() : "";
return switch (subCommand) {
case "save" -> saveBranch(branchName, context);
case "load" -> loadBranch(branchName, context);
case "list" -> listBranches(context);
case "delete" -> deleteBranch(branchName);
default -> AnsiStyle.red(" ✗ 未知子命令: " + subCommand) + "\n" + showUsage();
};
}
/**
* 保存当前对话为命名分支。
*
* @param branchName 分支名称
* @param context 命令上下文
* @return 操作结果信息
*/
private String saveBranch(String branchName, CommandContext context) {
if (branchName.isEmpty()) {
return AnsiStyle.red(" ✗ 请指定分支名称。") + "\n"
+ AnsiStyle.dim(" 用法: /branch save <name>");
}
List<Message> currentHistory = context.agentLoop().getMessageHistory();
// 创建消息快照(深拷贝列表引用)
List<Message> snapshot = new ArrayList<>(currentHistory);
String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
branches.put(branchName, new BranchSnapshot(snapshot, timestamp));
return AnsiStyle.green(" ✓ 分支已保存: ") + AnsiStyle.bold(branchName) + "\n"
+ AnsiStyle.dim(" 消息数: " + snapshot.size() + " 时间: " + timestamp);
}
/**
* 恢复到指定分支的对话状态。
*
* @param branchName 分支名称
* @param context 命令上下文
* @return 操作结果信息
*/
private String loadBranch(String branchName, CommandContext context) {
if (branchName.isEmpty()) {
return AnsiStyle.red(" ✗ 请指定分支名称。") + "\n"
+ AnsiStyle.dim(" 用法: /branch load <name>");
}
BranchSnapshot snapshot = branches.get(branchName);
if (snapshot == null) {
return AnsiStyle.red(" ✗ 分支不存在: " + branchName) + "\n"
+ AnsiStyle.dim(" 使用 /branch list 查看所有可用分支。");
}
// 恢复对话历史
context.agentLoop().replaceHistory(new ArrayList<>(snapshot.messages()));
return AnsiStyle.green(" ✓ 已恢复到分支: ") + AnsiStyle.bold(branchName) + "\n"
+ AnsiStyle.dim(" 已加载 " + snapshot.messages().size() + " 条消息 (保存于 " + snapshot.timestamp() + ")");
}
/**
* 列出所有已保存的分支。
*
* @param context 命令上下文
* @return 分支列表信息
*/
private String listBranches(CommandContext context) {
if (branches.isEmpty()) {
return AnsiStyle.dim(" 没有保存的分支。") + "\n"
+ AnsiStyle.dim(" 使用 /branch save <name> 保存当前对话。");
}
int currentSize = context.agentLoop().getMessageHistory().size();
StringBuilder sb = new StringBuilder();
sb.append(AnsiStyle.bold("\n 🌿 Conversation Branches\n\n"));
for (Map.Entry<String, BranchSnapshot> entry : branches.entrySet()) {
String name = entry.getKey();
BranchSnapshot snapshot = entry.getValue();
int msgCount = snapshot.messages().size();
// 标记与当前对话大小相同的分支
String marker = (msgCount == currentSize) ? AnsiStyle.green(" ◀ current size") : "";
sb.append("")
.append(AnsiStyle.bold(name))
.append(AnsiStyle.dim(" (" + msgCount + " messages, " + snapshot.timestamp() + ")"))
.append(marker)
.append("\n");
}
sb.append("\n").append(AnsiStyle.dim("" + branches.size() + " 个分支。")).append("\n");
return sb.toString();
}
/**
* 删除指定分支。
*
* @param branchName 分支名称
* @return 操作结果信息
*/
private String deleteBranch(String branchName) {
if (branchName.isEmpty()) {
return AnsiStyle.red(" ✗ 请指定分支名称。") + "\n"
+ AnsiStyle.dim(" 用法: /branch delete <name>");
}
BranchSnapshot removed = branches.remove(branchName);
if (removed == null) {
return AnsiStyle.red(" ✗ 分支不存在: " + branchName);
}
return AnsiStyle.green(" ✓ 分支已删除: ") + AnsiStyle.bold(branchName);
}
/**
* 显示用法帮助信息。
*
* @return 用法说明文本
*/
private String showUsage() {
StringBuilder sb = new StringBuilder();
sb.append(AnsiStyle.bold("\n 🌿 Branch — 对话分支管理\n\n"));
sb.append(" ").append(AnsiStyle.cyan("/branch save <name>")).append(" 保存当前对话为分支\n");
sb.append(" ").append(AnsiStyle.cyan("/branch load <name>")).append(" 恢复到指定分支\n");
sb.append(" ").append(AnsiStyle.cyan("/branch list")).append(" 列出所有分支\n");
sb.append(" ").append(AnsiStyle.cyan("/branch delete <name>")).append(" 删除指定分支\n");
return sb.toString();
}
/**
* 分支快照记录 —— 保存消息列表和创建时间。
*
* @param messages 消息快照
* @param timestamp 创建时间戳
*/
private record BranchSnapshot(List<Message> messages, String timestamp) {}
}
@@ -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 信息。
* <p>
* 按 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<HookRegistration> allHooks = hookManager.getHooks();
// 无已注册的 Hook
if (allHooks.isEmpty()) {
return AnsiStyle.dim(" No hooks registered.");
}
// 按类型分组
Map<HookType, List<HookRegistration>> 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<HookRegistration> 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 (收到响应后)";
};
}
}
@@ -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 命令 —— 管理 MCPModel Context Protocol)服务器连接。
* <p>
* 子命令:
* <ul>
* <li>{@code /mcp} —— 列出所有 MCP 服务器及状态</li>
* <li>{@code /mcp connect <name> <command> [args...]} —— 连接到 MCP 服务器</li>
* <li>{@code /mcp disconnect <name>} —— 断开 MCP 服务器</li>
* <li>{@code /mcp tools [server]} —— 列出 MCP 工具</li>
* <li>{@code /mcp resources [server]} —— 列出 MCP 资源</li>
* <li>{@code /mcp reload} —— 从配置文件重新加载</li>
* </ul>
*/
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<String, McpClient> clients = manager.getClients();
if (clients.isEmpty()) {
sb.append(" 无已连接的 MCP 服务器\n\n");
sb.append(AnsiStyle.dim(" 提示: 使用 /mcp connect <name> <command> [args] 连接服务器\n"));
sb.append(AnsiStyle.dim(" 或在 .mcp.json 配置文件中定义服务器\n"));
return sb.toString();
}
for (Map.Entry<String, McpClient> 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 <name> <command> [args...]");
}
String[] parts = args.split("\\s+");
if (parts.length < 2) {
return AnsiStyle.red(" 用法: /mcp connect <name> <command> [args...]");
}
String name = parts[0];
String command = parts[1];
List<String> 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 <name>");
}
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<McpClient.McpTool> 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<McpClient.McpResource> 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<String, McpClient> 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<McpToolBridge> 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 <name> <cmd> [args] 连接到 MCP 服务器
/mcp disconnect <name> 断开 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 实例。
* <p>
* 在应用启动时通过 {@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;
}
}
}
@@ -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 命令 —— 管理已加载的插件。
* <p>
* 子命令:
* <ul>
* <li>{@code /plugin} —— 列出所有已加载插件</li>
* <li>{@code /plugin load <path>} —— 从 JAR 路径加载插件</li>
* <li>{@code /plugin unload <id>} —— 卸载指定插件</li>
* <li>{@code /plugin reload} —— 重载所有插件</li>
* <li>{@code /plugin info <id>} —— 显示插件详细信息</li>
* </ul>
* <p>
* 通过 {@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<String> 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<PluginInfo> 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 <jar-path>");
}
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 <plugin-id>");
}
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 <plugin-id>");
}
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<Tool> 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<SlashCommand> 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 实例。
* <p>
* 通过 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 <path> 加载 JAR 插件
/plugin unload <id> 卸载插件
/plugin reload 重载所有插件
/plugin info <id> 查看插件详情""");
}
}
@@ -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 进行代码审查。
* <p>
* 支持三种模式:
* <ul>
* <li>无参数:审查当前未暂存的变更({@code git diff}</li>
* <li>{@code --staged}:审查已暂存的变更({@code git diff --staged}</li>
* <li>指定文件路径:审查特定文件的变更</li>
* </ul>
* <p>
* 获取 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<String> 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<String> 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<String> buildGitDiffCommand(String args) {
List<String> 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<String> 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();
}
}
@@ -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 命令 —— 回滚对话到之前的某个位置。
* <p>
* 按消息对(用户消息 + 助手消息)为单位进行回滚。
* <ul>
* <li>{@code /rewind} —— 移除最后 1 个消息对</li>
* <li>{@code /rewind <n>} —— 移除最后 n 个消息对</li>
* </ul>
* <p>
* 使用 {@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<Message> 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<Message> 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;
}
}
}
@@ -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 进行安全审查。
* <p>
* 获取当前项目的最新代码变更({@code git diff HEAD}),
* 发送给 AI 模型进行安全方面的专项审查。
* <p>
* 审查重点包括:
* <ul>
* <li>SQL 注入漏洞</li>
* <li>跨站脚本攻击(XSS</li>
* <li>身份认证和授权问题</li>
* <li>敏感信息泄露(密钥、密码等)</li>
* <li>依赖安全问题</li>
* </ul>
*/
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<String> 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;
}
/**
* 构建安全审查提示词。
* <p>
* 要求 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);
}
}
@@ -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 命令 —— 显示当前会话的使用统计信息。
* <p>
* 展示内容包括:
* <ul>
* <li>近似对话轮数(消息历史大小 / 2</li>
* <li>API 调用总次数</li>
* <li>Token 使用量(输入/输出/总计)</li>
* <li>估算费用(美元)</li>
* <li>每次调用平均 Token 数</li>
* <li>当前使用的模型名称</li>
* <li>JVM 运行时长</li>
* </ul>
*/
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 位小数)。
* <p>
* 根据费用高低使用不同颜色:
* <ul>
* <li>$0 以下(即免费):绿色</li>
* <li>$0.01 ~ $1.00:黄色</li>
* <li>$1.00 以上:红色</li>
* </ul>
*
* @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 运行时长。
* <p>
* 使用 {@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);
}
}
}
@@ -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 命令 —— 为当前对话位置打标签。
* <p>
* 标签记录当前消息历史的大小,可用于后续快速回溯到该位置。
* 标签数据存储在静态 Map 中,在 JVM 生命周期内持久化。
* <p>
* 支持的子命令:
* <ul>
* <li>{@code /tag <name>} —— 为当前位置打标签</li>
* <li>{@code /tag list} —— 列出所有标签</li>
* <li>{@code /tag goto <name>} —— 回溯到指定标签位置</li>
* </ul>
*/
public class TagCommand implements SlashCommand {
/** 静态标签存储:标签名称 -> 标签信息 */
private static final Map<String, TagInfo> 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 <name> 为当前位置打标签。");
}
int currentPosition = context.agentLoop().getMessageHistory().size();
StringBuilder sb = new StringBuilder();
sb.append(AnsiStyle.bold("\n 🏷️ Conversation Tags\n\n"));
for (Map.Entry<String, TagInfo> 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();
}
/**
* 回溯到指定标签对应的对话位置。
* <p>
* 将消息历史截断到标签记录的位置。如果当前消息数少于标签位置,
* 则不做截断(标签位置可能超出当前对话长度)。
*
* @param tagName 标签名称
* @param context 命令上下文
* @return 操作结果信息
*/
private String gotoTag(String tagName, CommandContext context) {
if (tagName.isEmpty()) {
return AnsiStyle.red(" ✗ 请指定标签名称。") + "\n"
+ AnsiStyle.dim(" 用法: /tag goto <name>");
}
TagInfo info = tags.get(tagName);
if (info == null) {
return AnsiStyle.red(" ✗ 标签不存在: " + tagName) + "\n"
+ AnsiStyle.dim(" 使用 /tag list 查看所有可用标签。");
}
List<Message> currentHistory = context.agentLoop().getMessageHistory();
int currentSize = currentHistory.size();
int targetPosition = info.position();
if (targetPosition >= currentSize) {
return AnsiStyle.yellow(" ⚠ 标签位置 (" + targetPosition + ") 不小于当前消息数 ("
+ currentSize + "),无需回溯。");
}
// 截断到标签位置
List<Message> 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 <name>")).append(" 为当前位置打标签\n");
sb.append(" ").append(AnsiStyle.cyan("/tag list")).append(" 列出所有标签\n");
sb.append(" ").append(AnsiStyle.cyan("/tag goto <name>")).append(" 回溯到指定标签位置\n");
return sb.toString();
}
/**
* 标签信息记录 —— 保存标签的消息位置和创建时间。
*
* @param position 消息历史中的位置(消息数量)
* @param timestamp 创建时间戳
*/
private record TagInfo(int position, String timestamp) {}
}
@@ -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) {
}
}
@@ -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<DiffFile> 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<DiffFile> files = parse(diffText);
if (files.isEmpty()) {
return AnsiStyle.dim(" (no changes)");
}
int totalAdded = 0, totalRemoved = 0;
int maxNameLen = 0;
List<FileStat> 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<DiffFile> parse(String diffText) {
if (diffText == null || diffText.isBlank()) return List.of();
List<DiffFile> 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<DiffHunk> 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<DiffLine> 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<DiffHunk> hunks, boolean binary) {}
/** 差异区块 */
public record DiffHunk(int oldStart, int oldCount, int newStart, int newCount, String header, List<DiffLine> 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) {}
}
@@ -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 功能。
* <p>
* 管理后台任务的创建、执行、查询和更新。
* 所有公共方法均线程安全,内部使用 {@link ConcurrentHashMap} 存储任务、
* 使用守护线程池异步执行带有 {@link Callable} 工作体的任务。
* </p>
*
* <h3>两种创建模式</h3>
* <ul>
* <li>{@link #createTask(String, Callable)} —— 自动执行模式:提交后立即在后台线程池中运行</li>
* <li>{@link #createManualTask(String)} —— 手动管理模式:仅创建 PENDING 状态的记录,
* 由外部通过 {@link #updateTask} 驱动状态流转</li>
* </ul>
*/
public class TaskManager {
/* ------------------------------------------------------------------ */
/* 任务状态枚举 */
/* ------------------------------------------------------------------ */
/**
* 任务生命周期状态。
*/
public enum TaskStatus {
/** 已创建,等待执行 */
PENDING,
/** 正在执行 */
RUNNING,
/** 执行成功完成 */
COMPLETED,
/** 执行失败 */
FAILED,
/** 已被取消 */
CANCELLED
}
/* ------------------------------------------------------------------ */
/* 任务信息记录 */
/* ------------------------------------------------------------------ */
/**
* 不可变的任务快照。每次状态变更都会创建新的 {@code TaskInfo} 实例写入映射表,
* 从而保证读取端永远拿到一致的快照。
*
* @param id 任务唯一 IDUUID 前 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<String, String> 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<String, TaskInfo> tasks = new ConcurrentHashMap<>();
/** 自动执行模式下对应的 Future,用于取消 */
private final ConcurrentHashMap<String, Future<?>> 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;
});
/* ------------------------------------------------------------------ */
/* 创建任务 */
/* ------------------------------------------------------------------ */
/**
* 创建并自动执行一个后台任务。
* <p>
* 任务会立即被提交到线程池执行;执行完成后状态自动变为 COMPLETED 或 FAILED。
* </p>
*
* @param description 任务描述
* @param work 要执行的工作体
* @return 生成的任务 IDUUID 前 8 位)
* @throws NullPointerException 若 description 或 work 为 null
*/
public String createTask(String description, Callable<String> 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<String> work,
Map<String, String> 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;
}
/**
* 创建一个手动管理的任务(不自动执行)。
* <p>
* 初始状态为 PENDING,需要外部通过 {@link #updateTask} 手动驱动状态变化。
* </p>
*
* @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<String, String> metadata) {
Objects.requireNonNull(description, "任务描述不能为 null");
String taskId = generateId();
Instant now = Instant.now();
Map<String, String> 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<TaskInfo> getTask(String taskId) {
if (taskId == null || taskId.isBlank()) {
return Optional.empty();
}
return Optional.ofNullable(tasks.get(taskId));
}
/**
* 列出所有任务。
*
* @return 任务列表(按创建时间升序)
*/
public List<TaskInfo> listTasks() {
return listTasks(null);
}
/**
* 列出任务,可按状态过滤。
*
* @param statusFilter 状态过滤器,{@code null} 表示不过滤
* @return 符合条件的任务列表(按创建时间升序)
*/
public List<TaskInfo> 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;
}
/* ------------------------------------------------------------------ */
/* 取消任务 */
/* ------------------------------------------------------------------ */
/**
* 取消指定任务。
* <p>
* 如果任务由线程池执行且尚未完成,会尝试中断执行线程。
* </p>
*
* @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<TaskStatus, Long> 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;
}
}
@@ -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/ 模块。
* <p>
* 负责与单个 MCP 服务器的完整生命周期管理:
* <ol>
* <li>通过 {@link McpTransport} 建立连接</li>
* <li>发送 {@code initialize} 握手请求</li>
* <li>发现服务器提供的工具({@code tools/list})和资源({@code resources/list}</li>
* <li>调用工具({@code tools/call})和读取资源({@code resources/read}</li>
* </ol>
* <p>
* 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<String, McpTool> tools = new ConcurrentHashMap<>();
/** 已发现的资源集合:uri -> McpResource */
private final Map<String, McpResource> 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 协议握手流程。
* <p>
* 步骤:
* <ol>
* <li>发送 {@code initialize} 请求,声明客户端能力和协议版本</li>
* <li>解析服务器返回的能力信息</li>
* <li>发送 {@code notifications/initialized} 通知</li>
* <li>发现服务器提供的工具和资源</li>
* </ol>
*
* @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<String, Object> 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<McpTool> getTools() {
return Collections.unmodifiableCollection(tools.values());
}
/**
* 获取所有已发现的资源(不可变视图)。
*/
public Collection<McpResource> getResources() {
return Collections.unmodifiableCollection(resources.values());
}
/**
* 按名称查找工具。
*
* @param toolName 工具名称
* @return 工具定义,若不存在则返回 {@link Optional#empty()}
*/
public Optional<McpTool> 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) {
}
}
@@ -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;
}
}
@@ -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 服务器连接的统一入口。
* <p>
* 职责:
* <ul>
* <li>从配置文件加载 MCP 服务器定义</li>
* <li>管理服务器连接的生命周期(连接、断开、重连)</li>
* <li>聚合所有服务器的工具和资源供上层使用</li>
* <li>路由工具调用到正确的服务器</li>
* </ul>
* <p>
* 配置文件格式({@code mcp.json}):
* <pre>{@code
* {
* "servers": {
* "server-name": {
* "command": "npx",
* "args": ["-y", "@modelcontextprotocol/server-filesystem"],
* "env": { "KEY": "VALUE" }
* }
* }
* }
* }</pre>
*
* @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<String, McpClient> clients = new ConcurrentHashMap<>();
/** 工具名称到服务器名称的映射:toolName -> serverName(用于路由调用) */
private final Map<String, String> toolToServer = new ConcurrentHashMap<>();
/**
* 从配置文件加载并连接所有 MCP 服务器。
* <p>
* 优先级:
* <ol>
* <li>项目级配置文件:当前工作目录下的 {@code .mcp.json}</li>
* <li>全局配置文件:{@code ~/.claude-code-java/mcp.json}</li>
* </ol>
* 两个配置文件中的服务器会合并加载。
*/
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<Map.Entry<String, JsonNode>> fields = serversNode.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> 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<String> args = new ArrayList<>();
if (serverDef.has("args") && serverDef.get("args").isArray()) {
for (JsonNode arg : serverDef.get("args")) {
args.add(arg.asText());
}
}
Map<String, String> env = new HashMap<>();
if (serverDef.has("env") && serverDef.get("env").isObject()) {
Iterator<Map.Entry<String, JsonNode>> envFields = serverDef.get("env").fields();
while (envFields.hasNext()) {
Map.Entry<String, JsonNode> 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<String> args, Map<String, String> 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<String, McpClient> getClients() {
return Collections.unmodifiableMap(clients);
}
/**
* 获取指定服务器的客户端。
*
* @param name 服务器名称
* @return 客户端实例,若不存在则返回 {@link Optional#empty()}
*/
public Optional<McpClient> getClient(String name) {
return Optional.ofNullable(clients.get(name));
}
/**
* 获取所有 MCP 工具(合并所有服务器的工具)。
*
* @return 所有已发现的工具列表
*/
public List<McpClient.McpTool> getAllTools() {
return clients.values().stream()
.filter(McpClient::isInitialized)
.flatMap(client -> client.getTools().stream())
.collect(Collectors.toList());
}
/**
* 获取指定服务器的工具。
*
* @param serverName 服务器名称
* @return 工具列表,若服务器不存在则返回空列表
*/
public List<McpClient.McpTool> 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<McpClient.McpResource> getAllResources() {
return clients.values().stream()
.filter(McpClient::isInitialized)
.flatMap(client -> client.getResources().stream())
.collect(Collectors.toList());
}
/**
* 获取指定服务器的资源。
*
* @param serverName 服务器名称
* @return 资源列表,若服务器不存在则返回空列表
*/
public List<McpClient.McpResource> 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<String, Object> 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<String, Object> 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<String> findServerForTool(String toolName) {
return Optional.ofNullable(toolToServer.get(toolName));
}
/**
* 重新加载配置文件并重连所有服务器。
* <p>
* 先断开所有已有连接,再重新加载配置。
*/
public void reload() {
log.info("重新加载 MCP 配置...");
// 断开所有现有连接
List<String> 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<String, McpClient> 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<Exception> errors = new ArrayList<>();
for (Map.Entry<String, McpClient> 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 连接已关闭");
}
}
@@ -0,0 +1,43 @@
package com.claudecode.mcp;
import com.fasterxml.jackson.databind.JsonNode;
/**
* MCP 传输层接口 —— 抽象不同的通信方式(StdIO / SSE / HTTP)。
* <p>
* MCP 协议底层基于 JSON-RPC 2.0,传输层负责将 JSON-RPC 消息
* 发送给 MCP 服务器并接收响应。不同传输实现(如 StdIO 子进程、
* HTTP+SSE 远程连接)均通过此接口统一暴露。
*
* @see StdioTransport
*/
public interface McpTransport extends AutoCloseable {
/**
* 发送 JSON-RPC 请求并等待响应。
* <p>
* 实现应根据请求中的 {@code id} 字段将响应与请求关联。
*
* @param jsonRpcRequest 完整的 JSON-RPC 2.0 请求字符串
* @return 服务器返回的 JSON-RPC 响应节点
* @throws McpException 通信异常或超时
*/
JsonNode sendRequest(String jsonRpcRequest) throws McpException;
/**
* 发送 JSON-RPC 通知(无需响应)。
* <p>
* 通知消息不包含 {@code id} 字段,服务器无需回复。
*
* @param jsonRpcNotification 完整的 JSON-RPC 2.0 通知字符串
* @throws McpException 通信异常
*/
void sendNotification(String jsonRpcNotification) throws McpException;
/**
* 传输层是否已连接且可用。
*
* @return 如果底层连接仍然活跃则返回 {@code true}
*/
boolean isConnected();
}
@@ -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 通信。
* <p>
* 工作原理:
* <ol>
* <li>启动外部 MCP 服务器进程(如 {@code npx -y @modelcontextprotocol/server-filesystem}</li>
* <li>通过进程的 stdin 发送 JSON-RPC 消息(每行一条 JSON</li>
* <li>通过独立读线程从 stdout 异步读取响应</li>
* <li>使用 {@link CompletableFuture} 按 {@code id} 字段进行请求-响应关联</li>
* </ol>
* <p>
* 消息分隔:每条 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<Object, CompletableFuture<JsonNode>> pendingRequests =
new ConcurrentHashMap<>();
/** 启动命令 */
private final String command;
/** 启动参数 */
private final List<String> args;
/** 环境变量(可选) */
private final Map<String, String> env;
/** 连接状态 */
private volatile boolean connected = false;
/**
* 创建 StdIO 传输实例。
*
* @param command 服务器可执行命令(如 "npx"
* @param args 命令参数列表
* @param env 额外的环境变量,可为 {@code null}
*/
public StdioTransport(String command, List<String> args, Map<String, String> 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<String>();
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 消息。
* <p>
* 若消息包含 {@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<JsonNode> 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<JsonNode> 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) + "...";
}
}
@@ -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 命令来切换输出样式。
* <p>
* 这是一个内建的示例插件,演示了插件系统的使用方式。
* 用户可以通过 /style 命令在不同的输出样式之间切换。
*
* <h3>可用样式</h3>
* <ul>
* <li><b>default</b> —— 默认彩色输出,包含 ANSI 颜色和格式</li>
* <li><b>minimal</b> —— 精简输出,无颜色无装饰</li>
* <li><b>verbose</b> —— 详细输出,包含额外的调试和时间戳信息</li>
* <li><b>markdown</b> —— 纯 Markdown 输出,适合导出和分享</li>
* </ul>
*
* <h3>线程安全</h3>
* <p>当前样式使用 {@link AtomicReference} 存储,支持并发读写。</p>
*/
public class OutputStylePlugin implements Plugin {
/** 所有支持的样式名称 */
private static final Set<String> SUPPORTED_STYLES = Set.of(
"default", "minimal", "verbose", "markdown"
);
/** 当前激活的输出样式,使用原子引用保证线程安全 */
private final AtomicReference<String> 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<SlashCommand> 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 命令 —— 查看或切换输出样式。
* <p>
* 用法:
* <ul>
* <li>{@code /style} —— 显示当前样式和所有可用样式</li>
* <li>{@code /style <name>} —— 切换到指定样式</li>
* </ul>
*/
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 <name>")).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 -> "";
};
}
}
}
@@ -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/ 模块。
* <p>
* 插件可以提供额外的工具和命令,扩展核心功能。
* 每个插件有独立的生命周期:初始化 → 运行 → 销毁。
*
* <h3>实现指南</h3>
* <ul>
* <li>每个插件必须有唯一的 {@link #id()},推荐使用 kebab-case 格式(如 "my-plugin"</li>
* <li>{@link #initialize(PluginContext)} 在加载时调用一次,用于初始化资源</li>
* <li>{@link #getTools()} 和 {@link #getCommands()} 返回插件提供的扩展</li>
* <li>{@link #destroy()} 在卸载时调用,释放资源</li>
* </ul>
*
* <h3>JAR 插件打包</h3>
* <p>
* 打包为 JAR 时需要在 {@code META-INF/MANIFEST.MF} 中指定:
* <pre>
* Plugin-Class: com.example.MyPlugin
* </pre>
*/
public interface Plugin {
/**
* 插件唯一标识。
* <p>
* 推荐使用 kebab-case 格式,如 "output-style"、"git-helper"。
* 标识在整个应用生命周期内必须唯一。
*
* @return 非空的插件标识字符串
*/
String id();
/**
* 插件显示名称。
*
* @return 人类可读的插件名称
*/
String name();
/**
* 插件版本号。
* <p>
* 推荐使用语义化版本号(SemVer),如 "1.0.0"。
*
* @return 版本号字符串
*/
String version();
/**
* 插件功能描述。
*
* @return 简短的功能描述
*/
String description();
/**
* 初始化插件。
* <p>
* 在插件加载后立即调用,用于:
* <ul>
* <li>初始化内部状态和资源</li>
* <li>读取配置</li>
* <li>建立外部连接</li>
* </ul>
* 如果初始化失败应抛出异常,插件将不会被注册。
*
* @param context 插件上下文,提供访问应用核心功能的接口
* @throws RuntimeException 初始化失败时抛出
*/
void initialize(PluginContext context);
/**
* 获取插件提供的工具列表。
* <p>
* 返回的工具将被注册到 {@link com.claudecode.tool.ToolRegistry}
* 可供 LLM 调用。
*
* @return 工具列表,默认为空列表
*/
default List<Tool> getTools() {
return List.of();
}
/**
* 获取插件提供的斜杠命令列表。
* <p>
* 返回的命令将被注册到 {@link com.claudecode.command.CommandRegistry}
* 用户可通过 /{@code name} 调用。
*
* @return 命令列表,默认为空列表
*/
default List<SlashCommand> getCommands() {
return List.of();
}
/**
* 销毁插件,释放资源。
* <p>
* 在插件卸载或应用关闭时调用。实现应:
* <ul>
* <li>关闭打开的连接和流</li>
* <li>停止后台线程</li>
* <li>释放外部资源</li>
* </ul>
* 此方法不应抛出异常。
*/
default void destroy() {
}
}
@@ -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;
/**
* 插件上下文 —— 为插件提供访问应用核心功能的接口。
* <p>
* 每个插件在初始化时会收到一个专属的 {@code PluginContext} 实例,
* 包含:
* <ul>
* <li>{@link ToolContext} —— 工具执行上下文(工作目录、共享状态)</li>
* <li>工作目录路径</li>
* <li>插件专属日志器 —— 日志前缀为 "plugin.{pluginId}"</li>
* </ul>
*
* <p>此类的引用字段在构造后不可变(shallowly immutable),但持有的
* {@link ToolContext} 本身是可变的,多个插件共享同一实例。</p>
*/
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);
}
/**
* 获取工具执行上下文。
* <p>
* 通过 ToolContext 可以访问工作目录、模型信息和共享状态。
*
* @return 工具执行上下文
*/
public ToolContext getToolContext() {
return toolContext;
}
/**
* 获取当前工作目录路径。
*
* @return 工作目录的绝对路径字符串
*/
public String getWorkDir() {
return workDir;
}
/**
* 获取插件专属日志器。
* <p>
* 日志器名称格式为 "plugin.{pluginId}",方便在日志中区分不同插件的输出。
*
* @return SLF4J Logger 实例
*/
public Logger getLogger() {
return pluginLogger;
}
}
@@ -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;
/**
* 插件管理器 —— 负责插件的加载、注册和生命周期管理。
* <p>
* 对应 claude-code 中的插件加载机制,支持从外部 JAR 文件动态加载插件。
*
* <h3>插件加载方式</h3>
* <ol>
* <li><b>全局插件</b>:从 {@code ~/.claude-code-java/plugins/} 目录加载 JAR 文件</li>
* <li><b>项目插件</b>:从项目 {@code .claude-code/plugins/} 目录加载 JAR 文件</li>
* </ol>
*
* <h3>JAR 插件要求</h3>
* <ul>
* <li>{@code META-INF/MANIFEST.MF} 中必须包含 {@code Plugin-Class} 属性</li>
* <li>指定的类必须实现 {@link Plugin} 接口</li>
* <li>类必须有无参公共构造器</li>
* </ul>
*
* <h3>线程安全</h3>
* <p>
* 插件列表使用 {@link CopyOnWriteArrayList} 存储,支持并发读取。
* 加载和卸载操作本身不是原子的,建议在应用启动阶段或由单一线程执行。
* </p>
*
* @see Plugin
* @see PluginContext
*/
public class PluginManager {
private static final Logger log = LoggerFactory.getLogger(PluginManager.class);
/** 已加载的插件信息列表,使用 COW 列表保证并发读安全 */
private final List<PluginInfo> 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");
}
/**
* 扫描并加载所有插件目录中的插件。
* <p>
* 依次扫描全局插件目录和项目插件目录。
* 加载失败的单个插件不会影响其他插件的加载。
*/
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 插件。
* <p>
* 流程:
* <ol>
* <li>读取 JAR 的 MANIFEST.MF,获取 Plugin-Class 属性</li>
* <li>使用 URLClassLoader 加载插件类</li>
* <li>验证插件类实现了 {@link Plugin} 接口</li>
* <li>实例化并初始化插件</li>
* <li>将插件信息添加到已加载列表</li>
* </ol>
*
* @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 的插件。
* <p>
* 调用插件的 {@link Plugin#destroy()} 方法,并关闭其类加载器。
* 注意:已注册到 ToolRegistry / CommandRegistry 的工具和命令不会自动移除。
* <p>
* 使用 {@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<PluginInfo> 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();
}
/**
* 关闭所有插件并清理资源。
* <p>
* 依次调用每个插件的 {@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
) {
}
}
@@ -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 工具 —— 获取或设置配置值。
* <p>
* 属于 P2 优先级的辅助工具。支持两种存储后端:
* </p>
* <ol>
* <li>首选从 ToolContext 中的配置映射(键 "CONFIG_STORE")读写</li>
* <li>回退到 {@link System#getProperty} / {@link System#setProperty}</li>
* </ol>
*
* <h3>参数</h3>
* <ul>
* <li><b>action</b>(必填)—— "get" 或 "set"</li>
* <li><b>key</b>(必填)—— 配置键名</li>
* <li><b>value</b>(可选,set 时必填)—— 配置值</li>
* </ul>
*
* <h3>返回</h3>
* <p>JSON 格式:get 返回当前值,set 返回确认信息。</p>
*/
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<String, Object> 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<String, String> 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<String, Object> 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<String, String> 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<String, Object> input,
ConcurrentHashMap<String, String> 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));
}
}
@@ -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} 实例。
* <p>
* 使 AI 模型可以像调用本地工具一样调用 MCP 服务器上的工具。
* 每个 {@code McpToolBridge} 实例对应一个 MCP 服务器上的一个工具。
* <p>
* 桥接流程:
* <ol>
* <li>从 {@link McpClient.McpTool} 提取工具定义(名称、描述、参数 schema</li>
* <li>实现 {@link Tool} 接口,使其可注册到 {@link com.claudecode.tool.ToolRegistry}</li>
* <li>执行时通过 {@link ToolContext} 中的 {@link McpManager} 路由到对应 MCP 服务器</li>
* </ol>
*
* @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 远程工具调用。
* <p>
* 通过 {@link ToolContext} 中存储的 {@link McpManager} 实例
* 路由调用到对应的 MCP 服务器。
*
* @param input 解析后的输入参数
* @param context 工具执行上下文(必须包含 {@code MCP_MANAGER} 键)
* @return MCP 工具的执行结果
*/
@Override
public String execute(Map<String, Object> 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<String, Object> 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<McpToolBridge> createBridges(String serverName,
Collection<McpClient.McpTool> mcpTools) {
return mcpTools.stream()
.map(tool -> new McpToolBridge(serverName, tool))
.toList();
}
/**
* 构建 inputSchema JSON 字符串。
* <p>
* 如果 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 + '\'' +
'}';
}
}
@@ -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 工具 —— 创建一个新的后台任务(手动管理模式)。
* <p>
* 对应 claude-code 中的 TaskCreate 命令。创建后任务处于 PENDING 状态,
* 需要通过 TaskUpdate 工具推动状态流转。
* </p>
*
* <h3>参数</h3>
* <ul>
* <li><b>description</b>(必填)—— 任务描述</li>
* <li><b>metadata</b>(可选)—— JSON 格式的附加元数据字符串</li>
* </ul>
*
* <h3>返回</h3>
* <p>JSON 格式,包含 task_id 与 status 字段。</p>
*/
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<String, Object> 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<String, String> 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<String, Object> input) {
return "📋 Creating task: " + input.getOrDefault("description", "unnamed");
}
/* ------------------------------------------------------------------ */
/* 辅助方法 */
/* ------------------------------------------------------------------ */
/**
* 解析 metadata JSON 字符串为 Map。
* 简易解析:支持 key:value 对的平面 JSON 对象。
* 解析失败时返回空 Map 而不抛异常。
*/
private Map<String, String> 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<String, String> 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));
}
}
@@ -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 工具 —— 查询指定任务的详细信息。
* <p>
* 对应 claude-code 中的 TaskGet 命令。根据 task_id 返回任务的完整快照,
* 包括状态、结果、时间戳和元数据。
* </p>
*
* <h3>参数</h3>
* <ul>
* <li><b>task_id</b>(必填)—— 要查询的任务 ID</li>
* </ul>
*
* <h3>返回</h3>
* <p>JSON 格式的任务详情,或错误信息。</p>
*/
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<String, Object> 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<TaskInfo> taskOpt = manager.getTask(taskId);
if (taskOpt.isEmpty()) {
return errorJson("未找到 ID 为 '" + taskId + "' 的任务");
}
// 返回任务详情 JSON
return taskInfoToJson(taskOpt.get());
}
@Override
public String activityDescription(Map<String, Object> 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<String, String> 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));
}
}
@@ -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 工具 —— 列出所有任务,支持按状态过滤。
* <p>
* 对应 claude-code 中的 TaskList 命令。返回任务列表的 JSON 数组,
* 每个元素包含任务 ID、描述和当前状态。
* </p>
*
* <h3>参数</h3>
* <ul>
* <li><b>status</b>(可选)—— 状态过滤器:PENDING / RUNNING / COMPLETED / FAILED / CANCELLED</li>
* </ul>
*
* <h3>返回</h3>
* <p>JSON 格式的任务列表。</p>
*/
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<String, Object> 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<TaskInfo> taskList = manager.listTasks(statusFilter);
// 构建 JSON 响应
return buildListJson(taskList, statusFilter);
}
@Override
public String activityDescription(Map<String, Object> 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<TaskInfo> 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<String, String> 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));
}
}
@@ -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 工具 —— 更新指定任务的状态和结果。
* <p>
* 对应 claude-code 中的 TaskUpdate 命令。用于推动手动管理任务的状态流转,
* 例如从 PENDING → RUNNING → COMPLETED。
* </p>
*
* <h3>参数</h3>
* <ul>
* <li><b>task_id</b>(必填)—— 要更新的任务 ID</li>
* <li><b>status</b>(必填)—— 新状态:PENDING / RUNNING / COMPLETED / FAILED / CANCELLED</li>
* <li><b>result</b>(可选)—— 任务执行结果或附加信息</li>
* </ul>
*
* <h3>返回</h3>
* <p>JSON 格式的更新确认,包含更新后的任务信息。</p>
*
* <h3>状态约束</h3>
* <p>已处于终态(COMPLETED / FAILED / CANCELLED)的任务不允许再次更新。</p>
*/
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<String, Object> 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<TaskInfo> 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<TaskInfo> 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<String, Object> 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));
}
}