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.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""");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user