From 2f4a248314fdd754b8344d955842fdd47f828fab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 01:16:39 +0000 Subject: [PATCH] feat: plugin system + voice-call --- CHANGELOG.md | 7 +- extensions/voice-call/README.md | 25 ++- extensions/voice-call/index.ts | 29 ++++ extensions/voice-call/package.json | 5 +- src/agents/skills.ts | 57 ++++--- src/cli/cron-cli.ts | 7 +- src/cli/plugins-cli.ts | 110 +++++++++++-- src/commands/doctor.ts | 21 +++ src/config/schema.ts | 76 ++++++++- src/gateway/server-bridge.ts | 29 +++- src/gateway/server-methods/config.ts | 30 +++- src/plugins/discovery.ts | 15 +- src/plugins/install.ts | 236 +++++++++++++++++++++++++++ src/plugins/loader.ts | 14 ++ src/plugins/registry.ts | 2 + src/plugins/types.ts | 9 + 16 files changed, 614 insertions(+), 58 deletions(-) create mode 100644 src/plugins/install.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fdd7770b..e99e90473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,13 @@ # Changelog -## 2026.1.11 (Unreleased) +## 2026.1.11 ### Changes -- Plugins: add extension loader (tools/RPC/CLI/services), discovery paths, config schema, and Control UI labels; ship voice-call plugin stub + skill. +- Plugins: add extension loader (tools/RPC/CLI/services), discovery paths, and config schema + Control UI labels (uiHints). +- Plugins: add `clawdbot plugins install` (path/tgz/npm), plus `list|info|enable|disable|doctor` UX. - Plugins: voice-call plugin now real (Twilio/log), adds start/status RPC/CLI/tool + tests. - Docs: add plugins doc + cross-links from tools/skills/gateway config. -- Tests: add Docker plugin loader smoke test. +- Tests: add Docker plugin loader + tgz-install smoke test. - Config: add `$include` directive for modular config files. (#731) — thanks @pasogott. - 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. diff --git a/extensions/voice-call/README.md b/extensions/voice-call/README.md index da629c924..796f72712 100644 --- a/extensions/voice-call/README.md +++ b/extensions/voice-call/README.md @@ -1,10 +1,23 @@ -# Voice Call Plugin +# @clawdbot/voice-call -Twilio-backed outbound voice calls (with a log-only fallback for dev). +Official Voice Call plugin for **Clawdbot**. + +- Provider: **Twilio** (real outbound calls) +- Dev fallback: `log` (no network) + +Docs: `https://docs.clawd.bot/plugins/voice-call` ## Install (local dev) -Option 1: copy into your global extensions folder: +### Option A: install via Clawdbot (recommended) + +```bash +clawdbot plugins install @clawdbot/voice-call +``` + +Restart the Gateway afterwards. + +### Option B: copy into your global extensions folder (dev) ```bash mkdir -p ~/.clawdbot/extensions @@ -12,13 +25,13 @@ cp -R extensions/voice-call ~/.clawdbot/extensions/voice-call cd ~/.clawdbot/extensions/voice-call && pnpm install ``` -Option 2: add via config: +### Option C: add via config (custom path) ```json5 { plugins: { - load: { paths: ["/absolute/path/to/extensions/voice-call"] }, - entries: { "voice-call": { enabled: true } } + load: { paths: ["/absolute/path/to/voice-call/index.ts"] }, + entries: { "voice-call": { enabled: true, config: { provider: "log" } } } } } ``` diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index b0453bd3b..f44aaa827 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -67,6 +67,35 @@ const voiceCallConfigSchema = { } return { provider: "log" }; }, + uiHints: { + provider: { + label: "Provider", + help: 'Use "twilio" for real calls or "log" for dev/no-network.', + }, + "twilio.accountSid": { + label: "Twilio Account SID", + placeholder: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + }, + "twilio.authToken": { + label: "Twilio Auth Token", + sensitive: true, + placeholder: "••••••••••••••••", + }, + "twilio.from": { + label: "Twilio From (E.164)", + placeholder: "+15551234567", + }, + "twilio.statusCallbackUrl": { + label: "Status Callback URL", + placeholder: "https://example.com/twilio-status", + advanced: true, + }, + "twilio.twimlUrl": { + label: "TwiML URL", + placeholder: "https://example.com/twiml", + advanced: true, + }, + }, }; const escapeXml = (input: string): string => diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 7b5f89ee4..3ea010c57 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,7 +1,6 @@ { - "name": "voice-call", - "version": "0.0.0", - "private": true, + "name": "@clawdbot/voice-call", + "version": "0.0.1", "type": "module", "description": "Clawdbot voice-call plugin (example)", "dependencies": { diff --git a/src/agents/skills.ts b/src/agents/skills.ts index 32fd071f7..3a5c492bb 100644 --- a/src/agents/skills.ts +++ b/src/agents/skills.ts @@ -13,6 +13,19 @@ import { CONFIG_DIR, resolveUserPath } from "../utils.js"; const fsp = fs.promises; +const SKILLS_SYNC_QUEUE = new Map>(); + +async function serializeByKey(key: string, task: () => Promise) { + const prev = SKILLS_SYNC_QUEUE.get(key) ?? Promise.resolve(); + const next = prev.then(task, task); + SKILLS_SYNC_QUEUE.set(key, next); + try { + return await next; + } finally { + if (SKILLS_SYNC_QUEUE.get(key) === next) SKILLS_SYNC_QUEUE.delete(key); + } +} + export type SkillInstallSpec = { id?: string; kind: "brew" | "node" | "go" | "uv"; @@ -649,29 +662,35 @@ export async function syncSkillsToWorkspace(params: { const sourceDir = resolveUserPath(params.sourceWorkspaceDir); const targetDir = resolveUserPath(params.targetWorkspaceDir); if (sourceDir === targetDir) return; - const targetSkillsDir = path.join(targetDir, "skills"); - const entries = loadSkillEntries(sourceDir, { - config: params.config, - managedSkillsDir: params.managedSkillsDir, - bundledSkillsDir: params.bundledSkillsDir, - }); + await serializeByKey(`syncSkills:${targetDir}`, async () => { + const targetSkillsDir = path.join(targetDir, "skills"); - await fsp.rm(targetSkillsDir, { recursive: true, force: true }); - await fsp.mkdir(targetSkillsDir, { recursive: true }); + const entries = loadSkillEntries(sourceDir, { + config: params.config, + managedSkillsDir: params.managedSkillsDir, + bundledSkillsDir: params.bundledSkillsDir, + }); - for (const entry of entries) { - const dest = path.join(targetSkillsDir, entry.skill.name); - try { - await fsp.cp(entry.skill.baseDir, dest, { recursive: true, force: true }); - } catch (error) { - const message = - error instanceof Error ? error.message : JSON.stringify(error); - console.warn( - `[skills] Failed to copy ${entry.skill.name} to sandbox: ${message}`, - ); + await fsp.rm(targetSkillsDir, { recursive: true, force: true }); + await fsp.mkdir(targetSkillsDir, { recursive: true }); + + for (const entry of entries) { + const dest = path.join(targetSkillsDir, entry.skill.name); + try { + await fsp.cp(entry.skill.baseDir, dest, { + recursive: true, + force: true, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : JSON.stringify(error); + console.warn( + `[skills] Failed to copy ${entry.skill.name} to sandbox: ${message}`, + ); + } } - } + }); } export function filterWorkspaceSkillEntries( diff --git a/src/cli/cron-cli.ts b/src/cli/cron-cli.ts index c0b1fd72f..14c0b279c 100644 --- a/src/cli/cron-cli.ts +++ b/src/cli/cron-cli.ts @@ -1,17 +1,14 @@ import type { Command } from "commander"; import type { CronJob, CronSchedule } from "../cron/types.js"; import { danger } from "../globals.js"; -import { listProviderPlugins } from "../providers/plugins/index.js"; +import { PROVIDER_IDS } from "../providers/registry.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; import type { GatewayRpcOpts } from "./gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js"; -const CRON_PROVIDER_OPTIONS = [ - "last", - ...listProviderPlugins().map((plugin) => plugin.id), -].join("|"); +const CRON_PROVIDER_OPTIONS = ["last", ...PROVIDER_IDS].join("|"); async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) { try { diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 7143d9e38..36deccbe4 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -1,8 +1,13 @@ import fs from "node:fs"; +import path from "node:path"; import chalk from "chalk"; import type { Command } from "commander"; import { loadConfig, writeConfigFile } from "../config/config.js"; +import { + installPluginFromArchive, + installPluginFromNpmSpec, +} from "../plugins/install.js"; import type { PluginRecord } from "../plugins/registry.js"; import { buildPluginStatusReport } from "../plugins/status.js"; import { defaultRuntime } from "../runtime.js"; @@ -202,29 +207,110 @@ export function registerPluginsCli(program: Command) { plugins .command("install") - .description("Add a plugin path to clawdbot.json") - .argument("", "Path to a plugin file or directory") - .action(async (rawPath: string) => { - const resolved = resolveUserPath(rawPath); - if (!fs.existsSync(resolved)) { + .description("Install a plugin (path, archive, or npm spec)") + .argument("", "Path (.ts/.js/.tgz) or an npm package spec") + .action(async (raw: string) => { + const resolved = resolveUserPath(raw); + const cfg = loadConfig(); + + if (fs.existsSync(resolved)) { + const ext = path.extname(resolved).toLowerCase(); + if (ext === ".tgz" || resolved.endsWith(".tar.gz")) { + const result = await installPluginFromArchive({ + archivePath: resolved, + logger: { + info: (msg) => defaultRuntime.log(msg), + warn: (msg) => defaultRuntime.log(chalk.yellow(msg)), + }, + }); + if (!result.ok) { + defaultRuntime.error(result.error); + process.exit(1); + } + + const next = { + ...cfg, + plugins: { + ...cfg.plugins, + entries: { + ...cfg.plugins?.entries, + [result.pluginId]: { + ...(cfg.plugins?.entries?.[result.pluginId] as + | object + | undefined), + enabled: true, + }, + }, + }, + }; + await writeConfigFile(next); + defaultRuntime.log(`Installed plugin: ${result.pluginId}`); + defaultRuntime.log(`Restart the gateway to load plugins.`); + return; + } + + 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.`); + return; + } + + const looksLikePath = + raw.startsWith(".") || + raw.startsWith("~") || + path.isAbsolute(raw) || + raw.endsWith(".ts") || + raw.endsWith(".js") || + raw.endsWith(".mjs") || + raw.endsWith(".cjs") || + raw.endsWith(".tgz") || + raw.endsWith(".tar.gz"); + if (looksLikePath) { 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 result = await installPluginFromNpmSpec({ + spec: raw, + logger: { + info: (msg) => defaultRuntime.log(msg), + warn: (msg) => defaultRuntime.log(chalk.yellow(msg)), + }, + }); + if (!result.ok) { + defaultRuntime.error(result.error); + process.exit(1); + } + const next = { ...cfg, plugins: { ...cfg.plugins, - load: { - ...cfg.plugins?.load, - paths: merged, + entries: { + ...cfg.plugins?.entries, + [result.pluginId]: { + ...(cfg.plugins?.entries?.[result.pluginId] as + | object + | undefined), + enabled: true, + }, }, }, }; await writeConfigFile(next); - defaultRuntime.log(`Added plugin path: ${resolved}`); + defaultRuntime.log(`Installed plugin: ${result.pluginId}`); defaultRuntime.log(`Restart the gateway to load plugins.`); }); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 7006b326c..c700f440f 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -521,6 +521,27 @@ export async function doctorCommand( debug: () => {}, }, }); + if (pluginRegistry.plugins.length > 0) { + const loaded = pluginRegistry.plugins.filter((p) => p.status === "loaded"); + const disabled = pluginRegistry.plugins.filter( + (p) => p.status === "disabled", + ); + const errored = pluginRegistry.plugins.filter((p) => p.status === "error"); + + const lines = [ + `Loaded: ${loaded.length}`, + `Disabled: ${disabled.length}`, + `Errors: ${errored.length}`, + errored.length > 0 + ? `- ${errored + .slice(0, 10) + .map((p) => p.id) + .join("\n- ")}${errored.length > 10 ? "\n- ..." : ""}` + : null, + ].filter((line): line is string => Boolean(line)); + + note(lines.join("\n"), "Plugins"); + } if (pluginRegistry.diagnostics.length > 0) { const lines = pluginRegistry.diagnostics.map((diag) => { const prefix = diag.level.toUpperCase(); diff --git a/src/config/schema.ts b/src/config/schema.ts index 14d513763..0875fce47 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -23,6 +23,19 @@ export type ConfigSchemaResponse = { generatedAt: string; }; +export type PluginUiMetadata = { + id: string; + name?: string; + description?: string; + configUiHints?: Record< + string, + Pick< + ConfigUiHint, + "label" | "help" | "advanced" | "sensitive" | "placeholder" + > + >; +}; + const GROUP_LABELS: Record = { wizard: "Wizard", logging: "Logging", @@ -327,10 +340,52 @@ function applySensitiveHints(hints: ConfigUiHints): ConfigUiHints { return next; } -let cached: ConfigSchemaResponse | null = null; +function applyPluginHints( + hints: ConfigUiHints, + plugins: PluginUiMetadata[], +): ConfigUiHints { + const next: ConfigUiHints = { ...hints }; + for (const plugin of plugins) { + const id = plugin.id.trim(); + if (!id) continue; + const name = (plugin.name ?? id).trim() || id; + const basePath = `plugins.entries.${id}`; -export function buildConfigSchema(): ConfigSchemaResponse { - if (cached) return cached; + next[basePath] = { + ...next[basePath], + label: name, + help: plugin.description + ? `${plugin.description} (plugin: ${id})` + : `Plugin entry for ${id}.`, + }; + next[`${basePath}.enabled`] = { + ...next[`${basePath}.enabled`], + label: `Enable ${name}`, + }; + next[`${basePath}.config`] = { + ...next[`${basePath}.config`], + label: `${name} Config`, + help: `Plugin-defined config payload for ${id}.`, + }; + + const uiHints = plugin.configUiHints ?? {}; + for (const [relPathRaw, hint] of Object.entries(uiHints)) { + const relPath = relPathRaw.trim().replace(/^\./, ""); + if (!relPath) continue; + const key = `${basePath}.config.${relPath}`; + next[key] = { + ...next[key], + ...hint, + }; + } + } + return next; +} + +let cachedBase: ConfigSchemaResponse | null = null; + +function buildBaseConfigSchema(): ConfigSchemaResponse { + if (cachedBase) return cachedBase; const schema = ClawdbotSchema.toJSONSchema({ target: "draft-07", unrepresentable: "any", @@ -343,6 +398,19 @@ export function buildConfigSchema(): ConfigSchemaResponse { version: VERSION, generatedAt: new Date().toISOString(), }; - cached = next; + cachedBase = next; return next; } + +export function buildConfigSchema(params?: { + plugins?: PluginUiMetadata[]; +}): ConfigSchemaResponse { + const base = buildBaseConfigSchema(); + const plugins = params?.plugins ?? []; + if (plugins.length === 0) return base; + const merged = applySensitiveHints(applyPluginHints(base.uiHints, plugins)); + return { + ...base, + uiHints: merged, + }; +} diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts index fa3d616a5..9cc520d81 100644 --- a/src/gateway/server-bridge.ts +++ b/src/gateway/server-bridge.ts @@ -1,5 +1,9 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; +import { + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import { resolveThinkingDefault } from "../agents/model-selection.js"; import { @@ -33,6 +37,7 @@ import { loadVoiceWakeConfig, setVoiceWakeTriggers, } from "../infra/voicewake.js"; +import { loadClawdbotPlugins } from "../plugins/loader.js"; import { clearCommandLane } from "../process/command-queue.js"; import { normalizeProviderId } from "../providers/plugins/index.js"; import { normalizeMainKey } from "../routing/session-key.js"; @@ -203,7 +208,29 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) { }, }; } - const schema = buildConfigSchema(); + const cfg = loadConfig(); + const workspaceDir = resolveAgentWorkspaceDir( + cfg, + resolveDefaultAgentId(cfg), + ); + const pluginRegistry = loadClawdbotPlugins({ + config: cfg, + workspaceDir, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + }, + }); + const schema = buildConfigSchema({ + plugins: pluginRegistry.plugins.map((plugin) => ({ + id: plugin.id, + name: plugin.name, + description: plugin.description, + configUiHints: plugin.configUiHints, + })), + }); return { ok: true, payloadJSON: JSON.stringify(schema) }; } case "config.set": { diff --git a/src/gateway/server-methods/config.ts b/src/gateway/server-methods/config.ts index 37d7f6f96..6735a9e88 100644 --- a/src/gateway/server-methods/config.ts +++ b/src/gateway/server-methods/config.ts @@ -1,5 +1,10 @@ +import { + resolveAgentWorkspaceDir, + resolveDefaultAgentId, +} from "../../agents/agent-scope.js"; import { CONFIG_PATH_CLAWDBOT, + loadConfig, parseConfigJson5, readConfigFileSnapshot, validateConfigObject, @@ -12,6 +17,7 @@ import { type RestartSentinelPayload, writeRestartSentinel, } from "../../infra/restart-sentinel.js"; +import { loadClawdbotPlugins } from "../../plugins/loader.js"; import { ErrorCodes, errorShape, @@ -51,7 +57,29 @@ export const configHandlers: GatewayRequestHandlers = { ); return; } - const schema = buildConfigSchema(); + const cfg = loadConfig(); + const workspaceDir = resolveAgentWorkspaceDir( + cfg, + resolveDefaultAgentId(cfg), + ); + const pluginRegistry = loadClawdbotPlugins({ + config: cfg, + workspaceDir, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + }, + }); + const schema = buildConfigSchema({ + plugins: pluginRegistry.plugins.map((plugin) => ({ + id: plugin.id, + name: plugin.name, + description: plugin.description, + configUiHints: plugin.configUiHints, + })), + }); respond(true, schema, undefined); }, "config.set": async ({ params, respond }) => { diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index b114d6993..adaed274e 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -61,10 +61,17 @@ function deriveIdHint(params: { 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}`; + const rawPackageName = params.packageName?.trim(); + if (!rawPackageName) return base; + + // Prefer the unscoped name so config keys stay stable even when the npm + // package is scoped (example: @clawdbot/voice-call -> voice-call). + const unscoped = rawPackageName.includes("/") + ? (rawPackageName.split("/").pop() ?? rawPackageName) + : rawPackageName; + + if (!params.hasMultipleExtensions) return unscoped; + return `${unscoped}/${base}`; } function addCandidate(params: { diff --git a/src/plugins/install.ts b/src/plugins/install.ts new file mode 100644 index 000000000..b16f650ff --- /dev/null +++ b/src/plugins/install.ts @@ -0,0 +1,236 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { runCommandWithTimeout } from "../process/exec.js"; +import { CONFIG_DIR, resolveUserPath } from "../utils.js"; + +type PluginInstallLogger = { + info?: (message: string) => void; + warn?: (message: string) => void; +}; + +type PackageManifest = { + name?: string; + dependencies?: Record; + clawdbot?: { extensions?: string[] }; +}; + +export type InstallPluginResult = + | { + ok: true; + pluginId: string; + targetDir: string; + manifestName?: string; + extensions: string[]; + } + | { ok: false; error: string }; + +const defaultLogger: PluginInstallLogger = {}; + +function unscopedPackageName(name: string): string { + const trimmed = name.trim(); + if (!trimmed) return trimmed; + return trimmed.includes("/") + ? (trimmed.split("/").pop() ?? trimmed) + : trimmed; +} + +function safeDirName(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return trimmed; + return trimmed.replaceAll("/", "__"); +} + +async function readJsonFile(filePath: string): Promise { + const raw = await fs.readFile(filePath, "utf-8"); + return JSON.parse(raw) as T; +} + +async function fileExists(filePath: string): Promise { + try { + await fs.stat(filePath); + return true; + } catch { + return false; + } +} + +async function resolvePackedPackageDir(extractDir: string): Promise { + const direct = path.join(extractDir, "package"); + if (await fileExists(direct)) return direct; + + const entries = await fs.readdir(extractDir, { withFileTypes: true }); + const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); + if (dirs.length !== 1) { + throw new Error(`unexpected archive layout (dirs: ${dirs.join(", ")})`); + } + const onlyDir = dirs[0]; + if (!onlyDir) { + throw new Error("unexpected archive layout (no package dir found)"); + } + return path.join(extractDir, onlyDir); +} + +async function ensureClawdbotExtensions(manifest: PackageManifest) { + const extensions = manifest.clawdbot?.extensions; + if (!Array.isArray(extensions)) { + throw new Error("package.json missing clawdbot.extensions"); + } + const list = extensions + .map((e) => (typeof e === "string" ? e.trim() : "")) + .filter(Boolean); + if (list.length === 0) { + throw new Error("package.json clawdbot.extensions is empty"); + } + return list; +} + +export async function installPluginFromArchive(params: { + archivePath: string; + extensionsDir?: string; + timeoutMs?: number; + logger?: PluginInstallLogger; +}): Promise { + const logger = params.logger ?? defaultLogger; + const timeoutMs = params.timeoutMs ?? 120_000; + + const archivePath = resolveUserPath(params.archivePath); + if (!(await fileExists(archivePath))) { + return { ok: false, error: `archive not found: ${archivePath}` }; + } + + const extensionsDir = params.extensionsDir + ? resolveUserPath(params.extensionsDir) + : path.join(CONFIG_DIR, "extensions"); + await fs.mkdir(extensionsDir, { recursive: true }); + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-plugin-")); + const extractDir = path.join(tmpDir, "extract"); + await fs.mkdir(extractDir, { recursive: true }); + + logger.info?.(`Extracting ${archivePath}…`); + const tarRes = await runCommandWithTimeout( + ["tar", "-xzf", archivePath, "-C", extractDir], + { timeoutMs }, + ); + if (tarRes.code !== 0) { + return { + ok: false, + error: `failed to extract archive: ${tarRes.stderr.trim() || tarRes.stdout.trim()}`, + }; + } + + let packageDir = ""; + try { + packageDir = await resolvePackedPackageDir(extractDir); + } catch (err) { + return { ok: false, error: String(err) }; + } + + const manifestPath = path.join(packageDir, "package.json"); + if (!(await fileExists(manifestPath))) { + return { ok: false, error: "extracted package missing package.json" }; + } + + let manifest: PackageManifest; + try { + manifest = await readJsonFile(manifestPath); + } catch (err) { + return { ok: false, error: `invalid package.json: ${String(err)}` }; + } + + let extensions: string[]; + try { + extensions = await ensureClawdbotExtensions(manifest); + } catch (err) { + return { ok: false, error: String(err) }; + } + + const pkgName = typeof manifest.name === "string" ? manifest.name : ""; + const pluginId = pkgName ? unscopedPackageName(pkgName) : "plugin"; + const targetDir = path.join(extensionsDir, safeDirName(pluginId)); + + if (await fileExists(targetDir)) { + return { + ok: false, + error: `plugin already exists: ${targetDir} (delete it first)`, + }; + } + + logger.info?.(`Installing to ${targetDir}…`); + await fs.cp(packageDir, targetDir, { recursive: true }); + + for (const entry of extensions) { + const resolvedEntry = path.resolve(targetDir, entry); + if (!(await fileExists(resolvedEntry))) { + logger.warn?.(`extension entry not found: ${entry}`); + } + } + + const deps = manifest.dependencies ?? {}; + const hasDeps = Object.keys(deps).length > 0; + if (hasDeps) { + logger.info?.("Installing plugin dependencies…"); + const npmRes = await runCommandWithTimeout( + ["npm", "install", "--omit=dev", "--silent"], + { timeoutMs: Math.max(timeoutMs, 300_000), cwd: targetDir }, + ); + if (npmRes.code !== 0) { + return { + ok: false, + error: `npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`, + }; + } + } + + return { + ok: true, + pluginId, + targetDir, + manifestName: pkgName || undefined, + extensions, + }; +} + +export async function installPluginFromNpmSpec(params: { + spec: string; + extensionsDir?: string; + timeoutMs?: number; + logger?: PluginInstallLogger; +}): Promise { + const logger = params.logger ?? defaultLogger; + const timeoutMs = params.timeoutMs ?? 120_000; + const spec = params.spec.trim(); + if (!spec) return { ok: false, error: "missing npm spec" }; + + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-npm-pack-")); + logger.info?.(`Downloading ${spec}…`); + const res = await runCommandWithTimeout(["npm", "pack", spec], { + timeoutMs: Math.max(timeoutMs, 300_000), + cwd: tmpDir, + env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" }, + }); + if (res.code !== 0) { + return { + ok: false, + error: `npm pack failed: ${res.stderr.trim() || res.stdout.trim()}`, + }; + } + + const packed = (res.stdout || "") + .split("\n") + .map((l) => l.trim()) + .filter(Boolean) + .pop(); + if (!packed) { + return { ok: false, error: "npm pack produced no archive" }; + } + + const archivePath = path.join(tmpDir, packed); + return await installPluginFromArchive({ + archivePath, + extensionsDir: params.extensionsDir, + timeoutMs, + logger, + }); +} diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 2915c10e6..d2029fc46 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -14,6 +14,7 @@ import type { ClawdbotPluginConfigSchema, ClawdbotPluginDefinition, ClawdbotPluginModule, + PluginConfigUiHint, PluginDiagnostic, PluginLogger, } from "./types.js"; @@ -208,6 +209,7 @@ function createPluginRecord(params: { cliCommands: [], services: [], configSchema: params.configSchema, + configUiHints: undefined, }; } @@ -307,6 +309,18 @@ export function loadClawdbotPlugins( record.description = definition?.description ?? record.description; record.version = definition?.version ?? record.version; record.configSchema = Boolean(definition?.configSchema); + record.configUiHints = + definition?.configSchema && + typeof definition.configSchema === "object" && + (definition.configSchema as { uiHints?: unknown }).uiHints && + typeof (definition.configSchema as { uiHints?: unknown }).uiHints === + "object" && + !Array.isArray((definition.configSchema as { uiHints?: unknown }).uiHints) + ? ((definition.configSchema as { uiHints?: unknown }).uiHints as Record< + string, + PluginConfigUiHint + >) + : undefined; const validatedConfig = validatePluginConfig({ schema: definition?.configSchema, diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 56be3bd46..1d7e81d22 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -10,6 +10,7 @@ import type { ClawdbotPluginService, ClawdbotPluginToolContext, ClawdbotPluginToolFactory, + PluginConfigUiHint, PluginDiagnostic, PluginLogger, PluginOrigin, @@ -51,6 +52,7 @@ export type PluginRecord = { cliCommands: string[]; services: string[]; configSchema: boolean; + configUiHints?: Record; }; export type PluginRegistry = { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index e73bb1797..6e4a23e31 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -11,6 +11,14 @@ export type PluginLogger = { error: (message: string) => void; }; +export type PluginConfigUiHint = { + label?: string; + help?: string; + advanced?: boolean; + sensitive?: boolean; + placeholder?: string; +}; + export type PluginConfigValidation = | { ok: true; value?: unknown } | { ok: false; errors: string[] }; @@ -25,6 +33,7 @@ export type ClawdbotPluginConfigSchema = { }; parse?: (value: unknown) => unknown; validate?: (value: unknown) => PluginConfigValidation; + uiHints?: Record; }; export type ClawdbotPluginToolContext = {