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 的目录获取与缓存。 + *

    + * 功能: + *

    + *

    + * 缓存位置: ~/.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 中插件自动更新逻辑。 + *

    + * 特性: + *

    + */ +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<>() {}); + } + } +}