diff --git a/README.md b/README.md
index b468522..df5bc29 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,46 @@
-# 💊 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.
diff --git a/examples/github-actions-plugin-inspector.yml b/examples/github-actions-plugin-inspector.yml
index cee02a8..1663ea7 100644
--- a/examples/github-actions-plugin-inspector.yml
+++ b/examples/github-actions-plugin-inspector.yml
@@ -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:
diff --git a/examples/plugin-inspector.config.json b/examples/plugin-inspector.config.json
index 6ca927f..55ba50f 100644
--- a/examples/plugin-inspector.config.json
+++ b/examples/plugin-inspector.config.json
@@ -9,6 +9,9 @@
"registrations": ["registerTool"]
}
},
+ "capture": {
+ "mockSdk": true
+ },
"openclaw": {
"defaultCheckoutPath": "../openclaw"
}
diff --git a/src/advanced.js b/src/advanced.js
index d0e6cb1..6815854 100644
--- a/src/advanced.js
+++ b/src/advanced.js
@@ -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,
diff --git a/src/api.js b/src/api.js
index f90d7ea..8c8c005 100644
--- a/src/api.js
+++ b/src/api.js
@@ -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 };
diff --git a/src/cli.js b/src/cli.js
index e04b356..8933897 100755
--- a/src/cli.js
+++ b/src/cli.js
@@ -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 ] [--out ] [--openclaw ] [--no-openclaw] [--capture] [--json]
+ plugin-inspector
+ plugin-inspector check [--plugin-root ] [--config ] [--out ] [--openclaw ] [--no-openclaw] [--runtime] [--mock-sdk|--real-sdk] [--json]
+ plugin-inspector init [--plugin-root ] [--config ] [--ci] [--package-manager npm|pnpm|yarn|bun] [--force]
plugin-inspector report --config [--out ] [--check] [--json]
plugin-inspector inspect --config [--out ] [--check] [--json]
plugin-inspector ci --config [--out ]
- PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector capture [--mock-sdk] [--plugin-root ] [--output ]
+ PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector capture [--mock-sdk|--real-sdk] [--plugin-root ] [--output ]
+
+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.
`);
}
diff --git a/src/config.js b/src/config.js
index 2ca926c..a52128f 100644
--- a/src/config.js
+++ b/src/config.js
@@ -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"];
diff --git a/src/index.js b/src/index.js
index 19570ac..29bc3ed 100644
--- a/src/index.js
+++ b/src/index.js
@@ -6,5 +6,6 @@ export {
loadPluginConfig,
renderTextSummary,
runPluginCheck,
+ setupPluginInspector,
writePluginReports,
} from "./api.js";
diff --git a/src/init.js b/src/init.js
new file mode 100644
index 0000000..c042cd0
--- /dev/null
+++ b/src/init.js
@@ -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",
+ };
+}
diff --git a/test/api.test.js b/test/api.test.js
index 6b3c10f..39767f5 100644
--- a/test/api.test.js
+++ b/test/api.test.js
@@ -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 });
diff --git a/test/cli.test.js b/test/cli.test.js
index aacc17f..1da5d99 100644
--- a/test/cli.test.js
+++ b/test/cli.test.js
@@ -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;
+}