mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 18:55:37 -04:00
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:
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user