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:
abel533
2026-04-05 10:36:10 +08:00
co-authored by Copilot
parent 76191f5035
commit 12557f23f0
5 changed files with 981 additions and 3 deletions
@@ -3,13 +3,14 @@ package com.claudecode.command.impl;
import com.claudecode.command.CommandContext;
import com.claudecode.command.SlashCommand;
import com.claudecode.console.AnsiStyle;
import com.claudecode.plugin.Plugin;
import com.claudecode.plugin.PluginManager;
import com.claudecode.plugin.*;
import com.claudecode.plugin.PluginManager.PluginInfo;
import com.claudecode.tool.Tool;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* /plugin 命令 —— 管理已加载的插件。
@@ -21,6 +22,10 @@ import java.util.List;
* <li>{@code /plugin unload <id>} —— 卸载指定插件</li>
* <li>{@code /plugin reload} —— 重载所有插件</li>
* <li>{@code /plugin info <id>} —— 显示插件详细信息</li>
* <li>{@code /plugin install <url|id>} —— 从市场或 URL 安装插件</li>
* <li>{@code /plugin remove <id>} —— 卸载并删除插件</li>
* <li>{@code /plugin update [id]} —— 检查/安装更新</li>
* <li>{@code /plugin search <query>} —— 搜索市场插件</li>
* </ul>
* <p>
* 通过 {@link com.claudecode.tool.ToolContext} 中 key 为
@@ -67,6 +72,10 @@ public class PluginCommand implements SlashCommand {
case "unload" -> unloadPlugin(manager, subArgs);
case "reload" -> reloadPlugins(manager);
case "info" -> pluginInfo(manager, subArgs);
case "install" -> installPlugin(context, subArgs);
case "remove" -> removePlugin(context, manager, subArgs);
case "update" -> checkUpdates(context, subArgs);
case "search" -> searchMarketplace(context, subArgs);
default -> AnsiStyle.yellow(" Unknown subcommand: " + subCommand) + "\n"
+ usageHelp();
};
@@ -205,6 +214,177 @@ public class PluginCommand implements SlashCommand {
return sb.toString();
}
// ==================== Marketplace subcommands ====================
/**
* 从市场或 URL 安装插件。
*/
private String installPlugin(CommandContext context, String target) {
if (target.isEmpty()) {
return AnsiStyle.yellow(" Usage: /plugin install <url|plugin-id>");
}
PluginInstaller installer = getInstaller(context);
if (installer == null) {
return AnsiStyle.red(" ✗ Plugin installer not available");
}
String downloadUrl;
if (target.startsWith("http://") || target.startsWith("https://")) {
downloadUrl = target;
} else {
// 从市场查找
MarketplaceManager marketplace = getMarketplace(context);
if (marketplace == null) {
return AnsiStyle.red(" ✗ Marketplace not available. Use URL instead: /plugin install <url>");
}
Optional<PluginManifest.MarketplaceEntry> entry = marketplace.getPlugin(target);
if (entry.isEmpty()) {
return AnsiStyle.red(" ✗ Plugin not found in marketplace: " + target);
}
downloadUrl = entry.get().downloadUrl();
if (downloadUrl == null || downloadUrl.isBlank()) {
return AnsiStyle.red(" ✗ No download URL for plugin: " + target);
}
}
var result = installer.install(downloadUrl, "user");
if (result.success()) {
return AnsiStyle.green("" + result.message()) + "\n"
+ AnsiStyle.dim(" Run /plugin reload to activate");
} else {
return AnsiStyle.red("" + result.message());
}
}
/**
* 卸载并删除插件。
*/
private String removePlugin(CommandContext context, PluginManager manager, String pluginId) {
if (pluginId.isEmpty()) {
return AnsiStyle.yellow(" Usage: /plugin remove <plugin-id>");
}
// 先从运行时卸载
manager.unload(pluginId);
// 再从磁盘删除
PluginInstaller installer = getInstaller(context);
if (installer != null) {
boolean deleted = installer.uninstall(pluginId, "user")
|| installer.uninstall(pluginId, "project");
if (deleted) {
return AnsiStyle.green(" ✓ Plugin removed: " + pluginId);
}
}
return AnsiStyle.yellow(" ⚠ Plugin unloaded from runtime but JAR not found on disk");
}
/**
* 检查/安装更新。
*/
private String checkUpdates(CommandContext context, String pluginId) {
PluginAutoUpdate autoUpdate = getAutoUpdate(context);
if (autoUpdate == null) {
return AnsiStyle.red(" ✗ Auto-update not available");
}
if (pluginId.isEmpty()) {
// 检查所有
var results = autoUpdate.checkForUpdates();
if (results.isEmpty()) {
return AnsiStyle.green(" ✓ All plugins are up to date");
}
StringBuilder sb = new StringBuilder();
sb.append("\n").append(AnsiStyle.bold(" 📦 Available Updates")).append("\n\n");
for (var result : results.values()) {
sb.append(String.format(" %s: %s → %s%n",
AnsiStyle.cyan(result.pluginId()),
AnsiStyle.dim(result.currentVersion()),
AnsiStyle.green(result.latestVersion())));
}
sb.append("\n").append(AnsiStyle.dim(" Run /plugin install <url> to update")).append("\n");
return sb.toString();
} else {
// 检查指定插件
var pending = autoUpdate.getPendingUpdates().get(pluginId);
if (pending != null && pending.hasUpdate()) {
return String.format(" %s: %s → %s\n Download: %s",
AnsiStyle.cyan(pluginId),
pending.currentVersion(), pending.latestVersion(),
pending.downloadUrl());
}
return AnsiStyle.green("" + pluginId + " is up to date");
}
}
/**
* 搜索市场插件。
*/
private String searchMarketplace(CommandContext context, String query) {
if (query.isEmpty()) {
return AnsiStyle.yellow(" Usage: /plugin search <query>");
}
MarketplaceManager marketplace = getMarketplace(context);
if (marketplace == null) {
return AnsiStyle.red(" ✗ Marketplace not available");
}
var results = marketplace.search(query);
if (results.isEmpty()) {
return AnsiStyle.dim(" No plugins found for: " + query);
}
StringBuilder sb = new StringBuilder();
sb.append("\n").append(AnsiStyle.bold(" 🔍 Search Results")).append(" (")
.append(results.size()).append(")\n\n");
for (var entry : results.stream().limit(10).toList()) {
sb.append(String.format(" %s %s %s%n",
AnsiStyle.bold(entry.name()),
AnsiStyle.dim("v" + entry.version()),
AnsiStyle.dim("by " + entry.author())));
sb.append(String.format(" %s ⬇ %d%n",
entry.description(),
entry.downloads()));
sb.append(String.format(" %s%n%n",
AnsiStyle.cyan("/plugin install " + entry.id())));
}
if (results.size() > 10) {
sb.append(AnsiStyle.dim(" ... and " + (results.size() - 10) + " more"));
}
return sb.toString();
}
// ==================== Context helpers ====================
private PluginInstaller getInstaller(CommandContext context) {
try {
Object obj = context.agentLoop().getToolContext().get("PLUGIN_INSTALLER");
if (obj instanceof PluginInstaller pi) return pi;
} catch (Exception ignored) {}
return null;
}
private MarketplaceManager getMarketplace(CommandContext context) {
try {
Object obj = context.agentLoop().getToolContext().get("MARKETPLACE_MANAGER");
if (obj instanceof MarketplaceManager mm) return mm;
} catch (Exception ignored) {}
return null;
}
private PluginAutoUpdate getAutoUpdate(CommandContext context) {
try {
Object obj = context.agentLoop().getToolContext().get("PLUGIN_AUTO_UPDATE");
if (obj instanceof PluginAutoUpdate pau) return pau;
} catch (Exception ignored) {}
return null;
}
/**
* 从 CommandContext 获取 PluginManager 实例。
* <p>
@@ -250,6 +430,10 @@ public class PluginCommand implements SlashCommand {
/plugin load <path> Load JAR plugin
/plugin unload <id> Unload plugin
/plugin reload Reload all plugins
/plugin info <id> View plugin details""");
/plugin info <id> View plugin details
/plugin install <x> Install from URL or marketplace
/plugin remove <id> Uninstall plugin
/plugin update [id] Check for updates
/plugin search <q> Search marketplace""");
}
}