feat: scaffold provider plugins from init (#94352)

* feat: scaffold provider plugins from init

* fix: satisfy plugin init scaffold CI guards

* fix: preserve plugin init id argument
This commit is contained in:
Patrick Erichsen 2026-06-26 16:43:51 -07:00 committed by GitHub
parent deb0ffdcdf
commit 808c227edb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 660 additions and 48 deletions

View file

@ -0,0 +1,51 @@
name: Plugin Init Scaffold Validation
on:
workflow_dispatch:
push:
branches: [main]
paths:
- ".github/workflows/plugin-init-scaffold-validation.yml"
- "package.json"
- "pnpm-lock.yaml"
- "scripts/validate-plugin-init-provider-scaffold.ts"
- "src/cli/plugins-authoring-command.ts"
- "src/cli/plugins-authoring-command.test.ts"
- "src/cli/plugins-cli.ts"
- "src/plugin-sdk/**"
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
paths:
- ".github/workflows/plugin-init-scaffold-validation.yml"
- "package.json"
- "pnpm-lock.yaml"
- "scripts/validate-plugin-init-provider-scaffold.ts"
- "src/cli/plugins-authoring-command.ts"
- "src/cli/plugins-authoring-command.test.ts"
- "src/cli/plugins-cli.ts"
- "src/plugin-sdk/**"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
validate-provider-scaffold:
name: Validate provider scaffold
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Generate and validate provider scaffold
run: pnpm test:plugins:init-provider-scaffold

View file

@ -54,8 +54,9 @@ openclaw plugins update <id-or-npm-spec>
openclaw plugins update --all
openclaw plugins marketplace list <marketplace>
openclaw plugins marketplace list <marketplace> --json
openclaw plugins init <id>
openclaw plugins init <id> --directory ./my-plugin --name "My Plugin"
openclaw plugins init my-tool --name "My Tool"
openclaw plugins init my-provider --name "My Provider" --type provider
openclaw plugins init my-provider --name "My Provider" --type provider --directory ./my-provider
openclaw plugins build --entry ./dist/index.js
openclaw plugins build --entry ./dist/index.js --check
openclaw plugins validate --entry ./dist/index.js
@ -86,12 +87,15 @@ npm run plugin:build
npm run plugin:validate
```
`plugins init` creates a minimal TypeScript tool plugin that uses
`defineToolPlugin`. `plugins build` imports that entry, reads its static tool
metadata, writes `openclaw.plugin.json`, and keeps `package.json`
`openclaw.extensions` aligned. `plugins validate` checks that the generated
manifest, package metadata, and current entry export still agree. See
[Tool Plugins](/plugins/tool-plugins) for the full authoring workflow.
`plugins init` creates a minimal TypeScript tool plugin by default. The first
argument is the plugin id; pass `--name` for the display name. OpenClaw uses the
id for the default output directory and package naming. Tool scaffolds use
`defineToolPlugin`.
`plugins build` imports the built entry, reads its static tool metadata, writes
`openclaw.plugin.json`, and keeps `package.json` `openclaw.extensions` aligned.
`plugins validate` checks that the generated manifest, package metadata, and
current entry export still agree. See [Tool Plugins](/plugins/tool-plugins) for
the full tool-authoring workflow.
The scaffold writes TypeScript source but generates metadata from the built
`./dist/index.js` entry so the workflow also works with the published CLI. Use
@ -99,6 +103,29 @@ The scaffold writes TypeScript source but generates metadata from the built
`plugins build --check` in CI to fail when generated metadata is stale without
rewriting files.
### Provider Scaffold
```bash
openclaw plugins init acme-models --name "Acme Models" --type provider
cd acme-models
npm install
npm run build
npm test
npm run validate
```
Provider scaffolds create a generic text/model provider plugin with OpenAI-compatible
API-key plumbing, a built-in `npm run validate` script for `clawhub package
validate`, ClawHub package metadata, and a manually dispatched GitHub workflow
for future trusted publishing through GitHub Actions OIDC. Provider scaffolds do
not generate skills and do not use `openclaw plugins build` or
`openclaw plugins validate`; those commands are for the tool scaffold's
generated metadata path.
Before publishing, replace the placeholder API base URL, model catalog, docs
route, credential text, and README copy with real provider details. Use the
generated README for first-time ClawHub publishing and trusted publisher setup.
### Install
```bash

View file

@ -1896,6 +1896,7 @@
"test:sqlite:perf:large": "node --import tsx scripts/bench-sqlite-state.ts --profile large --output .artifacts/sqlite-perf/large.json",
"test:sqlite:perf:smoke": "node --import tsx scripts/bench-sqlite-state.ts --profile smoke --output .artifacts/sqlite-perf/smoke.json",
"test:plugins:gateway-gauntlet": "node scripts/check-plugin-gateway-gauntlet.mjs",
"test:plugins:init-provider-scaffold": "node --import tsx scripts/validate-plugin-init-provider-scaffold.ts",
"test:plugins:kitchen-sink-live": "bash -lc 'if [ -x \"$HOME/.local/bin/openclaw-testbox-env\" ]; then exec \"$HOME/.local/bin/openclaw-testbox-env\" pnpm openclaw qa suite --provider-mode live-frontier --scenario kitchen-sink-live-openai; fi; exec pnpm openclaw qa suite --provider-mode live-frontier --scenario kitchen-sink-live-openai'",
"test:plugins:kitchen-sink-rpc": "node --import tsx scripts/e2e/kitchen-sink-rpc-walk.mjs",
"test:sectriage": "node scripts/run-with-env.mjs OPENCLAW_GATEWAY_PROJECT_SHARDS=1 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts && node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --exclude src/process/exec.test.ts",

View file

@ -0,0 +1,71 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { runPluginsInitCommand } from "../src/cli/plugins-authoring-command.js";
type InspectorReport = {
status?: unknown;
summary?: {
breakageCount?: unknown;
warningCount?: unknown;
issueCount?: unknown;
};
};
const artifactRoot = path.resolve(
process.env.OPENCLAW_PLUGIN_INIT_VALIDATE_ROOT ?? ".artifacts/plugin-init-provider-scaffold",
);
const projectDir = path.join(artifactRoot, "plugin-init-test");
const reportPath = path.join(projectDir, ".clawhub-validation", "plugin-inspector-report.json");
function run(command: string, args: string[], cwd: string): void {
console.log(`$ ${[command, ...args].join(" ")}`);
const result = spawnSync(command, args, {
cwd,
env: process.env,
stdio: "inherit",
});
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`${command} ${args.join(" ")} exited with status ${result.status}`);
}
}
function readInspectorReport(): InspectorReport {
if (!fs.existsSync(reportPath)) {
throw new Error(`ClawHub validation report not found: ${reportPath}`);
}
return JSON.parse(fs.readFileSync(reportPath, "utf8")) as InspectorReport;
}
function assertCleanInspectorReport(report: InspectorReport): void {
const breakageCount = Number(report.summary?.breakageCount ?? Number.NaN);
const warningCount = Number(report.summary?.warningCount ?? Number.NaN);
const issueCount = Number(report.summary?.issueCount ?? Number.NaN);
if (report.status !== "pass" || breakageCount !== 0 || warningCount !== 0 || issueCount !== 0) {
throw new Error(
`Plugin Inspector was not clean: status=${String(
report.status,
)}, breakages=${breakageCount}, warnings=${warningCount}, issues=${issueCount}`,
);
}
}
fs.rmSync(projectDir, { force: true, recursive: true });
fs.mkdirSync(artifactRoot, { recursive: true });
await runPluginsInitCommand("plugin-init-test", {
directory: projectDir,
name: "Plugin Init Test",
type: "provider",
});
run("npm", ["install", "--no-audit", "--fund=false"], projectDir);
run("npm", ["run", "build"], projectDir);
run("npm", ["test"], projectDir);
run("npm", ["run", "validate"], projectDir);
assertCleanInspectorReport(readInspectorReport());
console.log(`Generated provider scaffold passed ClawHub validation: ${projectDir}`);

View file

@ -5,6 +5,7 @@ import path from "node:path";
import { Type } from "typebox";
import { beforeAll, describe, expect, it } from "vitest";
import { defineToolPlugin, getToolPluginMetadata } from "../plugin-sdk/tool-plugin.js";
import { VERSION } from "../version.js";
import {
buildToolPluginManifest,
buildToolPluginPackageManifest,
@ -342,6 +343,7 @@ describe("plugin authoring commands", () => {
scripts: {
"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",
},
openclaw: {
extensions: ["./dist/index.js"],
@ -362,5 +364,110 @@ describe("plugin authoring commands", () => {
expect(fs.readFileSync(path.join(projectDir, "src/index.test.ts"), "utf8")).toContain(
"getToolPluginMetadata",
);
expect(fs.readFileSync(path.join(projectDir, "vitest.config.ts"), "utf8")).toContain(
'include: ["src/**/*.test.ts"]',
);
});
it("scaffolds a provider plugin project with ClawHub validation and release metadata", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-provider-init-"));
const projectDir = path.join(tmpDir, "plugin-init-test");
await runPluginsInitCommand("plugin-init-test", {
directory: projectDir,
name: "Plugin Init Test",
type: "provider",
});
const packageManifest = JSON.parse(
fs.readFileSync(path.join(projectDir, "package.json"), "utf8"),
);
expect(packageManifest).toMatchObject({
name: "openclaw-plugin-plugin-init-test",
scripts: {
build: "tsc -p tsconfig.json",
test: "vitest run --config ./vitest.config.ts",
validate: "npm run build && clawhub package validate . --out .clawhub-validation",
},
peerDependencies: {
openclaw: `>=${VERSION}`,
},
devDependencies: {
clawhub: "latest",
openclaw: "latest",
typescript: "^5.9.0",
vitest: "^3.2.0",
},
openclaw: {
extensions: ["./dist/index.js"],
install: {
clawhubSpec: "clawhub:openclaw-plugin-plugin-init-test",
defaultChoice: "clawhub",
minHostVersion: `>=${VERSION}`,
},
compat: {
pluginApi: `>=${VERSION}`,
},
build: {
openclawVersion: VERSION,
},
release: {
publishToClawHub: true,
},
},
});
expect(packageManifest.scripts).not.toHaveProperty("plugin:build");
expect(packageManifest.scripts).not.toHaveProperty("plugin:validate");
const manifest = JSON.parse(
fs.readFileSync(path.join(projectDir, "openclaw.plugin.json"), "utf8"),
);
expect(manifest).toMatchObject({
id: "plugin-init-test",
name: "Plugin Init Test",
version: "0.1.0",
providers: ["plugin-init-test"],
setup: {
providers: [
{
id: "plugin-init-test",
envVars: ["PLUGIN_INIT_TEST_API_KEY"],
},
],
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
});
const indexSource = fs.readFileSync(path.join(projectDir, "src/index.ts"), "utf8");
expect(indexSource).toContain("definePluginEntry");
expect(indexSource).toContain("api.registerProvider");
expect(indexSource).toContain("buildSingleProviderApiKeyCatalog");
expect(fs.readFileSync(path.join(projectDir, "src/index.test.ts"), "utf8")).toContain(
"OpenClawPluginApi",
);
expect(fs.readFileSync(path.join(projectDir, "vitest.config.ts"), "utf8")).toContain(
'include: ["src/**/*.test.ts"]',
);
const readme = fs.readFileSync(path.join(projectDir, "README.md"), "utf8");
expect(readme).toContain("npm run validate");
expect(readme).toContain("npm exec clawhub -- login");
expect(readme).toContain("npm exec clawhub -- package publish .");
expect(readme).toContain("npm exec clawhub -- package trusted-publisher set");
const workflow = fs.readFileSync(
path.join(projectDir, ".github/workflows/clawhub-publish.yml"),
"utf8",
);
expect(workflow).not.toContain("release:");
expect(workflow).not.toContain("secrets: inherit");
expect(workflow).toContain("workflow_dispatch:");
expect(workflow).toContain(
"openclaw/clawhub/.github/workflows/package-publish.yml@9d49df109d4ad3dc8a6ecf05d26b39f46d294721",
);
});
});

View file

@ -17,6 +17,7 @@ import { buildPluginLoaderAliasMap } from "../plugins/sdk-alias.js";
import { defaultRuntime } from "../runtime.js";
import { toSafeImportPath } from "../shared/import-specifier.js";
import { isRecord } from "../utils.js";
import { VERSION } from "../version.js";
type JsonObject = Record<string, unknown>;
@ -35,13 +36,22 @@ export type PluginsInitOptions = {
directory?: string;
force?: boolean;
name?: string;
type?: string;
};
type PluginScaffoldType = "tool" | "provider";
type LoadedToolPlugin = {
entry: unknown;
metadata: ToolPluginMetadata;
};
const SUPPORTED_PLUGIN_SCAFFOLD_TYPES = [
"tool",
"provider",
] as const satisfies readonly PluginScaffoldType[];
const CLAWHUB_PACKAGE_PUBLISH_WORKFLOW_REF = "9d49df109d4ad3dc8a6ecf05d26b39f46d294721";
const toolPluginEntryModuleLoaders = createPluginModuleLoaderCache();
function readJsonFile(filePath: string): JsonObject {
@ -336,6 +346,34 @@ function assertCanCreate(filePath: string, force: boolean): void {
}
}
function resolveScaffoldType(input: string | undefined): PluginScaffoldType {
const type = input ?? "tool";
if (SUPPORTED_PLUGIN_SCAFFOLD_TYPES.includes(type as PluginScaffoldType)) {
return type as PluginScaffoldType;
}
throw new Error(
`Unsupported plugin scaffold type "${type}". Supported types: ${SUPPORTED_PLUGIN_SCAFFOLD_TYPES.join(
", ",
)}.`,
);
}
function normalizeDisplayName(input: string): string {
const name = input.trim();
if (!name) {
throw new Error("Plugin display name is required.");
}
return name;
}
function normalizePluginId(input: string): string {
const id = input.trim();
if (!id) {
throw new Error("Plugin id is required.");
}
return id;
}
function titleFromId(id: string): string {
return id
.split(/[-_]/u)
@ -344,15 +382,61 @@ function titleFromId(id: string): string {
.join(" ");
}
export async function runPluginsInitCommand(id: string, opts: PluginsInitOptions): Promise<void> {
const rootDir = path.resolve(opts.directory ?? id);
const force = opts.force === true;
const name = opts.name ?? titleFromId(id);
assertCanCreate(rootDir, force);
fs.mkdirSync(path.join(rootDir, "src"), { recursive: true });
function upperSnakeFromId(id: string): string {
return id
.replace(/[^a-z0-9]+/giu, "_")
.replace(/^_+|_+$/gu, "")
.toUpperCase();
}
function lowerCamelFromId(id: string): string {
const parts = id.split(/-+/u).filter(Boolean);
return parts
.map((part, index) => (index === 0 ? part : `${part.charAt(0).toUpperCase()}${part.slice(1)}`))
.join("");
}
function createConfigSchema() {
return {
type: "object",
additionalProperties: false,
properties: {},
};
}
function buildScaffoldTsconfig(type: PluginScaffoldType): JsonObject {
return {
compilerOptions: {
target: "ES2022",
module: "NodeNext",
moduleResolution: "NodeNext",
strict: true,
declaration: type === "tool",
outDir: "dist",
skipLibCheck: true,
},
include: type === "provider" ? ["src/index.ts"] : ["src/**/*.ts"],
};
}
function writeScaffoldVitestConfig(rootDir: string): void {
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: { rootDir: string; id: string; name: string }): void {
const packageManifest = {
name: `openclaw-plugin-${id}`,
name: `openclaw-plugin-${params.id}`,
version: "0.1.0",
type: "module",
private: true,
@ -360,7 +444,7 @@ export async function runPluginsInitCommand(id: string, opts: PluginsInitOptions
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",
test: "vitest run --config ./vitest.config.ts",
},
files: ["dist", "openclaw.plugin.json", "README.md"],
peerDependencies: {
@ -378,9 +462,10 @@ export async function runPluginsInitCommand(id: string, opts: PluginsInitOptions
extensions: ["./dist/index.js"],
},
};
const idLiteral = jsStringLiteral(id);
const nameLiteral = jsStringLiteral(name);
const descriptionLiteral = jsStringLiteral(`Add ${name} tools to OpenClaw.`);
const idLiteral = jsStringLiteral(params.id);
const nameLiteral = jsStringLiteral(params.name);
const description = `Add ${params.name} tools to OpenClaw.`;
const descriptionLiteral = jsStringLiteral(description);
const indexSource = `import { Type } from "typebox";
import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin";
@ -410,7 +495,7 @@ describe(${idLiteral}, () => {
});
});
`;
const readmeSource = `# ${name}
const readmeSource = `# ${params.name}
Simple OpenClaw tool plugin.
@ -423,36 +508,305 @@ npm run plugin:validate
npm test
\`\`\`
`;
const tsconfig = {
compilerOptions: {
target: "ES2022",
module: "NodeNext",
moduleResolution: "NodeNext",
strict: true,
declaration: true,
outDir: "dist",
skipLibCheck: true,
},
include: ["src/**/*.ts"],
};
writeJsonFile(path.join(rootDir, "package.json"), packageManifest);
fs.writeFileSync(path.join(rootDir, "src/index.ts"), indexSource);
fs.writeFileSync(path.join(rootDir, "src/index.test.ts"), testSource);
fs.writeFileSync(path.join(rootDir, "README.md"), readmeSource);
writeJsonFile(path.join(rootDir, "tsconfig.json"), tsconfig);
writeJsonFile(path.join(rootDir, PLUGIN_MANIFEST_FILENAME), {
id,
name,
description: `Add ${name} tools to OpenClaw.`,
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: {
type: "object",
additionalProperties: false,
properties: {},
},
configSchema: createConfigSchema(),
activation: { onStartup: true },
contracts: { tools: ["echo"] },
});
}
function writeProviderPluginScaffold(params: { rootDir: string; id: string; name: string }): void {
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: "^5.9.0",
vitest: "^3.2.0",
},
openclaw: {
extensions: ["./dist/index.js"],
install: {
clawhubSpec: `clawhub:${packageName}`,
defaultChoice: "clawhub",
minHostVersion: `>=${VERSION}`,
},
compat: {
pluginApi: `>=${VERSION}`,
},
build: {
openclawVersion: VERSION,
},
release: {
publishToClawHub: true,
},
},
pluginInspector: {
version: 1,
plugin: {
id: params.id,
priority: "high",
seams: ["plugin-runtime"],
sourceRoot: ".",
expect: {
registrations: ["registerProvider"],
},
},
},
};
const idLiteral = jsStringLiteral(params.id);
const nameLiteral = jsStringLiteral(params.name);
const envVarLiteral = jsStringLiteral(envVar);
const optionKeyLiteral = jsStringLiteral(optionKey);
const flagNameLiteral = jsStringLiteral(flagName);
const defaultModelIdLiteral = jsStringLiteral(defaultModelId);
const defaultModelRefLiteral = jsStringLiteral(defaultModelRef);
const descriptionLiteral = jsStringLiteral(description);
const apiKeyLabelLiteral = jsStringLiteral(`${params.name} API key`);
const promptMessageLiteral = jsStringLiteral(`Enter ${params.name} API key`);
const noteMessageLiteral = jsStringLiteral(
`Replace https://api.example.com/v1 with your ${params.name} API base URL.`,
);
const indexSource = `import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog-shared";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
const PLUGIN_ID = ${idLiteral};
const PROVIDER_ID = PLUGIN_ID;
const DEFAULT_MODEL_ID = ${defaultModelIdLiteral};
const DEFAULT_MODEL_REF = ${defaultModelRefLiteral};
function buildProvider(): ModelProviderConfig {
return {
api: "openai-completions",
baseUrl: "https://api.example.com/v1",
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,
},
],
};
}
export default definePluginEntry({
id: PLUGIN_ID,
name: ${nameLiteral},
description: ${descriptionLiteral},
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: ${nameLiteral},
docsPath: "/providers/${params.id}",
envVars: [${envVarLiteral}],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: ${apiKeyLabelLiteral},
hint: "OpenAI-compatible API endpoint",
optionKey: ${optionKeyLiteral},
flagName: ${flagNameLiteral},
envVar: ${envVarLiteral},
promptMessage: ${promptMessageLiteral},
defaultModel: DEFAULT_MODEL_REF,
expectedProviders: [PROVIDER_ID],
noteTitle: ${nameLiteral},
noteMessage: ${noteMessageLiteral},
}),
],
catalog: {
order: "simple",
run: (ctx) =>
buildSingleProviderApiKeyCatalog({
ctx,
providerId: PROVIDER_ID,
buildProvider,
allowExplicitBaseUrl: true,
}),
},
});
},
});
`;
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", () => {
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 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"] },
});
}
export async function runPluginsInitCommand(
idInput: string,
opts: PluginsInitOptions,
): Promise<void> {
const id = normalizePluginId(idInput);
const name = opts.name ? normalizeDisplayName(opts.name) : titleFromId(id);
const type = resolveScaffoldType(opts.type);
const rootDir = path.resolve(opts.directory ?? id);
const force = opts.force === true;
assertCanCreate(rootDir, force);
fs.mkdirSync(path.join(rootDir, "src"), { recursive: true });
const tsconfig = buildScaffoldTsconfig(type);
if (type === "provider") {
writeProviderPluginScaffold({ rootDir, id, name });
} else {
writeToolPluginScaffold({ rootDir, id, name });
}
writeJsonFile(path.join(rootDir, "tsconfig.json"), tsconfig);
writeScaffoldVitestConfig(rootDir);
defaultRuntime.log(`Created ${path.relative(process.cwd(), rootDir) || "."}`);
}

View file

@ -58,7 +58,7 @@ export type PluginAuthoringValidateOptions = {
export type PluginAuthoringInitOptions = {
directory?: string;
force?: boolean;
name?: string;
type?: string;
};
function createModuleLoader<T>(load: () => Promise<T>): () => Promise<T> {
@ -261,10 +261,11 @@ export function registerPluginsCli(program: Command) {
plugins
.command("init")
.description("Create a simple tool plugin project")
.description("Create a plugin project")
.argument("<id>", "Plugin id")
.option("--directory <path>", "Output directory")
.option("--name <name>", "Display name")
.option("--type <type>", "Scaffold type (tool or provider)", "tool")
.option("--force", "Overwrite an existing output directory", false)
.action(async (id: string, opts: PluginAuthoringInitOptions) => {
const { runPluginsInitCommand } = await loadPluginsAuthoringCommands();