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:
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user