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>
This commit is contained in:
@@ -3,13 +3,14 @@ package com.claudecode.command.impl;
|
|||||||
import com.claudecode.command.CommandContext;
|
import com.claudecode.command.CommandContext;
|
||||||
import com.claudecode.command.SlashCommand;
|
import com.claudecode.command.SlashCommand;
|
||||||
import com.claudecode.console.AnsiStyle;
|
import com.claudecode.console.AnsiStyle;
|
||||||
import com.claudecode.plugin.Plugin;
|
import com.claudecode.plugin.*;
|
||||||
import com.claudecode.plugin.PluginManager;
|
|
||||||
import com.claudecode.plugin.PluginManager.PluginInfo;
|
import com.claudecode.plugin.PluginManager.PluginInfo;
|
||||||
import com.claudecode.tool.Tool;
|
import com.claudecode.tool.Tool;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /plugin 命令 —— 管理已加载的插件。
|
* /plugin 命令 —— 管理已加载的插件。
|
||||||
@@ -21,6 +22,10 @@ import java.util.List;
|
|||||||
* <li>{@code /plugin unload <id>} —— 卸载指定插件</li>
|
* <li>{@code /plugin unload <id>} —— 卸载指定插件</li>
|
||||||
* <li>{@code /plugin reload} —— 重载所有插件</li>
|
* <li>{@code /plugin reload} —— 重载所有插件</li>
|
||||||
* <li>{@code /plugin info <id>} —— 显示插件详细信息</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>
|
* </ul>
|
||||||
* <p>
|
* <p>
|
||||||
* 通过 {@link com.claudecode.tool.ToolContext} 中 key 为
|
* 通过 {@link com.claudecode.tool.ToolContext} 中 key 为
|
||||||
@@ -67,6 +72,10 @@ public class PluginCommand implements SlashCommand {
|
|||||||
case "unload" -> unloadPlugin(manager, subArgs);
|
case "unload" -> unloadPlugin(manager, subArgs);
|
||||||
case "reload" -> reloadPlugins(manager);
|
case "reload" -> reloadPlugins(manager);
|
||||||
case "info" -> pluginInfo(manager, subArgs);
|
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"
|
default -> AnsiStyle.yellow(" Unknown subcommand: " + subCommand) + "\n"
|
||||||
+ usageHelp();
|
+ usageHelp();
|
||||||
};
|
};
|
||||||
@@ -205,6 +214,177 @@ public class PluginCommand implements SlashCommand {
|
|||||||
return sb.toString();
|
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 实例。
|
* 从 CommandContext 获取 PluginManager 实例。
|
||||||
* <p>
|
* <p>
|
||||||
@@ -250,6 +430,10 @@ public class PluginCommand implements SlashCommand {
|
|||||||
/plugin load <path> Load JAR plugin
|
/plugin load <path> Load JAR plugin
|
||||||
/plugin unload <id> Unload plugin
|
/plugin unload <id> Unload plugin
|
||||||
/plugin reload Reload all plugins
|
/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 下载 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<>() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user