feat(plugin): add plugin marketplace with CLI commands

Add plugin discovery and management system:
- Registry index (plugins/index.json) with curated plugins
- Registry client with caching and fallback
- Plugin manager for install/remove operations
- CLI commands: plugin search, list, install, remove, info

Registry hosted as static JSON, uses existing npm/Bun infrastructure.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Artur Do Lago
2026-01-11 13:56:25 +01:00
parent 027ec63b70
commit 2a768697e5
10 changed files with 659 additions and 0 deletions
@@ -0,0 +1,20 @@
import { cmd } from "../cmd"
import { SearchCommand } from "./search"
import { ListCommand } from "./list"
import { InstallCommand } from "./install"
import { RemoveCommand } from "./remove"
import { InfoCommand } from "./info"
export const PluginCommand = cmd({
command: "plugin",
describe: "manage plugins (search, install, remove)",
builder: (yargs) =>
yargs
.command(SearchCommand)
.command(ListCommand)
.command(InstallCommand)
.command(RemoveCommand)
.command(InfoCommand)
.demandCommand(1, "Please specify a subcommand"),
async handler() {},
})
@@ -0,0 +1,82 @@
import { cmd } from "../cmd"
import { fetchRegistry, getPlugin } from "../../../plugin/registry"
import { getInstalled } from "../../../plugin/manager"
import { UI } from "../../ui"
export const InfoCommand = cmd({
command: "info <name>",
describe: "show detailed information about a plugin",
builder: (yargs) =>
yargs
.positional("name", {
describe: "plugin name",
type: "string",
demandOption: true,
})
.option("json", {
describe: "output as JSON",
type: "boolean",
default: false,
}),
async handler(args) {
const { name, json } = args
try {
const registry = await fetchRegistry()
const plugin = getPlugin(registry, name)
const installed = await getInstalled(name)
if (!plugin && !installed) {
UI.error(`Plugin "${name}" not found`)
UI.println(UI.Style.TEXT_DIM + "Search for plugins with: agent-core plugin search" + UI.Style.TEXT_NORMAL)
process.exit(1)
}
const info = {
...plugin,
installed: !!installed,
installedVersion: installed?.version,
}
if (json) {
console.log(JSON.stringify(info, null, 2))
return
}
// Display info
if (plugin) {
UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + plugin.displayName + UI.Style.TEXT_NORMAL)
UI.println(UI.Style.TEXT_DIM + plugin.name + UI.Style.TEXT_NORMAL)
UI.empty()
UI.println(plugin.description)
UI.empty()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "Package: " + UI.Style.TEXT_NORMAL + `${plugin.npm}@${plugin.version}`)
UI.println(UI.Style.TEXT_NORMAL_BOLD + "Category: " + UI.Style.TEXT_NORMAL + plugin.category)
UI.println(UI.Style.TEXT_NORMAL_BOLD + "Tags: " + UI.Style.TEXT_NORMAL + plugin.tags.join(", "))
UI.println(UI.Style.TEXT_NORMAL_BOLD + "Capabilities:" + UI.Style.TEXT_NORMAL + " " + plugin.capabilities.join(", "))
UI.println(UI.Style.TEXT_NORMAL_BOLD + "Author: " + UI.Style.TEXT_NORMAL + plugin.author)
if (plugin.homepage) {
UI.println(UI.Style.TEXT_NORMAL_BOLD + "Homepage: " + UI.Style.TEXT_INFO + plugin.homepage + UI.Style.TEXT_NORMAL)
}
UI.empty()
if (installed) {
UI.println(UI.Style.TEXT_SUCCESS + "✓ Installed" + UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` (${installed.spec})` + UI.Style.TEXT_NORMAL)
} else {
UI.println(UI.Style.TEXT_DIM + "Not installed" + UI.Style.TEXT_NORMAL)
UI.println(UI.Style.TEXT_DIM + `Install with: agent-core plugin install ${plugin.name}` + UI.Style.TEXT_NORMAL)
}
} else if (installed) {
// Plugin not in registry but is installed
UI.println(UI.Style.TEXT_HIGHLIGHT_BOLD + installed.name + UI.Style.TEXT_NORMAL)
UI.empty()
UI.println(UI.Style.TEXT_NORMAL_BOLD + "Package:" + UI.Style.TEXT_NORMAL + ` ${installed.spec}`)
UI.empty()
UI.println(UI.Style.TEXT_WARNING + "⚠" + UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + " This plugin is not in the official registry" + UI.Style.TEXT_NORMAL)
}
} catch (error) {
UI.error(`Failed to get plugin info: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}
},
})
@@ -0,0 +1,53 @@
import { cmd } from "../cmd"
import { installPlugin, isInstalled } from "../../../plugin/manager"
import { UI } from "../../ui"
export const InstallCommand = cmd({
command: "install <name>",
aliases: ["add", "i"],
describe: "install a plugin from the registry",
builder: (yargs) =>
yargs
.positional("name", {
describe: "plugin name",
type: "string",
demandOption: true,
})
.option("force", {
alias: "f",
describe: "force reinstall even if already installed",
type: "boolean",
default: false,
}),
async handler(args) {
const { name, force } = args
try {
// Check if already installed
if (!force && (await isInstalled(name))) {
UI.println(UI.Style.TEXT_WARNING + `Plugin "${name}" is already installed` + UI.Style.TEXT_NORMAL)
UI.println(UI.Style.TEXT_DIM + "Use --force to reinstall" + UI.Style.TEXT_NORMAL)
return
}
UI.println(UI.Style.TEXT_DIM + `Installing ${name}...` + UI.Style.TEXT_NORMAL)
const result = await installPlugin(name)
if (result.success) {
UI.println(UI.Style.TEXT_SUCCESS + "✓" + UI.Style.TEXT_NORMAL + " " + result.message)
if (result.plugin) {
UI.empty()
UI.println(UI.Style.TEXT_DIM + `Capabilities: ${result.plugin.capabilities.join(", ")}` + UI.Style.TEXT_NORMAL)
UI.println(UI.Style.TEXT_DIM + "Restart agent-core for changes to take effect" + UI.Style.TEXT_NORMAL)
}
} else {
UI.println(UI.Style.TEXT_DANGER + "✗" + UI.Style.TEXT_NORMAL + " " + result.message)
process.exit(1)
}
} catch (error) {
UI.error(`Install failed: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}
},
})
@@ -0,0 +1,52 @@
import { cmd } from "../cmd"
import { listInstalled } from "../../../plugin/manager"
import { UI } from "../../ui"
export const ListCommand = cmd({
command: "list",
aliases: ["ls"],
describe: "list installed plugins",
builder: (yargs) =>
yargs.option("json", {
describe: "output as JSON",
type: "boolean",
default: false,
}),
async handler(args) {
try {
const installed = await listInstalled()
if (args.json) {
console.log(JSON.stringify(installed, null, 2))
return
}
if (installed.length === 0) {
UI.println(UI.Style.TEXT_WARNING + "No plugins installed" + UI.Style.TEXT_NORMAL)
UI.println(UI.Style.TEXT_DIM + "Search for plugins with: agent-core plugin search" + UI.Style.TEXT_NORMAL)
return
}
UI.println(UI.Style.TEXT_NORMAL_BOLD + `Installed plugins (${installed.length}):` + UI.Style.TEXT_NORMAL)
UI.empty()
for (const plugin of installed) {
const name = plugin.registryInfo?.displayName ?? plugin.name
const badge = plugin.fromRegistry
? UI.Style.TEXT_SUCCESS + "✓" + UI.Style.TEXT_NORMAL
: UI.Style.TEXT_WARNING + "?" + UI.Style.TEXT_NORMAL
UI.println(`${badge} ${UI.Style.TEXT_HIGHLIGHT_BOLD}${name}${UI.Style.TEXT_NORMAL}`)
UI.println(UI.Style.TEXT_DIM + ` ${plugin.spec}` + UI.Style.TEXT_NORMAL)
if (plugin.registryInfo) {
UI.println(UI.Style.TEXT_DIM + ` ${plugin.registryInfo.description}` + UI.Style.TEXT_NORMAL)
}
UI.empty()
}
} catch (error) {
UI.error(`Failed to list plugins: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}
},
})
@@ -0,0 +1,42 @@
import { cmd } from "../cmd"
import { removePlugin, isInstalled } from "../../../plugin/manager"
import { UI } from "../../ui"
export const RemoveCommand = cmd({
command: "remove <name>",
aliases: ["rm", "uninstall"],
describe: "remove an installed plugin",
builder: (yargs) =>
yargs.positional("name", {
describe: "plugin name",
type: "string",
demandOption: true,
}),
async handler(args) {
const { name } = args
try {
// Check if installed
if (!(await isInstalled(name))) {
UI.println(UI.Style.TEXT_WARNING + `Plugin "${name}" is not installed` + UI.Style.TEXT_NORMAL)
UI.println(UI.Style.TEXT_DIM + "Use 'agent-core plugin list' to see installed plugins" + UI.Style.TEXT_NORMAL)
return
}
UI.println(UI.Style.TEXT_DIM + `Removing ${name}...` + UI.Style.TEXT_NORMAL)
const result = await removePlugin(name)
if (result.success) {
UI.println(UI.Style.TEXT_SUCCESS + "✓" + UI.Style.TEXT_NORMAL + " " + result.message)
UI.println(UI.Style.TEXT_DIM + "Restart agent-core for changes to take effect" + UI.Style.TEXT_NORMAL)
} else {
UI.println(UI.Style.TEXT_DANGER + "✗" + UI.Style.TEXT_NORMAL + " " + result.message)
process.exit(1)
}
} catch (error) {
UI.error(`Remove failed: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}
},
})
@@ -0,0 +1,70 @@
import { cmd } from "../cmd"
import { fetchRegistry, searchPlugins } from "../../../plugin/registry"
import { UI } from "../../ui"
export const SearchCommand = cmd({
command: "search [query]",
describe: "search for plugins in the registry",
builder: (yargs) =>
yargs
.positional("query", {
describe: "search query (matches name, description, tags)",
type: "string",
default: "",
})
.option("category", {
alias: "c",
describe: "filter by category",
type: "string",
})
.option("json", {
describe: "output as JSON",
type: "boolean",
default: false,
}),
async handler(args) {
try {
const registry = await fetchRegistry()
let plugins = args.query ? searchPlugins(registry, args.query) : registry.plugins
if (args.category) {
plugins = plugins.filter((p) => p.category === args.category)
}
if (args.json) {
console.log(JSON.stringify(plugins, null, 2))
return
}
if (plugins.length === 0) {
UI.println(UI.Style.TEXT_WARNING + "No plugins found" + UI.Style.TEXT_NORMAL)
if (args.query) {
UI.println(UI.Style.TEXT_DIM + "Try a different search term or browse all with: agent-core plugin search" + UI.Style.TEXT_NORMAL)
}
return
}
UI.println(UI.Style.TEXT_NORMAL_BOLD + `Found ${plugins.length} plugin(s):` + UI.Style.TEXT_NORMAL)
UI.empty()
for (const plugin of plugins) {
UI.println(
UI.Style.TEXT_HIGHLIGHT_BOLD + plugin.displayName + UI.Style.TEXT_NORMAL +
UI.Style.TEXT_DIM + ` (${plugin.name})` + UI.Style.TEXT_NORMAL
)
UI.println(" " + plugin.description)
UI.println(
UI.Style.TEXT_DIM +
` ${plugin.npm}@${plugin.version} · ${plugin.category} · ${plugin.tags.join(", ")}` +
UI.Style.TEXT_NORMAL
)
UI.empty()
}
UI.println(UI.Style.TEXT_DIM + "Install with: agent-core plugin install <name>" + UI.Style.TEXT_NORMAL)
} catch (error) {
UI.error(`Failed to fetch registry: ${error instanceof Error ? error.message : error}`)
process.exit(1)
}
},
})
+2
View File
@@ -28,6 +28,7 @@ import { WebCommand } from "./cli/cmd/web"
import { PrCommand } from "./cli/cmd/pr"
import { SessionCommand } from "./cli/cmd/session"
import { DaemonCommand, DaemonStatusCommand, DaemonStopCommand } from "./cli/cmd/daemon"
import { PluginCommand } from "./cli/cmd/plugin"
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
@@ -104,6 +105,7 @@ const cli = yargs(hideBin(process.argv))
.command(DaemonCommand)
.command(DaemonStatusCommand)
.command(DaemonStopCommand)
.command(PluginCommand)
.fail((msg, err) => {
if (
msg?.startsWith("Unknown argument") ||
+163
View File
@@ -0,0 +1,163 @@
import { BunProc } from "../bun"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { fetchRegistry, getPlugin, type RegistryPlugin, type Registry } from "./registry"
const log = Log.create({ service: "plugin-manager" })
export interface InstallResult {
success: boolean
message: string
plugin?: RegistryPlugin
}
export interface RemoveResult {
success: boolean
message: string
}
export interface InstalledPlugin {
name: string
npm: string
version: string
spec: string
fromRegistry: boolean
registryInfo?: RegistryPlugin
}
/**
* Install a plugin from the registry
*/
export async function installPlugin(name: string): Promise<InstallResult> {
const registry = await fetchRegistry()
const plugin = getPlugin(registry, name)
if (!plugin) {
return {
success: false,
message: `Plugin "${name}" not found in registry. Use 'agent-core plugin search' to find available plugins.`,
}
}
const pkgSpec = `${plugin.npm}@${plugin.version}`
log.info("Installing plugin", { name: plugin.name, spec: pkgSpec })
try {
// Install via Bun
await BunProc.install(plugin.npm, plugin.version)
// Add to config if not already present
const config = await Config.get()
const plugins = [...(config.plugin ?? [])]
// Check if already installed (any version)
const existingIdx = plugins.findIndex(
(p) => typeof p === "string" && (p === plugin.npm || p.startsWith(`${plugin.npm}@`))
)
if (existingIdx >= 0) {
// Update to new version
plugins[existingIdx] = pkgSpec
} else {
plugins.push(pkgSpec)
}
await Config.update({ plugin: plugins })
log.info("Plugin installed", { name: plugin.name, spec: pkgSpec })
return {
success: true,
message: `Installed ${plugin.displayName} (${pkgSpec})`,
plugin,
}
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
log.error("Failed to install plugin", { name, error: msg })
return {
success: false,
message: `Failed to install ${name}: ${msg}`,
}
}
}
/**
* Remove a plugin from config
*/
export async function removePlugin(name: string): Promise<RemoveResult> {
const config = await Config.get()
const plugins = [...(config.plugin ?? [])]
// Find plugin in config (could be name or name@version)
const idx = plugins.findIndex(
(p) => typeof p === "string" && (p === name || p.startsWith(`${name}@`))
)
if (idx === -1) {
return {
success: false,
message: `Plugin "${name}" not found in config. Use 'agent-core plugin list' to see installed plugins.`,
}
}
const removed = plugins[idx]
plugins.splice(idx, 1)
await Config.update({ plugin: plugins })
log.info("Plugin removed", { name, spec: removed })
return {
success: true,
message: `Removed ${removed}`,
}
}
/**
* List installed plugins with registry info
*/
export async function listInstalled(): Promise<InstalledPlugin[]> {
const config = await Config.get()
const configPlugins = (config.plugin ?? []).filter((p): p is string => typeof p === "string")
let registry: Registry | null = null
try {
registry = await fetchRegistry()
} catch {
// Continue without registry info
}
return configPlugins.map((spec) => {
// Parse spec (e.g., "opencode-copilot-auth@0.0.11" or "opencode-copilot-auth")
const lastAtIdx = spec.lastIndexOf("@")
const hasVersion = lastAtIdx > 0
const npm = hasVersion ? spec.substring(0, lastAtIdx) : spec
const version = hasVersion ? spec.substring(lastAtIdx + 1) : "latest"
const registryInfo = registry ? getPlugin(registry, npm) : undefined
return {
name: registryInfo?.name ?? npm,
npm,
version,
spec,
fromRegistry: !!registryInfo,
registryInfo,
}
})
}
/**
* Check if a plugin is installed
*/
export async function isInstalled(name: string): Promise<boolean> {
const installed = await listInstalled()
return installed.some((p) => p.name === name || p.npm === name)
}
/**
* Get details about an installed plugin
*/
export async function getInstalled(name: string): Promise<InstalledPlugin | undefined> {
const installed = await listInstalled()
return installed.find((p) => p.name === name || p.npm === name)
}
+133
View File
@@ -0,0 +1,133 @@
import { z } from "zod"
import { Log } from "../util/log"
import { Global } from "../global"
import * as fs from "node:fs/promises"
import * as path from "node:path"
const log = Log.create({ service: "plugin-registry" })
const RegistryPluginSchema = z.object({
name: z.string(),
displayName: z.string(),
description: z.string(),
npm: z.string(),
version: z.string(),
category: z.string(),
tags: z.array(z.string()),
capabilities: z.array(z.string()),
author: z.string(),
homepage: z.string().optional(),
})
const RegistrySchema = z.object({
version: z.number(),
plugins: z.array(RegistryPluginSchema),
categories: z.array(z.string()),
})
export type RegistryPlugin = z.infer<typeof RegistryPluginSchema>
export type Registry = z.infer<typeof RegistrySchema>
const REGISTRY_URL = "https://raw.githubusercontent.com/adolago/agent-core/main/plugins/index.json"
const CACHE_TTL = 3600000 // 1 hour
const CACHE_FILE = "plugin-registry.json"
interface CachedRegistry {
data: Registry
timestamp: number
}
let memoryCache: CachedRegistry | null = null
async function getCachePath(): Promise<string> {
return path.join(Global.Path.cache, CACHE_FILE)
}
async function readCacheFile(): Promise<CachedRegistry | null> {
try {
const cachePath = await getCachePath()
const content = await fs.readFile(cachePath, "utf-8")
return JSON.parse(content)
} catch {
return null
}
}
async function writeCacheFile(cached: CachedRegistry): Promise<void> {
try {
const cachePath = await getCachePath()
await fs.mkdir(path.dirname(cachePath), { recursive: true })
await fs.writeFile(cachePath, JSON.stringify(cached, null, 2))
} catch (error) {
log.warn("Failed to write registry cache", { error })
}
}
export async function fetchRegistry(forceRefresh = false): Promise<Registry> {
// Check memory cache first
if (!forceRefresh && memoryCache && Date.now() - memoryCache.timestamp < CACHE_TTL) {
return memoryCache.data
}
// Check file cache
if (!forceRefresh) {
const fileCache = await readCacheFile()
if (fileCache && Date.now() - fileCache.timestamp < CACHE_TTL) {
memoryCache = fileCache
return fileCache.data
}
}
// Fetch from remote
log.info("Fetching plugin registry", { url: REGISTRY_URL })
try {
const response = await fetch(REGISTRY_URL)
if (!response.ok) {
throw new Error(`Failed to fetch registry: ${response.status} ${response.statusText}`)
}
const data = await response.json()
const registry = RegistrySchema.parse(data)
const cached: CachedRegistry = { data: registry, timestamp: Date.now() }
memoryCache = cached
await writeCacheFile(cached)
log.info("Registry fetched", { plugins: registry.plugins.length })
return registry
} catch (error) {
// If fetch fails, try to use stale cache
const staleCache = memoryCache || (await readCacheFile())
if (staleCache) {
log.warn("Using stale registry cache", { error })
return staleCache.data
}
throw error
}
}
export function searchPlugins(registry: Registry, query: string): RegistryPlugin[] {
const q = query.toLowerCase().trim()
if (!q) return registry.plugins
return registry.plugins.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.displayName.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q) ||
p.tags.some((t) => t.toLowerCase().includes(q)) ||
p.category.toLowerCase().includes(q)
)
}
export function getPlugin(registry: Registry, name: string): RegistryPlugin | undefined {
return registry.plugins.find((p) => p.name === name || p.npm === name)
}
export function filterByCategory(registry: Registry, category: string): RegistryPlugin[] {
return registry.plugins.filter((p) => p.category === category)
}
export function filterByCapability(registry: Registry, capability: string): RegistryPlugin[] {
return registry.plugins.filter((p) => p.capabilities.includes(capability))
}
+42
View File
@@ -0,0 +1,42 @@
{
"version": 1,
"plugins": [
{
"name": "opencode-copilot-auth",
"displayName": "GitHub Copilot Auth",
"description": "Authenticate with GitHub Copilot for free model access",
"npm": "opencode-copilot-auth",
"version": "0.0.11",
"category": "auth",
"tags": ["auth", "github", "copilot", "free"],
"capabilities": ["auth"],
"author": "opencode-ai",
"homepage": "https://github.com/opencode-ai/opencode"
},
{
"name": "opencode-anthropic-auth",
"displayName": "Anthropic OAuth",
"description": "OAuth authentication for Anthropic Claude models",
"npm": "opencode-anthropic-auth",
"version": "0.0.8",
"category": "auth",
"tags": ["auth", "anthropic", "oauth", "claude"],
"capabilities": ["auth"],
"author": "opencode-ai",
"homepage": "https://github.com/opencode-ai/opencode"
},
{
"name": "opencode-google-auth",
"displayName": "Google Antigravity",
"description": "OAuth authentication for Google Gemini models via Antigravity proxy",
"npm": "opencode-google-auth",
"version": "0.0.5",
"category": "auth",
"tags": ["auth", "google", "gemini", "antigravity", "free"],
"capabilities": ["auth"],
"author": "opencode-ai",
"homepage": "https://github.com/opencode-ai/opencode"
}
],
"categories": ["auth", "tools", "personas", "integrations"]
}