- 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
parent
76191f5035
commit
12557f23f0
@ -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 下载 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<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…
Reference in new issue