feat: add plugin architecture

This commit is contained in:
Peter Steinberger
2026-01-11 12:11:12 +00:00
parent f2b8f7bd5b
commit cf0c72a557
37 changed files with 2408 additions and 8 deletions
+23 -1
View File
@@ -1,5 +1,7 @@
import type { ClawdbotConfig } from "../config/config.js";
import { resolvePluginTools } from "../plugins/tools.js";
import type { GatewayMessageProvider } from "../utils/message-provider.js";
import { resolveSessionAgentId } from "./agent-scope.js";
import { createAgentsListTool } from "./tools/agents-list-tool.js";
import { createBrowserTool } from "./tools/browser-tool.js";
import { createCanvasTool } from "./tools/canvas-tool.js";
@@ -25,6 +27,7 @@ export function createClawdbotTools(options?: {
agentProvider?: GatewayMessageProvider;
agentAccountId?: string;
agentDir?: string;
workspaceDir?: string;
sandboxed?: boolean;
config?: ClawdbotConfig;
/** Current channel ID for auto-threading (Slack). */
@@ -40,7 +43,7 @@ export function createClawdbotTools(options?: {
config: options?.config,
agentDir: options?.agentDir,
});
return [
const tools: AnyAgentTool[] = [
createBrowserTool({
defaultControlUrl: options?.browserControlUrl,
allowHostControl: options?.allowHostBrowserControl,
@@ -88,4 +91,23 @@ export function createClawdbotTools(options?: {
}),
...(imageTool ? [imageTool] : []),
];
const pluginTools = resolvePluginTools({
context: {
config: options?.config,
workspaceDir: options?.workspaceDir,
agentDir: options?.agentDir,
agentId: resolveSessionAgentId({
sessionKey: options?.agentSessionKey,
config: options?.config,
}),
sessionKey: options?.agentSessionKey,
messageProvider: options?.agentProvider,
agentAccountId: options?.agentAccountId,
sandboxed: options?.sandboxed,
},
existingToolNames: new Set(tools.map((tool) => tool.name)),
});
return [...tools, ...pluginTools];
}
+1
View File
@@ -577,6 +577,7 @@ export function createClawdbotCodingTools(options?: {
agentProvider: resolveGatewayMessageProvider(options?.messageProvider),
agentAccountId: options?.agentAccountId,
agentDir: options?.agentDir,
workspaceDir: options?.workspaceDir,
sandboxed: !!sandbox,
config: options?.config,
currentChannelId: options?.currentChannelId,
+266
View File
@@ -0,0 +1,266 @@
import fs from "node:fs";
import chalk from "chalk";
import type { Command } from "commander";
import { loadConfig, writeConfigFile } from "../config/config.js";
import type { PluginRecord } from "../plugins/registry.js";
import { buildPluginStatusReport } from "../plugins/status.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { resolveUserPath } from "../utils.js";
export type PluginsListOptions = {
json?: boolean;
enabled?: boolean;
verbose?: boolean;
};
export type PluginInfoOptions = {
json?: boolean;
};
function formatPluginLine(plugin: PluginRecord, verbose = false): string {
const status =
plugin.status === "loaded"
? chalk.green("✓")
: plugin.status === "disabled"
? chalk.yellow("disabled")
: chalk.red("error");
const name = plugin.name ? chalk.white(plugin.name) : chalk.white(plugin.id);
const idSuffix =
plugin.name !== plugin.id ? chalk.gray(` (${plugin.id})`) : "";
const desc = plugin.description
? chalk.gray(
plugin.description.length > 60
? `${plugin.description.slice(0, 57)}...`
: plugin.description,
)
: chalk.gray("(no description)");
if (!verbose) {
return `${name}${idSuffix} ${status} - ${desc}`;
}
const parts = [
`${name}${idSuffix} ${status}`,
` source: ${chalk.gray(plugin.source)}`,
` origin: ${plugin.origin}`,
];
if (plugin.version) parts.push(` version: ${plugin.version}`);
if (plugin.error) parts.push(chalk.red(` error: ${plugin.error}`));
return parts.join("\n");
}
export function registerPluginsCli(program: Command) {
const plugins = program
.command("plugins")
.description("Manage Clawdbot plugins/extensions");
plugins
.command("list")
.description("List discovered plugins")
.option("--json", "Print JSON")
.option("--enabled", "Only show enabled plugins", false)
.option("--verbose", "Show detailed entries", false)
.action((opts: PluginsListOptions) => {
const report = buildPluginStatusReport();
const list = opts.enabled
? report.plugins.filter((p) => p.status === "loaded")
: report.plugins;
if (opts.json) {
const payload = {
workspaceDir: report.workspaceDir,
plugins: list,
diagnostics: report.diagnostics,
};
defaultRuntime.log(JSON.stringify(payload, null, 2));
return;
}
if (list.length === 0) {
defaultRuntime.log("No plugins found.");
return;
}
const lines: string[] = [];
const loaded = list.filter((p) => p.status === "loaded").length;
lines.push(
`${chalk.bold.cyan("Plugins")} ${chalk.gray(`(${loaded}/${list.length} loaded)`)}`,
);
lines.push("");
for (const plugin of list) {
lines.push(formatPluginLine(plugin, opts.verbose));
if (opts.verbose) lines.push("");
}
defaultRuntime.log(lines.join("\n").trim());
});
plugins
.command("info")
.description("Show plugin details")
.argument("<id>", "Plugin id")
.option("--json", "Print JSON")
.action((id: string, opts: PluginInfoOptions) => {
const report = buildPluginStatusReport();
const plugin = report.plugins.find((p) => p.id === id || p.name === id);
if (!plugin) {
defaultRuntime.error(`Plugin not found: ${id}`);
process.exit(1);
}
if (opts.json) {
defaultRuntime.log(JSON.stringify(plugin, null, 2));
return;
}
const lines: string[] = [];
lines.push(chalk.bold.cyan(plugin.name || plugin.id));
if (plugin.name && plugin.name !== plugin.id) {
lines.push(chalk.gray(`id: ${plugin.id}`));
}
if (plugin.description) lines.push(plugin.description);
lines.push("");
lines.push(`Status: ${plugin.status}`);
lines.push(`Source: ${plugin.source}`);
lines.push(`Origin: ${plugin.origin}`);
if (plugin.version) lines.push(`Version: ${plugin.version}`);
if (plugin.toolNames.length > 0) {
lines.push(`Tools: ${plugin.toolNames.join(", ")}`);
}
if (plugin.gatewayMethods.length > 0) {
lines.push(`Gateway methods: ${plugin.gatewayMethods.join(", ")}`);
}
if (plugin.cliCommands.length > 0) {
lines.push(`CLI commands: ${plugin.cliCommands.join(", ")}`);
}
if (plugin.services.length > 0) {
lines.push(`Services: ${plugin.services.join(", ")}`);
}
if (plugin.error) lines.push(chalk.red(`Error: ${plugin.error}`));
defaultRuntime.log(lines.join("\n"));
});
plugins
.command("enable")
.description("Enable a plugin in config")
.argument("<id>", "Plugin id")
.action(async (id: string) => {
const cfg = loadConfig();
const next = {
...cfg,
plugins: {
...cfg.plugins,
entries: {
...(cfg.plugins?.entries ?? {}),
[id]: {
...(
cfg.plugins?.entries as
| Record<string, { enabled?: boolean }>
| undefined
)?.[id],
enabled: true,
},
},
},
};
await writeConfigFile(next);
defaultRuntime.log(
`Enabled plugin "${id}". Restart the gateway to apply.`,
);
});
plugins
.command("disable")
.description("Disable a plugin in config")
.argument("<id>", "Plugin id")
.action(async (id: string) => {
const cfg = loadConfig();
const next = {
...cfg,
plugins: {
...cfg.plugins,
entries: {
...(cfg.plugins?.entries ?? {}),
[id]: {
...(
cfg.plugins?.entries as
| Record<string, { enabled?: boolean }>
| undefined
)?.[id],
enabled: false,
},
},
},
};
await writeConfigFile(next);
defaultRuntime.log(
`Disabled plugin "${id}". Restart the gateway to apply.`,
);
});
plugins
.command("install")
.description("Add a plugin path to clawdbot.json")
.argument("<path>", "Path to a plugin file or directory")
.action(async (rawPath: string) => {
const resolved = resolveUserPath(rawPath);
if (!fs.existsSync(resolved)) {
defaultRuntime.error(`Path not found: ${resolved}`);
process.exit(1);
}
const cfg = loadConfig();
const existing = cfg.plugins?.load?.paths ?? [];
const merged = Array.from(new Set([...existing, resolved]));
const next = {
...cfg,
plugins: {
...cfg.plugins,
load: {
...cfg.plugins?.load,
paths: merged,
},
},
};
await writeConfigFile(next);
defaultRuntime.log(`Added plugin path: ${resolved}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
});
plugins
.command("doctor")
.description("Report plugin load issues")
.action(() => {
const report = buildPluginStatusReport();
const errors = report.plugins.filter((p) => p.status === "error");
const diags = report.diagnostics.filter((d) => d.level === "error");
if (errors.length === 0 && diags.length === 0) {
defaultRuntime.log("No plugin issues detected.");
return;
}
const lines: string[] = [];
if (errors.length > 0) {
lines.push(chalk.bold.red("Plugin errors:"));
for (const entry of errors) {
lines.push(
`- ${entry.id}: ${entry.error ?? "failed to load"} (${entry.source})`,
);
}
}
if (diags.length > 0) {
if (lines.length > 0) lines.push("");
lines.push(chalk.bold.yellow("Diagnostics:"));
for (const diag of diags) {
const target = diag.pluginId ? `${diag.pluginId}: ` : "";
lines.push(`- ${target}${diag.message}`);
}
}
const docs = formatDocsLink("/plugin", "docs.clawd.bot/plugin");
lines.push("");
lines.push(`${theme.muted("Docs:")} ${docs}`);
defaultRuntime.log(lines.join("\n"));
});
}
+4
View File
@@ -28,6 +28,7 @@ import {
} from "../config/config.js";
import { danger, setVerbose } from "../globals.js";
import { autoMigrateLegacyState } from "../infra/state-migrations.js";
import { registerPluginCliCommands } from "../plugins/cli.js";
import { listProviderPlugins } from "../providers/plugins/index.js";
import { DEFAULT_CHAT_PROVIDER } from "../providers/registry.js";
import { defaultRuntime } from "../runtime.js";
@@ -52,6 +53,7 @@ import { registerLogsCli } from "./logs-cli.js";
import { registerModelsCli } from "./models-cli.js";
import { registerNodesCli } from "./nodes-cli.js";
import { registerPairingCli } from "./pairing-cli.js";
import { registerPluginsCli } from "./plugins-cli.js";
import { forceFreePort } from "./ports.js";
import { runProviderLogin, runProviderLogout } from "./provider-auth.js";
import { registerProvidersCli } from "./providers-cli.js";
@@ -1216,9 +1218,11 @@ ${theme.muted("Docs:")} ${formatDocsLink(
registerDocsCli(program);
registerHooksCli(program);
registerPairingCli(program);
registerPluginsCli(program);
registerProvidersCli(program);
registerSkillsCli(program);
registerUpdateCli(program);
registerPluginCliCommands(program, loadConfig());
program
.command("status")
+21
View File
@@ -33,6 +33,7 @@ import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
import { runGatewayUpdate } from "../infra/update-runner.js";
import { loadClawdbotPlugins } from "../plugins/loader.js";
import { runCommandWithTimeout } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
@@ -510,6 +511,26 @@ export async function doctorCommand(
"Skills status",
);
const pluginRegistry = loadClawdbotPlugins({
config: cfg,
workspaceDir,
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
});
if (pluginRegistry.diagnostics.length > 0) {
const lines = pluginRegistry.diagnostics.map((diag) => {
const prefix = diag.level.toUpperCase();
const plugin = diag.pluginId ? ` ${diag.pluginId}` : "";
const source = diag.source ? ` (${diag.source})` : "";
return `- ${prefix}${plugin}: ${diag.message}${source}`;
});
note(lines.join("\n"), "Plugin diagnostics");
}
let healthOk = false;
try {
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
+20
View File
@@ -47,6 +47,7 @@ const GROUP_LABELS: Record<string, string> = {
imessage: "iMessage",
whatsapp: "WhatsApp",
skills: "Skills",
plugins: "Plugins",
discovery: "Discovery",
presence: "Presence",
voicewake: "Voice Wake",
@@ -75,6 +76,7 @@ const GROUP_ORDER: Record<string, number> = {
imessage: 180,
whatsapp: 190,
skills: 200,
plugins: 205,
discovery: 210,
presence: 220,
voicewake: 230,
@@ -153,6 +155,13 @@ const FIELD_LABELS: Record<string, string> = {
"slack.appToken": "Slack App Token",
"signal.account": "Signal Account",
"imessage.cliPath": "iMessage CLI Path",
"plugins.enabled": "Enable Plugins",
"plugins.allow": "Plugin Allowlist",
"plugins.deny": "Plugin Denylist",
"plugins.load.paths": "Plugin Load Paths",
"plugins.entries": "Plugin Entries",
"plugins.entries.*.enabled": "Plugin Enabled",
"plugins.entries.*.config": "Plugin Config",
};
const FIELD_HELP: Record<string, string> = {
@@ -187,6 +196,17 @@ const FIELD_HELP: Record<string, string> = {
"Failure window (hours) for backoff counters (default: 24).",
"agents.defaults.models":
"Configured model catalog (keys are full provider/model IDs).",
"plugins.enabled": "Enable plugin/extension loading (default: true).",
"plugins.allow":
"Optional allowlist of plugin ids; when set, only listed plugins load.",
"plugins.deny": "Optional denylist of plugin ids; deny wins over allowlist.",
"plugins.load.paths": "Additional plugin files or directories to load.",
"plugins.entries":
"Per-plugin settings keyed by plugin id (enable/disable + config payloads).",
"plugins.entries.*.enabled":
"Overrides plugin enable/disable for this entry (restart required).",
"plugins.entries.*.config":
"Plugin-defined config payload (schema is provided by the plugin).",
"agents.defaults.model.primary": "Primary model (provider/model).",
"agents.defaults.model.fallbacks":
"Ordered fallback models (provider/model). Used when the primary model fails.",
+22
View File
@@ -1276,6 +1276,27 @@ export type SkillsConfig = {
entries?: Record<string, SkillConfig>;
};
export type PluginEntryConfig = {
enabled?: boolean;
config?: Record<string, unknown>;
};
export type PluginsLoadConfig = {
/** Additional plugin/extension paths to load. */
paths?: string[];
};
export type PluginsConfig = {
/** Enable or disable plugin loading. */
enabled?: boolean;
/** Optional plugin allowlist (plugin ids). */
allow?: string[];
/** Optional plugin denylist (plugin ids). */
deny?: string[];
load?: PluginsLoadConfig;
entries?: Record<string, PluginEntryConfig>;
};
export type ModelApi =
| "openai-completions"
| "openai-responses"
@@ -1580,6 +1601,7 @@ export type ClawdbotConfig = {
seamColor?: string;
};
skills?: SkillsConfig;
plugins?: PluginsConfig;
models?: ModelsConfig;
agents?: AgentsConfig;
tools?: ToolsConfig;
+23
View File
@@ -1608,6 +1608,29 @@ export const ClawdbotSchema = z
.optional(),
})
.optional(),
plugins: z
.object({
enabled: z.boolean().optional(),
allow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
load: z
.object({
paths: z.array(z.string()).optional(),
})
.optional(),
entries: z
.record(
z.string(),
z
.object({
enabled: z.boolean().optional(),
config: z.record(z.string(), z.unknown()).optional(),
})
.passthrough(),
)
.optional(),
})
.optional(),
})
.superRefine((cfg, ctx) => {
const agents = cfg.agents?.list ?? [];
+1
View File
@@ -84,6 +84,7 @@ const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [
{ prefix: "session", kind: "none" },
{ prefix: "talk", kind: "none" },
{ prefix: "skills", kind: "none" },
{ prefix: "plugins", kind: "restart" },
{ prefix: "ui", kind: "none" },
{ prefix: "gateway", kind: "restart" },
{ prefix: "bridge", kind: "restart" },
+4 -3
View File
@@ -25,7 +25,7 @@ import { voicewakeHandlers } from "./server-methods/voicewake.js";
import { webHandlers } from "./server-methods/web.js";
import { wizardHandlers } from "./server-methods/wizard.js";
const handlers: GatewayRequestHandlers = {
export const coreGatewayHandlers: GatewayRequestHandlers = {
...connectHandlers,
...logsHandlers,
...voicewakeHandlers,
@@ -50,10 +50,11 @@ const handlers: GatewayRequestHandlers = {
};
export async function handleGatewayRequest(
opts: GatewayRequestOptions,
opts: GatewayRequestOptions & { extraHandlers?: GatewayRequestHandlers },
): Promise<void> {
const { req, respond, client, isWebchatConnect, context } = opts;
const handler = handlers[req.method];
const handler =
opts.extraHandlers?.[req.method] ?? coreGatewayHandlers[req.method];
if (!handler) {
respond(
false,
+54 -3
View File
@@ -3,6 +3,10 @@ import type { Server as HttpServer } from "node:http";
import os from "node:os";
import chalk from "chalk";
import { type WebSocket, WebSocketServer } from "ws";
import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import {
loadModelCatalog,
@@ -103,6 +107,11 @@ import {
getResolvedLoggerSettings,
runtimeForLogger,
} from "../logging.js";
import { loadClawdbotPlugins } from "../plugins/loader.js";
import {
type PluginServicesHandle,
startPluginServices,
} from "../plugins/services.js";
import { setCommandLaneConcurrency } from "../process/command-queue.js";
import {
listProviderPlugins,
@@ -177,7 +186,7 @@ import {
createGatewayHttpServer,
createHooksRequestHandler,
} from "./server-http.js";
import { handleGatewayRequest } from "./server-methods.js";
import { coreGatewayHandlers, handleGatewayRequest } from "./server-methods.js";
import { createProviderManager } from "./server-providers.js";
import type { DedupeEntry } from "./server-shared.js";
import { formatError } from "./server-utils.js";
@@ -438,6 +447,34 @@ export async function startGatewayServer(
const cfgAtStart = loadConfig();
await autoMigrateLegacyState({ cfg: cfgAtStart, log });
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
const defaultWorkspaceDir = resolveAgentWorkspaceDir(
cfgAtStart,
defaultAgentId,
);
const pluginRegistry = loadClawdbotPlugins({
config: cfgAtStart,
workspaceDir: defaultWorkspaceDir,
logger: {
info: (msg) => log.info(msg),
warn: (msg) => log.warn(msg),
error: (msg) => log.error(msg),
debug: (msg) => log.debug(msg),
},
coreGatewayHandlers,
});
const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers);
const gatewayMethods = Array.from(new Set([...METHODS, ...pluginMethods]));
if (pluginRegistry.diagnostics.length > 0) {
for (const diag of pluginRegistry.diagnostics) {
if (diag.level === "error") {
log.warn(`[plugins] ${diag.message}`);
} else {
log.info(`[plugins] ${diag.message}`);
}
}
}
let pluginServices: PluginServicesHandle | null = null;
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
if (!bindHost) {
@@ -1594,7 +1631,7 @@ export async function startGatewayServer(
host: os.hostname(),
connId,
},
features: { methods: METHODS, events: EVENTS },
features: { methods: gatewayMethods, events: EVENTS },
snapshot,
canvasHostUrl,
policy: {
@@ -1610,7 +1647,7 @@ export async function startGatewayServer(
logWs("out", "hello-ok", {
connId,
methods: METHODS.length,
methods: gatewayMethods.length,
events: EVENTS.length,
presence: snapshot.presence.length,
stateVersion: snapshot.stateVersion.presence,
@@ -1670,6 +1707,7 @@ export async function startGatewayServer(
respond,
client,
isWebchatConnect,
extraHandlers: pluginRegistry.gatewayHandlers,
context: {
deps,
cron,
@@ -1854,6 +1892,16 @@ export async function startGatewayServer(
logProviders.info("skipping provider start (CLAWDBOT_SKIP_PROVIDERS=1)");
}
try {
pluginServices = await startPluginServices({
registry: pluginRegistry,
config: cfgAtStart,
workspaceDir: defaultWorkspaceDir,
});
} catch (err) {
log.warn(`plugin services failed to start: ${String(err)}`);
}
const scheduleRestartSentinelWake = async () => {
const sentinel = await consumeRestartSentinel();
if (!sentinel) return;
@@ -2091,6 +2139,9 @@ export async function startGatewayServer(
for (const plugin of listProviderPlugins()) {
await stopProvider(plugin.id);
}
if (pluginServices) {
await pluginServices.stop().catch(() => {});
}
await stopGmailWatcher();
cron.stop();
heartbeatRunner.stop();
+57
View File
@@ -0,0 +1,57 @@
import type { Command } from "commander";
import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging.js";
import { loadClawdbotPlugins } from "./loader.js";
import type { PluginLogger } from "./types.js";
const log = createSubsystemLogger("plugins");
export function registerPluginCliCommands(
program: Command,
cfg?: ClawdbotConfig,
) {
const config = cfg ?? loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(
config,
resolveDefaultAgentId(config),
);
const logger: PluginLogger = {
info: (msg: string) => log.info(msg),
warn: (msg: string) => log.warn(msg),
error: (msg: string) => log.error(msg),
debug: (msg: string) => log.debug(msg),
};
const registry = loadClawdbotPlugins({
config,
workspaceDir,
logger,
});
for (const entry of registry.cliRegistrars) {
try {
const result = entry.register({
program,
config,
workspaceDir,
logger,
});
if (result && typeof (result as Promise<void>).then === "function") {
void (result as Promise<void>).catch((err) => {
log.warn(
`plugin CLI register failed (${entry.pluginId}): ${String(err)}`,
);
});
}
} catch (err) {
log.warn(
`plugin CLI register failed (${entry.pluginId}): ${String(err)}`,
);
}
}
}
+106
View File
@@ -0,0 +1,106 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const tempDirs: string[] = [];
function makeTempDir() {
const dir = path.join(os.tmpdir(), `clawdbot-plugins-${randomUUID()}`);
fs.mkdirSync(dir, { recursive: true });
tempDirs.push(dir);
return dir;
}
async function withStateDir<T>(stateDir: string, fn: () => Promise<T>) {
const prev = process.env.CLAWDBOT_STATE_DIR;
process.env.CLAWDBOT_STATE_DIR = stateDir;
vi.resetModules();
try {
return await fn();
} finally {
if (prev === undefined) {
delete process.env.CLAWDBOT_STATE_DIR;
} else {
process.env.CLAWDBOT_STATE_DIR = prev;
}
vi.resetModules();
}
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// ignore cleanup failures
}
}
});
describe("discoverClawdbotPlugins", () => {
it("discovers global and workspace extensions", async () => {
const stateDir = makeTempDir();
const workspaceDir = path.join(stateDir, "workspace");
const globalExt = path.join(stateDir, "extensions");
fs.mkdirSync(globalExt, { recursive: true });
fs.writeFileSync(
path.join(globalExt, "alpha.ts"),
"export default function () {}",
"utf-8",
);
const workspaceExt = path.join(workspaceDir, ".clawdbot", "extensions");
fs.mkdirSync(workspaceExt, { recursive: true });
fs.writeFileSync(
path.join(workspaceExt, "beta.ts"),
"export default function () {}",
"utf-8",
);
const { candidates } = await withStateDir(stateDir, async () => {
const { discoverClawdbotPlugins } = await import("./discovery.js");
return discoverClawdbotPlugins({ workspaceDir });
});
const ids = candidates.map((c) => c.idHint);
expect(ids).toContain("alpha");
expect(ids).toContain("beta");
});
it("loads package extension packs", async () => {
const stateDir = makeTempDir();
const globalExt = path.join(stateDir, "extensions", "pack");
fs.mkdirSync(path.join(globalExt, "src"), { recursive: true });
fs.writeFileSync(
path.join(globalExt, "package.json"),
JSON.stringify({
name: "pack",
clawdbot: { extensions: ["./src/one.ts", "./src/two.ts"] },
}),
"utf-8",
);
fs.writeFileSync(
path.join(globalExt, "src", "one.ts"),
"export default function () {}",
"utf-8",
);
fs.writeFileSync(
path.join(globalExt, "src", "two.ts"),
"export default function () {}",
"utf-8",
);
const { candidates } = await withStateDir(stateDir, async () => {
const { discoverClawdbotPlugins } = await import("./discovery.js");
return discoverClawdbotPlugins({});
});
const ids = candidates.map((c) => c.idHint);
expect(ids).toContain("pack/one");
expect(ids).toContain("pack/two");
});
});
+269
View File
@@ -0,0 +1,269 @@
import fs from "node:fs";
import path from "node:path";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import type { PluginDiagnostic, PluginOrigin } from "./types.js";
const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);
export type PluginCandidate = {
idHint: string;
source: string;
origin: PluginOrigin;
workspaceDir?: string;
packageName?: string;
packageVersion?: string;
packageDescription?: string;
};
export type PluginDiscoveryResult = {
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
};
type PackageManifest = {
name?: string;
version?: string;
description?: string;
clawdbot?: {
extensions?: string[];
};
};
function isExtensionFile(filePath: string): boolean {
const ext = path.extname(filePath);
if (!EXTENSION_EXTS.has(ext)) return false;
return !filePath.endsWith(".d.ts");
}
function readPackageManifest(dir: string): PackageManifest | null {
const manifestPath = path.join(dir, "package.json");
if (!fs.existsSync(manifestPath)) return null;
try {
const raw = fs.readFileSync(manifestPath, "utf-8");
return JSON.parse(raw) as PackageManifest;
} catch {
return null;
}
}
function resolvePackageExtensions(manifest: PackageManifest): string[] {
const raw = manifest.clawdbot?.extensions;
if (!Array.isArray(raw)) return [];
return raw
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean);
}
function deriveIdHint(params: {
filePath: string;
packageName?: string;
hasMultipleExtensions: boolean;
}): string {
const base = path.basename(params.filePath, path.extname(params.filePath));
const packageName = params.packageName?.trim();
if (!packageName) return base;
if (!params.hasMultipleExtensions) return packageName;
return `${packageName}/${base}`;
}
function addCandidate(params: {
candidates: PluginCandidate[];
seen: Set<string>;
idHint: string;
source: string;
origin: PluginOrigin;
workspaceDir?: string;
manifest?: PackageManifest | null;
}) {
const resolved = path.resolve(params.source);
if (params.seen.has(resolved)) return;
params.seen.add(resolved);
const manifest = params.manifest ?? null;
params.candidates.push({
idHint: params.idHint,
source: resolved,
origin: params.origin,
workspaceDir: params.workspaceDir,
packageName: manifest?.name?.trim() || undefined,
packageVersion: manifest?.version?.trim() || undefined,
packageDescription: manifest?.description?.trim() || undefined,
});
}
function discoverInDirectory(params: {
dir: string;
origin: PluginOrigin;
workspaceDir?: string;
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
seen: Set<string>;
}) {
if (!fs.existsSync(params.dir)) return;
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(params.dir, { withFileTypes: true });
} catch (err) {
params.diagnostics.push({
level: "warn",
message: `failed to read extensions dir: ${params.dir} (${String(err)})`,
source: params.dir,
});
return;
}
for (const entry of entries) {
const fullPath = path.join(params.dir, entry.name);
if (entry.isFile()) {
if (!isExtensionFile(fullPath)) continue;
addCandidate({
candidates: params.candidates,
seen: params.seen,
idHint: path.basename(entry.name, path.extname(entry.name)),
source: fullPath,
origin: params.origin,
workspaceDir: params.workspaceDir,
});
}
if (!entry.isDirectory()) continue;
const manifest = readPackageManifest(fullPath);
const extensions = manifest ? resolvePackageExtensions(manifest) : [];
if (extensions.length > 0) {
for (const extPath of extensions) {
const resolved = path.resolve(fullPath, extPath);
addCandidate({
candidates: params.candidates,
seen: params.seen,
idHint: deriveIdHint({
filePath: resolved,
packageName: manifest?.name,
hasMultipleExtensions: extensions.length > 1,
}),
source: resolved,
origin: params.origin,
workspaceDir: params.workspaceDir,
manifest,
});
}
continue;
}
const indexCandidates = ["index.ts", "index.js", "index.mjs", "index.cjs"];
const indexFile = indexCandidates
.map((candidate) => path.join(fullPath, candidate))
.find((candidate) => fs.existsSync(candidate));
if (indexFile && isExtensionFile(indexFile)) {
addCandidate({
candidates: params.candidates,
seen: params.seen,
idHint: entry.name,
source: indexFile,
origin: params.origin,
workspaceDir: params.workspaceDir,
});
}
}
}
function discoverFromPath(params: {
rawPath: string;
origin: PluginOrigin;
workspaceDir?: string;
candidates: PluginCandidate[];
diagnostics: PluginDiagnostic[];
seen: Set<string>;
}) {
const resolved = resolveUserPath(params.rawPath);
if (!fs.existsSync(resolved)) {
params.diagnostics.push({
level: "warn",
message: `plugin path not found: ${resolved}`,
source: resolved,
});
return;
}
const stat = fs.statSync(resolved);
if (stat.isFile()) {
if (!isExtensionFile(resolved)) {
params.diagnostics.push({
level: "warn",
message: `plugin path is not a supported file: ${resolved}`,
source: resolved,
});
return;
}
addCandidate({
candidates: params.candidates,
seen: params.seen,
idHint: path.basename(resolved, path.extname(resolved)),
source: resolved,
origin: params.origin,
workspaceDir: params.workspaceDir,
});
return;
}
if (stat.isDirectory()) {
discoverInDirectory({
dir: resolved,
origin: params.origin,
workspaceDir: params.workspaceDir,
candidates: params.candidates,
diagnostics: params.diagnostics,
seen: params.seen,
});
return;
}
}
export function discoverClawdbotPlugins(params: {
workspaceDir?: string;
extraPaths?: string[];
}): PluginDiscoveryResult {
const candidates: PluginCandidate[] = [];
const diagnostics: PluginDiagnostic[] = [];
const seen = new Set<string>();
const globalDir = path.join(CONFIG_DIR, "extensions");
discoverInDirectory({
dir: globalDir,
origin: "global",
candidates,
diagnostics,
seen,
});
const workspaceDir = params.workspaceDir?.trim();
if (workspaceDir) {
const workspaceRoot = resolveUserPath(workspaceDir);
const workspaceExt = path.join(workspaceRoot, ".clawdbot", "extensions");
discoverInDirectory({
dir: workspaceExt,
origin: "workspace",
workspaceDir: workspaceRoot,
candidates,
diagnostics,
seen,
});
}
const extra = params.extraPaths ?? [];
for (const extraPath of extra) {
if (typeof extraPath !== "string") continue;
const trimmed = extraPath.trim();
if (!trimmed) continue;
discoverFromPath({
rawPath: trimmed,
origin: "config",
workspaceDir: workspaceDir?.trim() || undefined,
candidates,
diagnostics,
seen,
});
}
return { candidates, diagnostics };
}
+105
View File
@@ -0,0 +1,105 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { loadClawdbotPlugins } from "./loader.js";
type TempPlugin = { dir: string; file: string; id: string };
const tempDirs: string[] = [];
function makeTempDir() {
const dir = path.join(os.tmpdir(), `clawdbot-plugin-${randomUUID()}`);
fs.mkdirSync(dir, { recursive: true });
tempDirs.push(dir);
return dir;
}
function writePlugin(params: { id: string; body: string }): TempPlugin {
const dir = makeTempDir();
const file = path.join(dir, `${params.id}.js`);
fs.writeFileSync(file, params.body, "utf-8");
return { dir, file, id: params.id };
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// ignore cleanup failures
}
}
});
describe("loadClawdbotPlugins", () => {
it("loads plugins from config paths", () => {
const plugin = writePlugin({
id: "allowed",
body: `export default function (api) { api.registerGatewayMethod("allowed.ping", ({ respond }) => respond(true, { ok: true })); }`,
});
const registry = loadClawdbotPlugins({
cache: false,
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["allowed"],
},
},
});
expect(registry.plugins.length).toBe(1);
expect(registry.plugins[0]?.status).toBe("loaded");
expect(Object.keys(registry.gatewayHandlers)).toContain("allowed.ping");
});
it("denylist disables plugins even if allowed", () => {
const plugin = writePlugin({
id: "blocked",
body: `export default function () {}`,
});
const registry = loadClawdbotPlugins({
cache: false,
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
allow: ["blocked"],
deny: ["blocked"],
},
},
});
expect(registry.plugins[0]?.status).toBe("disabled");
});
it("fails fast on invalid plugin config", () => {
const plugin = writePlugin({
id: "configurable",
body: `export default {\n id: "configurable",\n configSchema: {\n parse(value) {\n if (!value || typeof value !== "object" || Array.isArray(value)) {\n throw new Error("bad config");\n }\n return value;\n }\n },\n register() {}\n};`,
});
const registry = loadClawdbotPlugins({
cache: false,
workspaceDir: plugin.dir,
config: {
plugins: {
load: { paths: [plugin.file] },
entries: {
configurable: {
config: "nope" as unknown as Record<string, unknown>,
},
},
},
},
});
expect(registry.plugins[0]?.status).toBe("error");
expect(registry.diagnostics.some((d) => d.level === "error")).toBe(true);
});
});
+376
View File
@@ -0,0 +1,376 @@
import { createJiti } from "jiti";
import type { ClawdbotConfig } from "../config/config.js";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
import { createSubsystemLogger } from "../logging.js";
import { resolveUserPath } from "../utils.js";
import { discoverClawdbotPlugins } from "./discovery.js";
import {
createPluginRegistry,
type PluginRecord,
type PluginRegistry,
} from "./registry.js";
import type {
ClawdbotPluginConfigSchema,
ClawdbotPluginDefinition,
ClawdbotPluginModule,
PluginDiagnostic,
PluginLogger,
} from "./types.js";
export type PluginLoadResult = PluginRegistry;
export type PluginLoadOptions = {
config?: ClawdbotConfig;
workspaceDir?: string;
logger?: PluginLogger;
coreGatewayHandlers?: Record<string, GatewayRequestHandler>;
cache?: boolean;
};
type NormalizedPluginsConfig = {
enabled: boolean;
allow: string[];
deny: string[];
loadPaths: string[];
entries: Record<
string,
{ enabled?: boolean; config?: Record<string, unknown> }
>;
};
const registryCache = new Map<string, PluginRegistry>();
const defaultLogger = () => createSubsystemLogger("plugins");
const normalizeList = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
return value
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean);
};
const normalizePluginEntries = (
entries: unknown,
): NormalizedPluginsConfig["entries"] => {
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
return {};
}
const normalized: NormalizedPluginsConfig["entries"] = {};
for (const [key, value] of Object.entries(entries)) {
if (!key.trim()) continue;
if (!value || typeof value !== "object" || Array.isArray(value)) {
normalized[key] = {};
continue;
}
const entry = value as Record<string, unknown>;
normalized[key] = {
enabled: typeof entry.enabled === "boolean" ? entry.enabled : undefined,
config:
entry.config &&
typeof entry.config === "object" &&
!Array.isArray(entry.config)
? (entry.config as Record<string, unknown>)
: undefined,
};
}
return normalized;
};
const normalizePluginsConfig = (
config?: ClawdbotConfig["plugins"],
): NormalizedPluginsConfig => {
return {
enabled: config?.enabled !== false,
allow: normalizeList(config?.allow),
deny: normalizeList(config?.deny),
loadPaths: normalizeList(config?.load?.paths),
entries: normalizePluginEntries(config?.entries),
};
};
function buildCacheKey(params: {
workspaceDir?: string;
plugins: NormalizedPluginsConfig;
}): string {
const workspaceKey = params.workspaceDir
? resolveUserPath(params.workspaceDir)
: "";
return `${workspaceKey}::${JSON.stringify(params.plugins)}`;
}
function resolveEnableState(
id: string,
config: NormalizedPluginsConfig,
): { enabled: boolean; reason?: string } {
if (!config.enabled) {
return { enabled: false, reason: "plugins disabled" };
}
if (config.deny.includes(id)) {
return { enabled: false, reason: "blocked by denylist" };
}
if (config.allow.length > 0 && !config.allow.includes(id)) {
return { enabled: false, reason: "not in allowlist" };
}
const entry = config.entries[id];
if (entry?.enabled === false) {
return { enabled: false, reason: "disabled in config" };
}
return { enabled: true };
}
function validatePluginConfig(params: {
schema?: ClawdbotPluginConfigSchema;
value?: Record<string, unknown>;
}): { ok: boolean; value?: Record<string, unknown>; errors?: string[] } {
const schema = params.schema;
if (!schema) return { ok: true, value: params.value };
if (typeof schema.validate === "function") {
const result = schema.validate(params.value);
if (result.ok) {
return { ok: true, value: result.value as Record<string, unknown> };
}
return { ok: false, errors: result.errors };
}
if (typeof schema.safeParse === "function") {
const result = schema.safeParse(params.value);
if (result.success) {
return { ok: true, value: result.data as Record<string, unknown> };
}
const issues = result.error?.issues ?? [];
const errors = issues.map((issue) => {
const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
return `${path}: ${issue.message}`;
});
return { ok: false, errors };
}
if (typeof schema.parse === "function") {
try {
const parsed = schema.parse(params.value);
return { ok: true, value: parsed as Record<string, unknown> };
} catch (err) {
return { ok: false, errors: [String(err)] };
}
}
return { ok: true, value: params.value };
}
function resolvePluginModuleExport(moduleExport: unknown): {
definition?: ClawdbotPluginDefinition;
register?: ClawdbotPluginDefinition["register"];
} {
const resolved =
moduleExport &&
typeof moduleExport === "object" &&
"default" in (moduleExport as Record<string, unknown>)
? (moduleExport as { default: unknown }).default
: moduleExport;
if (typeof resolved === "function") {
return {
register: resolved as ClawdbotPluginDefinition["register"],
};
}
if (resolved && typeof resolved === "object") {
const def = resolved as ClawdbotPluginDefinition;
const register = def.register ?? def.activate;
return { definition: def, register };
}
return {};
}
function createPluginRecord(params: {
id: string;
name?: string;
description?: string;
version?: string;
source: string;
origin: PluginRecord["origin"];
workspaceDir?: string;
enabled: boolean;
configSchema: boolean;
}): PluginRecord {
return {
id: params.id,
name: params.name ?? params.id,
description: params.description,
version: params.version,
source: params.source,
origin: params.origin,
workspaceDir: params.workspaceDir,
enabled: params.enabled,
status: params.enabled ? "loaded" : "disabled",
toolNames: [],
gatewayMethods: [],
cliCommands: [],
services: [],
configSchema: params.configSchema,
};
}
function pushDiagnostics(
diagnostics: PluginDiagnostic[],
append: PluginDiagnostic[],
) {
diagnostics.push(...append);
}
export function loadClawdbotPlugins(
options: PluginLoadOptions = {},
): PluginRegistry {
const cfg = options.config ?? {};
const logger = options.logger ?? defaultLogger();
const normalized = normalizePluginsConfig(cfg.plugins);
const cacheKey = buildCacheKey({
workspaceDir: options.workspaceDir,
plugins: normalized,
});
const cacheEnabled = options.cache !== false;
if (cacheEnabled) {
const cached = registryCache.get(cacheKey);
if (cached) return cached;
}
const { registry, createApi } = createPluginRegistry({
logger,
coreGatewayHandlers: options.coreGatewayHandlers as Record<
string,
GatewayRequestHandler
>,
});
const discovery = discoverClawdbotPlugins({
workspaceDir: options.workspaceDir,
extraPaths: normalized.loadPaths,
});
pushDiagnostics(registry.diagnostics, discovery.diagnostics);
const jiti = createJiti(import.meta.url, {
interopDefault: true,
});
for (const candidate of discovery.candidates) {
const enableState = resolveEnableState(candidate.idHint, normalized);
const entry = normalized.entries[candidate.idHint];
const record = createPluginRecord({
id: candidate.idHint,
name: candidate.packageName ?? candidate.idHint,
description: candidate.packageDescription,
version: candidate.packageVersion,
source: candidate.source,
origin: candidate.origin,
workspaceDir: candidate.workspaceDir,
enabled: enableState.enabled,
configSchema: false,
});
if (!enableState.enabled) {
record.status = "disabled";
record.error = enableState.reason;
registry.plugins.push(record);
continue;
}
let mod: ClawdbotPluginModule | null = null;
try {
mod = jiti(candidate.source) as ClawdbotPluginModule;
} catch (err) {
record.status = "error";
record.error = String(err);
registry.plugins.push(record);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
source: record.source,
message: `failed to load plugin: ${String(err)}`,
});
continue;
}
const resolved = resolvePluginModuleExport(mod);
const definition = resolved.definition;
const register = resolved.register;
if (definition?.id && definition.id !== record.id) {
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message: `plugin id mismatch (config uses "${record.id}", export uses "${definition.id}")`,
});
}
record.name = definition?.name ?? record.name;
record.description = definition?.description ?? record.description;
record.version = definition?.version ?? record.version;
record.configSchema = Boolean(definition?.configSchema);
const validatedConfig = validatePluginConfig({
schema: definition?.configSchema,
value: entry?.config,
});
if (!validatedConfig.ok) {
record.status = "error";
record.error = `invalid config: ${validatedConfig.errors?.join(", ")}`;
registry.plugins.push(record);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
source: record.source,
message: record.error,
});
continue;
}
if (typeof register !== "function") {
record.status = "error";
record.error = "plugin export missing register/activate";
registry.plugins.push(record);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
source: record.source,
message: record.error,
});
continue;
}
const api = createApi(record, {
config: cfg,
pluginConfig: validatedConfig.value,
});
try {
const result = register(api);
if (result && typeof (result as Promise<void>).then === "function") {
registry.diagnostics.push({
level: "warn",
pluginId: record.id,
source: record.source,
message:
"plugin register returned a promise; async registration is ignored",
});
}
registry.plugins.push(record);
} catch (err) {
record.status = "error";
record.error = String(err);
registry.plugins.push(record);
registry.diagnostics.push({
level: "error",
pluginId: record.id,
source: record.source,
message: `plugin failed during register: ${String(err)}`,
});
}
}
if (cacheEnabled) {
registryCache.set(cacheKey, registry);
}
return registry;
}
+206
View File
@@ -0,0 +1,206 @@
import type { AnyAgentTool } from "../agents/tools/common.js";
import type {
GatewayRequestHandler,
GatewayRequestHandlers,
} from "../gateway/server-methods/types.js";
import { resolveUserPath } from "../utils.js";
import type {
ClawdbotPluginApi,
ClawdbotPluginCliRegistrar,
ClawdbotPluginService,
ClawdbotPluginToolContext,
ClawdbotPluginToolFactory,
PluginDiagnostic,
PluginLogger,
PluginOrigin,
} from "./types.js";
export type PluginToolRegistration = {
pluginId: string;
factory: ClawdbotPluginToolFactory;
names: string[];
source: string;
};
export type PluginCliRegistration = {
pluginId: string;
register: ClawdbotPluginCliRegistrar;
commands: string[];
source: string;
};
export type PluginServiceRegistration = {
pluginId: string;
service: ClawdbotPluginService;
source: string;
};
export type PluginRecord = {
id: string;
name: string;
version?: string;
description?: string;
source: string;
origin: PluginOrigin;
workspaceDir?: string;
enabled: boolean;
status: "loaded" | "disabled" | "error";
error?: string;
toolNames: string[];
gatewayMethods: string[];
cliCommands: string[];
services: string[];
configSchema: boolean;
};
export type PluginRegistry = {
plugins: PluginRecord[];
tools: PluginToolRegistration[];
gatewayHandlers: GatewayRequestHandlers;
cliRegistrars: PluginCliRegistration[];
services: PluginServiceRegistration[];
diagnostics: PluginDiagnostic[];
};
export type PluginRegistryParams = {
logger: PluginLogger;
coreGatewayHandlers?: GatewayRequestHandlers;
};
export function createPluginRegistry(registryParams: PluginRegistryParams) {
const registry: PluginRegistry = {
plugins: [],
tools: [],
gatewayHandlers: {},
cliRegistrars: [],
services: [],
diagnostics: [],
};
const coreGatewayMethods = new Set(
Object.keys(registryParams.coreGatewayHandlers ?? {}),
);
const pushDiagnostic = (diag: PluginDiagnostic) => {
registry.diagnostics.push(diag);
};
const registerTool = (
record: PluginRecord,
tool: AnyAgentTool | ClawdbotPluginToolFactory,
opts?: { name?: string; names?: string[] },
) => {
const names = opts?.names ?? (opts?.name ? [opts.name] : []);
const factory: ClawdbotPluginToolFactory =
typeof tool === "function"
? tool
: (_ctx: ClawdbotPluginToolContext) => tool;
if (typeof tool !== "function") {
names.push(tool.name);
}
const normalized = names.map((name) => name.trim()).filter(Boolean);
if (normalized.length > 0) {
record.toolNames.push(...normalized);
}
registry.tools.push({
pluginId: record.id,
factory,
names: normalized,
source: record.source,
});
};
const registerGatewayMethod = (
record: PluginRecord,
method: string,
handler: GatewayRequestHandler,
) => {
const trimmed = method.trim();
if (!trimmed) return;
if (coreGatewayMethods.has(trimmed) || registry.gatewayHandlers[trimmed]) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `gateway method already registered: ${trimmed}`,
});
return;
}
registry.gatewayHandlers[trimmed] = handler;
record.gatewayMethods.push(trimmed);
};
const registerCli = (
record: PluginRecord,
registrar: ClawdbotPluginCliRegistrar,
opts?: { commands?: string[] },
) => {
const commands = (opts?.commands ?? [])
.map((cmd) => cmd.trim())
.filter(Boolean);
record.cliCommands.push(...commands);
registry.cliRegistrars.push({
pluginId: record.id,
register: registrar,
commands,
source: record.source,
});
};
const registerService = (
record: PluginRecord,
service: ClawdbotPluginService,
) => {
const id = service.id.trim();
if (!id) return;
record.services.push(id);
registry.services.push({
pluginId: record.id,
service,
source: record.source,
});
};
const normalizeLogger = (logger: PluginLogger): PluginLogger => ({
info: logger.info,
warn: logger.warn,
error: logger.error,
debug: logger.debug,
});
const createApi = (
record: PluginRecord,
params: {
config: ClawdbotPluginApi["config"];
pluginConfig?: Record<string, unknown>;
},
): ClawdbotPluginApi => {
return {
id: record.id,
name: record.name,
version: record.version,
description: record.description,
source: record.source,
config: params.config,
pluginConfig: params.pluginConfig,
logger: normalizeLogger(registryParams.logger),
registerTool: (tool, opts) => registerTool(record, tool, opts),
registerGatewayMethod: (method, handler) =>
registerGatewayMethod(record, method, handler),
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
registerService: (service) => registerService(record, service),
resolvePath: (input: string) => resolveUserPath(input),
};
};
return {
registry,
createApi,
pushDiagnostic,
registerTool,
registerGatewayMethod,
registerCli,
registerService,
};
}
+70
View File
@@ -0,0 +1,70 @@
import type { ClawdbotConfig } from "../config/config.js";
import { STATE_DIR_CLAWDBOT } from "../config/paths.js";
import { createSubsystemLogger } from "../logging.js";
import type { PluginRegistry } from "./registry.js";
const log = createSubsystemLogger("plugins");
export type PluginServicesHandle = {
stop: () => Promise<void>;
};
export async function startPluginServices(params: {
registry: PluginRegistry;
config: ClawdbotConfig;
workspaceDir?: string;
}): Promise<PluginServicesHandle> {
const running: Array<{
id: string;
stop?: () => void | Promise<void>;
}> = [];
for (const entry of params.registry.services) {
const service = entry.service;
try {
await service.start({
config: params.config,
workspaceDir: params.workspaceDir,
stateDir: STATE_DIR_CLAWDBOT,
logger: {
info: (msg) => log.info(msg),
warn: (msg) => log.warn(msg),
error: (msg) => log.error(msg),
debug: (msg) => log.debug(msg),
},
});
running.push({
id: service.id,
stop: service.stop
? () =>
service.stop?.({
config: params.config,
workspaceDir: params.workspaceDir,
stateDir: STATE_DIR_CLAWDBOT,
logger: {
info: (msg) => log.info(msg),
warn: (msg) => log.warn(msg),
error: (msg) => log.error(msg),
debug: (msg) => log.debug(msg),
},
})
: undefined,
});
} catch (err) {
log.error(`plugin service failed (${service.id}): ${String(err)}`);
}
}
return {
stop: async () => {
for (const entry of running.reverse()) {
if (!entry.stop) continue;
try {
await entry.stop();
} catch (err) {
log.warn(`plugin service stop failed (${entry.id}): ${String(err)}`);
}
}
},
};
}
+42
View File
@@ -0,0 +1,42 @@
import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging.js";
import { loadClawdbotPlugins } from "./loader.js";
import type { PluginRegistry } from "./registry.js";
export type PluginStatusReport = PluginRegistry & {
workspaceDir?: string;
};
const log = createSubsystemLogger("plugins");
export function buildPluginStatusReport(params?: {
config?: ReturnType<typeof loadConfig>;
workspaceDir?: string;
}): PluginStatusReport {
const config = params?.config ?? loadConfig();
const workspaceDir = params?.workspaceDir
? params.workspaceDir
: (resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)) ??
resolveDefaultAgentWorkspaceDir());
const registry = loadClawdbotPlugins({
config,
workspaceDir,
logger: {
info: (msg) => log.info(msg),
warn: (msg) => log.warn(msg),
error: (msg) => log.error(msg),
debug: (msg) => log.debug(msg),
},
});
return {
workspaceDir,
...registry,
};
}
+47
View File
@@ -0,0 +1,47 @@
import type { AnyAgentTool } from "../agents/tools/common.js";
import { createSubsystemLogger } from "../logging.js";
import { loadClawdbotPlugins } from "./loader.js";
import type { ClawdbotPluginToolContext } from "./types.js";
const log = createSubsystemLogger("plugins");
export function resolvePluginTools(params: {
context: ClawdbotPluginToolContext;
existingToolNames?: Set<string>;
}): AnyAgentTool[] {
const registry = loadClawdbotPlugins({
config: params.context.config,
workspaceDir: params.context.workspaceDir,
logger: {
info: (msg) => log.info(msg),
warn: (msg) => log.warn(msg),
error: (msg) => log.error(msg),
debug: (msg) => log.debug(msg),
},
});
const tools: AnyAgentTool[] = [];
const existing = params.existingToolNames ?? new Set<string>();
for (const entry of registry.tools) {
let resolved: AnyAgentTool | AnyAgentTool[] | null | undefined = null;
try {
resolved = entry.factory(params.context);
} catch (err) {
log.error(`plugin tool failed (${entry.pluginId}): ${String(err)}`);
continue;
}
if (!resolved) continue;
const list = Array.isArray(resolved) ? resolved : [resolved];
for (const tool of list) {
if (existing.has(tool.name)) {
log.warn(`plugin tool name conflict (${entry.pluginId}): ${tool.name}`);
continue;
}
existing.add(tool.name);
tools.push(tool);
}
}
return tools;
}
+120
View File
@@ -0,0 +1,120 @@
import type { Command } from "commander";
import type { AnyAgentTool } from "../agents/tools/common.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { GatewayRequestHandler } from "../gateway/server-methods/types.js";
export type PluginLogger = {
debug?: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
};
export type PluginConfigValidation =
| { ok: true; value?: unknown }
| { ok: false; errors: string[] };
export type ClawdbotPluginConfigSchema = {
safeParse?: (value: unknown) => {
success: boolean;
data?: unknown;
error?: {
issues?: Array<{ path: Array<string | number>; message: string }>;
};
};
parse?: (value: unknown) => unknown;
validate?: (value: unknown) => PluginConfigValidation;
};
export type ClawdbotPluginToolContext = {
config?: ClawdbotConfig;
workspaceDir?: string;
agentDir?: string;
agentId?: string;
sessionKey?: string;
messageProvider?: string;
agentAccountId?: string;
sandboxed?: boolean;
};
export type ClawdbotPluginToolFactory = (
ctx: ClawdbotPluginToolContext,
) => AnyAgentTool | AnyAgentTool[] | null | undefined;
export type ClawdbotPluginGatewayMethod = {
method: string;
handler: GatewayRequestHandler;
};
export type ClawdbotPluginCliContext = {
program: Command;
config: ClawdbotConfig;
workspaceDir?: string;
logger: PluginLogger;
};
export type ClawdbotPluginCliRegistrar = (
ctx: ClawdbotPluginCliContext,
) => void | Promise<void>;
export type ClawdbotPluginServiceContext = {
config: ClawdbotConfig;
workspaceDir?: string;
stateDir: string;
logger: PluginLogger;
};
export type ClawdbotPluginService = {
id: string;
start: (ctx: ClawdbotPluginServiceContext) => void | Promise<void>;
stop?: (ctx: ClawdbotPluginServiceContext) => void | Promise<void>;
};
export type ClawdbotPluginDefinition = {
id?: string;
name?: string;
description?: string;
version?: string;
configSchema?: ClawdbotPluginConfigSchema;
register?: (api: ClawdbotPluginApi) => void | Promise<void>;
activate?: (api: ClawdbotPluginApi) => void | Promise<void>;
};
export type ClawdbotPluginModule =
| ClawdbotPluginDefinition
| ((api: ClawdbotPluginApi) => void | Promise<void>);
export type ClawdbotPluginApi = {
id: string;
name: string;
version?: string;
description?: string;
source: string;
config: ClawdbotConfig;
pluginConfig?: Record<string, unknown>;
logger: PluginLogger;
registerTool: (
tool: AnyAgentTool | ClawdbotPluginToolFactory,
opts?: { name?: string; names?: string[] },
) => void;
registerGatewayMethod: (
method: string,
handler: GatewayRequestHandler,
) => void;
registerCli: (
registrar: ClawdbotPluginCliRegistrar,
opts?: { commands?: string[] },
) => void;
registerService: (service: ClawdbotPluginService) => void;
resolvePath: (input: string) => string;
};
export type PluginOrigin = "global" | "workspace" | "config";
export type PluginDiagnostic = {
level: "warn" | "error";
message: string;
pluginId?: string;
source?: string;
};