claw-web-v2 / server /runtime /plugins.ts
Claw Web
Claw Web v1.0 β€” AI Agent Web Interface with MiMo-V2-Flash
7540aea
/**
* 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;
}