diff --git a/.github/workflows/plugin-init-scaffold-validation.yml b/.github/workflows/plugin-init-scaffold-validation.yml new file mode 100644 index 00000000000..ebb9d0af802 --- /dev/null +++ b/.github/workflows/plugin-init-scaffold-validation.yml @@ -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 diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index e8bddba90a8..cbf5cda76ec 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -54,8 +54,9 @@ openclaw plugins update openclaw plugins update --all openclaw plugins marketplace list openclaw plugins marketplace list --json -openclaw plugins init -openclaw plugins init --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 diff --git a/package.json b/package.json index f6da213bde9..65a091bbb0b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/validate-plugin-init-provider-scaffold.ts b/scripts/validate-plugin-init-provider-scaffold.ts new file mode 100644 index 00000000000..ba91eb7719f --- /dev/null +++ b/scripts/validate-plugin-init-provider-scaffold.ts @@ -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}`); diff --git a/src/cli/plugins-authoring-command.test.ts b/src/cli/plugins-authoring-command.test.ts index a677a7b6b08..95f4b7fdd0a 100644 --- a/src/cli/plugins-authoring-command.test.ts +++ b/src/cli/plugins-authoring-command.test.ts @@ -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", + ); }); }); diff --git a/src/cli/plugins-authoring-command.ts b/src/cli/plugins-authoring-command.ts index 83c8ae4a666..093e8f35202 100644 --- a/src/cli/plugins-authoring-command.ts +++ b/src/cli/plugins-authoring-command.ts @@ -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; @@ -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 { - 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; + + 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 / \\ + --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 { + 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) || "."}`); } diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index f7d18763bfd..b5f6d3b0cb0 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -58,7 +58,7 @@ export type PluginAuthoringValidateOptions = { export type PluginAuthoringInitOptions = { directory?: string; force?: boolean; - name?: string; + type?: string; }; function createModuleLoader(load: () => Promise): () => Promise { @@ -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("", "Plugin id") .option("--directory ", "Output directory") .option("--name ", "Display name") + .option("--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();