@openclaw/plugin-inspector 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-04-27
4
+
5
+ ### Added
6
+
7
+ - Add `--allow-execute` as a cross-platform runtime capture opt-in flag.
8
+ - Add `plugin-inspector init --dry-run` for setup previews.
9
+ - Add `plugin-inspector init --json` for machine-readable setup summaries.
10
+ - Add `plugin-inspector init --scripts` for `plugin:check` and `plugin:ci` package scripts.
11
+ - Add public fixture-set report helpers and synthetic probe suite helpers for Crabpot and downstream compatibility suites.
12
+ - Add a Crabpot follow-through release checklist for source refs, package pins, and smoke commands.
13
+
14
+ ### Changed
15
+
16
+ - Make generated runtime CI commands use `--allow-execute` instead of shell-specific inline environment syntax.
17
+ - Make `plugin-inspector init --ci` detect `packageManager` and common lockfiles before generating CI install/run commands.
18
+ - Make `plugin-inspector init` output repo-relative file paths and preflight generated files before writing.
19
+ - Make `plugin-inspector init` infer `sourceRoot: "src"` from package export maps like `"./src/index.js"`.
20
+ - Improve CLI failure summaries with report artifact paths and top blocking findings.
21
+ - Harden mock SDK capture by keeping generated loader fixtures available until subprocess exit.
22
+
3
23
  ## 0.2.0 - 2026-04-27
4
24
 
5
25
  ### Added
package/README.md CHANGED
@@ -24,6 +24,11 @@ Add a local config and GitHub Actions workflow:
24
24
  npx @openclaw/plugin-inspector init --ci
25
25
  ```
26
26
 
27
+ `init --ci` detects `packageManager` and common lockfiles. Pass
28
+ `--package-manager pnpm`, `npm`, `yarn`, or `bun` when you want to override it.
29
+ Add `--scripts` to write `plugin:check` and `plugin:ci` package scripts.
30
+ Use `--dry-run` to preview the files first.
31
+
27
32
  Or install it as a dev dependency:
28
33
 
29
34
  ```bash
@@ -40,6 +45,8 @@ npx @openclaw/plugin-inspector ci --no-openclaw
40
45
  npx @openclaw/plugin-inspector config
41
46
  npx @openclaw/plugin-inspector check --plugin-root ./plugins/weather
42
47
  npx @openclaw/plugin-inspector init --ci --package-manager pnpm
48
+ npx @openclaw/plugin-inspector init --ci --scripts --dry-run
49
+ npx @openclaw/plugin-inspector init --ci --scripts --dry-run --json
43
50
  ```
44
51
 
45
52
  `check` and `inspect` read the current directory as one plugin unless
@@ -132,7 +139,8 @@ For a single plugin package, the same config can live in `package.json`:
132
139
  `.github/workflows/plugin-inspector.yml`. Copy-ready examples also live in
133
140
  `examples/plugin-inspector.config.json` and
134
141
  `examples/package-json-plugin-inspector.json` and
135
- `examples/github-actions-plugin-inspector.yml`.
142
+ `examples/github-actions-plugin-inspector.yml`. SARIF/JUnit CI consumption
143
+ examples live alongside them.
136
144
 
137
145
  ## Runtime Capture
138
146
 
@@ -141,9 +149,13 @@ the registrations made during `register(api)`. It is opt-in because it executes
141
149
  plugin code:
142
150
 
143
151
  ```bash
144
- PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 npx @openclaw/plugin-inspector check --runtime --mock-sdk
152
+ npx @openclaw/plugin-inspector check --runtime --mock-sdk --allow-execute
145
153
  ```
146
154
 
155
+ `--allow-execute` is the explicit guard for modes that import plugin code. The
156
+ older `PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1` environment guard still works for
157
+ custom harnesses.
158
+
147
159
  By default, runtime capture uses a generated mock for `openclaw/plugin-sdk` and
148
160
  common external packages so plugin code can load in clean CI without OpenClaw
149
161
  installed. Use `--real-sdk` only when the plugin workspace already has real SDK
@@ -157,7 +169,7 @@ Runtime capture writes:
157
169
  You can also capture one entrypoint directly:
158
170
 
159
171
  ```bash
160
- PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector capture ./dist/index.js --mock-sdk
172
+ plugin-inspector capture ./dist/index.js --mock-sdk --allow-execute
161
173
  ```
162
174
 
163
175
  ## CI
@@ -168,7 +180,7 @@ Minimal package scripts:
168
180
  {
169
181
  "scripts": {
170
182
  "plugin:check": "plugin-inspector inspect --no-openclaw",
171
- "plugin:ci": "PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector ci --no-openclaw --runtime --mock-sdk"
183
+ "plugin:ci": "plugin-inspector ci --no-openclaw --runtime --mock-sdk --allow-execute"
172
184
  }
173
185
  }
174
186
  ```
@@ -193,7 +205,7 @@ jobs:
193
205
  node-version: 24
194
206
  cache: npm
195
207
  - run: npm ci
196
- - run: PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 npx @openclaw/plugin-inspector ci --no-openclaw --runtime --mock-sdk
208
+ - run: npx @openclaw/plugin-inspector ci --no-openclaw --runtime --mock-sdk --allow-execute
197
209
  - uses: actions/upload-artifact@v5
198
210
  if: always()
199
211
  with:
@@ -204,6 +216,15 @@ jobs:
204
216
  `ci` writes the normal report, CI summary, SARIF, and JUnit files by default.
205
217
  Pass `--no-sarif` or `--no-junit` only if your CI surface cannot consume them.
206
218
 
219
+ For GitHub code scanning, use
220
+ `examples/github-actions-code-scanning.yml`; it uploads
221
+ `reports/plugin-inspector.sarif` through CodeQL's SARIF upload action.
222
+
223
+ For CI test-summary UIs, point JUnit ingestion at
224
+ `reports/plugin-inspector.junit.xml`. Copy-ready GitLab and CircleCI examples
225
+ live in `examples/gitlab-ci-plugin-inspector.yml` and
226
+ `examples/circleci-plugin-inspector.yml`.
227
+
207
228
  ## Fixture Suites
208
229
 
209
230
  Fixture-set configs are still supported for crabpot-style compatibility suites:
@@ -0,0 +1,19 @@
1
+ version: 2.1
2
+
3
+ jobs:
4
+ plugin-inspector:
5
+ docker:
6
+ - image: cimg/node:24.0
7
+ steps:
8
+ - checkout
9
+ - run: npm ci
10
+ - run: npx @openclaw/plugin-inspector ci --no-openclaw --runtime --mock-sdk --allow-execute
11
+ - store_test_results:
12
+ path: reports
13
+ - store_artifacts:
14
+ path: reports
15
+
16
+ workflows:
17
+ plugin-inspector:
18
+ jobs:
19
+ - plugin-inspector
@@ -0,0 +1,31 @@
1
+ name: plugin-inspector
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ permissions:
9
+ contents: read
10
+ security-events: write
11
+
12
+ jobs:
13
+ check:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v5
17
+ - uses: actions/setup-node@v5
18
+ with:
19
+ node-version: 24
20
+ cache: npm
21
+ - run: npm ci
22
+ - run: npx @openclaw/plugin-inspector ci --no-openclaw --runtime --mock-sdk --allow-execute
23
+ - uses: github/codeql-action/upload-sarif@v3
24
+ if: always()
25
+ with:
26
+ sarif_file: reports/plugin-inspector.sarif
27
+ - uses: actions/upload-artifact@v5
28
+ if: always()
29
+ with:
30
+ name: plugin-inspector-reports
31
+ path: reports/plugin-inspector-*
@@ -15,7 +15,7 @@ jobs:
15
15
  node-version: 24
16
16
  cache: npm
17
17
  - run: npm ci
18
- - run: PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 npx @openclaw/plugin-inspector ci --no-openclaw --runtime --mock-sdk
18
+ - run: npx @openclaw/plugin-inspector ci --no-openclaw --runtime --mock-sdk --allow-execute
19
19
  - uses: actions/upload-artifact@v5
20
20
  if: always()
21
21
  with:
@@ -0,0 +1,11 @@
1
+ plugin_inspector:
2
+ image: node:24
3
+ script:
4
+ - npm ci
5
+ - npx @openclaw/plugin-inspector ci --no-openclaw --runtime --mock-sdk --allow-execute
6
+ artifacts:
7
+ when: always
8
+ paths:
9
+ - reports/plugin-inspector-*
10
+ reports:
11
+ junit: reports/plugin-inspector.junit.xml
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "scripts": {
3
3
  "plugin:check": "plugin-inspector inspect --no-openclaw",
4
- "plugin:ci": "PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector ci --no-openclaw --runtime --mock-sdk"
4
+ "plugin:ci": "plugin-inspector ci --no-openclaw --runtime --mock-sdk --allow-execute"
5
5
  },
6
6
  "pluginInspector": {
7
7
  "version": 1,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/plugin-inspector",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "Offline compatibility inspector for OpenClaw plugins.",
6
6
  "type": "module",
@@ -36,6 +36,8 @@
36
36
  "./ref-diff": "./src/ref-diff.js",
37
37
  "./runtime-capture-report": "./src/runtime-capture-report.js",
38
38
  "./runtime-profile": "./src/runtime-profile.js",
39
+ "./synthetic-probe-suite": "./src/synthetic-probe-suite.js",
40
+ "./synthetic-probes": "./src/synthetic-probes.js",
39
41
  "./workspace-plan": "./src/workspace-plan.js"
40
42
  },
41
43
  "files": [
@@ -47,6 +49,7 @@
47
49
  ],
48
50
  "scripts": {
49
51
  "check": "npm test && npm pack --dry-run",
52
+ "release:crabpot": "node scripts/check-crabpot-followthrough.mjs",
50
53
  "release:local": "npm run check",
51
54
  "test": "node --test test/*.test.js"
52
55
  },
package/src/advanced.js CHANGED
@@ -136,8 +136,10 @@ export {
136
136
  } from "./config.js";
137
137
  export {
138
138
  buildPluginInspectorConfig,
139
+ defaultInitPackageScripts,
139
140
  defaultInitConfigPath,
140
141
  defaultInitWorkflowPath,
142
+ detectPackageManager,
141
143
  renderGithubActionsWorkflow,
142
144
  writePluginInspectorInit,
143
145
  } from "./init.js";
@@ -196,6 +198,7 @@ export {
196
198
  validateSyntheticProbePlan,
197
199
  writeSyntheticProbePlan,
198
200
  } from "./synthetic-probes.js";
201
+ export { buildSyntheticProbePlanFromReport } from "./synthetic-probe-suite.js";
199
202
  export {
200
203
  buildWorkspacePlan,
201
204
  defaultWorkspacePlanOptions,
@@ -203,3 +206,10 @@ export {
203
206
  validateWorkspacePlan,
204
207
  writeWorkspacePlan,
205
208
  } from "./workspace-plan.js";
209
+ export {
210
+ inspectCompatibilityFixtureSetConfig,
211
+ renderFixtureSetIssuesReport,
212
+ renderFixtureSetMarkdownReport,
213
+ runFixtureSetReport,
214
+ writeFixtureSetReports,
215
+ } from "./api.js";
package/src/api.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { createCaptureApi } from "./capture-api.js";
3
3
  import { loadInspectorConfig, loadPluginRootConfig } from "./config.js";
4
+ import { renderCompatibilityIssuesReport, renderCompatibilityMarkdownReport } from "./compatibility-report.js";
4
5
  import { writePluginInspectorInit } from "./init.js";
5
6
  import { captureEntrypoint } from "./inspector.js";
6
7
  import { renderTextSummary, writeCompatibilityReport } from "./report.js";
@@ -35,6 +36,15 @@ export async function inspectFixtureSetConfig(options = {}) {
35
36
  return inspectFixtureSet(config, { generatedAt: options.generatedAt });
36
37
  }
37
38
 
39
+ export async function inspectCompatibilityFixtureSetConfig(options = {}) {
40
+ const config = await loadFixtureSetConfig(options);
41
+ return inspectCompatibilityFixtureSet(config, {
42
+ generatedAt: options.generatedAt,
43
+ openclawPath: options.openclawPath,
44
+ targetOpenClaw: options.targetOpenClaw,
45
+ });
46
+ }
47
+
38
48
  export async function writePluginReports(report, options = {}) {
39
49
  return writeCompatibilityReport(report, {
40
50
  basename: options.basename,
@@ -45,6 +55,38 @@ export async function writePluginReports(report, options = {}) {
45
55
  });
46
56
  }
47
57
 
58
+ export async function writeFixtureSetReports(report, options = {}) {
59
+ return writeCompatibilityReport(report, {
60
+ basename: options.basename,
61
+ check: options.check,
62
+ cwd: options.cwd,
63
+ formatEvidence: options.formatEvidence,
64
+ issuesBasename: options.issuesBasename,
65
+ issuesPath: options.issuesPath,
66
+ issuesTitle: options.issuesTitle,
67
+ jsonPath: options.jsonPath,
68
+ markdownPath: options.markdownPath,
69
+ markdownTitle: options.markdownTitle,
70
+ outDir: options.outDir,
71
+ severityLabels: options.severityLabels,
72
+ title: options.title,
73
+ });
74
+ }
75
+
76
+ export function renderFixtureSetMarkdownReport(report, options = {}) {
77
+ return renderCompatibilityMarkdownReport(report, options);
78
+ }
79
+
80
+ export function renderFixtureSetIssuesReport(report, options = {}) {
81
+ return renderCompatibilityIssuesReport(report, options);
82
+ }
83
+
84
+ export async function runFixtureSetReport(options = {}) {
85
+ const report = await inspectCompatibilityFixtureSetConfig(options);
86
+ const paths = options.write === false ? null : await writeFixtureSetReports(report, options);
87
+ return { report, paths };
88
+ }
89
+
48
90
  export async function runPluginCheck(options = {}) {
49
91
  const outDir = options.outDir ?? "reports";
50
92
  const config = await loadPluginConfig(options);
@@ -55,8 +97,8 @@ export async function runPluginCheck(options = {}) {
55
97
  const mockSdk = options.mockSdk ?? config.capture?.mockSdk ?? true;
56
98
 
57
99
  if (capture === true) {
58
- if (process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED !== "1") {
59
- throw new Error("runtime capture imports plugin code; rerun with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 in an isolated workspace");
100
+ if (!executionAllowed(options)) {
101
+ throw new Error("runtime capture imports plugin code; rerun with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 or --allow-execute in an isolated workspace");
60
102
  }
61
103
  const runtimeCapture = await buildRuntimeCaptureReport({
62
104
  mockSdk,
@@ -90,3 +132,17 @@ export async function setupPluginInspector(options = {}) {
90
132
  }
91
133
 
92
134
  export { createCaptureApi, renderTextSummary, writeCiOutputArtifacts };
135
+
136
+ function executionAllowed(options) {
137
+ return options.allowExecution === true || process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED === "1";
138
+ }
139
+
140
+ async function loadFixtureSetConfig(options) {
141
+ if (options.config) {
142
+ return {
143
+ ...options.config,
144
+ rootDir: options.config.rootDir ?? options.rootDir ?? options.cwd ?? process.cwd(),
145
+ };
146
+ }
147
+ return loadInspectorConfig(options.configPath, { cwd: options.cwd ?? options.rootDir });
148
+ }
@@ -1,6 +1,6 @@
1
1
  export const defaultCaptureApiRegistrarProfiles = {
2
2
  registerChannel: {
3
- returnValue: ({ args }) => registrationObject(args, { id: "channel" }),
3
+ returnValue: ({ args }) => channelRegistrationObject(args),
4
4
  },
5
5
  registerCli: {
6
6
  returnValue: ({ args }) => registrationObject(args, { name: "cli" }),
@@ -12,7 +12,7 @@ export const defaultCaptureApiRegistrarProfiles = {
12
12
  returnValue: ({ args }) => registrationObject(args, { id: "context-engine" }),
13
13
  },
14
14
  registerGatewayMethod: {
15
- returnValue: ({ args }) => registrationObject(args, { name: "gateway.method" }),
15
+ returnValue: ({ args }) => gatewayMethodRegistrationObject(args),
16
16
  },
17
17
  registerHook: {
18
18
  returnValue: ({ api }) => api,
@@ -37,9 +37,10 @@ export const defaultCaptureApiRegistrarProfiles = {
37
37
  },
38
38
  registerService: {
39
39
  returnValue: ({ args }) => ({
40
- ...registrationObject(args, { name: "service" }),
40
+ ...registrationObject(args, { id: "service", name: "service" }),
41
41
  start: async () => undefined,
42
42
  stop: async () => undefined,
43
+ dispose: async () => undefined,
43
44
  }),
44
45
  },
45
46
  registerSpeechProvider: {
@@ -125,6 +126,7 @@ export function createCaptureApi(options = {}) {
125
126
 
126
127
  export function createCaptureContext(options = {}) {
127
128
  return {
129
+ registrationMode: options.registrationMode ?? "full",
128
130
  config: options.config ?? {},
129
131
  logger: options.logger ?? console,
130
132
  pluginConfig: options.pluginConfig ?? {},
@@ -142,6 +144,12 @@ export function createCaptureContext(options = {}) {
142
144
  },
143
145
  gateway: options.gateway ?? {
144
146
  baseUrl: "http://127.0.0.1:0",
147
+ async call(method, params) {
148
+ return { ok: true, method, params };
149
+ },
150
+ respond(ok, result, error) {
151
+ return { ok, result, ...(error ? { error } : {}) };
152
+ },
145
153
  registerRoute(route) {
146
154
  return {
147
155
  ...route,
@@ -242,6 +250,36 @@ function registrationObject(args, defaults) {
242
250
  return withCallableDefaults({ ...defaults }, callable);
243
251
  }
244
252
 
253
+ function channelRegistrationObject(args) {
254
+ const first = args[0];
255
+ const registration = registrationObject(args, { id: "channel" });
256
+ if (first?.plugin && typeof first.plugin === "object") {
257
+ return {
258
+ ...registration,
259
+ id: objectId(first.plugin) ?? registration.id,
260
+ plugin: first.plugin,
261
+ };
262
+ }
263
+ return registration;
264
+ }
265
+
266
+ function gatewayMethodRegistrationObject(args) {
267
+ const [method, handler, options] = args;
268
+ const registration = registrationObject(args, { name: "gateway.method" });
269
+ if (typeof method !== "string") {
270
+ return registration;
271
+ }
272
+ return {
273
+ ...registration,
274
+ name: method,
275
+ method,
276
+ handler: typeof handler === "function" ? handler : registration.handler,
277
+ run: typeof handler === "function" ? handler : registration.run,
278
+ execute: typeof handler === "function" ? handler : registration.execute,
279
+ scope: options?.scope ?? registration.scope,
280
+ };
281
+ }
282
+
245
283
  function firstCallable(args) {
246
284
  return args.find((arg) => typeof arg === "function");
247
285
  }
package/src/cli.js CHANGED
@@ -72,8 +72,17 @@ async function runCheck(commandArgs) {
72
72
  const json = commandArgs.includes("--json");
73
73
  const capture = readRuntimeFlag(commandArgs);
74
74
  const mockSdk = readMockSdkFlag(commandArgs);
75
+ const allowExecution = readAllowExecutionFlag(commandArgs);
75
76
  const ciOutputs = readCiOutputFlags(commandArgs);
76
- const { report, paths } = await runPluginCheck({ configPath, pluginRoot, outDir, openclawPath, capture, mockSdk });
77
+ const { report, paths } = await runPluginCheck({
78
+ allowExecution,
79
+ capture,
80
+ configPath,
81
+ mockSdk,
82
+ openclawPath,
83
+ outDir,
84
+ pluginRoot,
85
+ });
77
86
  await writeCiOutputArtifacts(report, {
78
87
  ...ciOutputs,
79
88
  cwd: path.dirname(paths.jsonPath),
@@ -83,7 +92,7 @@ async function runCheck(commandArgs) {
83
92
  if (json) {
84
93
  console.log(JSON.stringify(report, null, 2));
85
94
  } else {
86
- console.log(renderTextSummary(report));
95
+ console.log(renderTextSummary(report, { artifacts: paths }));
87
96
  }
88
97
 
89
98
  if (report.status !== "pass") {
@@ -95,19 +104,27 @@ async function runInit(commandArgs) {
95
104
  const pluginRoot = readFlag(commandArgs, "--plugin-root") ?? readFlag(commandArgs, "--root");
96
105
  const configPath = readFlag(commandArgs, "--config") ?? undefined;
97
106
  const workflowPath = readFlag(commandArgs, "--workflow") ?? undefined;
98
- const packageManager = readFlag(commandArgs, "--package-manager") ?? "npm";
107
+ const packageManager = readFlag(commandArgs, "--package-manager") ?? undefined;
99
108
  const result = await writePluginInspectorInit({
100
109
  pluginRoot,
101
110
  configPath,
102
111
  workflowPath,
103
112
  packageManager,
104
113
  ci: commandArgs.includes("--ci"),
114
+ dryRun: commandArgs.includes("--dry-run"),
115
+ scripts: commandArgs.includes("--scripts"),
105
116
  force: commandArgs.includes("--force"),
106
117
  });
107
118
 
119
+ if (commandArgs.includes("--json")) {
120
+ console.log(JSON.stringify(initCommandSummary(result), null, 2));
121
+ return;
122
+ }
123
+
108
124
  for (const filePath of result.written) {
109
- console.log(`wrote ${filePath}`);
125
+ console.log(`${result.dryRun ? "would write" : "wrote"} ${path.relative(result.pluginRoot, filePath)}`);
110
126
  }
127
+ console.log(`package manager: ${result.packageManager}`);
111
128
  }
112
129
 
113
130
  async function runReport(command, commandArgs) {
@@ -128,7 +145,7 @@ async function runReport(command, commandArgs) {
128
145
  if (json) {
129
146
  console.log(JSON.stringify(report, null, 2));
130
147
  } else {
131
- console.log(renderTextSummary(report));
148
+ console.log(renderTextSummary(report, { artifacts: paths }));
132
149
  }
133
150
 
134
151
  if (check && report.status !== "pass") {
@@ -144,8 +161,10 @@ async function runCi(commandArgs) {
144
161
  const json = commandArgs.includes("--json");
145
162
  const capture = readRuntimeFlag(commandArgs);
146
163
  const mockSdk = readMockSdkFlag(commandArgs);
164
+ const allowExecution = readAllowExecutionFlag(commandArgs);
147
165
  const ciOutputs = readCiOutputFlags(commandArgs, { defaultEnabled: true });
148
166
  const { report, reportDir } = await runCiCompatibilityReport({
167
+ allowExecution,
149
168
  capture,
150
169
  configPath,
151
170
  mockSdk,
@@ -184,7 +203,7 @@ async function runCi(commandArgs) {
184
203
  }
185
204
  }
186
205
 
187
- async function runCiCompatibilityReport({ capture, configPath, mockSdk, openclawPath, outDir, pluginRoot }) {
206
+ async function runCiCompatibilityReport({ allowExecution, capture, configPath, mockSdk, openclawPath, outDir, pluginRoot }) {
188
207
  if (configPath) {
189
208
  const config = await loadInspectorConfig(configPath, { cwd: pluginRoot });
190
209
  const report = await inspectCompatibilityFixtureSet(config, { openclawPath });
@@ -195,7 +214,7 @@ async function runCiCompatibilityReport({ capture, configPath, mockSdk, openclaw
195
214
  };
196
215
  }
197
216
 
198
- const { report } = await runPluginCheck({ pluginRoot, outDir, openclawPath, capture, mockSdk });
217
+ const { report } = await runPluginCheck({ allowExecution, capture, mockSdk, openclawPath, outDir, pluginRoot });
199
218
  return {
200
219
  report,
201
220
  reportDir: path.resolve(pluginRoot ?? process.cwd(), outDir),
@@ -207,11 +226,12 @@ async function runCapture(commandArgs) {
207
226
  const outputPath = readFlag(commandArgs, "--output");
208
227
  const pluginRoot = readFlag(commandArgs, "--plugin-root");
209
228
  const mockSdk = readMockSdkFlag(commandArgs) ?? commandArgs.includes("--mock-sdk");
229
+ const allowExecution = readAllowExecutionFlag(commandArgs);
210
230
  if (!entrypoint) {
211
231
  throw new Error("capture requires an entrypoint path");
212
232
  }
213
- if (process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED !== "1") {
214
- throw new Error("capture imports plugin code; rerun with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 in an isolated workspace");
233
+ if (!allowExecution && process.env.PLUGIN_INSPECTOR_EXECUTE_ISOLATED !== "1") {
234
+ throw new Error("capture imports plugin code; rerun with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 or --allow-execute in an isolated workspace");
215
235
  }
216
236
 
217
237
  const result = await captureEntrypoint(entrypoint, { mockSdk, pluginRoot });
@@ -281,6 +301,10 @@ function readMockSdkFlag(commandArgs) {
281
301
  return undefined;
282
302
  }
283
303
 
304
+ function readAllowExecutionFlag(commandArgs) {
305
+ return commandArgs.includes("--allow-execute");
306
+ }
307
+
284
308
  function renderCiTextSummary(summary) {
285
309
  return [
286
310
  `Status: ${summary.status.toUpperCase()}`,
@@ -290,6 +314,15 @@ function renderCiTextSummary(summary) {
290
314
  ].join("\n");
291
315
  }
292
316
 
317
+ function initCommandSummary(result) {
318
+ return {
319
+ dryRun: result.dryRun,
320
+ packageManager: result.packageManager,
321
+ pluginRoot: result.pluginRoot,
322
+ files: result.written.map((filePath) => path.relative(result.pluginRoot, filePath)),
323
+ };
324
+ }
325
+
293
326
  function renderConfigTextSummary(config) {
294
327
  const fixture = config.fixtures[0];
295
328
  return [
@@ -308,16 +341,16 @@ function printHelp() {
308
341
 
309
342
  Usage:
310
343
  plugin-inspector
311
- plugin-inspector check [--plugin-root <path>] [--config <path>] [--out <dir>] [--openclaw <path>] [--no-openclaw] [--runtime] [--mock-sdk|--real-sdk] [--json]
344
+ plugin-inspector check [--plugin-root <path>] [--config <path>] [--out <dir>] [--openclaw <path>] [--no-openclaw] [--runtime] [--mock-sdk|--real-sdk] [--allow-execute] [--json]
312
345
  plugin-inspector config [--plugin-root <path>] [--config <path>] [--json]
313
- plugin-inspector init [--plugin-root <path>] [--config <path>] [--ci] [--package-manager npm|pnpm|yarn|bun] [--force]
346
+ plugin-inspector init [--plugin-root <path>] [--config <path>] [--ci] [--scripts] [--package-manager npm|pnpm|yarn|bun] [--dry-run] [--json] [--force]
314
347
  plugin-inspector report --config <path> [--out <dir>] [--check] [--json]
315
- plugin-inspector inspect [--plugin-root <path>] [--config <path>] [--out <dir>] [--check] [--json] [--sarif [path]] [--junit [path]]
316
- plugin-inspector ci [--plugin-root <path>] [--config <path>] [--out <dir>] [--openclaw <path>] [--no-openclaw] [--runtime] [--mock-sdk|--real-sdk] [--json] [--no-sarif] [--no-junit]
317
- PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 plugin-inspector capture <entrypoint> [--mock-sdk|--real-sdk] [--plugin-root <path>] [--output <path>]
348
+ plugin-inspector inspect [--plugin-root <path>] [--config <path>] [--out <dir>] [--check] [--json] [--sarif [path]] [--junit [path]] [--allow-execute]
349
+ plugin-inspector ci [--plugin-root <path>] [--config <path>] [--out <dir>] [--openclaw <path>] [--no-openclaw] [--runtime] [--mock-sdk|--real-sdk] [--allow-execute] [--json] [--no-sarif] [--no-junit]
350
+ plugin-inspector capture <entrypoint> [--mock-sdk|--real-sdk] [--allow-execute] [--plugin-root <path>] [--output <path>]
318
351
 
319
352
  Default check runs from the current plugin root and writes reports/ unless --out is set.
320
353
  CI writes SARIF and JUnit artifacts by default; check/inspect can write them with --sarif and --junit.
321
- Runtime capture is opt-in because it imports plugin code; use --runtime with PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1.
354
+ Runtime capture is opt-in because it imports plugin code; use --runtime with --allow-execute or PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1.
322
355
  `);
323
356
  }
package/src/index.js CHANGED
@@ -1,12 +1,17 @@
1
1
  export {
2
2
  capturePluginEntrypoint,
3
3
  createCaptureApi,
4
+ inspectCompatibilityFixtureSetConfig,
4
5
  inspectFixtureSetConfig,
5
6
  inspectPluginRoot,
6
7
  loadPluginConfig,
8
+ renderFixtureSetIssuesReport,
9
+ renderFixtureSetMarkdownReport,
7
10
  renderTextSummary,
11
+ runFixtureSetReport,
8
12
  runPluginCheck,
9
13
  setupPluginInspector,
10
14
  writeCiOutputArtifacts,
15
+ writeFixtureSetReports,
11
16
  writePluginReports,
12
17
  } from "./api.js";
package/src/init.js CHANGED
@@ -5,32 +5,66 @@ import { inferPluginSeams, packageId } from "./config.js";
5
5
 
6
6
  export const defaultInitConfigPath = "plugin-inspector.config.json";
7
7
  export const defaultInitWorkflowPath = ".github/workflows/plugin-inspector.yml";
8
+ export const defaultInitPackageScripts = {
9
+ "plugin:check": "plugin-inspector inspect --no-openclaw",
10
+ "plugin:ci": "plugin-inspector ci --no-openclaw --runtime --mock-sdk --allow-execute",
11
+ };
8
12
 
9
13
  export async function writePluginInspectorInit(options = {}) {
10
14
  const pluginRoot = path.resolve(options.pluginRoot ?? options.cwd ?? process.cwd());
11
15
  const configPath = path.resolve(pluginRoot, options.configPath ?? defaultInitConfigPath);
16
+ const workflowPath = options.ci === true ? path.resolve(pluginRoot, options.workflowPath ?? defaultInitWorkflowPath) : null;
17
+ const packageManager = options.packageManager ?? (await detectPackageManager(pluginRoot));
18
+ const dryRun = options.dryRun === true;
12
19
  const written = [];
13
20
 
14
- if (existsSync(configPath) && options.force !== true) {
21
+ if (!dryRun && existsSync(configPath) && options.force !== true) {
15
22
  throw new Error(`${path.relative(pluginRoot, configPath)} already exists; pass --force to overwrite it`);
16
23
  }
24
+ if (!dryRun && workflowPath && existsSync(workflowPath) && options.force !== true) {
25
+ throw new Error(`${path.relative(pluginRoot, workflowPath)} already exists; pass --force to overwrite it`);
26
+ }
27
+ const packageJsonPath = path.join(pluginRoot, "package.json");
28
+ const packageJson = options.scripts === true ? await readJsonIfExists(packageJsonPath) : null;
29
+ if (options.scripts === true) {
30
+ if (!packageJson) {
31
+ throw new Error("package.json is required to write plugin-inspector package scripts");
32
+ }
33
+ for (const name of Object.keys(defaultInitPackageScripts)) {
34
+ if (!dryRun && packageJson.scripts?.[name] && options.force !== true) {
35
+ throw new Error(`package.json scripts.${name} already exists; pass --force to overwrite it`);
36
+ }
37
+ }
38
+ }
17
39
 
18
40
  const config = await buildPluginInspectorConfig({ pluginRoot });
19
- await mkdir(path.dirname(configPath), { recursive: true });
20
- await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
41
+ if (!dryRun) {
42
+ await mkdir(path.dirname(configPath), { recursive: true });
43
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
44
+ }
21
45
  written.push(configPath);
22
46
 
23
- if (options.ci === true) {
24
- const workflowPath = path.resolve(pluginRoot, options.workflowPath ?? defaultInitWorkflowPath);
25
- if (existsSync(workflowPath) && options.force !== true) {
26
- throw new Error(`${path.relative(pluginRoot, workflowPath)} already exists; pass --force to overwrite it`);
47
+ if (workflowPath) {
48
+ if (!dryRun) {
49
+ await mkdir(path.dirname(workflowPath), { recursive: true });
50
+ await writeFile(workflowPath, renderGithubActionsWorkflow({ packageManager }), "utf8");
27
51
  }
28
- await mkdir(path.dirname(workflowPath), { recursive: true });
29
- await writeFile(workflowPath, renderGithubActionsWorkflow({ packageManager: options.packageManager }), "utf8");
30
52
  written.push(workflowPath);
31
53
  }
32
54
 
33
- return { pluginRoot, configPath, written };
55
+ if (options.scripts === true) {
56
+ const existingScripts = packageJson.scripts ?? {};
57
+ packageJson.scripts = {
58
+ ...existingScripts,
59
+ ...defaultInitPackageScripts,
60
+ };
61
+ if (!dryRun) {
62
+ await writeFile(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8");
63
+ }
64
+ written.push(packageJsonPath);
65
+ }
66
+
67
+ return { pluginRoot, configPath, dryRun, packageManager, written };
34
68
  }
35
69
 
36
70
  export async function buildPluginInspectorConfig(options = {}) {
@@ -79,7 +113,7 @@ jobs:
79
113
  node-version: 24
80
114
  cache: ${setup.cache}
81
115
  ${setup.corepack ? " - run: corepack enable\n" : ""} - run: ${setup.install}
82
- - run: PLUGIN_INSPECTOR_EXECUTE_ISOLATED=1 ${setup.exec} @openclaw/plugin-inspector ci --no-openclaw --runtime --mock-sdk
116
+ - run: ${setup.exec} @openclaw/plugin-inspector ci --no-openclaw --runtime --mock-sdk --allow-execute
83
117
  - uses: actions/upload-artifact@v5
84
118
  if: always()
85
119
  with:
@@ -88,19 +122,62 @@ ${setup.corepack ? " - run: corepack enable\n" : ""} - run: ${setup.in
88
122
  `;
89
123
  }
90
124
 
125
+ export async function detectPackageManager(pluginRoot) {
126
+ const root = path.resolve(pluginRoot ?? process.cwd());
127
+ const packageJson = await readJsonIfExists(path.join(root, "package.json"));
128
+ const packageManager = packageJson?.packageManager;
129
+ if (typeof packageManager === "string") {
130
+ const [name] = packageManager.split("@");
131
+ if (["npm", "pnpm", "yarn", "bun"].includes(name)) {
132
+ return name;
133
+ }
134
+ }
135
+
136
+ if (existsSync(path.join(root, "pnpm-lock.yaml"))) {
137
+ return "pnpm";
138
+ }
139
+ if (existsSync(path.join(root, "yarn.lock"))) {
140
+ return "yarn";
141
+ }
142
+ if (existsSync(path.join(root, "bun.lockb")) || existsSync(path.join(root, "bun.lock"))) {
143
+ return "bun";
144
+ }
145
+ return "npm";
146
+ }
147
+
91
148
  function inferSourceRoot(packageJson) {
92
149
  const entrypoints = [
93
150
  packageJson?.openclaw?.entrypoint,
94
151
  ...(packageJson?.openclaw?.extensions ?? []),
95
152
  ...(packageJson?.openclaw?.runtimeExtensions ?? []),
153
+ ...entrypointStrings(packageJson?.exports?.["."]),
154
+ ...entrypointStrings(packageJson?.exports),
155
+ packageJson?.module,
156
+ packageJson?.main,
96
157
  ].filter((value) => typeof value === "string");
97
- const entrypoint = entrypoints[0] ?? packageJson?.exports?.["."] ?? packageJson?.main ?? "src/index.js";
98
- if (typeof entrypoint === "string" && entrypoint.startsWith("src/")) {
158
+ const entrypoint = entrypoints[0] ?? "src/index.js";
159
+ if (stripRelativePrefix(entrypoint).startsWith("src/")) {
99
160
  return "src";
100
161
  }
101
162
  return ".";
102
163
  }
103
164
 
165
+ function entrypointStrings(value) {
166
+ if (typeof value === "string") {
167
+ return [value];
168
+ }
169
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
170
+ return [];
171
+ }
172
+ return ["import", "default", "require", "node", "module"]
173
+ .map((key) => value[key])
174
+ .filter((item) => typeof item === "string");
175
+ }
176
+
177
+ function stripRelativePrefix(filePath) {
178
+ return filePath.replace(/^\.\//, "");
179
+ }
180
+
104
181
  async function readJsonIfExists(filePath) {
105
182
  if (!existsSync(filePath)) {
106
183
  return null;
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { mkdtemp, rm } from "node:fs/promises";
2
+ import { rmSync } from "node:fs";
3
+ import { mkdtemp } from "node:fs/promises";
3
4
  import { register } from "node:module";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
@@ -26,13 +27,16 @@ async function run(options) {
26
27
  const pluginRoot = path.resolve(options.cwd ?? process.cwd(), options.pluginRoot ?? path.dirname(entrypoint));
27
28
  const workspace = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-mock-sdk-"));
28
29
 
29
- try {
30
- const { loaderPath } = await createMockSdkPackage(workspace, { pluginRoot });
31
- register(pathToFileURL(loaderPath));
32
- return await captureLinkedEntrypoint(entrypoint, options);
33
- } finally {
34
- await rm(workspace, { force: true, recursive: true });
35
- }
30
+ cleanupTempDirOnExit(workspace);
31
+ const { loaderPath } = await createMockSdkPackage(workspace, { pluginRoot });
32
+ register(pathToFileURL(loaderPath));
33
+ return await captureLinkedEntrypoint(entrypoint, options);
34
+ }
35
+
36
+ function cleanupTempDirOnExit(dir) {
37
+ process.once("exit", () => {
38
+ rmSync(dir, { force: true, recursive: true });
39
+ });
36
40
  }
37
41
 
38
42
  async function captureLinkedEntrypoint(entrypoint, options) {
package/src/report.js CHANGED
@@ -246,27 +246,79 @@ export async function writeReport(report, options = {}) {
246
246
  export async function writeCompatibilityReport(report, options = {}) {
247
247
  const outDir = path.resolve(options.cwd ?? process.cwd(), options.outDir ?? "reports");
248
248
  const basename = options.basename ?? "plugin-inspector-report";
249
- const jsonPath = path.join(outDir, `${basename}.json`);
250
- const markdownPath = path.join(outDir, `${basename}.md`);
251
- const issuesPath = path.join(outDir, options.issuesBasename ?? "plugin-inspector-issues.md");
249
+ const jsonPath = options.jsonPath ?? path.join(outDir, `${basename}.json`);
250
+ const markdownPath = options.markdownPath ?? path.join(outDir, `${basename}.md`);
251
+ const issuesPath = options.issuesPath ?? path.join(outDir, options.issuesBasename ?? "plugin-inspector-issues.md");
252
+ const markdownOptions = compatibilityRenderOptions(options, {
253
+ title: options.markdownTitle ?? options.title,
254
+ ...options.markdownOptions,
255
+ });
256
+ const issuesOptions = compatibilityRenderOptions(options, {
257
+ title: options.issuesTitle ?? options.title,
258
+ ...options.issuesOptions,
259
+ });
252
260
 
253
261
  return writeArtifacts(
254
262
  [
255
263
  { name: "jsonPath", path: jsonPath, json: report },
256
- { name: "markdownPath", path: markdownPath, markdown: renderCompatibilityMarkdownReport(report) },
257
- { name: "issuesPath", path: issuesPath, markdown: renderCompatibilityIssuesReport(report) },
264
+ { name: "markdownPath", path: markdownPath, markdown: renderCompatibilityMarkdownReport(report, markdownOptions) },
265
+ { name: "issuesPath", path: issuesPath, markdown: renderCompatibilityIssuesReport(report, issuesOptions) },
258
266
  ],
259
267
  { check: options.check },
260
268
  );
261
269
  }
262
270
 
263
- export function renderTextSummary(report) {
264
- return [
271
+ function compatibilityRenderOptions(options, overrides) {
272
+ const renderOptions = {
273
+ formatEvidence: options.formatEvidence,
274
+ severityLabels: options.severityLabels,
275
+ ...overrides,
276
+ };
277
+ return Object.fromEntries(Object.entries(renderOptions).filter(([, value]) => value !== undefined));
278
+ }
279
+
280
+ export function renderTextSummary(report, options = {}) {
281
+ const lines = [
265
282
  `Status: ${report.status.toUpperCase()}`,
266
283
  `Fixtures: ${report.summary.fixtureCount}`,
267
284
  `Breakages: ${report.summary.breakageCount}`,
285
+ ...(typeof report.summary.issueCount === "number" ? [`Issues: ${report.summary.issueCount}`] : []),
268
286
  `Logs: ${report.summary.logCount}`,
269
- ].join("\n");
287
+ ];
288
+ const artifacts = Object.entries(options.artifacts ?? {}).filter(([, filePath]) => Boolean(filePath));
289
+ if (artifacts.length > 0) {
290
+ lines.push("", "Reports:", ...artifacts.map(([name, filePath]) => `- ${artifactLabel(name)}: ${filePath}`));
291
+ }
292
+ const findings = topTextFindings(report, options.topFindings ?? 3);
293
+ if (findings.length > 0) {
294
+ lines.push("", "Top findings:", ...findings.map((finding) => `- ${finding}`));
295
+ }
296
+ return lines.join("\n");
297
+ }
298
+
299
+ function topTextFindings(report, limit) {
300
+ if (report.status === "pass" || limit <= 0) {
301
+ return [];
302
+ }
303
+ return [
304
+ ...(report.breakages ?? []).map((finding) => formatTextFinding(finding, "breakage")),
305
+ ...(report.issues ?? [])
306
+ .filter((issue) => issue.status === "blocking" || issue.severity === "P0" || issue.severity === "P1")
307
+ .map((issue) => formatTextFinding(issue, issue.severity ?? "issue")),
308
+ ...(report.warnings ?? []).map((finding) => formatTextFinding(finding, "warning")),
309
+ ].slice(0, limit);
310
+ }
311
+
312
+ function formatTextFinding(finding, fallbackLevel) {
313
+ const level = finding.level ?? finding.severity ?? fallbackLevel;
314
+ const code = finding.code ? ` ${finding.code}` : "";
315
+ const message = finding.message ?? finding.title ?? "see report";
316
+ const evidence = Array.isArray(finding.evidence) && finding.evidence.length > 0 ? ` (${finding.evidence[0]})` : "";
317
+ return `${String(level).toUpperCase()} ${finding.fixture ?? "unknown"}${code}: ${message}${evidence}`;
318
+ }
319
+
320
+ function artifactLabel(name) {
321
+ return String(name).replace(/Path$/u, "");
270
322
  }
271
323
 
272
324
  export function renderMarkdownReport(report) {
package/src/sdk-mock.js CHANGED
@@ -11,8 +11,11 @@ export const mockSdkSubpathExports = {
11
11
  "emptyPluginConfigSchema",
12
12
  ],
13
13
  core: [
14
+ "buildChannelOutboundSessionRoute",
14
15
  "buildChannelConfigSchema",
15
16
  "buildPluginConfigSchema",
17
+ "createActionGate",
18
+ "createChannelPluginBase",
16
19
  "createChatChannelPlugin",
17
20
  "createDedupeCache",
18
21
  "defineChannelPluginEntry",
@@ -22,12 +25,24 @@ export const mockSdkSubpathExports = {
22
25
  "emptyPluginConfigSchema",
23
26
  "jsonResult",
24
27
  "readNumberParam",
28
+ "readReactionParams",
29
+ "readStringArrayParam",
30
+ "readStringParam",
31
+ ],
32
+ "channel-actions": [
33
+ "createActionGate",
34
+ "jsonResult",
35
+ "readNumberParam",
36
+ "readReactionParams",
37
+ "readStringArrayParam",
25
38
  "readStringParam",
26
39
  ],
27
40
  "channel-core": [
28
41
  "buildChannelConfigSchema",
42
+ "buildChannelOutboundSessionRoute",
29
43
  "buildThreadAwareOutboundSessionRoute",
30
44
  "clearAccountEntryFields",
45
+ "createChannelPluginBase",
31
46
  "createChatChannelPlugin",
32
47
  "defineChannelPluginEntry",
33
48
  "defineSetupPluginEntry",
@@ -718,6 +733,10 @@ function mockSdkSource() {
718
733
  return typeof entry === "function" ? { register: entry } : entry;
719
734
  }
720
735
 
736
+ function normalizeRegistrationMode(api) {
737
+ return api?.registrationMode ?? "full";
738
+ }
739
+
721
740
  function isPlainObject(value) {
722
741
  return value !== null && typeof value === "object" && !Array.isArray(value);
723
742
  }
@@ -754,15 +773,139 @@ export function definePluginEntry(entry) {
754
773
  }
755
774
 
756
775
  export function defineChannelPluginEntry(entry) {
757
- return normalizeEntry(entry);
776
+ if (!isPlainObject(entry) || !entry.plugin) {
777
+ return normalizeEntry(entry);
778
+ }
779
+ const resolved = {
780
+ id: entry.id,
781
+ name: entry.name,
782
+ description: entry.description,
783
+ configSchema: createConfigSchema(entry.configSchema),
784
+ channelPlugin: entry.plugin,
785
+ register(api) {
786
+ const mode = normalizeRegistrationMode(api);
787
+ if (mode === "cli-metadata") {
788
+ entry.registerCliMetadata?.(api);
789
+ return;
790
+ }
791
+ api.registerChannel?.({ plugin: entry.plugin });
792
+ entry.setRuntime?.(api.runtime);
793
+ if (mode === "discovery") {
794
+ entry.registerCliMetadata?.(api);
795
+ return;
796
+ }
797
+ if (mode !== "full") {
798
+ return;
799
+ }
800
+ entry.registerCliMetadata?.(api);
801
+ entry.registerFull?.(api);
802
+ },
803
+ };
804
+ if (entry.setRuntime) {
805
+ resolved.setChannelRuntime = entry.setRuntime;
806
+ }
807
+ return resolved;
758
808
  }
759
809
 
760
810
  export function defineSetupPluginEntry(entry) {
761
- return normalizeEntry(entry);
811
+ return isPlainObject(entry) && entry.plugin ? entry : { plugin: entry };
762
812
  }
763
813
 
764
814
  export function createChatChannelPlugin(entry) {
765
- return normalizeEntry(entry);
815
+ if (!isPlainObject(entry) || !entry.base) {
816
+ return normalizeEntry(entry);
817
+ }
818
+ return {
819
+ ...entry.base,
820
+ conversationBindings: {
821
+ supportsCurrentConversationBinding: true,
822
+ ...(entry.base.conversationBindings ?? {}),
823
+ },
824
+ ...(entry.security ? { security: resolveChannelSecurity(entry.security) } : {}),
825
+ ...(entry.pairing ? { pairing: resolveChannelPairing(entry.pairing) } : {}),
826
+ ...(entry.threading ? { threading: resolveChannelThreading(entry.threading) } : {}),
827
+ ...(entry.outbound ? { outbound: resolveChannelOutbound(entry.outbound) } : {}),
828
+ };
829
+ }
830
+
831
+ export function createChannelPluginBase(params = {}) {
832
+ return {
833
+ id: params.id ?? "fixture-channel",
834
+ meta: { id: params.id ?? "fixture-channel", ...(params.meta ?? {}) },
835
+ ...(params.setupWizard ? { setupWizard: params.setupWizard } : {}),
836
+ ...(params.capabilities ? { capabilities: params.capabilities } : {}),
837
+ ...(params.commands ? { commands: params.commands } : {}),
838
+ ...(params.doctor ? { doctor: params.doctor } : {}),
839
+ ...(params.agentPrompt ? { agentPrompt: params.agentPrompt } : {}),
840
+ ...(params.streaming ? { streaming: params.streaming } : {}),
841
+ ...(params.reload ? { reload: params.reload } : {}),
842
+ ...(params.gatewayMethods ? { gatewayMethods: params.gatewayMethods } : {}),
843
+ ...(params.configSchema ? { configSchema: createConfigSchema(params.configSchema) } : {}),
844
+ ...(params.config ? { config: params.config } : {}),
845
+ ...(params.security ? { security: params.security } : {}),
846
+ ...(params.groups ? { groups: params.groups } : {}),
847
+ setup: params.setup ?? (() => ({})),
848
+ };
849
+ }
850
+
851
+ function resolveChannelSecurity(security) {
852
+ if (!isPlainObject(security) || !security.dm) {
853
+ return security;
854
+ }
855
+ return {
856
+ resolveDmPolicy: ({ account } = {}) => ({
857
+ policy: security.dm.resolvePolicy?.(account ?? {}) ?? security.dm.defaultPolicy ?? "allow",
858
+ allowFrom: security.dm.resolveAllowFrom?.(account ?? {}) ?? [],
859
+ }),
860
+ ...(security.collectWarnings ? { collectWarnings: security.collectWarnings } : {}),
861
+ ...(security.collectAuditFindings ? { collectAuditFindings: security.collectAuditFindings } : {}),
862
+ };
863
+ }
864
+
865
+ function resolveChannelPairing(pairing) {
866
+ if (!isPlainObject(pairing) || !pairing.text) {
867
+ return pairing;
868
+ }
869
+ return {
870
+ idLabel: pairing.text.idLabel,
871
+ normalizeAllowEntry: pairing.text.normalizeAllowEntry,
872
+ notifyApproval: (ctx) => pairing.text.notify?.({ ...ctx, message: pairing.text.message }),
873
+ };
874
+ }
875
+
876
+ function resolveChannelThreading(threading) {
877
+ if (!isPlainObject(threading)) {
878
+ return threading;
879
+ }
880
+ if (threading.resolveReplyToMode) {
881
+ return threading;
882
+ }
883
+ return {
884
+ ...threading,
885
+ resolveReplyToMode: () =>
886
+ threading.topLevelReplyToMode ??
887
+ threading.scopedAccountReplyToMode?.fallback ??
888
+ "thread",
889
+ };
890
+ }
891
+
892
+ function resolveChannelOutbound(outbound) {
893
+ if (!isPlainObject(outbound) || !outbound.attachedResults) {
894
+ return outbound;
895
+ }
896
+ const { base = {}, attachedResults } = outbound;
897
+ return {
898
+ ...base,
899
+ ...(attachedResults.sendText
900
+ ? { sendText: async (ctx) => ({ channel: attachedResults.channel, ...(await attachedResults.sendText(ctx)) }) }
901
+ : {}),
902
+ ...(attachedResults.sendMedia
903
+ ? { sendMedia: async (ctx) => ({ channel: attachedResults.channel, ...(await attachedResults.sendMedia(ctx)) }) }
904
+ : {}),
905
+ ...(attachedResults.sendPoll
906
+ ? { sendPoll: async (ctx) => ({ channel: attachedResults.channel, ...(await attachedResults.sendPoll(ctx)) }) }
907
+ : {}),
908
+ };
766
909
  }
767
910
 
768
911
  export function definePlugin(entry) {
@@ -805,13 +948,63 @@ export function jsonResult(value) {
805
948
  return { content: [{ type: "text", text: JSON.stringify(value) }] };
806
949
  }
807
950
 
808
- export function readNumberParam(value, fallback = 0) {
809
- const parsed = Number(value);
810
- return Number.isFinite(parsed) ? parsed : fallback;
951
+ export function readNumberParam(value, keyOrFallback = 0, options = {}) {
952
+ const raw = isPlainObject(value) ? value[keyOrFallback] : value;
953
+ const parsed = Number(raw);
954
+ if (Number.isFinite(parsed)) {
955
+ return options.integer ? Math.trunc(parsed) : parsed;
956
+ }
957
+ return isPlainObject(value) ? undefined : keyOrFallback;
811
958
  }
812
959
 
813
- export function readStringParam(value, fallback = "") {
814
- return typeof value === "string" ? value : fallback;
960
+ export function readStringParam(value, keyOrFallback = "") {
961
+ if (isPlainObject(value)) {
962
+ const raw = value[keyOrFallback];
963
+ return typeof raw === "string" ? raw : undefined;
964
+ }
965
+ return typeof value === "string" ? value : keyOrFallback;
966
+ }
967
+
968
+ export function readStringArrayParam(value, key) {
969
+ const raw = isPlainObject(value) ? value[key] : value;
970
+ if (Array.isArray(raw)) {
971
+ return raw.map((entry) => String(entry));
972
+ }
973
+ return typeof raw === "string" && raw ? [raw] : [];
974
+ }
975
+
976
+ export function readReactionParams(value = {}) {
977
+ return {
978
+ messageId: value.messageId ?? value.id ?? "",
979
+ reaction: value.reaction ?? value.emoji ?? "",
980
+ };
981
+ }
982
+
983
+ export function createActionGate(actions = {}) {
984
+ return (key, defaultValue = true) => {
985
+ const value = actions?.[key];
986
+ return value === undefined ? defaultValue : value !== false;
987
+ };
988
+ }
989
+
990
+ export function buildChannelOutboundSessionRoute(params = {}) {
991
+ const peer = params.peer ?? { kind: params.chatType ?? "direct", id: params.to ?? "fixture-peer" };
992
+ const baseSessionKey = [
993
+ params.agentId ?? "agent",
994
+ params.channel ?? "channel",
995
+ params.accountId ?? "default",
996
+ peer.kind,
997
+ peer.id,
998
+ ].filter(Boolean).join(":");
999
+ return {
1000
+ sessionKey: baseSessionKey,
1001
+ baseSessionKey,
1002
+ peer,
1003
+ chatType: params.chatType ?? peer.kind ?? "direct",
1004
+ from: params.from ?? "fixture-source",
1005
+ to: params.to ?? peer.id,
1006
+ ...(params.threadId !== undefined ? { threadId: params.threadId } : {}),
1007
+ };
815
1008
  }
816
1009
 
817
1010
  export function createDedupeCache() {
@@ -1129,7 +1322,7 @@ export function createSubsystemLogger() {
1129
1322
  }
1130
1323
 
1131
1324
  export function buildThreadAwareOutboundSessionRoute(route = {}) {
1132
- return route;
1325
+ return route.route ?? route;
1133
1326
  }
1134
1327
 
1135
1328
  export function clearAccountEntryFields(entry = {}) {
@@ -0,0 +1,19 @@
1
+ import { buildContractCapture } from "./contract-capture.js";
2
+ import { buildSyntheticProbePlan } from "./synthetic-probes.js";
3
+
4
+ export function buildSyntheticProbePlanFromReport(report, options = {}) {
5
+ const capture = options.capture ?? buildContractCapture({
6
+ report,
7
+ hookAssertions: options.hookAssertions,
8
+ hookContexts: options.hookContexts,
9
+ hookEvents: options.hookEvents,
10
+ registrationArguments: options.registrationArguments,
11
+ registrationAssertions: options.registrationAssertions,
12
+ });
13
+ return buildSyntheticProbePlan({
14
+ capture,
15
+ hookContexts: options.hookContexts,
16
+ hookEvents: options.hookEvents,
17
+ registrationArguments: options.registrationArguments,
18
+ });
19
+ }
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { mkdtemp, rm } from "node:fs/promises";
2
+ import { rmSync } from "node:fs";
3
+ import { mkdtemp } from "node:fs/promises";
3
4
  import { register } from "node:module";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
@@ -59,17 +60,20 @@ async function captureForSyntheticProbes(entrypoint, options) {
59
60
  const resolvedEntrypoint = path.resolve(process.cwd(), entrypoint);
60
61
  const pluginRoot = path.resolve(process.cwd(), options.pluginRoot ?? path.dirname(resolvedEntrypoint));
61
62
  const workspace = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-synthetic-mock-sdk-"));
62
- try {
63
- const { loaderPath } = await createMockSdkPackage(workspace, { pluginRoot });
64
- register(pathToFileURL(loaderPath));
65
- return captureEntrypoint(entrypoint, {
66
- ...options,
67
- mockSdk: false,
68
- pluginRoot,
69
- });
70
- } finally {
71
- await rm(workspace, { force: true, recursive: true });
72
- }
63
+ cleanupTempDirOnExit(workspace);
64
+ const { loaderPath } = await createMockSdkPackage(workspace, { pluginRoot });
65
+ register(pathToFileURL(loaderPath));
66
+ return captureEntrypoint(entrypoint, {
67
+ ...options,
68
+ mockSdk: false,
69
+ pluginRoot,
70
+ });
71
+ }
72
+
73
+ function cleanupTempDirOnExit(dir) {
74
+ process.once("exit", () => {
75
+ rmSync(dir, { force: true, recursive: true });
76
+ });
73
77
  }
74
78
 
75
79
  function readFlag(commandArgs, name) {
@@ -214,6 +214,12 @@ export const defaultSyntheticRegistrationProbeInputs = {
214
214
  handler: commandProbeArgs,
215
215
  run: commandProbeArgs,
216
216
  },
217
+ registerChannel: {
218
+ handleMessage: channelReceiveProbeArgs,
219
+ receive: channelReceiveProbeArgs,
220
+ send: channelSendProbeArgs,
221
+ sendMessage: channelSendProbeArgs,
222
+ },
217
223
  registerGatewayMethod: {
218
224
  execute: gatewayProbeArgs,
219
225
  handler: gatewayProbeArgs,
@@ -523,6 +529,9 @@ function syntheticRegistrationEvent(registrar, property, options) {
523
529
  id: beforeToolCall.toolCallId,
524
530
  name: beforeToolCall.toolName,
525
531
  },
532
+ respond(ok, result, error) {
533
+ return { ok, result, ...(error ? { error } : {}) };
534
+ },
526
535
  };
527
536
  }
528
537
 
@@ -576,9 +585,11 @@ function commandProbeArgs(event) {
576
585
  function gatewayProbeArgs(event) {
577
586
  return [
578
587
  {
588
+ ...event,
579
589
  params: event.params,
580
590
  body: event.body,
581
591
  headers: event.headers,
592
+ respond: event.respond,
582
593
  },
583
594
  {
584
595
  source: event.source,
@@ -587,6 +598,47 @@ function gatewayProbeArgs(event) {
587
598
  ];
588
599
  }
589
600
 
601
+ function channelSendProbeArgs(event) {
602
+ return [
603
+ {
604
+ source: event.source,
605
+ channelId: "fixture-channel",
606
+ accountId: "fixture-account",
607
+ to: "fixture-recipient",
608
+ text: "fixture message",
609
+ replyToId: "fixture-reply",
610
+ threadId: "fixture-thread",
611
+ logger: console,
612
+ signal: new AbortController().signal,
613
+ },
614
+ ];
615
+ }
616
+
617
+ function channelReceiveProbeArgs(event) {
618
+ return [
619
+ {
620
+ source: event.source,
621
+ channelId: "fixture-channel",
622
+ accountId: "fixture-account",
623
+ message: {
624
+ id: "message-fixture",
625
+ text: "fixture inbound message",
626
+ sender: { id: "sender-fixture", displayName: "Fixture Sender" },
627
+ },
628
+ route: {
629
+ sessionKey: "fixture-session",
630
+ baseSessionKey: "fixture-base-session",
631
+ peer: { kind: "direct", id: "sender-fixture" },
632
+ chatType: "direct",
633
+ from: "sender-fixture",
634
+ to: "fixture-channel",
635
+ },
636
+ logger: console,
637
+ signal: new AbortController().signal,
638
+ },
639
+ ];
640
+ }
641
+
590
642
  function interactiveProbeArgs(event) {
591
643
  return [
592
644
  {
@@ -604,7 +656,10 @@ function lifecycleProbeArgs(event) {
604
656
  return [
605
657
  {
606
658
  source: event.source,
659
+ config: {},
607
660
  logger: console,
661
+ runtime: { env: {}, logger: console },
662
+ secrets: { get: async () => null, has: async () => false },
608
663
  signal: new AbortController().signal,
609
664
  },
610
665
  ];