Spaces:
Sleeping
Sleeping
| /** | |
| * 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<PluginHooks>; | |
| lifecycle?: Partial<PluginLifecycle>; | |
| 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<string, InstalledPluginRecord>; | |
| } | |
| 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<string>(); | |
| 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<string>(); | |
| 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<string, PluginManager>(); | |
| 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<string, boolean> = new Map(); | |
| private externalDirs: string[] = []; | |
| private plugins: Map<string, RegisteredPlugin> = 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<PluginRegistry> { | |
| try { | |
| const data = await fs.readFile(this.registryPath, "utf-8"); | |
| return JSON.parse(data); | |
| } catch { | |
| return { plugins: {} }; | |
| } | |
| } | |
| async storeRegistry(registry: PluginRegistry): Promise<void> { | |
| 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<InstallOutcome> { | |
| 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<InstallOutcome> { | |
| 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<void> { | |
| 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<UpdateOutcome> { | |
| 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<void> { | |
| const settingsPath = path.join(this.configHome, "plugins", SETTINGS_FILE_NAME); | |
| const enabledMap: Record<string, boolean> = {}; | |
| 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<void> { | |
| 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<RegisteredPlugin[]> { | |
| // 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<string, any> | |
| ): Promise<string> { | |
| 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<string, string> = { | |
| ...process.env as Record<string, string>, | |
| 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<string>((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<string> { | |
| const env: Record<string, string> = { | |
| ...process.env as Record<string, string>, | |
| 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<void> { | |
| 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<string, string>, | |
| 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<void> { | |
| 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<void> { | |
| 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<void> { | |
| 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<void> { | |
| 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<PluginManifest | null> { | |
| // 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<void> { | |
| 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<RegisteredPlugin[]> { | |
| const manager = getPluginManager(configHome); | |
| const plugins = await manager.discoverAndLoadAll(); | |
| await manager.initializeAll(); | |
| return plugins; | |
| } | |