feat: Plugin marketplace - manifest, installer, auto-update, marketplace (Phase 3E)

- PluginManifest: manifest.json record with validation, DeclaredTool/Command/Hook, MarketplaceEntry
- PluginInstaller: HTTP download, JAR install/uninstall, version management, update checking
- MarketplaceManager: remote catalog fetch with 24h cache, search/filter/popular queries
- PluginAutoUpdate: scheduled update checks, parallel check, auto-install option, notification callback
- Enhanced /plugin command: install/remove/update/search subcommands (was: load/unload/reload/info)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
pull/1/head
abel533 1 month ago
parent 76191f5035
commit 12557f23f0
  1. 190
      src/main/java/com/claudecode/command/impl/PluginCommand.java
  2. 227
      src/main/java/com/claudecode/plugin/MarketplaceManager.java
  3. 191
      src/main/java/com/claudecode/plugin/PluginAutoUpdate.java
  4. 258
      src/main/java/com/claudecode/plugin/PluginInstaller.java
  5. 118
      src/main/java/com/claudecode/plugin/PluginManifest.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;
* <li>{@code /plugin unload <id>} 卸载指定插件</li>
* <li>{@code /plugin reload} 重载所有插件</li>
* <li>{@code /plugin info <id>} 显示插件详细信息</li>
* <li>{@code /plugin install <url|id>} 从市场或 URL 安装插件</li>
* <li>{@code /plugin remove <id>} 卸载并删除插件</li>
* <li>{@code /plugin update [id]} 检查/安装更新</li>
* <li>{@code /plugin search <query>} 搜索市场插件</li>
* </ul>
* <p>
* 通过 {@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 <url|plugin-id>");
}
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 <url>");
}
Optional<PluginManifest.MarketplaceEntry> 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 <plugin-id>");
}
// 先从运行时卸载
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 <url> 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 <query>");
}
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 实例
* <p>
@ -250,6 +430,10 @@ public class PluginCommand implements SlashCommand {
/plugin load <path> Load JAR plugin
/plugin unload <id> Unload plugin
/plugin reload Reload all plugins
/plugin info <id> View plugin details""");
/plugin info <id> View plugin details
/plugin install <x> Install from URL or marketplace
/plugin remove <id> Uninstall plugin
/plugin update [id] Check for updates
/plugin search <q> Search marketplace""");
}
}

@ -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 的目录获取与缓存
* <p>
* 功能
* <ul>
* <li>获取远程市场插件目录</li>
* <li>本地缓存TTL 24小时</li>
* <li>搜索和过滤</li>
* <li>热门推荐</li>
* </ul>
* <p>
* 缓存位置: ~/.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<String, PluginManifest.MarketplaceEntry> 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<PluginManifest.MarketplaceEntry> getCatalog(boolean forceRefresh) {
if (forceRefresh || isCacheExpired()) {
fetchRemoteCatalog();
}
return new ArrayList<>(catalog.values());
}
/**
* 搜索插件按名称描述标签
*/
public List<PluginManifest.MarketplaceEntry> 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<PluginManifest.MarketplaceEntry> getPlugin(String pluginId) {
if (isCacheExpired()) {
fetchRemoteCatalog();
}
return Optional.ofNullable(catalog.get(pluginId));
}
/**
* 获取热门插件
*/
public List<PluginManifest.MarketplaceEntry> getPopular(int limit) {
return catalog.values().stream()
.sorted(Comparator.comparingLong(PluginManifest.MarketplaceEntry::downloads).reversed())
.limit(limit)
.toList();
}
/**
* 获取指定标签的插件
*/
public List<PluginManifest.MarketplaceEntry> 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<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
List<PluginManifest.MarketplaceEntry> 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<PluginManifest.MarketplaceEntry> entries
) {}
}

@ -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.*;
/**
* 插件自动更新器 每日检查已安装插件的更新
* <p>
* 对应 claude-code 中插件自动更新逻辑
* <p>
* 特性
* <ul>
* <li>可配置检查间隔默认24小时</li>
* <li>只通知不自动安装除非配置 autoInstall=true</li>
* <li>并行检查所有已安装插件</li>
* <li>记录上次检查时间避免频繁检查</li>
* </ul>
*/
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<String, PluginInstaller.UpdateCheckResult> 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<String, PluginInstaller.UpdateCheckResult> checkForUpdates() {
Map<String, PluginInstaller.InstalledPluginInfo> 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<CompletableFuture<PluginInstaller.UpdateCheckResult>> futures = new ArrayList<>();
for (var entry : installed.values()) {
futures.add(installer.checkUpdate(
entry.id(), entry.version(), marketplaceUrl));
}
// 等待所有结果
Map<String, PluginInstaller.UpdateCheckResult> 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<String, PluginInstaller.UpdateCheckResult> 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<String, PluginInstaller.UpdateCheckResult> updates);
}
}

@ -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;
/**
* 插件安装器 处理插件的下载解压版本管理
* <p>
* 对应 claude-code marketplace 的安装逻辑
* <p>
* 安装目录结构
* <pre>
* ~/.claude-code-java/plugins/ (user )
* {project}/.claude-code/plugins/ (project )
* ~/.claude-code-java/plugin-cache/ (下载缓存)
* </pre>
*/
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 下载 URLJAR 文件
* @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<String> 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<UpdateCheckResult> 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<String> 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<String, InstalledPluginInfo> listInstalled() {
Map<String, InstalledPluginInfo> result = new TreeMap<>();
scanInstalled(userPluginDir, "user", result);
scanInstalled(projectPluginDir, "project", result);
return result;
}
private void scanInstalled(Path dir, String scope, Map<String, InstalledPluginInfo> 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<Path> 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) {}
}

@ -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 映射
* <p>
* 每个可安装的插件必须包含一个 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<DeclaredTool> tools,
/** 声明的命令 */
List<DeclaredCommand> commands,
/** 声明的 Hook */
List<DeclaredHook> hooks,
/** 安装作用域支持 */
List<String> scopes,
/** 依赖的其他插件 ID */
List<String> dependencies,
/** 额外元数据 */
Map<String, Object> 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<String> validate() {
var errors = new java.util.ArrayList<String>();
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<String> tags
) {
public static List<MarketplaceEntry> fromJsonArray(String json) throws Exception {
return MAPPER.readValue(json, new TypeReference<>() {});
}
}
}
Loading…
Cancel
Save