/** * Plugin System — EXACT parity with original claw-code Rust plugins crate. * * Features: * - Plugin kinds: builtin, bundled, external * - Plugin manifest (plugin.json) parsing and validation * - Plugin registry (installed.json) persistence * - Git clone install from URL * - Local path install * - Plugin hooks (PreToolUse, PostToolUse) * - Plugin lifecycle (Init, Shutdown) * - Plugin tools (external command execution) * - Plugin enable/disable * - Plugin update/uninstall */ import * as fs from "fs/promises"; import * as fsSync from "fs"; import * as path from "path"; import { exec, execSync, spawn } from "child_process"; import { promisify } from "util"; const execAsync = promisify(exec); // ─── Constants (matches original) ──────────────────────────────────────── const EXTERNAL_MARKETPLACE = "external"; const BUILTIN_MARKETPLACE = "builtin"; const BUNDLED_MARKETPLACE = "bundled"; const SETTINGS_FILE_NAME = "settings.json"; const REGISTRY_FILE_NAME = "installed.json"; const MANIFEST_FILE_NAME = "plugin.json"; const MANIFEST_RELATIVE_PATH = ".claw-plugin/plugin.json"; // ─── Types (matches original Rust structs) ─────────────────────────────── export type PluginKind = "builtin" | "bundled" | "external"; export interface PluginPermission { type: "read" | "write" | "execute"; } export interface PluginHooks { PreToolUse: string[]; PostToolUse: string[]; } export interface PluginLifecycle { Init: string[]; Shutdown: string[]; } export interface PluginToolManifest { name: string; description: string; inputSchema: any; command: string; args?: string[]; requiredPermission?: string; } export interface PluginCommandManifest { name: string; description: string; command: string; } export interface PluginManifest { name: string; version: string; description: string; permissions?: string[]; defaultEnabled?: boolean; hooks?: Partial; lifecycle?: Partial; tools?: PluginToolManifest[]; commands?: PluginCommandManifest[]; } export interface PluginMetadata { id: string; name: string; version: string; description: string; kind: PluginKind; source: string; defaultEnabled: boolean; root: string | null; } export interface PluginToolDefinition { name: string; description?: string; inputSchema: any; } export interface PluginTool { pluginId: string; pluginName: string; definition: PluginToolDefinition; command: string; args: string[]; requiredPermission: string; root: string | null; } export interface RegisteredPlugin { metadata: PluginMetadata; hooks: PluginHooks; lifecycle: PluginLifecycle; tools: PluginTool[]; commands: PluginCommandManifest[]; enabled: boolean; } export interface InstalledPluginRecord { id: string; name: string; version: string; description: string; kind: PluginKind; source: string; sourceType: "local_path" | "git_url"; installPath: string; installedAtUnixMs: number; updatedAtUnixMs: number; } export interface PluginRegistry { plugins: Record; } export interface InstallOutcome { pluginId: string; version: string; installPath: string; } export interface UpdateOutcome { pluginId: string; oldVersion: string; newVersion: string; installPath: string; } // ─── Manifest Validation (matches original validate_manifest) ──────────── export interface ManifestValidationError { field: string; message: string; } export function validateManifest( manifest: PluginManifest, rootDir?: string ): ManifestValidationError[] { const errors: ManifestValidationError[] = []; if (!manifest.name || manifest.name.trim() === "") { errors.push({ field: "name", message: "plugin manifest name cannot be empty" }); } if (!manifest.version || manifest.version.trim() === "") { errors.push({ field: "version", message: "plugin manifest version cannot be empty" }); } if (!manifest.description || manifest.description.trim() === "") { errors.push({ field: "description", message: "plugin manifest description cannot be empty" }); } // Validate permissions const validPerms = new Set(["read", "write", "execute"]); const seenPerms = new Set(); for (const perm of manifest.permissions || []) { if (!validPerms.has(perm)) { errors.push({ field: "permissions", message: `plugin manifest permission \`${perm}\` must be one of read, write, or execute`, }); } if (seenPerms.has(perm)) { errors.push({ field: "permissions", message: `plugin manifest permission \`${perm}\` is duplicated`, }); } seenPerms.add(perm); } // Validate tools const seenTools = new Set(); for (const tool of manifest.tools || []) { if (!tool.name || tool.name.trim() === "") { errors.push({ field: "tools.name", message: "plugin tool name cannot be empty" }); } if (!tool.description || tool.description.trim() === "") { errors.push({ field: "tools.description", message: `plugin tool \`${tool.name}\` description cannot be empty`, }); } if (!tool.command || tool.command.trim() === "") { errors.push({ field: "tools.command", message: `plugin tool \`${tool.name}\` command cannot be empty`, }); } if ( tool.inputSchema && (typeof tool.inputSchema !== "object" || Array.isArray(tool.inputSchema)) ) { errors.push({ field: "tools.inputSchema", message: `plugin tool \`${tool.name}\` inputSchema must be a JSON object`, }); } if (seenTools.has(tool.name)) { errors.push({ field: "tools", message: `plugin tool \`${tool.name}\` is duplicated`, }); } seenTools.add(tool.name); const validToolPerms = ["read-only", "workspace-write", "danger-full-access"]; if (tool.requiredPermission && !validToolPerms.includes(tool.requiredPermission)) { errors.push({ field: "tools.requiredPermission", message: `plugin tool \`${tool.name}\` requiredPermission \`${tool.requiredPermission}\` must be read-only, workspace-write, or danger-full-access`, }); } // Validate hook paths exist if (rootDir) { const cmdPath = path.resolve(rootDir, tool.command); if (!fsSync.existsSync(cmdPath) && !isSystemCommand(tool.command)) { errors.push({ field: "tools.command", message: `plugin tool \`${tool.name}\` command path \`${cmdPath}\` does not exist`, }); } } } // Validate hooks if (rootDir && manifest.hooks) { for (const hookPath of manifest.hooks.PreToolUse || []) { const fullPath = path.resolve(rootDir, hookPath); if (!fsSync.existsSync(fullPath)) { errors.push({ field: "hooks.PreToolUse", message: `hook path \`${fullPath}\` does not exist`, }); } } for (const hookPath of manifest.hooks.PostToolUse || []) { const fullPath = path.resolve(rootDir, hookPath); if (!fsSync.existsSync(fullPath)) { errors.push({ field: "hooks.PostToolUse", message: `hook path \`${fullPath}\` does not exist`, }); } } } return errors; } function isSystemCommand(cmd: string): boolean { try { execSync(`which ${cmd}`, { stdio: "ignore" }); return true; } catch { return false; } } // ─── Plugin Manager (matches original PluginManager) ───────────────────── export class PluginManager { private static instances = new Map(); static getInstance(configHome: string): PluginManager { if (!PluginManager.instances.has(configHome)) { PluginManager.instances.set(configHome, new PluginManager(configHome)); } return PluginManager.instances.get(configHome)!; } private configHome: string; private installRoot: string; private registryPath: string; private bundledRoot: string; private enabledPlugins: Map = new Map(); private externalDirs: string[] = []; private plugins: Map = new Map(); constructor(configHome: string) { this.configHome = configHome; this.installRoot = path.join(configHome, "plugins"); this.registryPath = path.join(configHome, "plugins", REGISTRY_FILE_NAME); this.bundledRoot = path.join(configHome, "bundled-plugins"); } // ─── Registry Persistence ─────────────────────────────────────────── async loadRegistry(): Promise { try { const data = await fs.readFile(this.registryPath, "utf-8"); return JSON.parse(data); } catch { return { plugins: {} }; } } async storeRegistry(registry: PluginRegistry): Promise { await fs.mkdir(path.dirname(this.registryPath), { recursive: true }); await fs.writeFile(this.registryPath, JSON.stringify(registry, null, 2)); } // ─── Install ──────────────────────────────────────────────────────── async installFromGitUrl(url: string): Promise { const tempDir = path.join(this.installRoot, ".tmp", `git-${Date.now()}`); await fs.mkdir(tempDir, { recursive: true }); try { // Git clone await execAsync(`git clone --depth 1 "${url}" "${tempDir}"`, { timeout: 60000 }); // Find and parse manifest const manifest = await this.loadManifestFromDir(tempDir); if (!manifest) { throw new Error(`No ${MANIFEST_FILE_NAME} found in cloned repository`); } // Validate const errors = validateManifest(manifest, tempDir); if (errors.length > 0) { throw new Error(`Manifest validation failed: ${errors.map((e) => e.message).join("; ")}`); } // Determine plugin ID and install path const pluginId = manifest.name.toLowerCase().replace(/[^a-z0-9-]/g, "-"); const installPath = path.join(this.installRoot, pluginId); // Remove old version if exists try { await fs.rm(installPath, { recursive: true, force: true }); } catch {} // Copy to install path await this.copyDir(tempDir, installPath); // Update registry const registry = await this.loadRegistry(); registry.plugins[pluginId] = { id: pluginId, name: manifest.name, version: manifest.version, description: manifest.description, kind: "external", source: url, sourceType: "git_url", installPath, installedAtUnixMs: Date.now(), updatedAtUnixMs: Date.now(), }; await this.storeRegistry(registry); // Register the plugin await this.registerPlugin(pluginId, installPath, manifest, "external", url); return { pluginId, version: manifest.version, installPath }; } finally { // Cleanup temp dir try { await fs.rm(tempDir, { recursive: true, force: true }); } catch {} } } async installFromLocalPath(localPath: string): Promise { const resolvedPath = path.resolve(localPath); // Find and parse manifest const manifest = await this.loadManifestFromDir(resolvedPath); if (!manifest) { throw new Error(`No ${MANIFEST_FILE_NAME} found at ${resolvedPath}`); } // Validate const errors = validateManifest(manifest, resolvedPath); if (errors.length > 0) { throw new Error(`Manifest validation failed: ${errors.map((e) => e.message).join("; ")}`); } // Determine plugin ID and install path const pluginId = manifest.name.toLowerCase().replace(/[^a-z0-9-]/g, "-"); const installPath = path.join(this.installRoot, pluginId); // Copy to install path try { await fs.rm(installPath, { recursive: true, force: true }); } catch {} await this.copyDir(resolvedPath, installPath); // Update registry const registry = await this.loadRegistry(); registry.plugins[pluginId] = { id: pluginId, name: manifest.name, version: manifest.version, description: manifest.description, kind: "external", source: resolvedPath, sourceType: "local_path", installPath, installedAtUnixMs: Date.now(), updatedAtUnixMs: Date.now(), }; await this.storeRegistry(registry); // Register the plugin await this.registerPlugin(pluginId, installPath, manifest, "external", resolvedPath); return { pluginId, version: manifest.version, installPath }; } // ─── Uninstall ────────────────────────────────────────────────────── async uninstall(pluginId: string): Promise { const registry = await this.loadRegistry(); const record = registry.plugins[pluginId]; if (!record) { throw new Error(`Plugin "${pluginId}" not found in registry`); } // Run shutdown lifecycle const plugin = this.plugins.get(pluginId); if (plugin) { await this.runLifecycleCommands(plugin, "shutdown"); } // Remove files try { await fs.rm(record.installPath, { recursive: true, force: true }); } catch {} // Remove from registry delete registry.plugins[pluginId]; await this.storeRegistry(registry); // Remove from loaded plugins this.plugins.delete(pluginId); } // ─── Update ───────────────────────────────────────────────────────── async update(pluginId: string): Promise { const registry = await this.loadRegistry(); const record = registry.plugins[pluginId]; if (!record) { throw new Error(`Plugin "${pluginId}" not found in registry`); } if (record.sourceType !== "git_url") { throw new Error(`Plugin "${pluginId}" was not installed from git, cannot auto-update`); } const oldVersion = record.version; // Re-clone from the same source const tempDir = path.join(this.installRoot, ".tmp", `update-${Date.now()}`); await fs.mkdir(tempDir, { recursive: true }); try { await execAsync(`git clone --depth 1 "${record.source}" "${tempDir}"`, { timeout: 60000 }); const manifest = await this.loadManifestFromDir(tempDir); if (!manifest) { throw new Error(`No ${MANIFEST_FILE_NAME} found in updated repository`); } // Replace install path try { await fs.rm(record.installPath, { recursive: true, force: true }); } catch {} await this.copyDir(tempDir, record.installPath); // Update registry registry.plugins[pluginId] = { ...record, version: manifest.version, description: manifest.description, updatedAtUnixMs: Date.now(), }; await this.storeRegistry(registry); // Re-register await this.registerPlugin(pluginId, record.installPath, manifest, record.kind, record.source); return { pluginId, oldVersion, newVersion: manifest.version, installPath: record.installPath, }; } finally { try { await fs.rm(tempDir, { recursive: true, force: true }); } catch {} } } // ─── Enable/Disable ───────────────────────────────────────────────── setEnabled(pluginId: string, enabled: boolean): void { this.enabledPlugins.set(pluginId, enabled); const plugin = this.plugins.get(pluginId); if (plugin) { plugin.enabled = enabled; } // Persist to settings.json this.persistEnabledState().catch(() => {}); } private async persistEnabledState(): Promise { const settingsPath = path.join(this.configHome, "plugins", SETTINGS_FILE_NAME); const enabledMap: Record = {}; for (const [id, enabled] of Array.from(this.enabledPlugins)) { enabledMap[id] = enabled; } try { await fs.mkdir(path.dirname(settingsPath), { recursive: true }); await fs.writeFile(settingsPath, JSON.stringify({ enabled: enabledMap }, null, 2)); } catch {} } private async loadEnabledState(): Promise { const settingsPath = path.join(this.configHome, "plugins", SETTINGS_FILE_NAME); try { const data = await fs.readFile(settingsPath, "utf-8"); const settings = JSON.parse(data); if (settings.enabled && typeof settings.enabled === "object") { for (const [id, enabled] of Object.entries(settings.enabled)) { if (typeof enabled === "boolean") { this.enabledPlugins.set(id, enabled); } } } } catch {} } isEnabled(pluginId: string): boolean { const override = this.enabledPlugins.get(pluginId); if (override !== undefined) return override; const plugin = this.plugins.get(pluginId); return plugin?.metadata.defaultEnabled ?? true; } // ─── Discovery ────────────────────────────────────────────────────── async discoverAndLoadAll(): Promise { // Load persisted enable/disable state first await this.loadEnabledState(); const registry = await this.loadRegistry(); for (const [id, record] of Object.entries(registry.plugins)) { try { const manifest = await this.loadManifestFromDir(record.installPath); if (manifest) { await this.registerPlugin(id, record.installPath, manifest, record.kind, record.source); } } catch (err: any) { console.error(`Failed to load plugin ${id}: ${err.message}`); } } // Also discover from external dirs for (const dir of this.externalDirs) { try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const pluginDir = path.join(dir, entry.name); const manifest = await this.loadManifestFromDir(pluginDir); if (manifest) { const pluginId = manifest.name.toLowerCase().replace(/[^a-z0-9-]/g, "-"); if (!this.plugins.has(pluginId)) { await this.registerPlugin( pluginId, pluginDir, manifest, "external", pluginDir ); } } } } } catch {} } return Array.from(this.plugins.values()); } // ─── Plugin Tool Execution ────────────────────────────────────────── async executePluginTool( pluginId: string, toolName: string, input: Record ): Promise { const plugin = this.plugins.get(pluginId); if (!plugin) throw new Error(`Plugin "${pluginId}" not found`); if (!plugin.enabled) throw new Error(`Plugin "${pluginId}" is disabled`); const tool = plugin.tools.find((t) => t.definition.name === toolName); if (!tool) throw new Error(`Tool "${toolName}" not found in plugin "${pluginId}"`); const inputJson = JSON.stringify(input); const env: Record = { ...process.env as Record, CLAW_PLUGIN_ID: tool.pluginId, CLAW_PLUGIN_NAME: tool.pluginName, CLAW_TOOL_NAME: tool.definition.name, CLAW_TOOL_INPUT: inputJson, }; if (tool.root) { env.CLAW_PLUGIN_ROOT = tool.root; } return new Promise((resolve, reject) => { const child = spawn(tool.command, tool.args, { cwd: tool.root || undefined, env, stdio: ["pipe", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; child.stdout?.on("data", (data: Buffer) => { stdout += data.toString(); }); child.stderr?.on("data", (data: Buffer) => { stderr += data.toString(); }); // Send input via stdin if (child.stdin) { child.stdin.write(inputJson); child.stdin.end(); } child.on("close", (code) => { if (code === 0) { resolve(stdout.trim()); } else { reject( new Error( `Plugin tool \`${toolName}\` from \`${pluginId}\` failed: ${stderr.trim() || `exit code ${code}`}` ) ); } }); child.on("error", (err) => { reject(new Error(`Plugin tool \`${toolName}\` from \`${pluginId}\` error: ${err.message}`)); }); // Timeout after 30s setTimeout(() => { child.kill(); reject(new Error(`Plugin tool \`${toolName}\` from \`${pluginId}\` timed out after 30s`)); }, 30000); }); } // ─── Hooks ────────────────────────────────────────────────────────── getActiveHooks(): { preToolUse: string[]; postToolUse: string[] } { const preToolUse: string[] = []; const postToolUse: string[] = []; for (const plugin of Array.from(this.plugins.values())) { if (!plugin.enabled) continue; preToolUse.push(...plugin.hooks.PreToolUse); postToolUse.push(...plugin.hooks.PostToolUse); } return { preToolUse, postToolUse }; } async runHook(hookPath: string, event: any): Promise { const env: Record = { ...process.env as Record, CLAW_HOOK_EVENT: JSON.stringify(event), }; try { const { stdout } = await execAsync(hookPath, { env, timeout: 10000 }); return stdout.trim(); } catch (err: any) { return `Hook error: ${err.message}`; } } // ─── Lifecycle ────────────────────────────────────────────────────── async runLifecycleCommands( plugin: RegisteredPlugin, phase: "init" | "shutdown" ): Promise { const commands = phase === "init" ? plugin.lifecycle.Init : plugin.lifecycle.Shutdown; for (const cmd of commands) { try { const fullCmd = plugin.metadata.root ? path.resolve(plugin.metadata.root, cmd) : cmd; await execAsync(fullCmd, { cwd: plugin.metadata.root || undefined, timeout: 30000, env: { ...process.env as Record, CLAW_PLUGIN_ID: plugin.metadata.id, CLAW_PLUGIN_NAME: plugin.metadata.name, }, }); } catch (err: any) { console.error( `Plugin ${plugin.metadata.id} ${phase} command "${cmd}" failed: ${err.message}` ); } } } async initializeAll(): Promise { for (const plugin of Array.from(this.plugins.values())) { if (plugin.enabled && plugin.lifecycle.Init.length > 0) { await this.runLifecycleCommands(plugin, "init"); } } } async shutdownAll(): Promise { for (const plugin of Array.from(this.plugins.values())) { if (plugin.enabled && plugin.lifecycle.Shutdown.length > 0) { await this.runLifecycleCommands(plugin, "shutdown"); } } } // ─── Convenience Methods (used by /plugin command) ───────────────── listPlugins(): Array<{ name: string; version: string; author: string; description: string; enabled: boolean; kind: PluginKind }> { return Array.from(this.plugins.values()).map(p => ({ name: p.metadata.name, version: p.metadata.version, author: p.metadata.source, description: p.metadata.description, enabled: p.enabled, kind: p.metadata.kind, })); } async installPlugin(source: string): Promise<{ name: string; version: string; author: string; description: string }> { let outcome: InstallOutcome; if (source.startsWith("http") || source.includes("github.com") || source.endsWith(".git")) { outcome = await this.installFromGitUrl(source); } else { outcome = await this.installFromLocalPath(source); } const plugin = this.plugins.get(outcome.pluginId); return { name: plugin?.metadata.name || outcome.pluginId, version: outcome.version, author: plugin?.metadata.source || source, description: plugin?.metadata.description || "", }; } async uninstallPlugin(nameOrId: string): Promise { const pluginId = this.findPluginId(nameOrId); if (!pluginId) throw new Error(`Plugin not found: ${nameOrId}`); await this.uninstall(pluginId); } enablePlugin(nameOrId: string): void { const pluginId = this.findPluginId(nameOrId); if (!pluginId) throw new Error(`Plugin not found: ${nameOrId}`); this.enabledPlugins.set(pluginId, true); const plugin = this.plugins.get(pluginId); if (plugin) plugin.enabled = true; } disablePlugin(nameOrId: string): void { const pluginId = this.findPluginId(nameOrId); if (!pluginId) throw new Error(`Plugin not found: ${nameOrId}`); this.enabledPlugins.set(pluginId, false); const plugin = this.plugins.get(pluginId); if (plugin) plugin.enabled = false; } getRegistry(): Array<{ name: string; version: string; author: string; description: string }> { // Return all known plugins (installed + built-in registry) return Array.from(this.plugins.values()).map(p => ({ name: p.metadata.name, version: p.metadata.version, author: p.metadata.source, description: p.metadata.description, })); } private findPluginId(nameOrId: string): string | null { const lower = nameOrId.toLowerCase(); if (this.plugins.has(lower)) return lower; for (const [id, p] of Array.from(this.plugins.entries())) { if (p.metadata.name.toLowerCase() === lower) return id; } return null; } // ─── Getters ──────────────────────────────────────────────────────── getPlugins(): RegisteredPlugin[] { return Array.from(this.plugins.values()); } getPlugin(pluginId: string): RegisteredPlugin | undefined { return this.plugins.get(pluginId); } getPluginTools(): PluginTool[] { const tools: PluginTool[] = []; for (const plugin of Array.from(this.plugins.values())) { if (plugin.enabled) { tools.push(...plugin.tools); } } return tools; } getPluginToolDefinitions(): Array<{ name: string; description: string; input_schema: any; }> { return this.getPluginTools().map((tool) => ({ name: `plugin__${tool.pluginId}__${tool.definition.name}`, description: tool.definition.description || tool.definition.name, input_schema: tool.definition.inputSchema || { type: "object", properties: {} }, })); } // ─── Internal Helpers ─────────────────────────────────────────────── private async registerPlugin( pluginId: string, installPath: string, manifest: PluginManifest, kind: PluginKind, source: string ): Promise { const tools: PluginTool[] = (manifest.tools || []).map((t) => ({ pluginId, pluginName: manifest.name, definition: { name: t.name, description: t.description, inputSchema: t.inputSchema || { type: "object", properties: {} }, }, command: t.command, args: t.args || [], requiredPermission: t.requiredPermission || "danger-full-access", root: installPath, })); const registered: RegisteredPlugin = { metadata: { id: pluginId, name: manifest.name, version: manifest.version, description: manifest.description, kind, source, defaultEnabled: manifest.defaultEnabled ?? true, root: installPath, }, hooks: { PreToolUse: manifest.hooks?.PreToolUse || [], PostToolUse: manifest.hooks?.PostToolUse || [], }, lifecycle: { Init: manifest.lifecycle?.Init || [], Shutdown: manifest.lifecycle?.Shutdown || [], }, tools, commands: manifest.commands || [], enabled: this.isEnabled(pluginId), }; this.plugins.set(pluginId, registered); } private async loadManifestFromDir(dir: string): Promise { // Try .claw-plugin/plugin.json first (standard location) const standardPath = path.join(dir, MANIFEST_RELATIVE_PATH); try { const data = await fs.readFile(standardPath, "utf-8"); return JSON.parse(data); } catch {} // Try root plugin.json const rootPath = path.join(dir, MANIFEST_FILE_NAME); try { const data = await fs.readFile(rootPath, "utf-8"); return JSON.parse(data); } catch {} return null; } private async copyDir(src: string, dest: string): Promise { await fs.mkdir(dest, { recursive: true }); const entries = await fs.readdir(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.name === ".git") continue; // Skip .git directory if (entry.isDirectory()) { await this.copyDir(srcPath, destPath); } else { await fs.copyFile(srcPath, destPath); } } } } // ─── Singleton ──────────────────────────────────────────────────────────── let pluginManagerInstance: PluginManager | null = null; export function getPluginManager(configHome?: string): PluginManager { if (!pluginManagerInstance && configHome) { pluginManagerInstance = new PluginManager(configHome); } if (!pluginManagerInstance) { pluginManagerInstance = new PluginManager( path.join(process.env.HOME || "/home/ubuntu", ".claw") ); } return pluginManagerInstance; } export async function initializePlugins(configHome: string): Promise { const manager = getPluginManager(configHome); const plugins = await manager.discoverAndLoadAll(); await manager.initializeAll(); return plugins; }