UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,069 lines (1,028 loc) • 43.7 kB
import { t as formatCwdRelativePathOrAbsolute } from "./safe-cwd-DOxDm8mD.mjs"; import { c as isRecord } from "./record-coerce-TIsquoVR.mjs"; import { n as filterStringEntries, y as uniqueStrings } from "./string-normalization-CECOqf64.mjs"; import { r as defaultRuntime } from "./runtime-DlO3PTAd.mjs"; import { f as shortenHomeInString } from "./utils-DgZn2dLu.mjs"; import { i as formatCliOperatorError } from "./failure-output-gtb9zaTk.mjs"; import { t as VERSION } from "./version-BJGPJZu6.mjs"; import { i as resolvePackageExtensionEntries, n as controlUiSource } from "./package-manifest-CEbdS_C4.mjs"; import { r as loadPluginManifest, t as PLUGIN_MANIFEST_FILENAME } from "./manifest-BXjFCp0F.mjs"; import { c as toSafeImportPath, t as getCachedPluginModuleLoader } from "./plugin-module-loader-cache-CUtYxPiw.mjs"; import { t as buildPluginLoaderAliasMap } from "./sdk-alias-A9rXLURS.mjs"; import "./control-ui-assets-2Tfx8Ftx.mjs"; import { i as jsonSchemaValuesEqual } from "./json-schema-BLReYZIW.mjs"; import { n as unwrapDefaultModuleExport } from "./module-export-BbYMQUy2.mjs"; import { n as getToolPluginMetadata } from "./tool-plugin-Ctuy9aFl.mjs"; import { createRequire, isBuiltin } from "node:module"; import fs from "node:fs"; import path, { join } from "node:path"; import fs$1 from "node:fs/promises"; import { createHash } from "node:crypto"; import { replaceFileAtomic } from "@openclaw/fs-safe/atomic"; //#region src/cli/plugins-build-bundle.ts const moduleLocationImport = "openclaw-plugin-bundle:module-location"; const runtimeFilesImport = "openclaw-plugin-bundle:runtime-files"; const loadDiagnostics = { "unsupported-dynamic-import": "error", "unsupported-require-call": "error", "indirect-require": "error" }; function isPluginBundleHostImport(specifier) { return isBuiltin(specifier) || specifier === "openclaw" || specifier.startsWith("openclaw/"); } async function buildPluginBundle(options) { const require = createRequire(join(options.absWorkingDir, "package.json")); let builder; try { builder = require("esbuild"); } catch (cause) { throw new Error("Install esbuild in this plugin's devDependencies, then run plugins build again.", { cause }); } const backend = options.platform === "node"; const recovery = backend ? "Use literal import or require paths and embed runtime resources, or use the regular package-install flow." : "Use literal browser imports, or provide prebuilt browser assets without package.json openclaw.controlUi."; const bindings = backend ? "metadata as \"import.meta\", directory as __dirname, filename as __filename, resolve as \"require.resolve\"" : "resolve as \"require.resolve\""; let result; try { result = await builder.build({ ...options, bundle: true, format: "esm", write: false, metafile: true, minifySyntax: true, logLevel: "silent", logOverride: loadDiagnostics, inject: [moduleLocationImport], plugins: [{ name: "plugin-bundle", setup(build) { build.onResolve({ filter: /^openclaw-plugin-bundle:module-location$/ }, () => ({ path: "module-location", namespace: "plugin-bundle", sideEffects: false })); build.onLoad({ filter: /.*/, namespace: "plugin-bundle" }, () => ({ contents: [`export { ${bindings} } from "${runtimeFilesImport}";`, ...backend ? ["import { createRequire } from \"node:module\"; export default createRequire(import.meta.url);"] : []].join("\n"), loader: "js" })); build.onResolve({ filter: /^openclaw-plugin-bundle:runtime-files$/ }, () => ({ path: runtimeFilesImport, external: true, sideEffects: false })); if (backend) { build.onResolve({ filter: /.*/ }, ({ path, kind }) => kind === "require-call" && isPluginBundleHostImport(path) ? { path, namespace: "plugin-bundle-host" } : void 0); build.onLoad({ filter: /.*/, namespace: "plugin-bundle-host" }, ({ path }) => ({ contents: isBuiltin(path) ? `import load from "${moduleLocationImport}"; module.exports = load(${JSON.stringify(path)});` : `import * as host from ${JSON.stringify(path)}; module.exports = host;`, loader: "js" })); } } }] }); } catch (cause) { if (cause instanceof Error && "errors" in cause && Array.isArray(cause.errors) && cause.errors.some((diagnostic) => isRecord(diagnostic) && typeof diagnostic.id === "string" && Object.hasOwn(loadDiagnostics, diagnostic.id))) throw new Error(`${cause.message}\n${recovery}`, { cause }); throw cause; } const imports = Object.values(result.metafile.outputs).flatMap((output) => output.imports); if (imports.some((item) => item.path === runtimeFilesImport)) throw new Error(`${backend ? "Plugin artifacts cannot use module-relative runtime files or require.resolve." : "Control UI builds cannot use require.resolve."} ${recovery}`); if (imports.some((item) => item.external && (!backend || !isPluginBundleHostImport(item.path))) || backend && result.outputFiles.some((file) => !file.path.endsWith(".js"))) throw new Error(backend ? "Plugin artifact must bundle all dependencies into its JavaScript runtime files." : "Control UI builds must bundle their browser dependencies."); return result.outputFiles.toSorted((left, right) => left.path.localeCompare(right.path)); } //#endregion //#region src/cli/plugins-control-ui-build.ts async function writePluginBuildManifest(rootDir, manifest) { const realRootDir = await fs$1.realpath(rootDir); await replaceFileAtomic({ filePath: path.join(realRootDir, PLUGIN_MANIFEST_FILENAME), content: `${JSON.stringify(manifest, null, 2)}\n`, mode: 438 & ~process.umask(), preserveExistingMode: true, dirMode: (await fs$1.stat(realRootDir)).mode & 4095, syncTempFile: true, syncParentDir: true, throwOnCleanupError: true }); } async function buildPluginControlUi(params) { const rootDir = await fs$1.realpath(params.rootDir); const entry = await fs$1.realpath(path.resolve(rootDir, params.source)); const relative = path.relative(rootDir, entry); if (relative.startsWith(`..${path.sep}`) || relative === ".." || path.isAbsolute(relative)) throw new Error("Control UI source must stay inside the plugin package."); const files = await buildPluginBundle({ absWorkingDir: rootDir, entryPoints: { index: entry }, outdir: path.join(rootDir, "dist/control-ui/build"), platform: "browser", target: "es2022", minify: true, legalComments: "none", sourcemap: false, tsconfigRaw: { compilerOptions: { experimentalDecorators: true, useDefineForClassFields: false } }, alias: buildPluginLoaderAliasMap(entry, process.argv[1], import.meta.url, "src") }); if (files.some((file) => file.contents.length > 4194304) || files.reduce((total, file) => total + file.contents.length, 0) > 8388608) throw new Error("Control UI builds allow at most 4 MiB per asset and 8 MiB per plugin. Reduce the browser bundle before installing."); if (!files.some((file) => path.basename(file.path) === "index.js") || files.some((file) => !["index.js", "index.css"].includes(path.basename(file.path)))) throw new Error("Control UI build must produce a JavaScript entrypoint and optional stylesheet."); const hash = createHash("sha256"); for (const file of files) hash.update(`${path.basename(file.path)}\0${file.contents.length}\0`).update(file.contents); const output = `dist/control-ui/${hash.digest("hex")}`; const outputDir = path.join(rootDir, output); const declaration = { entry: `${output}/index.js`, ...files.some((file) => file.path.endsWith(".css")) ? { styles: [`${output}/index.css`] } : {} }; if (params.check) { for (const file of files) if (!(await fs$1.readFile(path.join(outputDir, path.basename(file.path))).catch(() => null))?.equals(Buffer.from(file.contents))) throw new Error("Control UI build is missing or stale. Run openclaw plugins build."); return declaration; } const generations = path.dirname(outputDir); await fs$1.mkdir(generations, { recursive: true }); await fs$1.chmod(generations, 493); const staging = await fs$1.mkdtemp(path.join(generations, ".build-")); try { for (const file of files) await fs$1.writeFile(path.join(staging, path.basename(file.path)), file.contents); await normalizeGenerationPermissions(staging, files); try { await fs$1.rename(staging, outputDir); } catch (error) { if (!isRecord(error) || error.code !== "EEXIST" && error.code !== "ENOTEMPTY" && error.code !== "EPERM") throw error; for (const file of files) if (!(await fs$1.readFile(path.join(outputDir, path.basename(file.path)))).equals(Buffer.from(file.contents))) throw new Error("An immutable Control UI build was modified. Remove that build and rebuild.", { cause: error }); await normalizeGenerationPermissions(outputDir, files); } } finally { await fs$1.rm(staging, { recursive: true, force: true }); } return declaration; } async function normalizeGenerationPermissions(directory, files) { await fs$1.chmod(directory, 493); for (const file of files) await fs$1.chmod(path.join(directory, path.basename(file.path)), 420); } //#endregion //#region src/cli/plugins-feature-scaffold.ts function writeFeaturePluginScaffold(params) { if (!/^[a-z][a-z0-9-]*$/u.test(params.id)) throw new Error("Feature plugin ids must start with a lowercase letter and contain lowercase letters, digits, or hyphens."); const literal = JSON.stringify; const toolName = `${params.id.replaceAll("-", "_")}_analyze`; const description = `Analyze drafts with ${params.name} tools and native UI.`; const files = { "package.json": JSON.stringify({ name: `openclaw-plugin-${params.id}`, version: "0.1.0", type: "module", private: true, scripts: { build: "tsc -p tsconfig.json && openclaw plugins build", validate: "openclaw plugins validate --json", pack: "openclaw plugins pack --json" }, files: [ "dist", "openclaw.plugin.json", "README.md" ], peerDependencies: { openclaw: `>=${VERSION}` }, dependencies: { typebox: "^1.3.17" }, devDependencies: { openclaw: "latest", esbuild: "0.28.2", typescript: "7.0.2" }, openclaw: { extensions: ["./dist/index.js"], controlUi: "./src/control-ui.ts", compat: { pluginApi: `>=${VERSION}` }, build: { openclawVersion: VERSION } } }, null, 2), "src/contract.ts": `import { Type } from "typebox"; import { defineFeatureContract } from "openclaw/plugin-sdk/feature-contract"; export const contract = defineFeatureContract({ pluginId: ${literal(params.id)}, operations: { analyze: { kind: "query", description: "Count words and lines and list Markdown headings in a draft.", input: Type.Object({ text: Type.String({ maxLength: 32000 }) }, { additionalProperties: false }), output: Type.Object({ words: Type.Integer(), lines: Type.Integer(), headings: Type.Array(Type.String()) }), tool: { name: ${literal(toolName)}, label: "Analyze draft" }, }, }, events: {}, }); `, "src/index.ts": `import { defineFeaturePlugin } from "openclaw/plugin-sdk/feature-plugin"; import { contract } from "./contract.js"; export default defineFeaturePlugin({ contract, name: ${literal(params.name)}, description: ${literal(description)}, setup() { return { analyze: ({ text }) => ({ words: text.trim() ? text.trim().split(/\\s+/u).length : 0, lines: text ? text.split("\\n").length : 0, headings: text.split("\\n").filter((line) => /^#{1,6} /u.test(line)), }), }; }, }); `, "src/control-ui.ts": `import { defineControlUiPlugin } from "openclaw/plugin-sdk/control-ui"; import { createFeatureClient } from "openclaw/plugin-sdk/feature-contract"; import { contract } from "./contract.js"; import "./control-ui.css"; export default defineControlUiPlugin({ id: contract.pluginId, activate(host) { host.ui.registerNavigation({ id: "drafts", label: ${literal(params.name)}, page: { id: "drafts" }, icon: "fileText" }); host.ui.registerPage({ id: "drafts", label: ${literal(params.name)}, mount(container, context) { const feature = createFeatureClient(contract, context.host); const section = document.createElement("section"); section.className = "feature-draft-page"; const heading = document.createElement("h1"); heading.textContent = ${literal(params.name)}; const input = document.createElement("textarea"); input.placeholder = "Paste a draft to analyze"; input.setAttribute("aria-label", "Draft to analyze"); const button = document.createElement("button"); button.textContent = "Analyze draft"; const result = document.createElement("output"); result.setAttribute("aria-live", "polite"); button.onclick = async () => { button.disabled = true; try { const report = await feature.invoke("analyze", { text: input.value }); if (!context.signal.aborted) { result.textContent = report.words + " words · " + report.lines + " lines\\n" + report.headings.join("\\n"); } } catch (error) { if (!context.signal.aborted) result.textContent = String(error); } finally { if (!context.signal.aborted) button.disabled = false; } }; section.append(heading, input, button, result); container.append(section); return { dispose: () => section.remove() }; }, }); host.ui.registerReplacement({ id: "draft-composer", surface: "composer", label: "Draft composer", mount(container, context) { let current = context; const form = document.createElement("form"); form.className = "feature-draft-composer"; const input = document.createElement("textarea"); input.setAttribute("aria-label", "Draft message"); input.oninput = () => current.props.setDraft(input.value); const send = document.createElement("button"); send.type = "submit"; send.textContent = "Send message"; const error = document.createElement("output"); error.setAttribute("aria-live", "polite"); form.onsubmit = async (event) => { event.preventDefault(); if (!current.props.canSend) return; const submitted = current; error.textContent = ""; try { const accepted = await submitted.props.send(); if (!submitted.signal.aborted && accepted === false) error.textContent = "Message was not sent. Check the connection and session permissions."; } catch (cause) { if (!submitted.signal.aborted) error.textContent = String(cause); } }; const update = (next: typeof context) => { current = next; if (input.value !== next.props.draft) input.value = next.props.draft; send.disabled = !next.props.canSend; }; update(context); form.append(input, send, error); container.append(form); return { update, focus: () => input.focus(), dispose: () => form.remove() }; }, }); }, }); `, "src/control-ui.css": `.feature-draft-page, .feature-draft-composer { display: grid; gap: 16px; padding: 24px; color: var(--text); } .feature-draft-page textarea, .feature-draft-composer textarea { min-height: 140px; padding: 12px; color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: 8px; font: inherit; } .feature-draft-page button, .feature-draft-composer button { justify-self: start; padding: 8px 14px; border-radius: 6px; color: var(--accent-foreground, var(--text)); background: var(--accent); border: 0; font: inherit; } .feature-draft-page output { white-space: pre-wrap; } `, "openclaw.plugin.json": JSON.stringify({ id: params.id, name: params.name, description, version: "0.1.0", configSchema: { type: "object", properties: {}, additionalProperties: false }, activation: { onStartup: true }, contracts: { tools: [toolName] } }, null, 2), "README.md": `# ${params.name} This OpenClaw feature plugin includes a typed draft-analysis operation, a model tool, a native page, and a composer replacement. The browser entry owns its DOM and uses the host's canonical draft and send operations. ## Build and install \`\`\`sh npm install npm run build npm run validate openclaw plugins install . \`\`\` Installation applies the plugin in the running local Gateway. If the Gateway is stopped, start it to load the saved installation. For native UI, enable **Settings > Labs > Custom plugin UI** (\`gateway.controlUi.experimental.customPlugins: true\`), then restart the Gateway and reload the browser tab. This setting is off by default; backend installation does not require it. Select ${params.name} in the Control UI sidebar. Open **Plugins > Customize UI** and choose Draft composer to try the replacement; choose Built-in to restore it. For agent-requested activation, run \`npm run pack\`. The receipt contains the exact archive path and SHA-256 digest for \`plugin_activate_artifact\`. Approval applies to those bundled bytes and does not enable Custom plugin UI. The archive has no install scripts or package dependencies; backend activation applies through the running Gateway. After browser-only changes, run the build again and use **Plugins > Customize UI > Reload plugin UI** as an administrator. After editing installed backend source, run \`openclaw plugins reload ${params.id}\`. Rebuild compiled code before reloading; for a copied installation, reinstall the rebuilt package. Plugin install and update commands apply changes through the running Gateway. Native plugins run trusted code in the Gateway and browser; install only code you trust. Keep browser imports on the browser-safe \`control-ui\` and \`feature-contract\` SDK entrypoints. Bundle framework dependencies with the plugin. Return a dispose handle for DOM, subscriptions, and other resources; check the view's abort signal after asynchronous work. ` }; for (const [file, text] of Object.entries(files)) fs.writeFileSync(path.join(params.rootDir, file), text.endsWith("\n") ? text : `${text}\n`); } //#endregion //#region src/cli/plugins-scaffold-config.ts function buildScaffoldTsconfig(type) { return { compilerOptions: { target: "ES2022", module: "NodeNext", moduleResolution: "NodeNext", strict: true, declaration: type === "tool", rootDir: "src", outDir: "dist", skipLibCheck: true }, include: type === "feature" ? ["src/**/*.ts"] : ["src/index.ts"] }; } //#endregion //#region src/cli/plugins-authoring-command.ts const SUPPORTED_PLUGIN_SCAFFOLD_TYPES = [ "tool", "provider", "feature" ]; const CLAWHUB_PACKAGE_PUBLISH_WORKFLOW_REF = "9d49df109d4ad3dc8a6ecf05d26b39f46d294721"; const TOOL_PLUGIN_API_RANGE = ">=2026.5.17"; function readJsonFile(filePath) { const raw = fs.readFileSync(filePath, "utf8"); try { return JSON.parse(raw); } catch (err) { throw new Error(`Malformed JSON in ${filePath}`, { cause: err }); } } function writeJsonFile(filePath, value) { fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); } function normalizeRelativePath(rootDir, targetPath) { const relative = path.relative(rootDir, path.resolve(rootDir, targetPath)).replaceAll(path.sep, "/"); if (relative === ".." || relative.startsWith("../")) throw new Error(`entry must stay inside plugin root: ${targetPath}`); return relative.startsWith(".") ? relative : `./${relative}`; } function resolveEntryPath(rootDir, entry) { if (entry) return path.resolve(rootDir, entry); const packagePath = path.join(rootDir, "package.json"); if (fs.existsSync(packagePath)) { const extensionResolution = resolvePackageExtensionEntries(readJsonFile(packagePath)); if (extensionResolution.status === "ok" && extensionResolution.entries[0]) return path.resolve(rootDir, extensionResolution.entries[0]); } return path.resolve(rootDir, "src/index.ts"); } function readPackageManifest(rootDir) { const packagePath = path.join(rootDir, "package.json"); if (!fs.existsSync(packagePath)) throw new Error(`package.json not found: ${packagePath}`); return readJsonFile(packagePath); } async function importToolPluginEntry(entryPath) { const loaded = getCachedPluginModuleLoader({ modulePath: entryPath, importerUrl: import.meta.url, loaderFilename: entryPath, aliasMap: buildPluginLoaderAliasMap(entryPath, process.argv[1], import.meta.url) })(toSafeImportPath(entryPath)); const mod = loaded && typeof loaded === "object" ? loaded : void 0; const candidate = unwrapDefaultModuleExport(mod?.default ?? mod?.createEntry ?? mod?.entry ?? loaded); return typeof candidate === "function" ? candidate() : candidate; } async function loadToolPlugin(params) { if (!fs.existsSync(params.entryPath)) throw new Error(`plugin entry not found: ${normalizeRelativePath(params.rootDir, params.entryPath)}`); const entry = await importToolPluginEntry(params.entryPath); const metadata = getToolPluginMetadata(entry); if (!metadata) throw new Error(`plugin entry does not expose tool or feature authoring metadata: ${normalizeRelativePath(params.rootDir, params.entryPath)}`); return { entry, metadata }; } function buildToolPluginManifest(params) { const toolMetadata = buildToolPluginToolMetadata(params.metadata, params.existingManifest); const existingContracts = isRecord(params.existingManifest?.contracts) ? params.existingManifest.contracts : {}; const { contracts: _existingContracts, toolMetadata: _existingToolMetadata, ...existingManifestFields } = params.existingManifest ?? {}; const manifest = { ...existingManifestFields, id: params.metadata.id, name: params.metadata.name, description: params.metadata.description, version: typeof params.packageManifest.version === "string" ? params.packageManifest.version : "0.0.0", configSchema: params.metadata.configSchema, activation: params.metadata.activation, contracts: { ...existingContracts, tools: params.metadata.tools.map((tool) => tool.name) }, ...toolMetadata ? { toolMetadata } : {} }; const serializedManifest = JSON.stringify(manifest); return JSON.parse(serializedManifest); } function buildToolPluginToolMetadata(metadata, existingManifest) { const existingToolMetadata = isRecord(existingManifest?.toolMetadata) ? existingManifest.toolMetadata : {}; const nextEntries = metadata.tools.map((tool) => { const existingValue = existingToolMetadata[tool.name]; const existing = isRecord(existingValue) ? { ...existingValue } : {}; if (tool.optional) existing.optional = true; else delete existing.optional; return [tool.name, existing]; }).filter(([, value]) => Object.keys(value).length > 0); return nextEntries.length > 0 ? Object.fromEntries(nextEntries) : void 0; } function buildToolPluginPackageManifest(params) { const openclaw = isRecord(params.packageManifest.openclaw) ? params.packageManifest.openclaw : {}; const existingExtensions = filterStringEntries(openclaw.extensions); const extensions = uniqueStrings([...existingExtensions, params.entry]); return { ...params.packageManifest, openclaw: { ...openclaw, extensions } }; } function validateToolPluginProject(params) { const errors = []; const expectedManifest = buildToolPluginManifest({ metadata: params.metadata, packageManifest: params.packageManifest, existingManifest: params.manifest }); if (!jsonSchemaValuesEqual(params.manifest, expectedManifest)) errors.push("openclaw.plugin.json generated metadata is stale. Run openclaw plugins build."); if (params.manifest.id !== params.metadata.id) errors.push(`openclaw.plugin.json id (${String(params.manifest.id)}) must match entry id (${params.metadata.id})`); if (!params.manifest.configSchema || typeof params.manifest.configSchema !== "object") errors.push("openclaw.plugin.json must include object configSchema"); const manifestContracts = params.manifest.contracts; const manifestTools = filterStringEntries(manifestContracts?.tools); const metadataTools = params.metadata.tools.map((tool) => tool.name); const missing = metadataTools.filter((tool) => !manifestTools.includes(tool)); const extra = manifestTools.filter((tool) => !metadataTools.includes(tool)); if (missing.length > 0) errors.push(`openclaw.plugin.json contracts.tools is missing: ${missing.join(", ")}`); if (extra.length > 0) errors.push(`openclaw.plugin.json contracts.tools has no matching defineToolPlugin tool: ${extra.join(", ")}`); const extensionResolution = resolvePackageExtensionEntries(params.packageManifest); if (extensionResolution.status !== "ok") errors.push(extensionResolution.status === "missing" || extensionResolution.status === "empty" ? "package.json must include openclaw.extensions" : extensionResolution.error); else if (!extensionResolution.entries.includes(params.entry)) errors.push(`package.json openclaw.extensions must include ${params.entry}`); return errors; } async function runPluginsBuildCommand(opts) { const rootDir = path.resolve(opts.root ?? process.cwd()); const entryPath = resolveEntryPath(rootDir, opts.entry); const entryRelative = normalizeRelativePath(rootDir, entryPath); const packagePath = path.join(rootDir, "package.json"); const packageManifest = readPackageManifest(rootDir); const { metadata } = await loadToolPlugin({ rootDir, entryPath }); const manifestPath = path.join(rootDir, PLUGIN_MANIFEST_FILENAME); const currentManifest = fs.existsSync(manifestPath) ? readJsonFile(manifestPath) : void 0; const manifest = buildToolPluginManifest({ metadata, packageManifest, existingManifest: currentManifest }); const browserSource = controlUiSource(packageManifest); if (browserSource) manifest.controlUi = await buildPluginControlUi({ rootDir, source: browserSource, check: opts.check }); const nextPackageManifest = buildToolPluginPackageManifest({ packageManifest, entry: entryRelative }); if (opts.check) { const currentPackage = readJsonFile(packagePath); if (!jsonSchemaValuesEqual(currentManifest, manifest) || !jsonSchemaValuesEqual(currentPackage, nextPackageManifest)) { defaultRuntime.error("Generated plugin metadata is out of date. Run openclaw plugins build."); return defaultRuntime.exit(1); } defaultRuntime.log("Plugin metadata is up to date."); return; } const realRootDir = fs.realpathSync(rootDir); await replaceFileAtomic({ filePath: path.join(realRootDir, "package.json"), content: `${JSON.stringify(nextPackageManifest, null, 2)}\n`, preserveExistingMode: true, dirMode: (await fs.promises.stat(realRootDir)).mode & 4095, syncTempFile: true, syncParentDir: true }); await writePluginBuildManifest(realRootDir, manifest); defaultRuntime.log(`Wrote ${formatCwdRelativePathOrAbsolute(manifestPath, PLUGIN_MANIFEST_FILENAME)}`); defaultRuntime.log(`Updated ${formatCwdRelativePathOrAbsolute(packagePath, "package.json")}`); } async function collectPluginsValidationResult(opts) { const rootDir = path.resolve(opts.root ?? process.cwd()); const entryPath = resolveEntryPath(rootDir, opts.entry); const entryRelative = normalizeRelativePath(rootDir, entryPath); const packageManifest = readPackageManifest(rootDir); const manifestResult = loadPluginManifest(rootDir, false); if (!manifestResult.ok) return { valid: false, errors: [manifestResult.error] }; const manifest = readJsonFile(path.join(rootDir, PLUGIN_MANIFEST_FILENAME)); const { metadata } = await loadToolPlugin({ rootDir, entryPath }); const errors = validateToolPluginProject({ metadata, manifest, packageManifest, entry: entryRelative }); const browserSource = controlUiSource(packageManifest); if (browserSource) { const declaration = await buildPluginControlUi({ rootDir, source: browserSource, check: true }); if (!jsonSchemaValuesEqual(manifest.controlUi, declaration)) errors.push("Control UI manifest is stale. Run openclaw plugins build."); } if (errors.length > 0) return { valid: false, pluginId: metadata.id, errors }; return { valid: true, pluginId: metadata.id, errors: [] }; } async function runPluginsValidateCommand(opts) { let result; try { result = await collectPluginsValidationResult(opts); } catch (err) { if (!opts.json) throw err; const failure = err instanceof Error ? err : String(err); result = { valid: false, errors: [formatCliOperatorError(failure)] }; } if (!result.valid) { for (const error of result.errors) defaultRuntime.error(error); if (opts.json) defaultRuntime.writeJson({ ...result, errors: result.errors.map(shortenHomeInString) }); return defaultRuntime.exit(1, opts.json ? { resetStream: process.stderr } : void 0); } if (opts.json) { defaultRuntime.writeJson(result); return; } defaultRuntime.log(`Plugin ${result.pluginId} is valid.`); } function resolveScaffoldType(input) { const type = input ?? "tool"; const supported = SUPPORTED_PLUGIN_SCAFFOLD_TYPES.find((candidate) => candidate === type); if (supported) return supported; throw new Error(`Unsupported plugin scaffold type "${type}". Supported types: ${SUPPORTED_PLUGIN_SCAFFOLD_TYPES.join(", ")}.`); } function normalizeRequiredPluginText(input, label) { const value = input.trim(); if (!value) throw new Error(`Plugin ${label} is required.`); return value; } function titleFromId(id) { return id.split(/[-_]/u).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "); } function upperSnakeFromId(id) { return id.replace(/[^a-z0-9]+/giu, "_").replace(/^_+|_+$/gu, "").toUpperCase(); } function lowerCamelFromId(id) { return id.split(/-+/u).filter(Boolean).map((part, index) => index === 0 ? part : `${part.charAt(0).toUpperCase()}${part.slice(1)}`).join(""); } function createConfigSchema() { return { type: "object", additionalProperties: false, properties: {} }; } const createPluginPackageMetadata = (pluginApi) => ({ extensions: ["./dist/index.js"], compat: { pluginApi }, build: { openclawVersion: VERSION } }); function writeScaffoldVitestConfig(rootDir) { fs.writeFileSync(path.join(rootDir, "vitest.config.ts"), `import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", include: ["src/**/*.test.ts"], }, }); `); } function writeToolPluginScaffold(params) { const packageManifest = { name: `openclaw-plugin-${params.id}`, version: "0.1.0", type: "module", private: true, scripts: { build: "tsc -p tsconfig.json", "plugin:build": "npm run build && openclaw plugins build --entry ./dist/index.js", "plugin:validate": "npm run build && openclaw plugins validate --entry ./dist/index.js", test: "vitest run --config ./vitest.config.ts" }, files: [ "dist", "openclaw.plugin.json", "README.md" ], peerDependencies: { openclaw: TOOL_PLUGIN_API_RANGE }, dependencies: { typebox: "^1.1.38" }, devDependencies: { openclaw: "latest", typescript: "7.0.2", vitest: "^3.2.0" }, openclaw: createPluginPackageMetadata(TOOL_PLUGIN_API_RANGE) }; const idLiteral = JSON.stringify(params.id); const description = `Add ${params.name} tools to OpenClaw.`; const indexSource = `import { Type } from "typebox"; import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin"; export default defineToolPlugin({ id: ${idLiteral}, name: ${JSON.stringify(params.name)}, description: ${JSON.stringify(description)}, tools: (tool) => [ tool({ name: "echo", description: "Echo input text.", parameters: Type.Object({ input: Type.String({ description: "Text to echo." }), }), execute: async ({ input }) => ({ input }), }), ], }); `; const testSource = `import { describe, expect, it } from "vitest"; import entry from "./index.js"; import { getToolPluginMetadata } from "openclaw/plugin-sdk/tool-plugin"; describe(${idLiteral}, () => { it("declares tool metadata", () => { expect(getToolPluginMetadata(entry)?.tools.map((tool) => tool.name)).toEqual(["echo"]); }); }); `; const readmeSource = `# ${params.name} Simple OpenClaw tool plugin. ## Build \`\`\`bash npm install npm run plugin:build npm run plugin:validate npm test \`\`\` `; writeJsonFile(path.join(params.rootDir, "package.json"), packageManifest); fs.writeFileSync(path.join(params.rootDir, "src/index.ts"), indexSource); fs.writeFileSync(path.join(params.rootDir, "src/index.test.ts"), testSource); fs.writeFileSync(path.join(params.rootDir, "README.md"), readmeSource); writeJsonFile(path.join(params.rootDir, PLUGIN_MANIFEST_FILENAME), { id: params.id, name: params.name, description, version: packageManifest.version, configSchema: createConfigSchema(), activation: { onStartup: true }, contracts: { tools: ["echo"] } }); } function writeProviderPluginScaffold(params) { const packageName = `openclaw-plugin-${params.id}`; const envVar = `${upperSnakeFromId(params.id)}_API_KEY`; const optionKey = `${lowerCamelFromId(params.id)}ApiKey`; const flagName = `--${params.id}-api-key`; const defaultModelId = "example-chat"; const defaultModelRef = `${params.id}/${defaultModelId}`; const description = `Add ${params.name} models to OpenClaw.`; const packageManifest = { name: packageName, version: "0.1.0", description: `OpenClaw provider plugin for ${params.name}.`, type: "module", scripts: { build: "tsc -p tsconfig.json", test: "vitest run --config ./vitest.config.ts", validate: "npm run build && clawhub package validate . --out .clawhub-validation" }, files: [ "dist", "openclaw.plugin.json", "README.md" ], peerDependencies: { openclaw: `>=${VERSION}` }, peerDependenciesMeta: { openclaw: { optional: true } }, devDependencies: { clawhub: "latest", openclaw: "latest", typescript: "7.0.2", vitest: "^3.2.0" }, openclaw: { ...createPluginPackageMetadata(`>=${VERSION}`), install: { clawhubSpec: `clawhub:${packageName}`, defaultChoice: "clawhub", minHostVersion: `>=${VERSION}` }, release: { publishToClawHub: true } }, pluginInspector: { version: 1, plugin: { id: params.id, priority: "high", seams: ["plugin-runtime"], sourceRoot: ".", expect: { registrations: ["registerProvider"] } } } }; const idLiteral = JSON.stringify(params.id); const nameLiteral = JSON.stringify(params.name); const envVarLiteral = JSON.stringify(envVar); const defaultModelIdLiteral = JSON.stringify(defaultModelId); const defaultModelRefLiteral = JSON.stringify(defaultModelRef); const indexSource = `import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth"; const PLUGIN_ID = ${idLiteral}; const PROVIDER_ID = PLUGIN_ID; const DEFAULT_MODEL_ID = ${defaultModelIdLiteral}; const DEFAULT_MODEL_REF = ${defaultModelRefLiteral}; export default definePluginEntry({ id: PLUGIN_ID, name: ${nameLiteral}, description: ${JSON.stringify(description)}, register(api) { api.registerProvider({ id: PROVIDER_ID, label: ${nameLiteral}, docsPath: "/providers/${params.id}", envVars: [${envVarLiteral}], auth: [ createProviderApiKeyAuthMethod({ providerId: PROVIDER_ID, methodId: "api-key", label: ${JSON.stringify(`${params.name} API key`)}, hint: "OpenAI-compatible API endpoint", optionKey: ${JSON.stringify(optionKey)}, flagName: ${JSON.stringify(flagName)}, envVar: ${envVarLiteral}, promptMessage: ${JSON.stringify(`Enter ${params.name} API key`)}, defaultModel: DEFAULT_MODEL_REF, expectedProviders: [PROVIDER_ID], noteTitle: ${nameLiteral}, noteMessage: ${JSON.stringify(`Replace https://api.example.com/v1 with your ${params.name} API base URL.`)}, }), ], catalog: { order: "simple", run: async (ctx) => { const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey; if (!apiKey) { return null; } const configuredProvider = Object.entries(ctx.config.models?.providers ?? {}).find( ([providerId]) => providerId.trim().toLowerCase() === PROVIDER_ID.toLowerCase(), )?.[1]; return { provider: { api: "openai-completions", models: [ { id: DEFAULT_MODEL_ID, name: "Example Chat", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, }, contextWindow: 128000, maxTokens: 8192, }, ], apiKey, baseUrl: configuredProvider?.baseUrl?.trim() || "https://api.example.com/v1", }, }; }, }, }); }, }); `; const testSource = `import { describe, expect, it } from "vitest"; import type { OpenClawPluginApi, ProviderPlugin } from "openclaw/plugin-sdk/plugin-entry"; import entry from "./index.js"; describe(${idLiteral}, () => { it("registers the provider and resolves its authenticated catalog", async () => { const providers: ProviderPlugin[] = []; const api = { registerProvider(provider: ProviderPlugin) { providers.push(provider); }, } as Partial<OpenClawPluginApi>; entry.register(api as OpenClawPluginApi); expect(providers.map((provider) => provider.id)).toEqual([${idLiteral}]); expect(providers[0]?.label).toBe(${nameLiteral}); expect(providers[0]?.envVars).toEqual([${envVarLiteral}]); const provider = providers[0]; if (!provider?.catalog) { throw new Error("Generated provider did not register its catalog"); } expect(provider.auth).toMatchObject([ { id: "api-key", kind: "api_key", starterModel: ${defaultModelRefLiteral} }, ]); for (const [apiKey, configuredId, baseUrl, expectedBaseUrl] of [ [undefined, ${idLiteral}, "https://configured.example/v1", null], ["fixture-api-key", ${JSON.stringify(`${params.id}-unrelated`)}, "https://unrelated.example/v1", "https://api.example.com/v1"], ["fixture-api-key", ${JSON.stringify(` ${params.id.toUpperCase()} `)}, " https://configured.example/v1 ", "https://configured.example/v1"], ["fixture-api-key", ${idLiteral}, " ", "https://api.example.com/v1"], ] as const) { const result = await provider.catalog.run({ config: { models: { providers: { [configuredId]: { baseUrl, models: [] } } } }, env: {}, resolveProviderApiKey: () => ({ apiKey }), resolveProviderAuth: () => ({ apiKey, mode: "api_key", source: "env" }), }); expect(result).toEqual( expectedBaseUrl === null ? null : { provider: { api: "openai-completions", apiKey, baseUrl: expectedBaseUrl, models: [{ id: ${defaultModelIdLiteral}, name: "Example Chat", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 8192, }], }, }, ); } }); }); `; const readmeSource = `# ${params.name} OpenClaw provider plugin for ${params.name}. ## Commands \`\`\`bash npm install npm run build npm test npm run validate \`\`\` \`npm run validate\` builds the plugin and runs \`clawhub package validate . --out .clawhub-validation\`. ## Provider Setup The generated provider uses an OpenAI-compatible API shape, \`${envVar}\` for API-key auth, and \`https://api.example.com/v1\` as a placeholder base URL. Update \`src/index.ts\` with your provider's real base URL, model list, docs route, and credential copy before publishing. ## First Publish Install dependencies, log in to the ClawHub CLI, then validate and publish manually once: \`\`\`bash npm install npm exec clawhub -- login npm run validate npm exec clawhub -- package publish . \`\`\` That first publish creates the ClawHub package and establishes the package managers who can configure trusted publishing. ## Trusted Publishing After the first publish, configure GitHub Actions OIDC publishing for future releases: \`\`\`bash npm exec clawhub -- package trusted-publisher set ${packageName} \\ --repository <owner>/<repo> \\ --workflow-filename clawhub-publish.yml \`\`\` Future release publishes can run through the manually dispatched \`.github/workflows/clawhub-publish.yml\` action without a long-lived ClawHub token. Run it first with \`dry_run=true\`, then rerun with \`dry_run=false\` after the preview is clean. `; const workflowSource = `name: ClawHub Publish on: workflow_dispatch: inputs: dry_run: description: Preview without publishing required: true type: boolean default: true jobs: publish: permissions: actions: read contents: read id-token: write uses: openclaw/clawhub/.github/workflows/package-publish.yml@${CLAWHUB_PACKAGE_PUBLISH_WORKFLOW_REF} with: dry_run: \${{ inputs.dry_run }} `; writeJsonFile(path.join(params.rootDir, "package.json"), packageManifest); fs.writeFileSync(path.join(params.rootDir, "src/index.ts"), indexSource); fs.writeFileSync(path.join(params.rootDir, "src/index.test.ts"), testSource); fs.writeFileSync(path.join(params.rootDir, "README.md"), readmeSource); fs.mkdirSync(path.join(params.rootDir, ".github/workflows"), { recursive: true }); fs.writeFileSync(path.join(params.rootDir, ".github/workflows/clawhub-publish.yml"), workflowSource); writeJsonFile(path.join(params.rootDir, PLUGIN_MANIFEST_FILENAME), { id: params.id, name: params.name, description, version: packageManifest.version, providers: [params.id], setup: { providers: [{ id: params.id, envVars: [envVar] }] }, configSchema: createConfigSchema(), activation: { onStartup: true, providers: [params.id], capabilities: ["provider"] } }); } async function runPluginsInitCommand(idInput, opts) { const id = normalizeRequiredPluginText(idInput, "id"); const name = opts.name ? normalizeRequiredPluginText(opts.name, "display name") : titleFromId(id); const type = resolveScaffoldType(opts.type); const rootDir = path.resolve(opts.directory ?? id); if (opts.force !== true && fs.existsSync(rootDir)) throw new Error(`Refusing to overwrite existing path: ${rootDir}`); fs.mkdirSync(path.join(rootDir, "src"), { recursive: true }); const tsconfig = buildScaffoldTsconfig(type); if (type === "feature") writeFeaturePluginScaffold({ rootDir, id, name }); else if (type === "provider") writeProviderPluginScaffold({ rootDir, id, name }); else writeToolPluginScaffold({ rootDir, id, name }); writeJsonFile(path.join(rootDir, "tsconfig.json"), tsconfig); if (type !== "feature") writeScaffoldVitestConfig(rootDir); defaultRuntime.log(`Created ${formatCwdRelativePathOrAbsolute(rootDir, ".")}`); } //#endregion export { runPluginsBuildCommand as a, validateToolPluginProject as c, loadToolPlugin as i, buildPluginBundle as l, buildToolPluginPackageManifest as n, runPluginsInitCommand as o, collectPluginsValidationResult as r, runPluginsValidateCommand as s, buildToolPluginManifest as t };