feat: add plugin architecture
This commit is contained in:
@@ -3,6 +3,8 @@
|
|||||||
## 2026.1.11 (Unreleased)
|
## 2026.1.11 (Unreleased)
|
||||||
|
|
||||||
### Changes
|
### 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.
|
- 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.
|
- 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.
|
- 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.
|
- Skills: bundle `skill-creator` to guide creating and packaging skills.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Doctor: surface plugin diagnostics in the report.
|
||||||
- CLI/Update: preserve base environment when passing overrides to update subprocesses. (#713) — thanks @danielz1z.
|
- 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: 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.
|
- Agents: route subagent transcripts to the target agent sessions directory and add regression coverage. (#708) — thanks @xMikeMickelson.
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
list
|
list
|
||||||
info
|
info
|
||||||
check
|
check
|
||||||
|
plugins
|
||||||
|
list
|
||||||
|
info
|
||||||
|
install
|
||||||
|
enable
|
||||||
|
disable
|
||||||
|
doctor
|
||||||
message
|
message
|
||||||
agent
|
agent
|
||||||
agents
|
agents
|
||||||
@@ -167,6 +174,20 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
tui
|
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 <id>` — show details for a plugin.
|
||||||
|
- `clawdbot plugins install <path>` — add a plugin path to `plugins.load.paths`.
|
||||||
|
- `clawdbot plugins enable <id>` / `disable <id>` — toggle `plugins.entries.<id>.enabled`.
|
||||||
|
- `clawdbot plugins doctor` — report plugin load errors.
|
||||||
|
|
||||||
|
Most plugin changes require a gateway restart. See [/plugin](/plugin).
|
||||||
|
|
||||||
## Chat slash commands
|
## Chat slash commands
|
||||||
|
|
||||||
Chat messages support `/...` commands (text and native). See [/tools/slash-commands](/tools/slash-commands).
|
Chat messages support `/...` commands (text and native). See [/tools/slash-commands](/tools/slash-commands).
|
||||||
|
|||||||
@@ -548,6 +548,10 @@
|
|||||||
{
|
{
|
||||||
"source": "/oauth",
|
"source": "/oauth",
|
||||||
"destination": "/concepts/oauth"
|
"destination": "/concepts/oauth"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/plugins",
|
||||||
|
"destination": "/plugin"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"navigation": {
|
"navigation": {
|
||||||
@@ -689,6 +693,7 @@
|
|||||||
"group": "Tools & Skills",
|
"group": "Tools & Skills",
|
||||||
"pages": [
|
"pages": [
|
||||||
"tools",
|
"tools",
|
||||||
|
"plugin",
|
||||||
"tools/bash",
|
"tools/bash",
|
||||||
"tools/elevated",
|
"tools/elevated",
|
||||||
"tools/browser",
|
"tools/browser",
|
||||||
|
|||||||
@@ -1775,6 +1775,44 @@ Example:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `plugins` (extensions)
|
||||||
|
|
||||||
|
Controls plugin discovery, allow/deny, and per-plugin config. Plugins are loaded
|
||||||
|
from `~/.clawdbot/extensions`, `<workspace>/.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.<pluginId>`: 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)
|
### `browser` (clawd-managed Chrome)
|
||||||
|
|
||||||
Clawdbot can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server.
|
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`
|
- `bridge`
|
||||||
- `discovery`
|
- `discovery`
|
||||||
- `canvasHost`
|
- `canvasHost`
|
||||||
|
- `plugins`
|
||||||
- Any unknown/unsupported config path (defaults to restart for safety)
|
- Any unknown/unsupported config path (defaults to restart for safety)
|
||||||
|
|
||||||
### Multi-instance isolation
|
### Multi-instance isolation
|
||||||
|
|||||||
@@ -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).
|
- **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.
|
- **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)
|
## 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:
|
All current DM-capable providers support a DM policy (`dmPolicy` or `*.dm.policy`) that gates inbound DMs **before** the message is processed:
|
||||||
|
|||||||
192
docs/plugin.md
Normal file
192
docs/plugin.md
Normal file
@@ -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
|
||||||
|
- `<workspace>/.clawdbot/extensions/*.ts`
|
||||||
|
- `<workspace>/.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/<fileBase>`.
|
||||||
|
|
||||||
|
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.<id>`: per‑plugin toggles + config
|
||||||
|
|
||||||
|
Config changes **require a gateway restart**.
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins list
|
||||||
|
clawdbot plugins info <id>
|
||||||
|
clawdbot plugins install <path>
|
||||||
|
clawdbot plugins enable <id>
|
||||||
|
clawdbot plugins disable <id>
|
||||||
|
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/<name>/SKILL.md`).
|
||||||
|
Enable it with `plugins.entries.<id>.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.
|
||||||
@@ -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
|
## Tool inventory
|
||||||
|
|
||||||
### `bash`
|
### `bash`
|
||||||
|
|||||||
@@ -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
|
If the same skill name exists in more than one place, the usual precedence
|
||||||
applies: workspace wins, then managed/local, then bundled.
|
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 (install + sync)
|
||||||
|
|
||||||
ClawdHub is the public skills registry for Clawdbot. Use it to discover,
|
ClawdHub is the public skills registry for Clawdbot. Use it to discover,
|
||||||
|
|||||||
64
extensions/voice-call/README.md
Normal file
64
extensions/voice-call/README.md
Normal file
@@ -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.
|
||||||
122
extensions/voice-call/index.ts
Normal file
122
extensions/voice-call/index.ts
Normal file
@@ -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>", "Target to call")
|
||||||
|
.option("--message <text>", "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;
|
||||||
13
extensions/voice-call/package.json
Normal file
13
extensions/voice-call/package.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,6 +152,7 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"file-type": "^21.3.0",
|
"file-type": "^21.3.0",
|
||||||
"grammy": "^1.39.2",
|
"grammy": "^1.39.2",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"long": "5.3.2",
|
"long": "5.3.2",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -103,6 +103,9 @@ importers:
|
|||||||
grammy:
|
grammy:
|
||||||
specifier: ^1.39.2
|
specifier: ^1.39.2
|
||||||
version: 1.39.2
|
version: 1.39.2
|
||||||
|
jiti:
|
||||||
|
specifier: ^2.6.1
|
||||||
|
version: 2.6.1
|
||||||
json5:
|
json5:
|
||||||
specifier: ^2.2.3
|
specifier: ^2.2.3
|
||||||
version: 2.2.3
|
version: 2.2.3
|
||||||
|
|||||||
29
skills/voice-call/SKILL.md
Normal file
29
skills/voice-call/SKILL.md
Normal file
@@ -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`.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { resolvePluginTools } from "../plugins/tools.js";
|
||||||
import type { GatewayMessageProvider } from "../utils/message-provider.js";
|
import type { GatewayMessageProvider } from "../utils/message-provider.js";
|
||||||
|
import { resolveSessionAgentId } from "./agent-scope.js";
|
||||||
import { createAgentsListTool } from "./tools/agents-list-tool.js";
|
import { createAgentsListTool } from "./tools/agents-list-tool.js";
|
||||||
import { createBrowserTool } from "./tools/browser-tool.js";
|
import { createBrowserTool } from "./tools/browser-tool.js";
|
||||||
import { createCanvasTool } from "./tools/canvas-tool.js";
|
import { createCanvasTool } from "./tools/canvas-tool.js";
|
||||||
@@ -25,6 +27,7 @@ export function createClawdbotTools(options?: {
|
|||||||
agentProvider?: GatewayMessageProvider;
|
agentProvider?: GatewayMessageProvider;
|
||||||
agentAccountId?: string;
|
agentAccountId?: string;
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
|
workspaceDir?: string;
|
||||||
sandboxed?: boolean;
|
sandboxed?: boolean;
|
||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
/** Current channel ID for auto-threading (Slack). */
|
/** Current channel ID for auto-threading (Slack). */
|
||||||
@@ -40,7 +43,7 @@ export function createClawdbotTools(options?: {
|
|||||||
config: options?.config,
|
config: options?.config,
|
||||||
agentDir: options?.agentDir,
|
agentDir: options?.agentDir,
|
||||||
});
|
});
|
||||||
return [
|
const tools: AnyAgentTool[] = [
|
||||||
createBrowserTool({
|
createBrowserTool({
|
||||||
defaultControlUrl: options?.browserControlUrl,
|
defaultControlUrl: options?.browserControlUrl,
|
||||||
allowHostControl: options?.allowHostBrowserControl,
|
allowHostControl: options?.allowHostBrowserControl,
|
||||||
@@ -88,4 +91,23 @@ export function createClawdbotTools(options?: {
|
|||||||
}),
|
}),
|
||||||
...(imageTool ? [imageTool] : []),
|
...(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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -577,6 +577,7 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
agentProvider: resolveGatewayMessageProvider(options?.messageProvider),
|
agentProvider: resolveGatewayMessageProvider(options?.messageProvider),
|
||||||
agentAccountId: options?.agentAccountId,
|
agentAccountId: options?.agentAccountId,
|
||||||
agentDir: options?.agentDir,
|
agentDir: options?.agentDir,
|
||||||
|
workspaceDir: options?.workspaceDir,
|
||||||
sandboxed: !!sandbox,
|
sandboxed: !!sandbox,
|
||||||
config: options?.config,
|
config: options?.config,
|
||||||
currentChannelId: options?.currentChannelId,
|
currentChannelId: options?.currentChannelId,
|
||||||
|
|||||||
266
src/cli/plugins-cli.ts
Normal file
266
src/cli/plugins-cli.ts
Normal 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"));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import { danger, setVerbose } from "../globals.js";
|
import { danger, setVerbose } from "../globals.js";
|
||||||
import { autoMigrateLegacyState } from "../infra/state-migrations.js";
|
import { autoMigrateLegacyState } from "../infra/state-migrations.js";
|
||||||
|
import { registerPluginCliCommands } from "../plugins/cli.js";
|
||||||
import { listProviderPlugins } from "../providers/plugins/index.js";
|
import { listProviderPlugins } from "../providers/plugins/index.js";
|
||||||
import { DEFAULT_CHAT_PROVIDER } from "../providers/registry.js";
|
import { DEFAULT_CHAT_PROVIDER } from "../providers/registry.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
@@ -52,6 +53,7 @@ import { registerLogsCli } from "./logs-cli.js";
|
|||||||
import { registerModelsCli } from "./models-cli.js";
|
import { registerModelsCli } from "./models-cli.js";
|
||||||
import { registerNodesCli } from "./nodes-cli.js";
|
import { registerNodesCli } from "./nodes-cli.js";
|
||||||
import { registerPairingCli } from "./pairing-cli.js";
|
import { registerPairingCli } from "./pairing-cli.js";
|
||||||
|
import { registerPluginsCli } from "./plugins-cli.js";
|
||||||
import { forceFreePort } from "./ports.js";
|
import { forceFreePort } from "./ports.js";
|
||||||
import { runProviderLogin, runProviderLogout } from "./provider-auth.js";
|
import { runProviderLogin, runProviderLogout } from "./provider-auth.js";
|
||||||
import { registerProvidersCli } from "./providers-cli.js";
|
import { registerProvidersCli } from "./providers-cli.js";
|
||||||
@@ -1216,9 +1218,11 @@ ${theme.muted("Docs:")} ${formatDocsLink(
|
|||||||
registerDocsCli(program);
|
registerDocsCli(program);
|
||||||
registerHooksCli(program);
|
registerHooksCli(program);
|
||||||
registerPairingCli(program);
|
registerPairingCli(program);
|
||||||
|
registerPluginsCli(program);
|
||||||
registerProvidersCli(program);
|
registerProvidersCli(program);
|
||||||
registerSkillsCli(program);
|
registerSkillsCli(program);
|
||||||
registerUpdateCli(program);
|
registerUpdateCli(program);
|
||||||
|
registerPluginCliCommands(program, loadConfig());
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("status")
|
.command("status")
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
|||||||
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
||||||
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
|
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
|
||||||
import { runGatewayUpdate } from "../infra/update-runner.js";
|
import { runGatewayUpdate } from "../infra/update-runner.js";
|
||||||
|
import { loadClawdbotPlugins } from "../plugins/loader.js";
|
||||||
import { runCommandWithTimeout } from "../process/exec.js";
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
@@ -510,6 +511,26 @@ export async function doctorCommand(
|
|||||||
"Skills status",
|
"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;
|
let healthOk = false;
|
||||||
try {
|
try {
|
||||||
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const GROUP_LABELS: Record<string, string> = {
|
|||||||
imessage: "iMessage",
|
imessage: "iMessage",
|
||||||
whatsapp: "WhatsApp",
|
whatsapp: "WhatsApp",
|
||||||
skills: "Skills",
|
skills: "Skills",
|
||||||
|
plugins: "Plugins",
|
||||||
discovery: "Discovery",
|
discovery: "Discovery",
|
||||||
presence: "Presence",
|
presence: "Presence",
|
||||||
voicewake: "Voice Wake",
|
voicewake: "Voice Wake",
|
||||||
@@ -75,6 +76,7 @@ const GROUP_ORDER: Record<string, number> = {
|
|||||||
imessage: 180,
|
imessage: 180,
|
||||||
whatsapp: 190,
|
whatsapp: 190,
|
||||||
skills: 200,
|
skills: 200,
|
||||||
|
plugins: 205,
|
||||||
discovery: 210,
|
discovery: 210,
|
||||||
presence: 220,
|
presence: 220,
|
||||||
voicewake: 230,
|
voicewake: 230,
|
||||||
@@ -153,6 +155,13 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"slack.appToken": "Slack App Token",
|
"slack.appToken": "Slack App Token",
|
||||||
"signal.account": "Signal Account",
|
"signal.account": "Signal Account",
|
||||||
"imessage.cliPath": "iMessage CLI Path",
|
"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> = {
|
const FIELD_HELP: Record<string, string> = {
|
||||||
@@ -187,6 +196,17 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"Failure window (hours) for backoff counters (default: 24).",
|
"Failure window (hours) for backoff counters (default: 24).",
|
||||||
"agents.defaults.models":
|
"agents.defaults.models":
|
||||||
"Configured model catalog (keys are full provider/model IDs).",
|
"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.primary": "Primary model (provider/model).",
|
||||||
"agents.defaults.model.fallbacks":
|
"agents.defaults.model.fallbacks":
|
||||||
"Ordered fallback models (provider/model). Used when the primary model fails.",
|
"Ordered fallback models (provider/model). Used when the primary model fails.",
|
||||||
|
|||||||
@@ -1276,6 +1276,27 @@ export type SkillsConfig = {
|
|||||||
entries?: Record<string, SkillConfig>;
|
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 =
|
export type ModelApi =
|
||||||
| "openai-completions"
|
| "openai-completions"
|
||||||
| "openai-responses"
|
| "openai-responses"
|
||||||
@@ -1580,6 +1601,7 @@ export type ClawdbotConfig = {
|
|||||||
seamColor?: string;
|
seamColor?: string;
|
||||||
};
|
};
|
||||||
skills?: SkillsConfig;
|
skills?: SkillsConfig;
|
||||||
|
plugins?: PluginsConfig;
|
||||||
models?: ModelsConfig;
|
models?: ModelsConfig;
|
||||||
agents?: AgentsConfig;
|
agents?: AgentsConfig;
|
||||||
tools?: ToolsConfig;
|
tools?: ToolsConfig;
|
||||||
|
|||||||
@@ -1608,6 +1608,29 @@ export const ClawdbotSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.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) => {
|
.superRefine((cfg, ctx) => {
|
||||||
const agents = cfg.agents?.list ?? [];
|
const agents = cfg.agents?.list ?? [];
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [
|
|||||||
{ prefix: "session", kind: "none" },
|
{ prefix: "session", kind: "none" },
|
||||||
{ prefix: "talk", kind: "none" },
|
{ prefix: "talk", kind: "none" },
|
||||||
{ prefix: "skills", kind: "none" },
|
{ prefix: "skills", kind: "none" },
|
||||||
|
{ prefix: "plugins", kind: "restart" },
|
||||||
{ prefix: "ui", kind: "none" },
|
{ prefix: "ui", kind: "none" },
|
||||||
{ prefix: "gateway", kind: "restart" },
|
{ prefix: "gateway", kind: "restart" },
|
||||||
{ prefix: "bridge", kind: "restart" },
|
{ prefix: "bridge", kind: "restart" },
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { voicewakeHandlers } from "./server-methods/voicewake.js";
|
|||||||
import { webHandlers } from "./server-methods/web.js";
|
import { webHandlers } from "./server-methods/web.js";
|
||||||
import { wizardHandlers } from "./server-methods/wizard.js";
|
import { wizardHandlers } from "./server-methods/wizard.js";
|
||||||
|
|
||||||
const handlers: GatewayRequestHandlers = {
|
export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||||
...connectHandlers,
|
...connectHandlers,
|
||||||
...logsHandlers,
|
...logsHandlers,
|
||||||
...voicewakeHandlers,
|
...voicewakeHandlers,
|
||||||
@@ -50,10 +50,11 @@ const handlers: GatewayRequestHandlers = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function handleGatewayRequest(
|
export async function handleGatewayRequest(
|
||||||
opts: GatewayRequestOptions,
|
opts: GatewayRequestOptions & { extraHandlers?: GatewayRequestHandlers },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { req, respond, client, isWebchatConnect, context } = opts;
|
const { req, respond, client, isWebchatConnect, context } = opts;
|
||||||
const handler = handlers[req.method];
|
const handler =
|
||||||
|
opts.extraHandlers?.[req.method] ?? coreGatewayHandlers[req.method];
|
||||||
if (!handler) {
|
if (!handler) {
|
||||||
respond(
|
respond(
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { Server as HttpServer } from "node:http";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { type WebSocket, WebSocketServer } from "ws";
|
import { type WebSocket, WebSocketServer } from "ws";
|
||||||
|
import {
|
||||||
|
resolveAgentWorkspaceDir,
|
||||||
|
resolveDefaultAgentId,
|
||||||
|
} from "../agents/agent-scope.js";
|
||||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||||
import {
|
import {
|
||||||
loadModelCatalog,
|
loadModelCatalog,
|
||||||
@@ -103,6 +107,11 @@ import {
|
|||||||
getResolvedLoggerSettings,
|
getResolvedLoggerSettings,
|
||||||
runtimeForLogger,
|
runtimeForLogger,
|
||||||
} from "../logging.js";
|
} 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 { setCommandLaneConcurrency } from "../process/command-queue.js";
|
||||||
import {
|
import {
|
||||||
listProviderPlugins,
|
listProviderPlugins,
|
||||||
@@ -177,7 +186,7 @@ import {
|
|||||||
createGatewayHttpServer,
|
createGatewayHttpServer,
|
||||||
createHooksRequestHandler,
|
createHooksRequestHandler,
|
||||||
} from "./server-http.js";
|
} from "./server-http.js";
|
||||||
import { handleGatewayRequest } from "./server-methods.js";
|
import { coreGatewayHandlers, handleGatewayRequest } from "./server-methods.js";
|
||||||
import { createProviderManager } from "./server-providers.js";
|
import { createProviderManager } from "./server-providers.js";
|
||||||
import type { DedupeEntry } from "./server-shared.js";
|
import type { DedupeEntry } from "./server-shared.js";
|
||||||
import { formatError } from "./server-utils.js";
|
import { formatError } from "./server-utils.js";
|
||||||
@@ -438,6 +447,34 @@ export async function startGatewayServer(
|
|||||||
|
|
||||||
const cfgAtStart = loadConfig();
|
const cfgAtStart = loadConfig();
|
||||||
await autoMigrateLegacyState({ cfg: cfgAtStart, log });
|
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 bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
|
||||||
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
|
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
|
||||||
if (!bindHost) {
|
if (!bindHost) {
|
||||||
@@ -1594,7 +1631,7 @@ export async function startGatewayServer(
|
|||||||
host: os.hostname(),
|
host: os.hostname(),
|
||||||
connId,
|
connId,
|
||||||
},
|
},
|
||||||
features: { methods: METHODS, events: EVENTS },
|
features: { methods: gatewayMethods, events: EVENTS },
|
||||||
snapshot,
|
snapshot,
|
||||||
canvasHostUrl,
|
canvasHostUrl,
|
||||||
policy: {
|
policy: {
|
||||||
@@ -1610,7 +1647,7 @@ export async function startGatewayServer(
|
|||||||
|
|
||||||
logWs("out", "hello-ok", {
|
logWs("out", "hello-ok", {
|
||||||
connId,
|
connId,
|
||||||
methods: METHODS.length,
|
methods: gatewayMethods.length,
|
||||||
events: EVENTS.length,
|
events: EVENTS.length,
|
||||||
presence: snapshot.presence.length,
|
presence: snapshot.presence.length,
|
||||||
stateVersion: snapshot.stateVersion.presence,
|
stateVersion: snapshot.stateVersion.presence,
|
||||||
@@ -1670,6 +1707,7 @@ export async function startGatewayServer(
|
|||||||
respond,
|
respond,
|
||||||
client,
|
client,
|
||||||
isWebchatConnect,
|
isWebchatConnect,
|
||||||
|
extraHandlers: pluginRegistry.gatewayHandlers,
|
||||||
context: {
|
context: {
|
||||||
deps,
|
deps,
|
||||||
cron,
|
cron,
|
||||||
@@ -1854,6 +1892,16 @@ export async function startGatewayServer(
|
|||||||
logProviders.info("skipping provider start (CLAWDBOT_SKIP_PROVIDERS=1)");
|
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 scheduleRestartSentinelWake = async () => {
|
||||||
const sentinel = await consumeRestartSentinel();
|
const sentinel = await consumeRestartSentinel();
|
||||||
if (!sentinel) return;
|
if (!sentinel) return;
|
||||||
@@ -2091,6 +2139,9 @@ export async function startGatewayServer(
|
|||||||
for (const plugin of listProviderPlugins()) {
|
for (const plugin of listProviderPlugins()) {
|
||||||
await stopProvider(plugin.id);
|
await stopProvider(plugin.id);
|
||||||
}
|
}
|
||||||
|
if (pluginServices) {
|
||||||
|
await pluginServices.stop().catch(() => {});
|
||||||
|
}
|
||||||
await stopGmailWatcher();
|
await stopGmailWatcher();
|
||||||
cron.stop();
|
cron.stop();
|
||||||
heartbeatRunner.stop();
|
heartbeatRunner.stop();
|
||||||
|
|||||||
57
src/plugins/cli.ts
Normal file
57
src/plugins/cli.ts
Normal 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
src/plugins/discovery.test.ts
Normal file
106
src/plugins/discovery.test.ts
Normal 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
src/plugins/discovery.ts
Normal file
269
src/plugins/discovery.ts
Normal 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
src/plugins/loader.test.ts
Normal file
105
src/plugins/loader.test.ts
Normal 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
src/plugins/loader.ts
Normal file
376
src/plugins/loader.ts
Normal 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
src/plugins/registry.ts
Normal file
206
src/plugins/registry.ts
Normal 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
src/plugins/services.ts
Normal file
70
src/plugins/services.ts
Normal 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
src/plugins/status.ts
Normal file
42
src/plugins/status.ts
Normal 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
src/plugins/tools.ts
Normal file
47
src/plugins/tools.ts
Normal 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
src/plugins/types.ts
Normal file
120
src/plugins/types.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -190,6 +190,45 @@ describe("config form renderer", () => {
|
|||||||
expect(onPatch).toHaveBeenCalledWith(["slack"], {});
|
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", () => {
|
it("flags unsupported unions", () => {
|
||||||
const schema = {
|
const schema = {
|
||||||
type: "object",
|
type: "object",
|
||||||
|
|||||||
@@ -388,7 +388,23 @@ function defaultValue(schema?: JsonSchema): unknown {
|
|||||||
|
|
||||||
function hintForPath(path: Array<string | number>, hints: ConfigUiHints) {
|
function hintForPath(path: Array<string | number>, hints: ConfigUiHints) {
|
||||||
const key = pathKey(path);
|
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 | number>): string {
|
function pathKey(path: Array<string | number>): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user