diff --git a/src/main/java/com/claudecode/command/impl/PluginCommand.java b/src/main/java/com/claudecode/command/impl/PluginCommand.java
index 7a0125d..860e194 100644
--- a/src/main/java/com/claudecode/command/impl/PluginCommand.java
+++ b/src/main/java/com/claudecode/command/impl/PluginCommand.java
@@ -3,13 +3,14 @@ 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.*;
import com.claudecode.plugin.PluginManager.PluginInfo;
import com.claudecode.tool.Tool;
import java.nio.file.Path;
import java.util.List;
+import java.util.Map;
+import java.util.Optional;
/**
* /plugin 命令 —— 管理已加载的插件。
@@ -21,6 +22,10 @@ import java.util.List;
*
{@code /plugin unload } —— 卸载指定插件
* {@code /plugin reload} —— 重载所有插件
* {@code /plugin info } —— 显示插件详细信息
+ * {@code /plugin install } —— 从市场或 URL 安装插件
+ * {@code /plugin remove } —— 卸载并删除插件
+ * {@code /plugin update [id]} —— 检查/安装更新
+ * {@code /plugin search } —— 搜索市场插件
*
*
* 通过 {@link com.claudecode.tool.ToolContext} 中 key 为
@@ -67,6 +72,10 @@ public class PluginCommand implements SlashCommand {
case "unload" -> unloadPlugin(manager, subArgs);
case "reload" -> reloadPlugins(manager);
case "info" -> pluginInfo(manager, subArgs);
+ case "install" -> installPlugin(context, subArgs);
+ case "remove" -> removePlugin(context, manager, subArgs);
+ case "update" -> checkUpdates(context, subArgs);
+ case "search" -> searchMarketplace(context, subArgs);
default -> AnsiStyle.yellow(" Unknown subcommand: " + subCommand) + "\n"
+ usageHelp();
};
@@ -205,6 +214,177 @@ public class PluginCommand implements SlashCommand {
return sb.toString();
}
+ // ==================== Marketplace subcommands ====================
+
+ /**
+ * 从市场或 URL 安装插件。
+ */
+ private String installPlugin(CommandContext context, String target) {
+ if (target.isEmpty()) {
+ return AnsiStyle.yellow(" Usage: /plugin install ");
+ }
+
+ PluginInstaller installer = getInstaller(context);
+ if (installer == null) {
+ return AnsiStyle.red(" ✗ Plugin installer not available");
+ }
+
+ String downloadUrl;
+ if (target.startsWith("http://") || target.startsWith("https://")) {
+ downloadUrl = target;
+ } else {
+ // 从市场查找
+ MarketplaceManager marketplace = getMarketplace(context);
+ if (marketplace == null) {
+ return AnsiStyle.red(" ✗ Marketplace not available. Use URL instead: /plugin install ");
+ }
+ Optional entry = marketplace.getPlugin(target);
+ if (entry.isEmpty()) {
+ return AnsiStyle.red(" ✗ Plugin not found in marketplace: " + target);
+ }
+ downloadUrl = entry.get().downloadUrl();
+ if (downloadUrl == null || downloadUrl.isBlank()) {
+ return AnsiStyle.red(" ✗ No download URL for plugin: " + target);
+ }
+ }
+
+ var result = installer.install(downloadUrl, "user");
+ if (result.success()) {
+ return AnsiStyle.green(" ✓ " + result.message()) + "\n"
+ + AnsiStyle.dim(" Run /plugin reload to activate");
+ } else {
+ return AnsiStyle.red(" ✗ " + result.message());
+ }
+ }
+
+ /**
+ * 卸载并删除插件。
+ */
+ private String removePlugin(CommandContext context, PluginManager manager, String pluginId) {
+ if (pluginId.isEmpty()) {
+ return AnsiStyle.yellow(" Usage: /plugin remove ");
+ }
+
+ // 先从运行时卸载
+ manager.unload(pluginId);
+
+ // 再从磁盘删除
+ PluginInstaller installer = getInstaller(context);
+ if (installer != null) {
+ boolean deleted = installer.uninstall(pluginId, "user")
+ || installer.uninstall(pluginId, "project");
+ if (deleted) {
+ return AnsiStyle.green(" ✓ Plugin removed: " + pluginId);
+ }
+ }
+ return AnsiStyle.yellow(" ⚠ Plugin unloaded from runtime but JAR not found on disk");
+ }
+
+ /**
+ * 检查/安装更新。
+ */
+ private String checkUpdates(CommandContext context, String pluginId) {
+ PluginAutoUpdate autoUpdate = getAutoUpdate(context);
+ if (autoUpdate == null) {
+ return AnsiStyle.red(" ✗ Auto-update not available");
+ }
+
+ if (pluginId.isEmpty()) {
+ // 检查所有
+ var results = autoUpdate.checkForUpdates();
+ if (results.isEmpty()) {
+ return AnsiStyle.green(" ✓ All plugins are up to date");
+ }
+ StringBuilder sb = new StringBuilder();
+ sb.append("\n").append(AnsiStyle.bold(" 📦 Available Updates")).append("\n\n");
+ for (var result : results.values()) {
+ sb.append(String.format(" %s: %s → %s%n",
+ AnsiStyle.cyan(result.pluginId()),
+ AnsiStyle.dim(result.currentVersion()),
+ AnsiStyle.green(result.latestVersion())));
+ }
+ sb.append("\n").append(AnsiStyle.dim(" Run /plugin install to update")).append("\n");
+ return sb.toString();
+ } else {
+ // 检查指定插件
+ var pending = autoUpdate.getPendingUpdates().get(pluginId);
+ if (pending != null && pending.hasUpdate()) {
+ return String.format(" %s: %s → %s\n Download: %s",
+ AnsiStyle.cyan(pluginId),
+ pending.currentVersion(), pending.latestVersion(),
+ pending.downloadUrl());
+ }
+ return AnsiStyle.green(" ✓ " + pluginId + " is up to date");
+ }
+ }
+
+ /**
+ * 搜索市场插件。
+ */
+ private String searchMarketplace(CommandContext context, String query) {
+ if (query.isEmpty()) {
+ return AnsiStyle.yellow(" Usage: /plugin search ");
+ }
+
+ MarketplaceManager marketplace = getMarketplace(context);
+ if (marketplace == null) {
+ return AnsiStyle.red(" ✗ Marketplace not available");
+ }
+
+ var results = marketplace.search(query);
+ if (results.isEmpty()) {
+ return AnsiStyle.dim(" No plugins found for: " + query);
+ }
+
+ StringBuilder sb = new StringBuilder();
+ sb.append("\n").append(AnsiStyle.bold(" 🔍 Search Results")).append(" (")
+ .append(results.size()).append(")\n\n");
+
+ for (var entry : results.stream().limit(10).toList()) {
+ sb.append(String.format(" %s %s %s%n",
+ AnsiStyle.bold(entry.name()),
+ AnsiStyle.dim("v" + entry.version()),
+ AnsiStyle.dim("by " + entry.author())));
+ sb.append(String.format(" %s ⬇ %d%n",
+ entry.description(),
+ entry.downloads()));
+ sb.append(String.format(" %s%n%n",
+ AnsiStyle.cyan("/plugin install " + entry.id())));
+ }
+
+ if (results.size() > 10) {
+ sb.append(AnsiStyle.dim(" ... and " + (results.size() - 10) + " more"));
+ }
+
+ return sb.toString();
+ }
+
+ // ==================== Context helpers ====================
+
+ private PluginInstaller getInstaller(CommandContext context) {
+ try {
+ Object obj = context.agentLoop().getToolContext().get("PLUGIN_INSTALLER");
+ if (obj instanceof PluginInstaller pi) return pi;
+ } catch (Exception ignored) {}
+ return null;
+ }
+
+ private MarketplaceManager getMarketplace(CommandContext context) {
+ try {
+ Object obj = context.agentLoop().getToolContext().get("MARKETPLACE_MANAGER");
+ if (obj instanceof MarketplaceManager mm) return mm;
+ } catch (Exception ignored) {}
+ return null;
+ }
+
+ private PluginAutoUpdate getAutoUpdate(CommandContext context) {
+ try {
+ Object obj = context.agentLoop().getToolContext().get("PLUGIN_AUTO_UPDATE");
+ if (obj instanceof PluginAutoUpdate pau) return pau;
+ } catch (Exception ignored) {}
+ return null;
+ }
+
/**
* 从 CommandContext 获取 PluginManager 实例。
*
@@ -250,6 +430,10 @@ public class PluginCommand implements SlashCommand {
/plugin load Load JAR plugin
/plugin unload Unload plugin
/plugin reload Reload all plugins
- /plugin info View plugin details""");
+ /plugin info View plugin details
+ /plugin install Install from URL or marketplace
+ /plugin remove Uninstall plugin
+ /plugin update [id] Check for updates
+ /plugin search Search marketplace""");
}
}
diff --git a/src/main/java/com/claudecode/plugin/MarketplaceManager.java b/src/main/java/com/claudecode/plugin/MarketplaceManager.java
new file mode 100644
index 0000000..f394e2f
--- /dev/null
+++ b/src/main/java/com/claudecode/plugin/MarketplaceManager.java
@@ -0,0 +1,227 @@
+package com.claudecode.plugin;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.*;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 市场管理器 —— 对应 claude-code 中 marketplace 的目录获取与缓存。
+ *
+ * 功能:
+ *
+ * - 获取远程市场插件目录
+ * - 本地缓存(TTL 24小时)
+ * - 搜索和过滤
+ * - 热门推荐
+ *
+ *
+ * 缓存位置: ~/.claude-code-java/marketplace-cache.json
+ */
+public class MarketplaceManager {
+
+ private static final Logger log = LoggerFactory.getLogger(MarketplaceManager.class);
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ /** 默认市场 URL(可配置) */
+ private static final String DEFAULT_MARKETPLACE_URL =
+ "https://marketplace.claude-code.dev/api/v1";
+
+ /** 缓存 TTL:24小时 */
+ private static final Duration CACHE_TTL = Duration.ofHours(24);
+
+ private final String marketplaceUrl;
+ private final Path cacheFile;
+ private final HttpClient httpClient;
+
+ /** 内存缓存 */
+ private final ConcurrentHashMap catalog = new ConcurrentHashMap<>();
+ private Instant lastFetchTime = Instant.EPOCH;
+
+ public MarketplaceManager() {
+ this(DEFAULT_MARKETPLACE_URL);
+ }
+
+ public MarketplaceManager(String marketplaceUrl) {
+ this.marketplaceUrl = marketplaceUrl;
+ this.cacheFile = Path.of(
+ System.getProperty("user.home"), ".claude-code-java", "marketplace-cache.json");
+ this.httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(15))
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .build();
+
+ // 尝试从本地缓存加载
+ loadCache();
+ }
+
+ /**
+ * 获取完整目录(必要时从远程刷新)。
+ */
+ public List getCatalog(boolean forceRefresh) {
+ if (forceRefresh || isCacheExpired()) {
+ fetchRemoteCatalog();
+ }
+ return new ArrayList<>(catalog.values());
+ }
+
+ /**
+ * 搜索插件(按名称、描述、标签)。
+ */
+ public List search(String query) {
+ if (isCacheExpired()) {
+ fetchRemoteCatalog();
+ }
+
+ String lowerQuery = query.toLowerCase();
+ return catalog.values().stream()
+ .filter(e -> matchesQuery(e, lowerQuery))
+ .sorted(Comparator.comparingLong(PluginManifest.MarketplaceEntry::downloads).reversed())
+ .toList();
+ }
+
+ /**
+ * 获取单个插件信息。
+ */
+ public Optional getPlugin(String pluginId) {
+ if (isCacheExpired()) {
+ fetchRemoteCatalog();
+ }
+ return Optional.ofNullable(catalog.get(pluginId));
+ }
+
+ /**
+ * 获取热门插件。
+ */
+ public List getPopular(int limit) {
+ return catalog.values().stream()
+ .sorted(Comparator.comparingLong(PluginManifest.MarketplaceEntry::downloads).reversed())
+ .limit(limit)
+ .toList();
+ }
+
+ /**
+ * 获取指定标签的插件。
+ */
+ public List getByTag(String tag) {
+ return catalog.values().stream()
+ .filter(e -> e.tags() != null && e.tags().contains(tag))
+ .toList();
+ }
+
+ /**
+ * 目录大小。
+ */
+ public int size() {
+ return catalog.size();
+ }
+
+ /**
+ * 上次获取时间。
+ */
+ public Instant getLastFetchTime() {
+ return lastFetchTime;
+ }
+
+ // ==================== 远程获取 ====================
+
+ private void fetchRemoteCatalog() {
+ try {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(marketplaceUrl + "/plugins"))
+ .timeout(Duration.ofSeconds(30))
+ .header("Accept", "application/json")
+ .header("User-Agent", "claude-code-java/1.0")
+ .GET()
+ .build();
+
+ HttpResponse response = httpClient.send(request,
+ HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() == 200) {
+ List entries =
+ PluginManifest.MarketplaceEntry.fromJsonArray(response.body());
+
+ catalog.clear();
+ for (var entry : entries) {
+ catalog.put(entry.id(), entry);
+ }
+ lastFetchTime = Instant.now();
+ saveCache();
+
+ log.info("Fetched {} plugins from marketplace", entries.size());
+ } else {
+ log.warn("Marketplace API returned status: {}", response.statusCode());
+ }
+ } catch (Exception e) {
+ log.warn("Failed to fetch marketplace catalog: {}", e.getMessage());
+ // 使用本地缓存(如果有)
+ }
+ }
+
+ // ==================== 本地缓存 ====================
+
+ private void loadCache() {
+ if (!Files.isRegularFile(cacheFile)) return;
+ try {
+ CacheData data = MAPPER.readValue(cacheFile.toFile(), CacheData.class);
+ if (data.entries != null) {
+ for (var entry : data.entries) {
+ catalog.put(entry.id(), entry);
+ }
+ }
+ if (data.fetchTime != null) {
+ lastFetchTime = Instant.parse(data.fetchTime);
+ }
+ log.debug("Loaded {} entries from marketplace cache", catalog.size());
+ } catch (Exception e) {
+ log.debug("Failed to load marketplace cache: {}", e.getMessage());
+ }
+ }
+
+ private void saveCache() {
+ try {
+ Files.createDirectories(cacheFile.getParent());
+ CacheData data = new CacheData(
+ lastFetchTime.toString(), new ArrayList<>(catalog.values()));
+ MAPPER.writerWithDefaultPrettyPrinter()
+ .writeValue(cacheFile.toFile(), data);
+ } catch (Exception e) {
+ log.debug("Failed to save marketplace cache: {}", e.getMessage());
+ }
+ }
+
+ private boolean isCacheExpired() {
+ return Duration.between(lastFetchTime, Instant.now()).compareTo(CACHE_TTL) > 0;
+ }
+
+ private boolean matchesQuery(PluginManifest.MarketplaceEntry entry, String query) {
+ if (entry.name() != null && entry.name().toLowerCase().contains(query)) return true;
+ if (entry.description() != null && entry.description().toLowerCase().contains(query)) return true;
+ if (entry.id() != null && entry.id().toLowerCase().contains(query)) return true;
+ if (entry.author() != null && entry.author().toLowerCase().contains(query)) return true;
+ if (entry.tags() != null) {
+ for (String tag : entry.tags()) {
+ if (tag.toLowerCase().contains(query)) return true;
+ }
+ }
+ return false;
+ }
+
+ // ==================== 缓存数据结构 ====================
+
+ private record CacheData(
+ String fetchTime,
+ List entries
+ ) {}
+}
diff --git a/src/main/java/com/claudecode/plugin/PluginAutoUpdate.java b/src/main/java/com/claudecode/plugin/PluginAutoUpdate.java
new file mode 100644
index 0000000..41ec255
--- /dev/null
+++ b/src/main/java/com/claudecode/plugin/PluginAutoUpdate.java
@@ -0,0 +1,191 @@
+package com.claudecode.plugin;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+/**
+ * 插件自动更新器 —— 每日检查已安装插件的更新。
+ *
+ * 对应 claude-code 中插件自动更新逻辑。
+ *
+ * 特性:
+ *
+ * - 可配置检查间隔(默认24小时)
+ * - 只通知,不自动安装(除非配置 autoInstall=true)
+ * - 并行检查所有已安装插件
+ * - 记录上次检查时间,避免频繁检查
+ *
+ */
+public class PluginAutoUpdate {
+
+ private static final Logger log = LoggerFactory.getLogger(PluginAutoUpdate.class);
+
+ private final PluginInstaller installer;
+ private final PluginManager pluginManager;
+ private final String marketplaceUrl;
+
+ /** 是否自动安装更新 */
+ private boolean autoInstall = false;
+
+ /** 检查间隔 */
+ private Duration checkInterval = Duration.ofHours(24);
+
+ /** 上次检查时间 */
+ private Instant lastCheckTime = Instant.EPOCH;
+
+ /** 可用更新缓存 */
+ private final ConcurrentHashMap pendingUpdates =
+ new ConcurrentHashMap<>();
+
+ /** 更新通知回调 */
+ private UpdateNotificationCallback notificationCallback;
+
+ private final ScheduledExecutorService scheduler =
+ Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "plugin-auto-update");
+ t.setDaemon(true);
+ return t;
+ });
+
+ public PluginAutoUpdate(PluginInstaller installer, PluginManager pluginManager) {
+ this(installer, pluginManager, "https://marketplace.claude-code.dev/api/v1");
+ }
+
+ public PluginAutoUpdate(PluginInstaller installer, PluginManager pluginManager,
+ String marketplaceUrl) {
+ this.installer = installer;
+ this.pluginManager = pluginManager;
+ this.marketplaceUrl = marketplaceUrl;
+ }
+
+ /**
+ * 启动定期检查。
+ */
+ public void start() {
+ long intervalHours = checkInterval.toHours();
+ scheduler.scheduleWithFixedDelay(this::checkForUpdates,
+ 1, intervalHours, TimeUnit.HOURS);
+ log.info("Plugin auto-update started (interval: {}h)", intervalHours);
+ }
+
+ /**
+ * 停止定期检查。
+ */
+ public void stop() {
+ scheduler.shutdown();
+ try {
+ if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
+ scheduler.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ scheduler.shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ /**
+ * 立即检查所有已安装插件的更新。
+ */
+ public Map checkForUpdates() {
+ Map installed = installer.listInstalled();
+ if (installed.isEmpty()) {
+ log.debug("No installed plugins to check for updates");
+ return Map.of();
+ }
+
+ log.info("Checking updates for {} plugins...", installed.size());
+
+ // 并行检查所有插件
+ List> futures = new ArrayList<>();
+ for (var entry : installed.values()) {
+ futures.add(installer.checkUpdate(
+ entry.id(), entry.version(), marketplaceUrl));
+ }
+
+ // 等待所有结果
+ Map results = new HashMap<>();
+ for (var future : futures) {
+ try {
+ var result = future.get(30, TimeUnit.SECONDS);
+ if (result.hasUpdate()) {
+ results.put(result.pluginId(), result);
+ pendingUpdates.put(result.pluginId(), result);
+ log.info("Update available: {} {} → {}",
+ result.pluginId(), result.currentVersion(), result.latestVersion());
+
+ // 自动安装
+ if (autoInstall && result.downloadUrl() != null) {
+ var installed2 = installer.listInstalled().get(result.pluginId());
+ if (installed2 != null) {
+ var installResult = installer.install(
+ result.downloadUrl(), installed2.scope());
+ log.info("Auto-updated {}: {}", result.pluginId(), installResult.message());
+ }
+ }
+ }
+ } catch (Exception e) {
+ log.debug("Update check failed: {}", e.getMessage());
+ }
+ }
+
+ lastCheckTime = Instant.now();
+
+ // 通知回调
+ if (!results.isEmpty() && notificationCallback != null) {
+ notificationCallback.onUpdatesAvailable(results);
+ }
+
+ log.info("Update check complete: {} updates available", results.size());
+ return results;
+ }
+
+ /**
+ * 获取待处理的更新列表。
+ */
+ public Map getPendingUpdates() {
+ return Map.copyOf(pendingUpdates);
+ }
+
+ /**
+ * 清除指定插件的待更新标记。
+ */
+ public void clearPendingUpdate(String pluginId) {
+ pendingUpdates.remove(pluginId);
+ }
+
+ /**
+ * 是否需要检查(距上次检查超过 interval)。
+ */
+ public boolean shouldCheck() {
+ return Duration.between(lastCheckTime, Instant.now()).compareTo(checkInterval) > 0;
+ }
+
+ // ==================== 配置 ====================
+
+ public void setAutoInstall(boolean autoInstall) {
+ this.autoInstall = autoInstall;
+ }
+
+ public void setCheckInterval(Duration interval) {
+ this.checkInterval = interval;
+ }
+
+ public void setNotificationCallback(UpdateNotificationCallback callback) {
+ this.notificationCallback = callback;
+ }
+
+ public Instant getLastCheckTime() {
+ return lastCheckTime;
+ }
+
+ // ==================== 回调接口 ====================
+
+ @FunctionalInterface
+ public interface UpdateNotificationCallback {
+ void onUpdatesAvailable(Map updates);
+ }
+}
diff --git a/src/main/java/com/claudecode/plugin/PluginInstaller.java b/src/main/java/com/claudecode/plugin/PluginInstaller.java
new file mode 100644
index 0000000..a501a6e
--- /dev/null
+++ b/src/main/java/com/claudecode/plugin/PluginInstaller.java
@@ -0,0 +1,258 @@
+package com.claudecode.plugin;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.*;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.*;
+import java.time.Duration;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+/**
+ * 插件安装器 —— 处理插件的下载、解压、版本管理。
+ *
+ * 对应 claude-code 中 marketplace 的安装逻辑。
+ *
+ * 安装目录结构:
+ *
+ * ~/.claude-code-java/plugins/ (user 级)
+ * {project}/.claude-code/plugins/ (project 级)
+ * ~/.claude-code-java/plugin-cache/ (下载缓存)
+ *
+ */
+public class PluginInstaller {
+
+ private static final Logger log = LoggerFactory.getLogger(PluginInstaller.class);
+
+ private final HttpClient httpClient;
+ private final Path cacheDir;
+ private final Path userPluginDir;
+ private final Path projectPluginDir;
+
+ public PluginInstaller(Path workDir) {
+ this.httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(30))
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .build();
+ Path home = Path.of(System.getProperty("user.home"), ".claude-code-java");
+ this.cacheDir = home.resolve("plugin-cache");
+ this.userPluginDir = home.resolve("plugins");
+ this.projectPluginDir = workDir.resolve(".claude-code").resolve("plugins");
+ }
+
+ /**
+ * 安装插件。
+ *
+ * @param downloadUrl 下载 URL(JAR 文件)
+ * @param scope 安装作用域:user / project
+ * @return 安装结果
+ */
+ public InstallResult install(String downloadUrl, String scope) {
+ try {
+ // 下载
+ Path downloaded = download(downloadUrl);
+
+ // 读取并校验 manifest
+ PluginManifest manifest = readManifestFromJar(downloaded);
+ if (manifest == null) {
+ return new InstallResult(false, null, "No manifest.json found in JAR");
+ }
+
+ List errors = manifest.validate();
+ if (!errors.isEmpty()) {
+ return new InstallResult(false, null, "Invalid manifest: " + String.join(", ", errors));
+ }
+
+ if (!manifest.supportsScope(scope)) {
+ return new InstallResult(false, manifest.id(),
+ "Plugin does not support scope: " + scope);
+ }
+
+ // 确定目标目录
+ Path targetDir = "project".equals(scope) ? projectPluginDir : userPluginDir;
+ Files.createDirectories(targetDir);
+
+ // 检查已安装版本
+ Path existing = targetDir.resolve(manifest.id() + ".jar");
+ if (Files.exists(existing)) {
+ PluginManifest existingManifest = readManifestFromJar(existing);
+ if (existingManifest != null &&
+ existingManifest.version().equals(manifest.version())) {
+ return new InstallResult(true, manifest.id(),
+ "Already installed: " + manifest.name() + " v" + manifest.version());
+ }
+ // 备份旧版本
+ Path backup = targetDir.resolve(manifest.id() + ".jar.bak");
+ Files.move(existing, backup, StandardCopyOption.REPLACE_EXISTING);
+ log.info("Backed up existing version to {}", backup.getFileName());
+ }
+
+ // 复制到目标
+ Files.copy(downloaded, existing, StandardCopyOption.REPLACE_EXISTING);
+
+ log.info("Installed plugin: {} v{} to {} ({})",
+ manifest.name(), manifest.version(), targetDir, scope);
+ return new InstallResult(true, manifest.id(),
+ "Installed " + manifest.name() + " v" + manifest.version());
+
+ } catch (Exception e) {
+ log.error("Plugin installation failed: {}", e.getMessage(), e);
+ return new InstallResult(false, null, "Installation failed: " + e.getMessage());
+ }
+ }
+
+ /**
+ * 卸载插件。
+ */
+ public boolean uninstall(String pluginId, String scope) {
+ Path targetDir = "project".equals(scope) ? projectPluginDir : userPluginDir;
+ Path jarFile = targetDir.resolve(pluginId + ".jar");
+ try {
+ if (Files.deleteIfExists(jarFile)) {
+ // 也清理备份
+ Files.deleteIfExists(targetDir.resolve(pluginId + ".jar.bak"));
+ log.info("Uninstalled plugin: {} from {}", pluginId, scope);
+ return true;
+ }
+ log.warn("Plugin JAR not found: {}", jarFile);
+ return false;
+ } catch (IOException e) {
+ log.error("Failed to uninstall plugin: {}", e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * 检查插件是否有更新。
+ */
+ public CompletableFuture checkUpdate(
+ String pluginId, String currentVersion, String checkUrl) {
+ return CompletableFuture.supplyAsync(() -> {
+ try {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(checkUrl + "/" + pluginId + "/latest"))
+ .timeout(Duration.ofSeconds(10))
+ .GET()
+ .build();
+
+ HttpResponse response = httpClient.send(request,
+ HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() == 200) {
+ PluginManifest.MarketplaceEntry entry =
+ new com.fasterxml.jackson.databind.ObjectMapper()
+ .readValue(response.body(),
+ PluginManifest.MarketplaceEntry.class);
+ boolean hasUpdate = compareVersions(entry.version(), currentVersion) > 0;
+ return new UpdateCheckResult(pluginId, currentVersion,
+ entry.version(), hasUpdate, entry.downloadUrl());
+ }
+ return new UpdateCheckResult(pluginId, currentVersion, null, false, null);
+ } catch (Exception e) {
+ log.debug("Update check failed for {}: {}", pluginId, e.getMessage());
+ return new UpdateCheckResult(pluginId, currentVersion, null, false, null);
+ }
+ });
+ }
+
+ /**
+ * 列出已安装的插件 JAR。
+ */
+ public Map listInstalled() {
+ Map result = new TreeMap<>();
+ scanInstalled(userPluginDir, "user", result);
+ scanInstalled(projectPluginDir, "project", result);
+ return result;
+ }
+
+ private void scanInstalled(Path dir, String scope, Map result) {
+ if (!Files.isDirectory(dir)) return;
+ try (var stream = Files.list(dir)) {
+ stream.filter(p -> p.toString().endsWith(".jar"))
+ .forEach(jar -> {
+ PluginManifest m = readManifestFromJar(jar);
+ if (m != null) {
+ result.put(m.id(), new InstalledPluginInfo(
+ m.id(), m.name(), m.version(), scope, jar));
+ }
+ });
+ } catch (IOException e) {
+ log.debug("Failed to scan {}: {}", dir, e.getMessage());
+ }
+ }
+
+ // ==================== 内部方法 ====================
+
+ private Path download(String url) throws Exception {
+ Files.createDirectories(cacheDir);
+ String fileName = url.substring(url.lastIndexOf('/') + 1);
+ if (!fileName.endsWith(".jar")) fileName += ".jar";
+ Path target = cacheDir.resolve(fileName);
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(url))
+ .timeout(Duration.ofMinutes(5))
+ .GET()
+ .build();
+
+ HttpResponse response = httpClient.send(request,
+ HttpResponse.BodyHandlers.ofFile(target));
+
+ if (response.statusCode() != 200) {
+ throw new IOException("Download failed with status: " + response.statusCode());
+ }
+
+ log.info("Downloaded {} ({} bytes)", fileName, Files.size(target));
+ return target;
+ }
+
+ static PluginManifest readManifestFromJar(Path jarPath) {
+ try (JarFile jar = new JarFile(jarPath.toFile())) {
+ JarEntry entry = jar.getJarEntry("manifest.json");
+ if (entry == null) return null;
+
+ try (InputStream is = jar.getInputStream(entry)) {
+ return PluginManifest.fromJson(is.readAllBytes());
+ }
+ } catch (Exception e) {
+ log.debug("Failed to read manifest from {}: {}", jarPath.getFileName(), e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * 简单版本比较 (a.b.c 格式)。
+ * @return 正数表示 v1 > v2, 负数表示 v1 < v2, 0 表示相等
+ */
+ static int compareVersions(String v1, String v2) {
+ // 去掉非数字后缀 (e.g., "1.2.3-beta" → "1.2.3")
+ String[] parts1 = v1.replaceAll("[^0-9.].*", "").split("\\.");
+ String[] parts2 = v2.replaceAll("[^0-9.].*", "").split("\\.");
+
+ int maxLen = Math.max(parts1.length, parts2.length);
+ for (int i = 0; i < maxLen; i++) {
+ int p1 = i < parts1.length ? Integer.parseInt(parts1[i]) : 0;
+ int p2 = i < parts2.length ? Integer.parseInt(parts2[i]) : 0;
+ if (p1 != p2) return p1 - p2;
+ }
+ return 0;
+ }
+
+ // ==================== 结果类型 ====================
+
+ public record InstallResult(boolean success, String pluginId, String message) {}
+
+ public record UpdateCheckResult(
+ String pluginId, String currentVersion, String latestVersion,
+ boolean hasUpdate, String downloadUrl) {}
+
+ public record InstalledPluginInfo(
+ String id, String name, String version, String scope, Path jarPath) {}
+}
diff --git a/src/main/java/com/claudecode/plugin/PluginManifest.java b/src/main/java/com/claudecode/plugin/PluginManifest.java
new file mode 100644
index 0000000..e627ba7
--- /dev/null
+++ b/src/main/java/com/claudecode/plugin/PluginManifest.java
@@ -0,0 +1,118 @@
+package com.claudecode.plugin;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 插件清单 —— 对应 claude-code 中 manifest.json 的 Java 映射。
+ *
+ * 每个可安装的插件必须包含一个 manifest.json,声明其元数据和能力。
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record PluginManifest(
+ /** 插件唯一 ID (kebab-case) */
+ String id,
+ /** 显示名称 */
+ String name,
+ /** 版本号 (semver) */
+ String version,
+ /** 功能描述 */
+ String description,
+ /** 作者 */
+ String author,
+ /** 主页 URL */
+ String homepage,
+ /** 许可证 */
+ String license,
+ /** 最低 claude-code-java 版本 */
+ String minAppVersion,
+ /** 入口类(JAR 中实现 Plugin 接口的类) */
+ String mainClass,
+ /** 声明的工具 */
+ List tools,
+ /** 声明的命令 */
+ List commands,
+ /** 声明的 Hook */
+ List hooks,
+ /** 安装作用域支持 */
+ List scopes,
+ /** 依赖的其他插件 ID */
+ List dependencies,
+ /** 额外元数据 */
+ Map metadata
+) {
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ /** 从 JSON 字符串解析 */
+ public static PluginManifest fromJson(String json) throws Exception {
+ return MAPPER.readValue(json, PluginManifest.class);
+ }
+
+ /** 从 JSON bytes 解析 */
+ public static PluginManifest fromJson(byte[] json) throws Exception {
+ return MAPPER.readValue(json, PluginManifest.class);
+ }
+
+ /** 序列化为 JSON */
+ public String toJson() throws Exception {
+ return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(this);
+ }
+
+ /** 校验必填字段 */
+ public List validate() {
+ var errors = new java.util.ArrayList();
+ if (id == null || id.isBlank()) errors.add("id is required");
+ if (name == null || name.isBlank()) errors.add("name is required");
+ if (version == null || version.isBlank()) errors.add("version is required");
+ if (mainClass == null || mainClass.isBlank()) errors.add("mainClass is required");
+
+ if (id != null && !id.matches("^[a-z][a-z0-9-]*$")) {
+ errors.add("id must be kebab-case: " + id);
+ }
+ if (version != null && !version.matches("^\\d+\\.\\d+\\.\\d+.*$")) {
+ errors.add("version must be semver: " + version);
+ }
+ return errors;
+ }
+
+ /** 是否支持指定作用域 */
+ public boolean supportsScope(String scope) {
+ return scopes == null || scopes.isEmpty() || scopes.contains(scope);
+ }
+
+ // ---- 子记录 ----
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record DeclaredTool(String name, String description, boolean readOnly) {}
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record DeclaredCommand(String name, String description) {}
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record DeclaredHook(String event, String handler) {}
+
+ /**
+ * 市场目录条目 —— 市场 API 返回的精简信息。
+ */
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public record MarketplaceEntry(
+ String id,
+ String name,
+ String version,
+ String description,
+ String author,
+ String downloadUrl,
+ long downloads,
+ double rating,
+ String updatedAt,
+ List tags
+ ) {
+ public static List fromJsonArray(String json) throws Exception {
+ return MAPPER.readValue(json, new TypeReference<>() {});
+ }
+ }
+}