feat(cli): streamline plugin root checks

Adds plugin-root init, explicit runtime/mock SDK flags, config-driven runtime capture, and npx-oriented docs/CI examples.
This commit is contained in:
Vincent Koc 2026-04-27 03:23:46 -07:00 committed by GitHub
parent a1a5bcac20
commit 0fc9697a14
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 480 additions and 62 deletions

131
README.md
View File

@ -1,36 +1,46 @@
<img src="docs/plugin-inspector-banner.jpg" alt="openclaw plugin inspector banner"/>
# 💊 OpenClaw Plugin Inspector
# OpenClaw Plugin Inspector
`plugin-inspector` is the reusable OpenClaw plugin compatibility inspector. It
wraps the static inspection, registration capture, and report model prototyped
in crabpot into an npm-publishable package.
`plugin-inspector` is the offline compatibility check for OpenClaw plugins. Run
it from a plugin root to inspect package metadata, `openclaw.plugin.json`, SDK
imports, `api.on(...)`, `api.register*`, and optional runtime registration
capture.
## Install
## Quick Start
Install it as a dev dependency in a plugin repo:
From a plugin package directory:
```bash
npx @openclaw/plugin-inspector
```
That runs `check`, writes report artifacts to `reports/`, and exits non-zero
when compatibility breakages are found.
Add a local config and GitHub Actions workflow:
```bash
npx @openclaw/plugin-inspector init --ci
```
Or install it as a dev dependency:
```bash
npm install --save-dev @openclaw/plugin-inspector
npx plugin-inspector check
```
Then run it from the plugin root:
## Commands
```bash
npx @openclaw/plugin-inspector check
npx @openclaw/plugin-inspector check --plugin-root ./plugins/weather
npx @openclaw/plugin-inspector init --ci --package-manager pnpm
```
## CLI
Run the default plugin-root check from a plugin package directory:
```bash
plugin-inspector check
```
That command reads the current directory as one plugin, inspects package
metadata, `openclaw.plugin.json`, source imports, `api.on(...)`,
`api.register*`, and writes:
`check` reads the current directory as one plugin unless `--plugin-root` is set.
It writes:
- `reports/plugin-inspector-report.json`
- `reports/plugin-inspector-report.md`
@ -43,8 +53,8 @@ checkout:
plugin-inspector check --no-openclaw
```
Use a simple plugin-root config when you want stable fixture metadata or
expected seams:
Use `plugin-inspector.config.json` when CI needs stable fixture metadata,
expected seams, or runtime capture defaults:
```json
{
@ -58,6 +68,9 @@ expected seams:
"registrations": ["registerTool"]
}
},
"capture": {
"mockSdk": true
},
"openclaw": {
"defaultCheckoutPath": "../openclaw"
}
@ -70,48 +83,51 @@ Then run:
plugin-inspector check --config plugin-inspector.config.json
```
Copy-ready examples live in `examples/plugin-inspector.config.json` and
`init --ci` writes this shape for you, plus
`.github/workflows/plugin-inspector.yml`. Copy-ready examples also live in
`examples/plugin-inspector.config.json` and
`examples/github-actions-plugin-inspector.yml`.
Fixture-set configs are still supported for crabpot-style compatibility suites:
## Runtime Capture
Runtime capture imports plugin entrypoints in an isolated subprocess and records
the registrations made during `register(api)`. It is opt-in because it executes
plugin code:
```bash
plugin-inspector report --config crabpot.config.json --out reports
PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 npx @openclaw/plugin-inspector check --runtime --mock-sdk
```
Capture a plugin entrypoint in an explicitly isolated execution lane:
By default, runtime capture uses a generated mock for `openclaw/plugin-sdk` and
common external packages so plugin code can load in clean CI without OpenClaw
installed. Use `--real-sdk` only when the plugin workspace already has real SDK
dependencies installed and you intentionally want to test that path.
Runtime capture writes:
- `reports/plugin-inspector-runtime-capture.json`
- `reports/plugin-inspector-runtime-capture.md`
You can also capture one entrypoint directly:
```bash
PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector capture ./dist/index.js --mock-sdk
```
Run the optional runtime capture smoke during `check`:
## CI
```bash
PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector check --no-openclaw --capture
```
Runtime capture creates a temporary mock `openclaw/plugin-sdk` package, imports
declared OpenClaw package entrypoints, calls their `register(api)` function with
the capture API, and writes:
- `reports/plugin-inspector-runtime-capture.json`
- `reports/plugin-inspector-runtime-capture.md`
### CI
With a dev dependency:
Minimal package scripts:
```json
{
"scripts": {
"plugin:check": "plugin-inspector check --no-openclaw",
"plugin:check:runtime": "PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector check --no-openclaw --capture"
"plugin:check:runtime": "PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector check --no-openclaw --runtime --mock-sdk"
}
}
```
GitHub Actions:
GitHub Actions without a local dev dependency:
```yaml
name: plugin-inspector
@ -131,8 +147,8 @@ jobs:
node-version: 24
cache: npm
- run: npm ci
- run: npm run plugin:check
- run: npm run plugin:check:runtime
- run: npx @openclaw/plugin-inspector check --no-openclaw
- run: PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 npx @openclaw/plugin-inspector check --no-openclaw --runtime --mock-sdk
- uses: actions/upload-artifact@v5
if: always()
with:
@ -140,6 +156,31 @@ jobs:
path: reports/plugin-inspector-*
```
## Fixture Suites
Fixture-set configs are still supported for crabpot-style compatibility suites:
```bash
plugin-inspector report --config crabpot.config.json --out reports
```
Use fixture suites when one repo wants to inspect many plugins. Use plugin-root
`check` for normal plugin CI.
## Mocking Model
Default inspection is static, offline, and credential-free. Runtime capture is
the only mode that imports plugin code.
When `--mock-sdk` is enabled, the inspector generates temporary modules for
`openclaw/plugin-sdk` subpaths and unresolved external packages discovered in
the plugin import graph. The mock SDK captures registrations; it does not call
network services, launch OpenClaw, run provider SDKs, or emulate service
lifecycle side effects.
Use the mock lane for plugin compatibility CI. Keep live provider/service tests
in the plugin repo behind their own credentials and explicit opt-in flags.
## Scope
Default inspection is offline and credential-free. It reads manifests, package
@ -148,5 +189,5 @@ metadata, and source files, then reports observed `api.on(...)`,
OpenClaw target checkout parsing is limited to public compatibility registries,
SDK package exports, manifest types, hooks, and captured registrar metadata.
Cold import capture and synthetic contract probes are explicit opt-in modes.
Live lanes will stay credential-gated and must never run in default CI.
Cold import capture, synthetic contract probes, and runtime capture are explicit
opt-in modes. Live lanes stay credential-gated and must never run in default CI.

View File

@ -15,8 +15,8 @@ jobs:
node-version: 24
cache: npm
- run: npm ci
- run: npm run plugin:check
- run: npm run plugin:check:runtime
- run: npx @openclaw/plugin-inspector check --no-openclaw
- run: PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 npx @openclaw/plugin-inspector check --no-openclaw --runtime --mock-sdk
- uses: actions/upload-artifact@v5
if: always()
with:

View File

@ -9,6 +9,9 @@
"registrations": ["registerTool"]
}
},
"capture": {
"mockSdk": true
},
"openclaw": {
"defaultCheckoutPath": "../openclaw"
}

View File

@ -116,12 +116,21 @@ export {
defaultPluginRootConfigFiles,
fixtureCheckoutPath,
fixtureSourceRoot,
inferPluginSeams,
loadInspectorConfig,
loadPluginRootConfig,
normalizeInspectorConfig,
normalizePluginRootConfig,
packageId,
validateInspectorConfig,
} from "./config.js";
export {
buildPluginInspectorConfig,
defaultInitConfigPath,
defaultInitWorkflowPath,
renderGithubActionsWorkflow,
writePluginInspectorInit,
} from "./init.js";
export {
buildPlatformProbes,
defaultPlatformTargets,

View File

@ -1,6 +1,7 @@
import path from "node:path";
import { createCaptureApi } from "./capture-api.js";
import { loadInspectorConfig, loadPluginRootConfig } from "./config.js";
import { writePluginInspectorInit } from "./init.js";
import { captureEntrypoint } from "./inspector.js";
import { renderTextSummary, writeCompatibilityReport } from "./report.js";
import { buildRuntimeCaptureReport, writeRuntimeCaptureReport } from "./runtime-capture-report.js";
@ -45,24 +46,25 @@ export async function writePluginReports(report, options = {}) {
export async function runPluginCheck(options = {}) {
const outDir = options.outDir ?? "reports";
const report = await inspectPluginRoot(options);
const paths = await writePluginReports(report, { ...options, outDir });
const config = await loadPluginConfig(options);
const report = await inspectPluginRoot({ ...options, config });
const paths = await writePluginReports(report, { ...options, pluginRoot: config.rootDir, outDir });
const result = { report, paths };
const capture = options.capture ?? config.capture?.runtime ?? false;
const mockSdk = options.mockSdk ?? config.capture?.mockSdk ?? true;
if (options.capture === true) {
if (capture === true) {
if (process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED !== "1") {
throw new Error("runtime capture imports plugin code; rerun with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 in an isolated workspace");
}
const config = await loadPluginConfig(options);
const runtimeCapture = await buildRuntimeCaptureReport({
mockSdk: options.mockSdk ?? true,
mockSdk,
report,
rootDir: config.rootDir,
});
const outputRoot = options.cwd ?? options.pluginRoot ?? process.cwd();
const runtimeCapturePaths = await writeRuntimeCaptureReport(runtimeCapture, {
jsonPath: path.resolve(outputRoot, outDir, "plugin-inspector-runtime-capture.json"),
markdownPath: path.resolve(outputRoot, outDir, "plugin-inspector-runtime-capture.md"),
jsonPath: path.resolve(config.rootDir, outDir, "plugin-inspector-runtime-capture.json"),
markdownPath: path.resolve(config.rootDir, outDir, "plugin-inspector-runtime-capture.md"),
});
result.runtimeCapture = runtimeCapture;
result.runtimeCapturePaths = runtimeCapturePaths;
@ -82,4 +84,8 @@ export async function capturePluginEntrypoint(entrypoint, options = {}) {
return captureEntrypoint(entrypoint, options);
}
export async function setupPluginInspector(options = {}) {
return writePluginInspectorInit(options);
}
export { createCaptureApi, renderTextSummary };

View File

@ -7,6 +7,7 @@ import {
captureEntrypoint,
inspectFixtureSet,
loadInspectorConfig,
writePluginInspectorInit,
writeArtifacts,
writeReport,
} from "./advanced.js";
@ -20,6 +21,8 @@ try {
printHelp();
} else if (command === "check") {
await runCheck(commandArgs);
} else if (command === "init") {
await runInit(commandArgs);
} else if (command === "inspect" || command === "report" || command === "ci") {
await runReport(command, commandArgs);
} else if (command === "capture") {
@ -34,11 +37,13 @@ try {
async function runCheck(commandArgs) {
const configPath = readFlag(commandArgs, "--config");
const pluginRoot = readFlag(commandArgs, "--plugin-root") ?? readFlag(commandArgs, "--root");
const outDir = readFlag(commandArgs, "--out") ?? "reports";
const openclawPath = commandArgs.includes("--no-openclaw") ? false : readFlag(commandArgs, "--openclaw");
const json = commandArgs.includes("--json");
const capture = commandArgs.includes("--capture");
const { report } = await runPluginCheck({ configPath, outDir, openclawPath, capture });
const capture = readRuntimeFlag(commandArgs);
const mockSdk = readMockSdkFlag(commandArgs);
const { report } = await runPluginCheck({ configPath, pluginRoot, outDir, openclawPath, capture, mockSdk });
if (json) {
console.log(JSON.stringify(report, null, 2));
@ -51,6 +56,25 @@ async function runCheck(commandArgs) {
}
}
async function runInit(commandArgs) {
const pluginRoot = readFlag(commandArgs, "--plugin-root") ?? readFlag(commandArgs, "--root");
const configPath = readFlag(commandArgs, "--config") ?? undefined;
const workflowPath = readFlag(commandArgs, "--workflow") ?? undefined;
const packageManager = readFlag(commandArgs, "--package-manager") ?? "npm";
const result = await writePluginInspectorInit({
pluginRoot,
configPath,
workflowPath,
packageManager,
ci: commandArgs.includes("--ci"),
force: commandArgs.includes("--force"),
});
for (const filePath of result.written) {
console.log(`wrote ${filePath}`);
}
}
async function runReport(command, commandArgs) {
const configPath = readFlag(commandArgs, "--config");
const outDir = readFlag(commandArgs, "--out") ?? "reports";
@ -75,7 +99,7 @@ async function runCapture(commandArgs) {
const entrypoint = commandArgs.find((arg) => !arg.startsWith("-"));
const outputPath = readFlag(commandArgs, "--output");
const pluginRoot = readFlag(commandArgs, "--plugin-root");
const mockSdk = commandArgs.includes("--mock-sdk");
const mockSdk = readMockSdkFlag(commandArgs) ?? commandArgs.includes("--mock-sdk");
if (!entrypoint) {
throw new Error("capture requires an entrypoint path");
}
@ -100,14 +124,49 @@ function readFlag(commandArgs, name) {
return commandArgs[index + 1] ?? null;
}
function readRuntimeFlag(commandArgs) {
if (commandArgs.includes("--runtime") || commandArgs.includes("--capture")) {
return true;
}
if (commandArgs.includes("--no-runtime") || commandArgs.includes("--no-capture")) {
return false;
}
return undefined;
}
function readMockSdkFlag(commandArgs) {
const sdk = readFlag(commandArgs, "--sdk");
if (sdk === "mock") {
return true;
}
if (sdk === "real") {
return false;
}
if (sdk && !["mock", "real"].includes(sdk)) {
throw new Error("--sdk must be mock or real");
}
if (commandArgs.includes("--mock-sdk")) {
return true;
}
if (commandArgs.includes("--real-sdk")) {
return false;
}
return undefined;
}
function printHelp() {
console.log(`plugin-inspector
Usage:
plugin-inspector check [--config <path>] [--out <dir>] [--openclaw <path>] [--no-openclaw] [--capture] [--json]
plugin-inspector
plugin-inspector check [--plugin-root <path>] [--config <path>] [--out <dir>] [--openclaw <path>] [--no-openclaw] [--runtime] [--mock-sdk|--real-sdk] [--json]
plugin-inspector init [--plugin-root <path>] [--config <path>] [--ci] [--package-manager npm|pnpm|yarn|bun] [--force]
plugin-inspector report --config <path> [--out <dir>] [--check] [--json]
plugin-inspector inspect --config <path> [--out <dir>] [--check] [--json]
plugin-inspector ci --config <path> [--out <dir>]
PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector capture <entrypoint> [--mock-sdk] [--plugin-root <path>] [--output <path>]
PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector capture <entrypoint> [--mock-sdk|--real-sdk] [--plugin-root <path>] [--output <path>]
Default check runs from the current plugin root and writes reports/ unless --out is set.
Runtime capture is opt-in because it imports plugin code; use --runtime with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1.
`);
}

View File

@ -52,6 +52,19 @@ export function validateInspectorConfig(config) {
errors.push("config.fixtures must be a non-empty array");
}
if (config.capture !== undefined) {
if (!config.capture || typeof config.capture !== "object" || Array.isArray(config.capture)) {
errors.push("config.capture must be an object when present");
} else {
if (config.capture.runtime !== undefined && typeof config.capture.runtime !== "boolean") {
errors.push("config.capture.runtime must be a boolean when present");
}
if (config.capture.mockSdk !== undefined && typeof config.capture.mockSdk !== "boolean") {
errors.push("config.capture.mockSdk must be a boolean when present");
}
}
}
const ids = new Set();
const paths = new Set();
for (const fixture of config.fixtures ?? []) {
@ -134,6 +147,7 @@ export async function normalizePluginRootConfig(config, options = {}) {
return {
version: 1,
submoduleRoot: ".",
capture: config.capture,
openclaw: config.openclaw,
fixtures: [fixture],
};
@ -157,7 +171,7 @@ async function readJsonIfExists(filePath) {
return JSON.parse(await readFile(filePath, "utf8"));
}
function packageId(packageName) {
export function packageId(packageName) {
if (!packageName) {
return null;
}
@ -170,7 +184,7 @@ function packageId(packageName) {
.toLowerCase();
}
function inferPluginSeams(pluginManifest, packageJson) {
export function inferPluginSeams(pluginManifest, packageJson) {
const contracts = Object.keys(pluginManifest?.contracts ?? {});
if (contracts.includes("tools")) {
return ["dynamic-tool"];

View File

@ -6,5 +6,6 @@ export {
loadPluginConfig,
renderTextSummary,
runPluginCheck,
setupPluginInspector,
writePluginReports,
} from "./api.js";

150
src/init.js Normal file
View File

@ -0,0 +1,150 @@
import { existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { inferPluginSeams, packageId } from "./config.js";
export const defaultInitConfigPath = "plugin-inspector.config.json";
export const defaultInitWorkflowPath = ".github/workflows/plugin-inspector.yml";
export async function writePluginInspectorInit(options = {}) {
const pluginRoot = path.resolve(options.pluginRoot ?? options.cwd ?? process.cwd());
const configPath = path.resolve(pluginRoot, options.configPath ?? defaultInitConfigPath);
const written = [];
if (existsSync(configPath) && options.force !== true) {
throw new Error(`${path.relative(pluginRoot, configPath)} already exists; pass --force to overwrite it`);
}
const config = await buildPluginInspectorConfig({ pluginRoot });
await mkdir(path.dirname(configPath), { recursive: true });
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
written.push(configPath);
if (options.ci === true) {
const workflowPath = path.resolve(pluginRoot, options.workflowPath ?? defaultInitWorkflowPath);
if (existsSync(workflowPath) && options.force !== true) {
throw new Error(`${path.relative(pluginRoot, workflowPath)} already exists; pass --force to overwrite it`);
}
await mkdir(path.dirname(workflowPath), { recursive: true });
await writeFile(workflowPath, renderGithubActionsWorkflow({ packageManager: options.packageManager }), "utf8");
written.push(workflowPath);
}
return { pluginRoot, configPath, written };
}
export async function buildPluginInspectorConfig(options = {}) {
const pluginRoot = path.resolve(options.pluginRoot ?? options.cwd ?? process.cwd());
const packageJson = await readJsonIfExists(path.join(pluginRoot, "package.json"));
const pluginManifest = await readJsonIfExists(path.join(pluginRoot, "openclaw.plugin.json"));
const sourceRoot = inferSourceRoot(packageJson);
const plugin = {
id: pluginManifest?.id ?? packageId(packageJson?.name) ?? "plugin",
priority: "high",
seams: inferPluginSeams(pluginManifest, packageJson),
};
if (sourceRoot !== ".") {
plugin.sourceRoot = sourceRoot;
}
return {
version: 1,
plugin,
capture: {
mockSdk: true,
},
};
}
export function renderGithubActionsWorkflow(options = {}) {
const packageManager = normalizePackageManager(options.packageManager);
const setup = packageManagerSetup(packageManager);
return `name: plugin-inspector
on:
pull_request:
push:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
cache: ${setup.cache}
${setup.corepack ? " - run: corepack enable\n" : ""} - run: ${setup.install}
- run: ${setup.exec} @openclaw/plugin-inspector check --no-openclaw
- run: PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 ${setup.exec} @openclaw/plugin-inspector check --no-openclaw --runtime --mock-sdk
- uses: actions/upload-artifact@v5
if: always()
with:
name: plugin-inspector-reports
path: reports/plugin-inspector-*
`;
}
function inferSourceRoot(packageJson) {
const entrypoints = [
packageJson?.openclaw?.entrypoint,
...(packageJson?.openclaw?.extensions ?? []),
...(packageJson?.openclaw?.runtimeExtensions ?? []),
].filter((value) => typeof value === "string");
const entrypoint = entrypoints[0] ?? packageJson?.exports?.["."] ?? packageJson?.main ?? "src/index.js";
if (typeof entrypoint === "string" && entrypoint.startsWith("src/")) {
return "src";
}
return ".";
}
async function readJsonIfExists(filePath) {
if (!existsSync(filePath)) {
return null;
}
return JSON.parse(await readFile(filePath, "utf8"));
}
function normalizePackageManager(packageManager = "npm") {
if (["npm", "pnpm", "yarn", "bun"].includes(packageManager)) {
return packageManager;
}
throw new Error("--package-manager must be npm, pnpm, yarn, or bun");
}
function packageManagerSetup(packageManager) {
if (packageManager === "pnpm") {
return {
cache: "pnpm",
corepack: true,
install: "pnpm install --frozen-lockfile",
exec: "pnpm dlx",
};
}
if (packageManager === "yarn") {
return {
cache: "yarn",
corepack: true,
install: "yarn install --immutable",
exec: "yarn dlx",
};
}
if (packageManager === "bun") {
return {
cache: "npm",
corepack: false,
install: "bun install --frozen-lockfile",
exec: "bunx",
};
}
return {
cache: "npm",
corepack: false,
install: "npm ci",
exec: "npx",
};
}

View File

@ -9,6 +9,7 @@ import {
inspectPluginRoot,
loadPluginConfig,
runPluginCheck,
setupPluginInspector,
} from "../src/index.js";
test("public API runs the plugin-root check and writes reports", async () => {
@ -61,6 +62,41 @@ test("public API exposes capture through an explicit entrypoint helper", async (
);
});
test("public API can initialize plugin inspector files", async () => {
const pluginRoot = await createPluginRoot();
const result = await setupPluginInspector({ pluginRoot, ci: true, packageManager: "npm" });
const config = JSON.parse(await readFile(path.join(pluginRoot, "plugin-inspector.config.json"), "utf8"));
const workflow = await readFile(path.join(pluginRoot, ".github", "workflows", "plugin-inspector.yml"), "utf8");
assert.equal(result.written.length, 2);
assert.equal(config.plugin.id, "weather");
assert.equal(config.capture.mockSdk, true);
assert.match(workflow, /npx @openclaw\/plugin-inspector check --no-openclaw/);
});
test("public API honors config-driven runtime capture", async () => {
const pluginRoot = await createPluginRoot();
await writeFile(
path.join(pluginRoot, "plugin-inspector.config.json"),
`${JSON.stringify({ version: 1, capture: { runtime: true, mockSdk: true } }, null, 2)}\n`,
"utf8",
);
const previous = process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED;
process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED = "1";
try {
const result = await runPluginCheck({ pluginRoot, outDir: "reports", openclawPath: false });
assert.equal(result.runtimeCapture.summary.registrationCount, 1);
} finally {
if (previous === undefined) {
delete process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED;
} else {
process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED = previous;
}
}
});
async function createPluginRoot() {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-api-root-"));
await mkdir(path.join(rootDir, "src"), { recursive: true });

View File

@ -65,3 +65,102 @@ test("check command runs from a plugin root without fixture config", async () =>
assert.equal(capture.summary.capturedCount, 1);
assert.equal(capture.summary.registrationCount, 1);
});
test("check command can target a plugin root and use runtime aliases", async () => {
const rootDir = await createCliPluginRoot("plugin-inspector-cli-target-");
const cliPath = path.resolve("src/cli.js");
await execFileAsync(
process.execPath,
[cliPath, "--plugin-root", rootDir, "--out", "reports", "--no-openclaw", "--runtime", "--mock-sdk"],
{
cwd: os.tmpdir(),
env: {
...process.env,
PLUGIN_INSPECTOR_EXECUTE_ISOLATED: "1",
},
},
);
const report = JSON.parse(await readFile(path.join(rootDir, "reports", "plugin-inspector-report.json"), "utf8"));
const capture = JSON.parse(
await readFile(path.join(rootDir, "reports", "plugin-inspector-runtime-capture.json"), "utf8"),
);
assert.equal(report.fixtures[0].id, "weather");
assert.equal(capture.summary.capturedCount, 1);
});
test("check command can enable runtime capture from plugin config", async () => {
const rootDir = await createCliPluginRoot("plugin-inspector-cli-config-runtime-");
await writeFile(
path.join(rootDir, "plugin-inspector.config.json"),
`${JSON.stringify({ version: 1, capture: { runtime: true, mockSdk: true } }, null, 2)}\n`,
"utf8",
);
const cliPath = path.resolve("src/cli.js");
await execFileAsync(process.execPath, [cliPath, "check", "--out", "reports", "--no-openclaw"], {
cwd: rootDir,
env: {
...process.env,
PLUGIN_INSPECTOR_EXECUTE_ISOLATED: "1",
},
});
const capture = JSON.parse(
await readFile(path.join(rootDir, "reports", "plugin-inspector-runtime-capture.json"), "utf8"),
);
assert.equal(capture.summary.registrationCount, 1);
});
test("init command writes plugin config and CI workflow", async () => {
const rootDir = await createCliPluginRoot("plugin-inspector-cli-init-");
const cliPath = path.resolve("src/cli.js");
const { stdout } = await execFileAsync(
process.execPath,
[cliPath, "init", "--plugin-root", rootDir, "--ci", "--package-manager", "pnpm"],
);
const config = JSON.parse(await readFile(path.join(rootDir, "plugin-inspector.config.json"), "utf8"));
const workflow = await readFile(path.join(rootDir, ".github", "workflows", "plugin-inspector.yml"), "utf8");
assert.match(stdout, /plugin-inspector\.config\.json/);
assert.equal(config.plugin.id, "weather");
assert.equal(config.plugin.sourceRoot, "src");
assert.equal(config.capture.mockSdk, true);
assert.match(workflow, /pnpm dlx @openclaw\/plugin-inspector check --no-openclaw/);
assert.match(workflow, /--runtime --mock-sdk/);
});
async function createCliPluginRoot(prefix) {
const rootDir = await mkdtemp(path.join(os.tmpdir(), prefix));
await mkdir(path.join(rootDir, "src"), { recursive: true });
await writeFile(
path.join(rootDir, "package.json"),
`${JSON.stringify(
{
name: "@example/openclaw-weather",
version: "1.0.0",
type: "module",
openclaw: {
extensions: ["src/index.js"],
compat: { pluginApi: "^1.0.0" },
},
},
null,
2,
)}\n`,
"utf8",
);
await writeFile(
path.join(rootDir, "openclaw.plugin.json"),
`${JSON.stringify({ id: "weather", name: "Weather", version: "1.0.0", contracts: { tools: {} } }, null, 2)}\n`,
"utf8",
);
await writeFile(
path.join(rootDir, "src", "index.js"),
'import { definePluginEntry } from "openclaw/plugin-sdk";\nexport default definePluginEntry((api) => api.registerTool({ name: "weather" }));\n',
"utf8",
);
return rootDir;
}