mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
deb0ffdcdf
commit
808c227edb
7 changed files with 660 additions and 48 deletions
51
.github/workflows/plugin-init-scaffold-validation.yml
vendored
Normal file
51
.github/workflows/plugin-init-scaffold-validation.yml
vendored
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
71
scripts/validate-plugin-init-provider-scaffold.ts
Normal file
71
scripts/validate-plugin-init-provider-scaffold.ts
Normal 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}`);
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) || "."}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue