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) {}
}