From bd60fb7b52625100fbe033f1dbff87d7169b8567 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Tue, 31 Mar 2026 04:02:25 +0530 Subject: [PATCH] extract marketplace catalog to JSON with refresh --- packages/cli/src/assets/plugin-registry.json | 59 ++++ packages/cli/src/lib/plugin-marketplace.ts | 299 +++++++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 packages/cli/src/assets/plugin-registry.json create mode 100644 packages/cli/src/lib/plugin-marketplace.ts diff --git a/packages/cli/src/assets/plugin-registry.json b/packages/cli/src/assets/plugin-registry.json new file mode 100644 index 000000000..4265d912c --- /dev/null +++ b/packages/cli/src/assets/plugin-registry.json @@ -0,0 +1,59 @@ +[ + { + "id": "agent-codex", + "package": "@composio/ao-plugin-agent-codex", + "slot": "agent", + "description": "Agent plugin: OpenAI Codex CLI", + "source": "registry", + "latestVersion": "0.2.0" + }, + { + "id": "agent-aider", + "package": "@composio/ao-plugin-agent-aider", + "slot": "agent", + "description": "Agent plugin: Aider CLI", + "source": "registry", + "latestVersion": "0.2.0" + }, + { + "id": "agent-opencode", + "package": "@composio/ao-plugin-agent-opencode", + "slot": "agent", + "description": "Agent plugin: OpenCode CLI", + "source": "registry", + "latestVersion": "0.2.0" + }, + { + "id": "notifier-openclaw", + "package": "@composio/ao-plugin-notifier-openclaw", + "slot": "notifier", + "description": "Notifier plugin: OpenClaw webhook notifications", + "source": "registry", + "setupAction": "openclaw-setup", + "latestVersion": "0.1.1" + }, + { + "id": "notifier-discord", + "package": "@composio/ao-plugin-notifier-discord", + "slot": "notifier", + "description": "Notifier plugin: Discord notifications", + "source": "registry", + "latestVersion": "0.1.0" + }, + { + "id": "tracker-gitlab", + "package": "@composio/ao-plugin-tracker-gitlab", + "slot": "tracker", + "description": "Tracker plugin: GitLab issues", + "source": "registry", + "latestVersion": "0.1.1" + }, + { + "id": "scm-gitlab", + "package": "@composio/ao-plugin-scm-gitlab", + "slot": "scm", + "description": "SCM plugin: GitLab pull/merge requests", + "source": "registry", + "latestVersion": "0.1.1" + } +] diff --git a/packages/cli/src/lib/plugin-marketplace.ts b/packages/cli/src/lib/plugin-marketplace.ts new file mode 100644 index 000000000..c8f5190c6 --- /dev/null +++ b/packages/cli/src/lib/plugin-marketplace.ts @@ -0,0 +1,299 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { InstalledPluginConfig, PluginModule, PluginSlot } from "@composio/ao-core"; +import registryData from "../assets/plugin-registry.json"; + +export interface MarketplacePluginEntry { + id: string; + package: string; + slot: PluginSlot; + description: string; + source: "registry"; + setupAction?: "openclaw-setup"; + latestVersion?: string; +} + +export const BUNDLED_MARKETPLACE_PLUGIN_CATALOG = registryData as MarketplacePluginEntry[]; +export const MARKETPLACE_PLUGIN_CATALOG = BUNDLED_MARKETPLACE_PLUGIN_CATALOG; +export const DEFAULT_REMOTE_MARKETPLACE_REGISTRY_URL = + "https://raw.githubusercontent.com/ComposioHQ/agent-orchestrator/main/packages/cli/src/assets/plugin-registry.json"; + +const LOCAL_PLUGIN_ENTRY_CANDIDATES = ["dist/index.js", "index.js"] as const; +const MARKETPLACE_CACHE_FILE = "plugin-registry.json"; + +function isPluginSlot(value: unknown): value is PluginSlot { + return ( + value === "runtime" || + value === "agent" || + value === "workspace" || + value === "tracker" || + value === "scm" || + value === "notifier" || + value === "terminal" + ); +} + +function isMarketplacePluginEntry(value: unknown): value is MarketplacePluginEntry { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return ( + typeof candidate.id === "string" && + typeof candidate.package === "string" && + isPluginSlot(candidate.slot) && + typeof candidate.description === "string" && + candidate.source === "registry" + ); +} + +function mergeMarketplaceCatalogs( + primary: MarketplacePluginEntry[], + fallback: MarketplacePluginEntry[], +): MarketplacePluginEntry[] { + const merged = new Map(); + for (const entry of fallback) { + merged.set(entry.id, entry); + } + for (const entry of primary) { + merged.set(entry.id, entry); + } + return [...merged.values()]; +} + +export function getMarketplaceRegistryCachePath(): string { + const override = process.env["AO_PLUGIN_REGISTRY_CACHE_PATH"]; + if (override && override.trim().length > 0) { + return override; + } + return join(homedir(), ".agent-orchestrator", MARKETPLACE_CACHE_FILE); +} + +function validateMarketplaceCatalog(payload: unknown, sourceLabel: string): MarketplacePluginEntry[] { + if (!Array.isArray(payload)) { + throw new Error(`${sourceLabel} did not return a registry array.`); + } + + const entries = payload.filter(isMarketplacePluginEntry); + if (entries.length !== payload.length) { + throw new Error(`${sourceLabel} returned invalid marketplace registry entries.`); + } + + return entries; +} + +function readMarketplaceCatalogFile(filePath: string): MarketplacePluginEntry[] | null { + if (!existsSync(filePath)) return null; + + try { + const parsed = JSON.parse(readFileSync(filePath, "utf-8")) as unknown; + return validateMarketplaceCatalog(parsed, filePath); + } catch { + return null; + } +} + +function writeMarketplaceCatalogCache(entries: MarketplacePluginEntry[]): void { + const cachePath = getMarketplaceRegistryCachePath(); + mkdirSync(dirname(cachePath), { recursive: true }); + const tempPath = `${cachePath}.tmp.${process.pid}.${Date.now()}`; + writeFileSync(tempPath, `${JSON.stringify(entries, null, 2)}\n`, "utf-8"); + renameSync(tempPath, cachePath); +} + +export function loadMarketplaceCatalog(): MarketplacePluginEntry[] { + const cached = readMarketplaceCatalogFile(getMarketplaceRegistryCachePath()); + if (!cached) { + return [...BUNDLED_MARKETPLACE_PLUGIN_CATALOG]; + } + return mergeMarketplaceCatalogs(cached, BUNDLED_MARKETPLACE_PLUGIN_CATALOG); +} + +export async function refreshMarketplaceCatalog( + url = process.env["AO_PLUGIN_REGISTRY_URL"] ?? DEFAULT_REMOTE_MARKETPLACE_REGISTRY_URL, +): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch marketplace registry from ${url} (HTTP ${response.status}).`); + } + + const parsed = (await response.json()) as unknown; + const remoteEntries = validateMarketplaceCatalog(parsed, url); + const mergedEntries = mergeMarketplaceCatalogs(remoteEntries, BUNDLED_MARKETPLACE_PLUGIN_CATALOG); + writeMarketplaceCatalogCache(mergedEntries); + return mergedEntries; +} + +function isPluginModule(value: unknown): value is PluginModule { + if (!value || typeof value !== "object") return false; + const candidate = value as Partial; + return Boolean(candidate.manifest && typeof candidate.create === "function"); +} + +export function normalizeImportedPluginModule(value: unknown): PluginModule | null { + if (isPluginModule(value)) return value; + if (value && typeof value === "object" && "default" in value) { + const defaultExport = (value as { default?: unknown }).default; + if (isPluginModule(defaultExport)) return defaultExport; + } + return null; +} + +function resolvePackageExportsEntry(exportsField: unknown): string | null { + if (typeof exportsField === "string") return exportsField; + if (!exportsField || typeof exportsField !== "object") return null; + + const exportsRecord = exportsField as Record; + const dotEntry = exportsRecord["."]; + if (typeof dotEntry === "string") return dotEntry; + if (dotEntry && typeof dotEntry === "object") { + const importEntry = (dotEntry as Record)["import"]; + if (typeof importEntry === "string") return importEntry; + const defaultEntry = (dotEntry as Record)["default"]; + if (typeof defaultEntry === "string") return defaultEntry; + } + + const importEntry = exportsRecord["import"]; + if (typeof importEntry === "string") return importEntry; + + return null; +} + +function resolveLocalPluginEntrypoint(pluginPath: string): string | null { + if (!existsSync(pluginPath)) return null; + + const stat = statSync(pluginPath); + if (stat.isFile()) return pluginPath; + if (!stat.isDirectory()) return null; + + const packageJsonPath = join(pluginPath, "package.json"); + if (existsSync(packageJsonPath)) { + try { + const raw = readFileSync(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(raw) as { exports?: unknown; main?: unknown; module?: unknown }; + const exportsEntry = resolvePackageExportsEntry(packageJson.exports); + if (exportsEntry) { + const resolvedExportsEntry = resolve(pluginPath, exportsEntry); + if (existsSync(resolvedExportsEntry)) return resolvedExportsEntry; + } + if (typeof packageJson.module === "string") { + const moduleEntry = resolve(pluginPath, packageJson.module); + if (existsSync(moduleEntry)) return moduleEntry; + } + if (typeof packageJson.main === "string") { + const mainEntry = resolve(pluginPath, packageJson.main); + if (existsSync(mainEntry)) return mainEntry; + } + } catch { + // Fall through to common entrypoints below. + } + } + + for (const candidate of LOCAL_PLUGIN_ENTRY_CANDIDATES) { + const entry = join(pluginPath, candidate); + if (existsSync(entry)) return entry; + } + + return null; +} + +export function isLocalPluginReference(reference: string): boolean { + return ( + reference.startsWith("./") || + reference.startsWith("../") || + reference.startsWith("/") || + reference.startsWith("~/") + ); +} + +export function findMarketplacePlugin(reference: string): MarketplacePluginEntry | undefined { + return loadMarketplaceCatalog().find( + (plugin) => plugin.id === reference || plugin.package === reference, + ); +} + +function expandHomePath(value: string): string { + return value.startsWith("~/") ? join(homedir(), value.slice(2)) : value; +} + +function parseNpmPackageReference(reference: string): { packageName: string; version?: string } { + if (reference.startsWith("@")) { + const slashIndex = reference.indexOf("/"); + const versionSeparator = slashIndex >= 0 ? reference.indexOf("@", slashIndex + 1) : -1; + if (versionSeparator > 0) { + return { + packageName: reference.slice(0, versionSeparator), + version: reference.slice(versionSeparator + 1), + }; + } + return { packageName: reference }; + } + + const versionSeparator = reference.lastIndexOf("@"); + if (versionSeparator > 0) { + return { + packageName: reference.slice(0, versionSeparator), + version: reference.slice(versionSeparator + 1), + }; + } + + return { packageName: reference }; +} + +export function buildPluginDescriptor( + reference: string, + configPath: string, +): { + descriptor: InstalledPluginConfig; + specifier: string; + setupAction?: MarketplacePluginEntry["setupAction"]; +} { + const marketplacePlugin = findMarketplacePlugin(reference); + if (marketplacePlugin) { + return { + descriptor: { + name: marketplacePlugin.id, + source: marketplacePlugin.source, + package: marketplacePlugin.package, + version: marketplacePlugin.latestVersion, + enabled: true, + }, + specifier: marketplacePlugin.package, + setupAction: marketplacePlugin.setupAction, + }; + } + + if (isLocalPluginReference(reference)) { + const expandedReference = expandHomePath(reference); + const absolutePath = isAbsolute(expandedReference) + ? expandedReference + : resolve(dirname(configPath), expandedReference); + const entrypoint = resolveLocalPluginEntrypoint(absolutePath); + if (!entrypoint) { + throw new Error(`Could not resolve a plugin entrypoint from ${reference}`); + } + + return { + descriptor: { + name: reference, + source: "local", + path: reference, + enabled: true, + }, + specifier: pathToFileURL(entrypoint).href, + }; + } + + const { packageName, version } = parseNpmPackageReference(reference); + + return { + descriptor: { + name: reference, + source: "npm", + package: packageName, + version, + enabled: true, + }, + specifier: packageName, + }; +}