import { FileClient, PluginsClient, } from "@openhands/typescript-client/clients"; import { getActiveBackend } from "./backend-registry/active-store"; import { getAgentServerClientOptions } from "./agent-server-client-options"; /** Summary of a skill bundled in a plugin (agent-server `PluginSkillSummary`). */ export interface PluginBundledSkill { name: string; description?: string | null; } /** * A plugin in the dynamic marketplace catalog, with attachable coordinates and * install state. Matches the agent-server `MarketplacePluginInfo` / the * typescript-client `MarketplacePlugin`. The contents fields (`path`, `skills`, * `files`) are populated when the entry resolves to a directory in the * server's local marketplace clone, and are absent on older agent-servers. */ export interface MarketplacePlugin { name: string; description: string | null; source: string; ref?: string | null; repo_path?: string | null; installed: boolean; path?: string | null; skills?: PluginBundledSkill[] | null; files?: string[] | null; } /** * A locally-discovered ("ambient") plugin reported by the agent-server — one * found in the user's local plugin directories (e.g. `~/.agents/plugins`). * These auto-load into conversations and are not managed via install/uninstall, * so the Plugins page renders them as a read-only "Local" group. Matches the * typescript-client `PluginInfo`; the contents fields are absent on older * agent-servers. */ export interface LocalPlugin { name: string; version: string; description: string; path?: string; skills?: PluginBundledSkill[]; files?: string[]; } /** Content of a single plugin file fetched for the detail-modal viewer. */ export interface PluginFileContent { kind: "text" | "binary"; text: string | null; } function isLikelyBinary(buffer: ArrayBuffer): boolean { // Same heuristic git uses: presence of a NUL byte in the first ~8KB. Small // private copy of `isLikelyBinary` in `use-workspace-file-content.ts` — that // module is conversation-workspace-specific and heavy to import from here. const view = new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 8000)); for (let i = 0; i < view.length; i += 1) { if (view[i] === 0) return true; } return false; } class PluginsService { /** * Fetch the dynamic plugins marketplace catalog. * * Local backend only for now: the catalog is fetched at run time from the * agent-server via the typed client (no bundled catalog, so the list stays * dynamic). On a cloud backend an empty catalog is returned — there is no * cloud plugins-marketplace endpoint yet (tracked as a follow-up ticket). */ static async getPluginsMarketplace(): Promise { if (getActiveBackend().backend.kind === "cloud") { return []; } try { const response = await new PluginsClient( getAgentServerClientOptions(), ).getPluginsMarketplace(); return (response.plugins ?? []) as MarketplacePlugin[]; } catch { // Agent-server may not support the plugins endpoint or be unreachable; // surface an empty catalog rather than throwing. return []; } } /** * Fetch the locally-discovered ("ambient") plugins from the agent-server. * * Only user-level plugins are requested (`~/.agents/plugins`, * `~/.openhands/plugins`, plus enabled installed plugins): the Plugins page is * global, so there is no project workspace to scope project plugins to. * * Local backend only — a cloud backend has no local plugin directories, so an * empty list is returned. Errors surface as an empty list (mirrors the * catalog) rather than throwing. */ static async getLocalPlugins(): Promise { if (getActiveBackend().backend.kind === "cloud") { return []; } try { const response = await new PluginsClient( getAgentServerClientOptions(), ).getPlugins({ load_user: true, load_project: false }); return (response.plugins ?? []) as LocalPlugin[]; } catch { return []; } } /** * Fetch one plugin file's content for the detail-modal viewer. `basePath` is * the plugin directory reported by the agent-server (`path`/`install_path`) * and `relativePath` a POSIX path from the plugin's `files` listing. * * Local backend only — plugin files live on the local agent-server's disk. * Errors propagate so the caller can render a load-error state. */ static async getPluginFileContent( basePath: string, relativePath: string, ): Promise { if (getActiveBackend().backend.kind === "cloud") { throw new Error( "Reading plugin files is only available on a local backend.", ); } const buffer = await new FileClient( getAgentServerClientOptions(), ).downloadFile(`${basePath}/${relativePath}`); if (isLikelyBinary(buffer)) { return { kind: "binary", text: null }; } return { kind: "text", text: new TextDecoder("utf-8", { fatal: false }).decode(buffer), }; } } export default PluginsService;