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