From cf0c72a557af74b96549b06ba3d13be82fcdf44a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 11 Jan 2026 12:11:12 +0000 Subject: [PATCH] feat: add plugin architecture --- CHANGELOG.md | 3 + docs/cli/index.md | 21 ++ docs/docs.json | 5 + docs/gateway/configuration.md | 39 +++ docs/gateway/security.md | 9 + docs/plugin.md | 192 +++++++++++++ docs/tools/index.md | 7 + docs/tools/skills.md | 7 + extensions/voice-call/README.md | 64 +++++ extensions/voice-call/index.ts | 122 +++++++++ extensions/voice-call/package.json | 13 + package.json | 1 + pnpm-lock.yaml | 3 + skills/voice-call/SKILL.md | 29 ++ src/agents/clawdbot-tools.ts | 24 +- src/agents/pi-tools.ts | 1 + src/cli/plugins-cli.ts | 266 ++++++++++++++++++ src/cli/program.ts | 4 + src/commands/doctor.ts | 21 ++ src/config/schema.ts | 20 ++ src/config/types.ts | 22 ++ src/config/zod-schema.ts | 23 ++ src/gateway/config-reload.ts | 1 + src/gateway/server-methods.ts | 7 +- src/gateway/server.ts | 57 +++- src/plugins/cli.ts | 57 ++++ src/plugins/discovery.test.ts | 106 ++++++++ src/plugins/discovery.ts | 269 ++++++++++++++++++ src/plugins/loader.test.ts | 105 +++++++ src/plugins/loader.ts | 376 ++++++++++++++++++++++++++ src/plugins/registry.ts | 206 ++++++++++++++ src/plugins/services.ts | 70 +++++ src/plugins/status.ts | 42 +++ src/plugins/tools.ts | 47 ++++ src/plugins/types.ts | 120 ++++++++ ui/src/ui/config-form.browser.test.ts | 39 +++ ui/src/ui/views/config-form.ts | 18 +- 37 files changed, 2408 insertions(+), 8 deletions(-) create mode 100644 docs/plugin.md create mode 100644 extensions/voice-call/README.md create mode 100644 extensions/voice-call/index.ts create mode 100644 extensions/voice-call/package.json create mode 100644 skills/voice-call/SKILL.md create mode 100644 src/cli/plugins-cli.ts create mode 100644 src/plugins/cli.ts create mode 100644 src/plugins/discovery.test.ts create mode 100644 src/plugins/discovery.ts create mode 100644 src/plugins/loader.test.ts create mode 100644 src/plugins/loader.ts create mode 100644 src/plugins/registry.ts create mode 100644 src/plugins/services.ts create mode 100644 src/plugins/status.ts create mode 100644 src/plugins/tools.ts create mode 100644 src/plugins/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 41e66021d..22293cd4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## 2026.1.11 (Unreleased) ### Changes +- Plugins: add extension loader (tools/RPC/CLI/services), discovery paths, config schema, and Control UI labels; ship voice-call plugin stub + skill. +- Docs: add plugins doc + cross-links from tools/skills/gateway config. - Build: set pnpm minimum release age to 2880 minutes (2 days). (#718) — thanks @dan-dr. - macOS: prompt to install the global `clawdbot` CLI when missing in local mode; install via `clawd.bot/install-cli.sh` (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime. - Docs: add gog calendar event color IDs from `gog calendar colors`. (#715) — thanks @mjrussell. @@ -10,6 +12,7 @@ - Skills: bundle `skill-creator` to guide creating and packaging skills. ### Fixes +- Doctor: surface plugin diagnostics in the report. - CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z. - Agents: treat message tool errors as failures so fallback replies still send; require `to` + `message` for `action=send`. (#717) — thanks @theglove44. - Agents: route subagent transcripts to the target agent sessions directory and add regression coverage. (#708) — thanks @xMikeMickelson. diff --git a/docs/cli/index.md b/docs/cli/index.md index 6c6f8f73c..c89c375bc 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -63,6 +63,13 @@ clawdbot [--dev] [--profile ] list info check + plugins + list + info + install + enable + disable + doctor message agent agents @@ -167,6 +174,20 @@ clawdbot [--dev] [--profile ] tui ``` +Note: plugins can add additional top-level commands (for example `clawdbot voicecall`). + +## Plugins + +Manage extensions and their config: + +- `clawdbot plugins list` — discover plugins (use `--json` for machine output). +- `clawdbot plugins info ` — show details for a plugin. +- `clawdbot plugins install ` — add a plugin path to `plugins.load.paths`. +- `clawdbot plugins enable ` / `disable ` — toggle `plugins.entries..enabled`. +- `clawdbot plugins doctor` — report plugin load errors. + +Most plugin changes require a gateway restart. See [/plugin](/plugin). + ## Chat slash commands Chat messages support `/...` commands (text and native). See [/tools/slash-commands](/tools/slash-commands). diff --git a/docs/docs.json b/docs/docs.json index a7ce0a9d3..41d5a315a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -548,6 +548,10 @@ { "source": "/oauth", "destination": "/concepts/oauth" + }, + { + "source": "/plugins", + "destination": "/plugin" } ], "navigation": { @@ -689,6 +693,7 @@ "group": "Tools & Skills", "pages": [ "tools", + "plugin", "tools/bash", "tools/elevated", "tools/browser", diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 7c76b1861..7d7d67c46 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1775,6 +1775,44 @@ Example: } ``` +### `plugins` (extensions) + +Controls plugin discovery, allow/deny, and per-plugin config. Plugins are loaded +from `~/.clawdbot/extensions`, `/.clawdbot/extensions`, plus any +`plugins.load.paths` entries. **Config changes require a gateway restart.** +See [/plugin](/plugin) for full usage. + +Fields: +- `enabled`: master toggle for plugin loading (default: true). +- `allow`: optional allowlist of plugin ids; when set, only listed plugins load. +- `deny`: optional denylist of plugin ids (deny wins). +- `load.paths`: extra plugin files or directories to load (absolute or `~`). +- `entries.`: per-plugin overrides. + - `enabled`: set `false` to disable. + - `config`: plugin-specific config object (validated by the plugin if provided). + +Example: + +```json5 +{ + plugins: { + enabled: true, + allow: ["voice-call"], + load: { + paths: ["~/Projects/oss/voice-call-extension"] + }, + entries: { + "voice-call": { + enabled: true, + config: { + provider: "twilio" + } + } + } + } +} +``` + ### `browser` (clawd-managed Chrome) Clawdbot can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server. @@ -1942,6 +1980,7 @@ Requires full Gateway restart: - `bridge` - `discovery` - `canvasHost` +- `plugins` - Any unknown/unsupported config path (defaults to restart for safety) ### Multi-instance isolation diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 25b3bf8e2..43d3fc80a 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -34,6 +34,15 @@ Clawdbot’s stance: - **Scope next:** decide where the bot is allowed to act (group allowlists + mention gating, tools, sandboxing, device permissions). - **Model last:** assume the model can be manipulated; design so manipulation has limited blast radius. +## Plugins/extensions + +Plugins run **in-process** with the Gateway. Treat them as trusted code: + +- Only install plugins from sources you trust. +- Prefer explicit `plugins.allow` allowlists. +- Review plugin config before enabling. +- Restart the Gateway after plugin changes. + ## DM access model (pairing / allowlist / open / disabled) All current DM-capable providers support a DM policy (`dmPolicy` or `*.dm.policy`) that gates inbound DMs **before** the message is processed: diff --git a/docs/plugin.md b/docs/plugin.md new file mode 100644 index 000000000..9a934d22e --- /dev/null +++ b/docs/plugin.md @@ -0,0 +1,192 @@ +--- +summary: "Clawdbot plugins/extensions: discovery, config, and safety" +read_when: + - Adding or modifying plugins/extensions + - Documenting plugin install or load rules +--- +# Plugins (Extensions) + +Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can +register: + +- Gateway RPC methods +- Agent tools +- CLI commands +- Background services +- Optional config validation + +Plugins run **in‑process** with the Gateway, so treat them as trusted code. + +## Discovery & precedence + +Clawdbot scans, in order: + +1) Global extensions +- `~/.clawdbot/extensions/*.ts` +- `~/.clawdbot/extensions/*/index.ts` + +2) Workspace extensions +- `/.clawdbot/extensions/*.ts` +- `/.clawdbot/extensions/*/index.ts` + +3) Config paths +- `plugins.load.paths` (file or directory) + +### Package packs + +A plugin directory may include a `package.json` with `clawdbot.extensions`: + +```json +{ + "name": "my-pack", + "clawdbot": { + "extensions": ["./src/safety.ts", "./src/tools.ts"] + } +} +``` + +Each entry becomes a plugin. If the pack lists multiple extensions, the plugin id +becomes `name/`. + +If your plugin imports npm deps, install them in that directory so +`node_modules` is available (`npm install` / `pnpm install`). + +## Plugin IDs + +Default plugin ids: + +- Package packs: `package.json` `name` +- Standalone file: file base name (`~/.../voice-call.ts` → `voice-call`) + +If a plugin exports `id`, Clawdbot uses it but warns when it doesn’t match the +configured id. + +## Config + +```json5 +{ + plugins: { + enabled: true, + allow: ["voice-call"], + deny: ["untrusted-plugin"], + load: { paths: ["~/Projects/oss/voice-call-extension"] }, + entries: { + "voice-call": { enabled: true, config: { provider: "twilio" } } + } + } +} +``` + +Fields: +- `enabled`: master toggle (default: true) +- `allow`: allowlist (optional) +- `deny`: denylist (optional; deny wins) +- `load.paths`: extra plugin files/dirs +- `entries.`: per‑plugin toggles + config + +Config changes **require a gateway restart**. + +## CLI + +```bash +clawdbot plugins list +clawdbot plugins info +clawdbot plugins install +clawdbot plugins enable +clawdbot plugins disable +clawdbot plugins doctor +``` + +Plugins may also register their own top‑level commands (example: `clawdbot voicecall`). + +## Plugin API (overview) + +Plugins export either: + +- A function: `(api) => { ... }` +- An object: `{ id, name, configSchema, register(api) { ... } }` + +### Register a tool + +```ts +import { Type } from "@sinclair/typebox"; + +export default function (api) { + api.registerTool({ + name: "my_tool", + description: "Do a thing", + parameters: Type.Object({ + input: Type.String(), + }), + async execute(_id, params) { + return { content: [{ type: "text", text: params.input }] }; + }, + }); +} +``` + +### Register a gateway RPC method + +```ts +export default function (api) { + api.registerGatewayMethod("myplugin.status", ({ respond }) => { + respond(true, { ok: true }); + }); +} +``` + +### Register CLI commands + +```ts +export default function (api) { + api.registerCli(({ program }) => { + program.command("mycmd").action(() => { + console.log("Hello"); + }); + }, { commands: ["mycmd"] }); +} +``` + +### Register background services + +```ts +export default function (api) { + api.registerService({ + id: "my-service", + start: () => api.logger.info("ready"), + stop: () => api.logger.info("bye"), + }); +} +``` + +## Naming conventions + +- Gateway methods: `pluginId.action` (example: `voicecall.status`) +- Tools: `snake_case` (example: `voice_call`) +- CLI commands: kebab or camel, but avoid clashing with core commands + +## Skills + +Plugins can ship a skill in the repo (`skills//SKILL.md`). +Enable it with `plugins.entries..enabled` (or other config gates) and ensure +it’s present in your workspace/managed skills locations. + +## Example plugin: Voice Call + +This repo includes a voice‑call placeholder plugin: + +- Source: `extensions/voice-call` +- Skill: `skills/voice-call` +- CLI: `clawdbot voicecall status` +- Tool: `voice_call` +- RPC: `voicecall.status` + +See `extensions/voice-call/README.md` for setup and usage. + +## Safety notes + +Plugins run in-process with the Gateway. Treat them as trusted code: + +- Only install plugins you trust. +- Prefer `plugins.allow` allowlists. +- Restart the Gateway after changes. diff --git a/docs/tools/index.md b/docs/tools/index.md index c7c2fb17c..80fb6587e 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -22,6 +22,13 @@ You can globally allow/deny tools via `tools.allow` / `tools.deny` in `clawdbot. } ``` +## Plugins + tools + +Plugins can register **additional tools** (and CLI commands) beyond the core set. +See [Plugins](/plugin) for install + config, and [Skills](/tools/skills) for how +tool usage guidance is injected into prompts. Some plugins ship their own skills +alongside tools (for example, the voice-call plugin). + ## Tool inventory ### `bash` diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 42a92eae8..e78db559a 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -36,6 +36,13 @@ In **multi-agent** setups, each agent has its own workspace. That means: If the same skill name exists in more than one place, the usual precedence applies: workspace wins, then managed/local, then bundled. +## Plugins + skills + +Plugins can ship their own skills (for example, `voice-call`) and gate them via +`metadata.clawdbot.requires.config` on the plugin’s config entry. See +[Plugins](/plugin) for plugin discovery/config and [Tools](/tools) for the tool +surface those skills teach. + ## ClawdHub (install + sync) ClawdHub is the public skills registry for Clawdbot. Use it to discover, diff --git a/extensions/voice-call/README.md b/extensions/voice-call/README.md new file mode 100644 index 000000000..35e79a726 --- /dev/null +++ b/extensions/voice-call/README.md @@ -0,0 +1,64 @@ +# Voice Call Plugin (Placeholder) + +This is a **stub** plugin used to validate the Clawdbot plugin API. +It does not place real calls yet. + +## Install (local dev) + +Option 1: copy into your global extensions folder: + +```bash +mkdir -p ~/.clawdbot/extensions +cp -R extensions/voice-call ~/.clawdbot/extensions/voice-call +cd ~/.clawdbot/extensions/voice-call && pnpm install +``` + +Option 2: add via config: + +```json5 +{ + plugins: { + load: { paths: ["/absolute/path/to/extensions/voice-call"] }, + entries: { + "voice-call": { enabled: true, config: { provider: "twilio" } } + } + } +} +``` + +Restart the Gateway after changes. + +## CLI + +```bash +clawdbot voicecall status +clawdbot voicecall start --to "+15555550123" --message "Hello" +``` + +## Tool + +Tool name: `voice_call` + +Parameters: +- `mode`: `"call" | "status"` +- `to`: target string +- `message`: optional intro text + +## Gateway RPC + +- `voicecall.status` + +## Skill + +The repo includes `skills/voice-call/SKILL.md` for agent guidance. Enable it by +setting: + +```json5 +{ plugins: { entries: { "voice-call": { enabled: true } } } } +``` + +## Notes + +- This plugin is a placeholder. Implement your real call flow in the tool and + RPC handlers. +- Use `voicecall.*` for RPC names and `voice_call` for tool naming consistency. diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts new file mode 100644 index 000000000..0f4870def --- /dev/null +++ b/extensions/voice-call/index.ts @@ -0,0 +1,122 @@ +import { Type } from "@sinclair/typebox"; + +const voiceCallConfigSchema = { + parse(value) { + if (value === undefined) return {}; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("voice-call config must be an object"); + } + return value; + }, +}; + +const voiceCallPlugin = { + id: "voice-call", + name: "Voice Call", + description: "Voice-call plugin stub (placeholder)", + configSchema: voiceCallConfigSchema, + register(api) { + api.registerGatewayMethod("voicecall.status", ({ respond }) => { + respond(true, { + status: "idle", + provider: api.pluginConfig?.provider ?? "unset", + }); + }); + + api.registerTool( + { + name: "voice_call", + label: "Voice Call", + description: "Start or inspect a voice call via the voice-call plugin", + parameters: Type.Object({ + mode: Type.Optional( + Type.Union([Type.Literal("call"), Type.Literal("status")]), + ), + to: Type.Optional(Type.String({ description: "Call target" })), + message: Type.Optional( + Type.String({ description: "Optional intro message" }), + ), + }), + async execute(_toolCallId, params) { + if (params.mode === "status") { + return { + content: [ + { + type: "text", + text: JSON.stringify({ status: "idle" }, null, 2), + }, + ], + details: { status: "idle" }, + }; + } + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + status: "not_implemented", + to: params.to ?? null, + message: params.message ?? null, + }, + null, + 2, + ), + }, + ], + details: { + status: "not_implemented", + to: params.to ?? null, + message: params.message ?? null, + }, + }; + }, + }, + { name: "voice_call" }, + ); + + api.registerCli(({ program }) => { + const voicecall = program + .command("voicecall") + .description("Voice call plugin commands"); + + voicecall + .command("status") + .description("Show voice-call status") + .action(() => { + console.log(JSON.stringify({ status: "idle" }, null, 2)); + }); + + voicecall + .command("start") + .description("Start a voice call (placeholder)") + .option("--to ", "Target to call") + .option("--message ", "Optional intro message") + .action((opts) => { + console.log( + JSON.stringify( + { + status: "not_implemented", + to: opts.to ?? null, + message: opts.message ?? null, + }, + null, + 2, + ), + ); + }); + }, { commands: ["voicecall"] }); + + api.registerService({ + id: "voicecall", + start: () => { + api.logger.info("voice-call service ready (placeholder)"); + }, + stop: () => { + api.logger.info("voice-call service stopped (placeholder)"); + }, + }); + }, +}; + +export default voiceCallPlugin; diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json new file mode 100644 index 000000000..7b5f89ee4 --- /dev/null +++ b/extensions/voice-call/package.json @@ -0,0 +1,13 @@ +{ + "name": "voice-call", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Clawdbot voice-call plugin (example)", + "dependencies": { + "@sinclair/typebox": "0.34.47" + }, + "clawdbot": { + "extensions": ["./index.ts"] + } +} diff --git a/package.json b/package.json index afe82ec6b..9f724d622 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "express": "^5.2.1", "file-type": "^21.3.0", "grammy": "^1.39.2", + "jiti": "^2.6.1", "json5": "^2.2.3", "long": "5.3.2", "markdown-it": "^14.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30c3eeeb2..1de373b74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: grammy: specifier: ^1.39.2 version: 1.39.2 + jiti: + specifier: ^2.6.1 + version: 2.6.1 json5: specifier: ^2.2.3 version: 2.2.3 diff --git a/skills/voice-call/SKILL.md b/skills/voice-call/SKILL.md new file mode 100644 index 000000000..dfd99d603 --- /dev/null +++ b/skills/voice-call/SKILL.md @@ -0,0 +1,29 @@ +--- +name: voice-call +description: Start voice calls via the Clawdbot voice-call plugin. +metadata: {"clawdbot":{"emoji":"📞","skillKey":"voice-call","requires":{"config":["plugins.entries.voice-call.enabled"]}}} +--- + +# Voice Call + +Use the voice-call plugin to start or inspect calls. + +## CLI + +```bash +clawdbot voicecall status +clawdbot voicecall start --to "+15555550123" --message "Hello" +``` + +## Tool + +Use `voice_call` for agent-initiated calls. + +Parameters: +- `to` (string): phone number or provider target +- `message` (string, optional): optional intro or instruction +- `mode` ("call" | "status", optional) + +Notes: +- Requires the voice-call plugin to be enabled. +- Plugin config lives under `plugins.entries.voice-call.config`. diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 2506df014..851f0a123 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -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]; } diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 898a8bc18..7f4a23758 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -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, diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts new file mode 100644 index 000000000..628d46421 --- /dev/null +++ b/src/cli/plugins-cli.ts @@ -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("", "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("", "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 + | 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("", "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 + | 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 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")); + }); +} diff --git a/src/cli/program.ts b/src/cli/program.ts index 143169ca4..0f8da8c73 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -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") diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index d3657a5d5..7006b326c 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -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); diff --git a/src/config/schema.ts b/src/config/schema.ts index 2d4052ea9..14d513763 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -47,6 +47,7 @@ const GROUP_LABELS: Record = { imessage: "iMessage", whatsapp: "WhatsApp", skills: "Skills", + plugins: "Plugins", discovery: "Discovery", presence: "Presence", voicewake: "Voice Wake", @@ -75,6 +76,7 @@ const GROUP_ORDER: Record = { imessage: 180, whatsapp: 190, skills: 200, + plugins: 205, discovery: 210, presence: 220, voicewake: 230, @@ -153,6 +155,13 @@ const FIELD_LABELS: Record = { "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 = { @@ -187,6 +196,17 @@ const FIELD_HELP: Record = { "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.", diff --git a/src/config/types.ts b/src/config/types.ts index 16fbe4dc8..8a827a0ee 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1276,6 +1276,27 @@ export type SkillsConfig = { entries?: Record; }; +export type PluginEntryConfig = { + enabled?: boolean; + config?: Record; +}; + +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; +}; + 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; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index dd8ff021f..f7fa0c7c9 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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 ?? []; diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index f6475d9bd..5adbcb0bc 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -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" }, diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index c6d7d352f..67102ab60 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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 { 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, diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 140c4de8a..8fffc48d7 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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(); diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts new file mode 100644 index 000000000..ab0cae1ae --- /dev/null +++ b/src/plugins/cli.ts @@ -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).then === "function") { + void (result as Promise).catch((err) => { + log.warn( + `plugin CLI register failed (${entry.pluginId}): ${String(err)}`, + ); + }); + } + } catch (err) { + log.warn( + `plugin CLI register failed (${entry.pluginId}): ${String(err)}`, + ); + } + } +} diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts new file mode 100644 index 000000000..4742e296a --- /dev/null +++ b/src/plugins/discovery.test.ts @@ -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(stateDir: string, fn: () => Promise) { + 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"); + }); +}); diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts new file mode 100644 index 000000000..b114d6993 --- /dev/null +++ b/src/plugins/discovery.ts @@ -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; + 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; +}) { + 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; +}) { + 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(); + + 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 }; +} diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts new file mode 100644 index 000000000..c1aab8713 --- /dev/null +++ b/src/plugins/loader.test.ts @@ -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, + }, + }, + }, + }, + }); + + expect(registry.plugins[0]?.status).toBe("error"); + expect(registry.diagnostics.some((d) => d.level === "error")).toBe(true); + }); +}); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts new file mode 100644 index 000000000..2915c10e6 --- /dev/null +++ b/src/plugins/loader.ts @@ -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; + cache?: boolean; +}; + +type NormalizedPluginsConfig = { + enabled: boolean; + allow: string[]; + deny: string[]; + loadPaths: string[]; + entries: Record< + string, + { enabled?: boolean; config?: Record } + >; +}; + +const registryCache = new Map(); + +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; + 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) + : 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; +}): { ok: boolean; value?: Record; 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 }; + } + 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 }; + } + const issues = result.error?.issues ?? []; + const errors = issues.map((issue) => { + const path = issue.path.length > 0 ? issue.path.join(".") : ""; + 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 }; + } 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) + ? (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).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; +} diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts new file mode 100644 index 000000000..56be3bd46 --- /dev/null +++ b/src/plugins/registry.ts @@ -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; + }, + ): 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, + }; +} diff --git a/src/plugins/services.ts b/src/plugins/services.ts new file mode 100644 index 000000000..538618daf --- /dev/null +++ b/src/plugins/services.ts @@ -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; +}; + +export async function startPluginServices(params: { + registry: PluginRegistry; + config: ClawdbotConfig; + workspaceDir?: string; +}): Promise { + const running: Array<{ + id: string; + stop?: () => void | Promise; + }> = []; + + 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)}`); + } + } + }, + }; +} diff --git a/src/plugins/status.ts b/src/plugins/status.ts new file mode 100644 index 000000000..8e68c6af0 --- /dev/null +++ b/src/plugins/status.ts @@ -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; + 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, + }; +} diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts new file mode 100644 index 000000000..8b4fcabab --- /dev/null +++ b/src/plugins/tools.ts @@ -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; +}): 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(); + + 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; +} diff --git a/src/plugins/types.ts b/src/plugins/types.ts new file mode 100644 index 000000000..e73bb1797 --- /dev/null +++ b/src/plugins/types.ts @@ -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; 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; + +export type ClawdbotPluginServiceContext = { + config: ClawdbotConfig; + workspaceDir?: string; + stateDir: string; + logger: PluginLogger; +}; + +export type ClawdbotPluginService = { + id: string; + start: (ctx: ClawdbotPluginServiceContext) => void | Promise; + stop?: (ctx: ClawdbotPluginServiceContext) => void | Promise; +}; + +export type ClawdbotPluginDefinition = { + id?: string; + name?: string; + description?: string; + version?: string; + configSchema?: ClawdbotPluginConfigSchema; + register?: (api: ClawdbotPluginApi) => void | Promise; + activate?: (api: ClawdbotPluginApi) => void | Promise; +}; + +export type ClawdbotPluginModule = + | ClawdbotPluginDefinition + | ((api: ClawdbotPluginApi) => void | Promise); + +export type ClawdbotPluginApi = { + id: string; + name: string; + version?: string; + description?: string; + source: string; + config: ClawdbotConfig; + pluginConfig?: Record; + 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; +}; diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index 8d012e488..f6014d29d 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -190,6 +190,45 @@ describe("config form renderer", () => { expect(onPatch).toHaveBeenCalledWith(["slack"], {}); }); + it("supports wildcard uiHints for map entries", () => { + const onPatch = vi.fn(); + const container = document.createElement("div"); + const schema = { + type: "object", + properties: { + plugins: { + type: "object", + properties: { + entries: { + type: "object", + additionalProperties: { + type: "object", + properties: { + enabled: { type: "boolean" }, + }, + }, + }, + }, + }, + }, + }; + const analysis = analyzeConfigSchema(schema); + render( + renderConfigForm({ + schema: analysis.schema, + uiHints: { + "plugins.entries.*.enabled": { label: "Plugin Enabled" }, + }, + unsupportedPaths: analysis.unsupportedPaths, + value: { plugins: { entries: { "voice-call": { enabled: true } } } }, + onPatch, + }), + container, + ); + + expect(container.textContent).toContain("Plugin Enabled"); + }); + it("flags unsupported unions", () => { const schema = { type: "object", diff --git a/ui/src/ui/views/config-form.ts b/ui/src/ui/views/config-form.ts index 5dbd2aa0c..09d3f82c3 100644 --- a/ui/src/ui/views/config-form.ts +++ b/ui/src/ui/views/config-form.ts @@ -388,7 +388,23 @@ function defaultValue(schema?: JsonSchema): unknown { function hintForPath(path: Array, hints: ConfigUiHints) { const key = pathKey(path); - return hints[key]; + const direct = hints[key]; + if (direct) return direct; + const segments = key.split("."); + for (const [hintKey, hint] of Object.entries(hints)) { + if (!hintKey.includes("*")) continue; + const hintSegments = hintKey.split("."); + if (hintSegments.length !== segments.length) continue; + let match = true; + for (let i = 0; i < segments.length; i += 1) { + if (hintSegments[i] !== "*" && hintSegments[i] !== segments[i]) { + match = false; + break; + } + } + if (match) return hint; + } + return undefined; } function pathKey(path: Array): string {