- 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