mirror of https://github.com/openclaw/openclaw.git
CLI: reuse loader semantics for root help descriptors
This commit is contained in:
parent
14138f9ab1
commit
bb86b1c7dc
|
|
@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai
|
|||
- iOS/Live Activities: mark the `ActivityKit` import in `LiveActivityManager.swift` as `@preconcurrency` so Xcode 26.4 / Swift 6 builds stop failing on strict concurrency checks. (#57180) Thanks @ngutman.
|
||||
- Plugins/Matrix: mirror the Matrix crypto WASM runtime dependency into the root packaged install and enforce root/plugin dependency parity so bundled Matrix E2EE crypto resolves correctly in shipped builds. (#57163) Thanks @gumadeiras.
|
||||
- Plugins/CLI: add descriptor-backed lazy plugin CLI registration so Matrix can keep its CLI module lazy-loaded without dropping `openclaw matrix ...` from parse-time command registration. (#57165) Thanks @gumadeiras.
|
||||
- Plugins/CLI: keep root-help plugin descriptor loads non-activating while reusing loader-side plugin config validation and channel entry selection semantics. (#57294) thanks @gumadeiras.
|
||||
|
||||
## 2026.3.28
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
---
|
||||
title: "Root Help CLI Descriptor Loader Note"
|
||||
summary: "Move root-help descriptor capture onto shared loader semantics while keeping channel plugins non-activating."
|
||||
author: "Gustavo Madeira Santana"
|
||||
github_username: "gumadeiras"
|
||||
created: "2026-03-29"
|
||||
status: "implemented"
|
||||
---
|
||||
|
||||
This note covers the follow-up on PR #57294 after review found that the first descriptor-capture approach rebuilt plugin discovery and import logic inside `src/plugins/cli.ts`.
|
||||
|
||||
Decision:
|
||||
|
||||
- Root help should be non-activating, not semantically different.
|
||||
- That means `openclaw --help` should keep loader semantics for enable-state, per-plugin config, config validation, and channel/plugin selection rules.
|
||||
- The loader gets a dedicated CLI-metadata snapshot mode instead of `src/plugins/cli.ts` importing plugin entries by itself.
|
||||
|
||||
Implementation shape:
|
||||
|
||||
- Add `captureCliMetadataOnly` to `loadOpenClawPlugins()`.
|
||||
- In that mode, enabled channel plugins load from their real entry file but receive `registrationMode: "setup-only"` so `registerFull(...)` work does not run.
|
||||
- Non-channel plugins keep the normal validated loader path, including `pluginConfig`.
|
||||
- `getPluginCliCommandDescriptors()` now asks the loader for a non-activating snapshot registry and reads `registry.cliRegistrars`.
|
||||
|
||||
Why this replaced the earlier approach:
|
||||
|
||||
- The manual import loop in `src/plugins/cli.ts` dropped `api.pluginConfig`, which broke config-dependent CLI plugins.
|
||||
- It also drifted away from loader behavior around channel setup entry selection and plugin registration rules.
|
||||
|
||||
Regression coverage added:
|
||||
|
||||
- A loader test that proves CLI metadata snapshot loads still receive validated `pluginConfig`.
|
||||
- A loader test that proves channel CLI metadata capture uses the real channel entry in `setup-only` mode instead of the package `setupEntry`.
|
||||
|
|
@ -1,6 +1,3 @@
|
|||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
|
@ -11,15 +8,6 @@ const mocks = vi.hoisted(() => ({
|
|||
memoryListAction: vi.fn(),
|
||||
loadOpenClawPlugins: vi.fn(),
|
||||
applyPluginAutoEnable: vi.fn(),
|
||||
discoverOpenClawPlugins: vi.fn(),
|
||||
loadPluginManifestRegistry: vi.fn(),
|
||||
resolveEffectiveEnableState: vi.fn(),
|
||||
resolveMemorySlotDecision: vi.fn(),
|
||||
createJiti: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("jiti", () => ({
|
||||
createJiti: (...args: unknown[]) => mocks.createJiti(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./loader.js", () => ({
|
||||
|
|
@ -30,23 +18,6 @@ vi.mock("../config/plugin-auto-enable.js", () => ({
|
|||
applyPluginAutoEnable: (...args: unknown[]) => mocks.applyPluginAutoEnable(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./discovery.js", () => ({
|
||||
discoverOpenClawPlugins: (...args: unknown[]) => mocks.discoverOpenClawPlugins(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./manifest-registry.js", () => ({
|
||||
loadPluginManifestRegistry: (...args: unknown[]) => mocks.loadPluginManifestRegistry(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./config-state.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./config-state.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveEffectiveEnableState: (...args: unknown[]) => mocks.resolveEffectiveEnableState(...args),
|
||||
resolveMemorySlotDecision: (...args: unknown[]) => mocks.resolveMemorySlotDecision(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import { getPluginCliCommandDescriptors, registerPluginCliCommands } from "./cli.js";
|
||||
|
||||
function createProgram(existingCommandName?: string) {
|
||||
|
|
@ -142,22 +113,6 @@ describe("registerPluginCliCommands", () => {
|
|||
mocks.loadOpenClawPlugins.mockReturnValue(createCliRegistry());
|
||||
mocks.applyPluginAutoEnable.mockReset();
|
||||
mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] }));
|
||||
mocks.discoverOpenClawPlugins.mockReset();
|
||||
mocks.discoverOpenClawPlugins.mockReturnValue({
|
||||
candidates: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
mocks.loadPluginManifestRegistry.mockReset();
|
||||
mocks.loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
mocks.resolveEffectiveEnableState.mockReset();
|
||||
mocks.resolveEffectiveEnableState.mockReturnValue({ enabled: true });
|
||||
mocks.resolveMemorySlotDecision.mockReset();
|
||||
mocks.resolveMemorySlotDecision.mockReturnValue({ enabled: true });
|
||||
mocks.createJiti.mockReset();
|
||||
mocks.createJiti.mockReturnValue(() => ({}));
|
||||
});
|
||||
|
||||
it("skips plugin CLI registrars when commands already exist", async () => {
|
||||
|
|
@ -195,165 +150,55 @@ describe("registerPluginCliCommands", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("captures channel plugin descriptors without using the activating loader", () => {
|
||||
const tempRoot = mkdtempSync(path.join(tmpdir(), "openclaw-cli-descriptors-"));
|
||||
const pluginRoot = path.join(tempRoot, "matrix");
|
||||
const source = path.join(pluginRoot, "index.ts");
|
||||
mkdirSync(pluginRoot, { recursive: true });
|
||||
writeFileSync(source, "export default {}");
|
||||
mocks.discoverOpenClawPlugins.mockReturnValue({
|
||||
candidates: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
mocks.loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [
|
||||
it("loads root-help descriptors through the non-activating loader path", () => {
|
||||
const { rawConfig, autoEnabledConfig } = createAutoEnabledCliFixture();
|
||||
mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] });
|
||||
mocks.loadOpenClawPlugins.mockReturnValue({
|
||||
cliRegistrars: [
|
||||
{
|
||||
id: "matrix",
|
||||
enabledByDefault: true,
|
||||
format: "openclaw",
|
||||
channels: ["matrix"],
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
origin: "bundled",
|
||||
rootDir: pluginRoot,
|
||||
source,
|
||||
manifestPath: path.join(pluginRoot, "openclaw.plugin.json"),
|
||||
pluginId: "matrix",
|
||||
register: vi.fn(),
|
||||
commands: ["matrix"],
|
||||
descriptors: [
|
||||
{
|
||||
name: "matrix",
|
||||
description: "Matrix channel utilities",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
source: "bundled",
|
||||
},
|
||||
{
|
||||
pluginId: "duplicate-matrix",
|
||||
register: vi.fn(),
|
||||
commands: ["matrix"],
|
||||
descriptors: [
|
||||
{
|
||||
name: "matrix",
|
||||
description: "Duplicate Matrix channel utilities",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
source: "bundled",
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
const registerFull = vi.fn();
|
||||
mocks.createJiti.mockReturnValue(
|
||||
() =>
|
||||
({
|
||||
default: {
|
||||
id: "matrix",
|
||||
name: "Matrix",
|
||||
description: "Matrix channel plugin",
|
||||
channelPlugin: {},
|
||||
register(api: {
|
||||
registrationMode: "full" | "setup-only" | "setup-runtime";
|
||||
registerChannel: (registration: unknown) => void;
|
||||
registerCli: (
|
||||
registrar: () => void,
|
||||
opts: {
|
||||
descriptors: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
hasSubcommands: boolean;
|
||||
}>;
|
||||
},
|
||||
) => void;
|
||||
}) {
|
||||
api.registerChannel({ plugin: {} });
|
||||
api.registerCli(() => {}, {
|
||||
descriptors: [
|
||||
{
|
||||
name: "matrix",
|
||||
description: "Matrix channel utilities",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (api.registrationMode === "full") {
|
||||
registerFull();
|
||||
}
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
|
||||
expect(getPluginCliCommandDescriptors(rawConfig)).toEqual([
|
||||
{
|
||||
name: "matrix",
|
||||
description: "Matrix channel utilities",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
]);
|
||||
expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: autoEnabledConfig,
|
||||
activate: false,
|
||||
cache: false,
|
||||
captureCliMetadataOnly: true,
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
expect(getPluginCliCommandDescriptors({} as OpenClawConfig)).toEqual([
|
||||
{
|
||||
name: "matrix",
|
||||
description: "Matrix channel utilities",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
]);
|
||||
expect(registerFull).not.toHaveBeenCalled();
|
||||
expect(mocks.loadOpenClawPlugins).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps non-channel descriptor capture in full registration mode", () => {
|
||||
const tempRoot = mkdtempSync(path.join(tmpdir(), "openclaw-cli-descriptors-"));
|
||||
const pluginRoot = path.join(tempRoot, "memory-core");
|
||||
const source = path.join(pluginRoot, "index.ts");
|
||||
mkdirSync(pluginRoot, { recursive: true });
|
||||
writeFileSync(source, "export default {}");
|
||||
mocks.loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "memory-core",
|
||||
enabledByDefault: true,
|
||||
format: "openclaw",
|
||||
kind: "memory",
|
||||
channels: [],
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
skills: [],
|
||||
hooks: [],
|
||||
origin: "bundled",
|
||||
rootDir: pluginRoot,
|
||||
source,
|
||||
manifestPath: path.join(pluginRoot, "openclaw.plugin.json"),
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
const seenModes: string[] = [];
|
||||
mocks.createJiti.mockReturnValue(
|
||||
() =>
|
||||
({
|
||||
default: {
|
||||
id: "memory-core",
|
||||
name: "Memory (Core)",
|
||||
description: "Memory plugin",
|
||||
register(api: {
|
||||
registrationMode: string;
|
||||
registerCli: (
|
||||
registrar: () => void,
|
||||
opts: {
|
||||
descriptors: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
hasSubcommands: boolean;
|
||||
}>;
|
||||
},
|
||||
) => void;
|
||||
}) {
|
||||
seenModes.push(api.registrationMode);
|
||||
api.registerCli(() => {}, {
|
||||
descriptors: [
|
||||
{
|
||||
name: "memory",
|
||||
description: "Memory commands",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
}) as never,
|
||||
);
|
||||
|
||||
try {
|
||||
expect(getPluginCliCommandDescriptors({} as OpenClawConfig)).toEqual([
|
||||
{
|
||||
name: "memory",
|
||||
description: "Memory commands",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
]);
|
||||
expect(seenModes).toEqual(["full"]);
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("lazy-registers descriptor-backed plugin commands on first invocation", async () => {
|
||||
|
|
|
|||
|
|
@ -1,30 +1,14 @@
|
|||
import fs from "node:fs";
|
||||
import type { Command } from "commander";
|
||||
import { createJiti } from "jiti";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { removeCommandByName } from "../cli/program/command-tree.js";
|
||||
import { registerLazyCommand } from "../cli/program/register-lazy-command.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
import { openBoundaryFileSync } from "../infra/boundary-file-read.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { createCapturedPluginRegistration } from "./captured-registration.js";
|
||||
import {
|
||||
normalizePluginsConfig,
|
||||
resolveEffectiveEnableState,
|
||||
resolveMemorySlotDecision,
|
||||
} from "./config-state.js";
|
||||
import { discoverOpenClawPlugins } from "./discovery.js";
|
||||
import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js";
|
||||
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
||||
import {
|
||||
buildPluginLoaderAliasMap,
|
||||
buildPluginLoaderJitiOptions,
|
||||
shouldPreferNativeJiti,
|
||||
} from "./sdk-alias.js";
|
||||
import type { OpenClawPluginCliCommandDescriptor } from "./types.js";
|
||||
import type { OpenClawPluginDefinition, OpenClawPluginModule, PluginLogger } from "./types.js";
|
||||
import type { PluginLogger } from "./types.js";
|
||||
|
||||
const log = createSubsystemLogger("plugins");
|
||||
|
||||
|
|
@ -35,39 +19,6 @@ type RegisterPluginCliOptions = {
|
|||
primary?: string | null;
|
||||
};
|
||||
|
||||
function resolvePluginModuleExport(moduleExport: unknown): {
|
||||
definition?: OpenClawPluginDefinition;
|
||||
register?: OpenClawPluginDefinition["register"];
|
||||
} {
|
||||
const resolved =
|
||||
moduleExport &&
|
||||
typeof moduleExport === "object" &&
|
||||
"default" in (moduleExport as Record<string, unknown>)
|
||||
? (moduleExport as { default: unknown }).default
|
||||
: moduleExport;
|
||||
if (typeof resolved === "function") {
|
||||
return {
|
||||
register: resolved as OpenClawPluginDefinition["register"],
|
||||
};
|
||||
}
|
||||
if (resolved && typeof resolved === "object") {
|
||||
const definition = resolved as OpenClawPluginDefinition;
|
||||
return {
|
||||
definition,
|
||||
register: definition.register ?? definition.activate,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function isChannelPluginDefinition(definition: OpenClawPluginDefinition | undefined): boolean {
|
||||
return Boolean(
|
||||
definition &&
|
||||
typeof definition === "object" &&
|
||||
"channelPlugin" in (definition as Record<string, unknown>),
|
||||
);
|
||||
}
|
||||
|
||||
function canRegisterPluginCliLazily(entry: {
|
||||
commands: string[];
|
||||
descriptors: OpenClawPluginCliCommandDescriptor[];
|
||||
|
|
@ -82,7 +33,10 @@ function canRegisterPluginCliLazily(entry: {
|
|||
function loadPluginCliRegistry(
|
||||
cfg?: OpenClawConfig,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
loaderOptions?: Pick<PluginLoadOptions, "pluginSdkResolution">,
|
||||
loaderOptions?: Pick<
|
||||
PluginLoadOptions,
|
||||
"pluginSdkResolution" | "activate" | "cache" | "captureCliMetadataOnly"
|
||||
>,
|
||||
) {
|
||||
const config = cfg ?? loadConfig();
|
||||
const resolvedConfig = applyPluginAutoEnable({ config, env: env ?? process.env }).config;
|
||||
|
|
@ -110,125 +64,19 @@ function loadPluginCliRegistry(
|
|||
};
|
||||
}
|
||||
|
||||
// Root help only needs parse-time CLI metadata. Capture `registerCli(...)`
|
||||
// against a throwaway API so plugin runtime activation does not leak into
|
||||
// `openclaw --help`.
|
||||
function getPluginCliCommandDescriptorsFromMetadata(
|
||||
export function getPluginCliCommandDescriptors(
|
||||
cfg?: OpenClawConfig,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): OpenClawPluginCliCommandDescriptor[] {
|
||||
const config = cfg ?? loadConfig();
|
||||
const resolvedEnv = env ?? process.env;
|
||||
const resolvedConfig = applyPluginAutoEnable({ config, env: resolvedEnv }).config;
|
||||
const workspaceDir = resolveAgentWorkspaceDir(
|
||||
resolvedConfig,
|
||||
resolveDefaultAgentId(resolvedConfig),
|
||||
);
|
||||
const normalized = normalizePluginsConfig(resolvedConfig.plugins);
|
||||
const discovery = discoverOpenClawPlugins({
|
||||
workspaceDir,
|
||||
extraPaths: normalized.loadPaths,
|
||||
cache: false,
|
||||
env: resolvedEnv,
|
||||
});
|
||||
const manifestRegistry = loadPluginManifestRegistry({
|
||||
config: resolvedConfig,
|
||||
workspaceDir,
|
||||
cache: false,
|
||||
env: resolvedEnv,
|
||||
candidates: discovery.candidates,
|
||||
diagnostics: discovery.diagnostics,
|
||||
});
|
||||
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
|
||||
const getJiti = (modulePath: string) => {
|
||||
const tryNative = shouldPreferNativeJiti(modulePath);
|
||||
const aliasMap = buildPluginLoaderAliasMap(
|
||||
modulePath,
|
||||
process.argv[1],
|
||||
import.meta.url,
|
||||
undefined,
|
||||
);
|
||||
const cacheKey = JSON.stringify({
|
||||
tryNative,
|
||||
aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)),
|
||||
try {
|
||||
const { registry } = loadPluginCliRegistry(cfg, env, {
|
||||
activate: false,
|
||||
cache: false,
|
||||
captureCliMetadataOnly: true,
|
||||
});
|
||||
const cached = jitiLoaders.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const loader = createJiti(import.meta.url, {
|
||||
...buildPluginLoaderJitiOptions(aliasMap),
|
||||
tryNative,
|
||||
});
|
||||
jitiLoaders.set(cacheKey, loader);
|
||||
return loader;
|
||||
};
|
||||
|
||||
const descriptors: OpenClawPluginCliCommandDescriptor[] = [];
|
||||
const seen = new Set<string>();
|
||||
let selectedMemoryPluginId: string | null = null;
|
||||
|
||||
for (const manifest of manifestRegistry.plugins) {
|
||||
const enableState = resolveEffectiveEnableState({
|
||||
id: manifest.id,
|
||||
origin: manifest.origin,
|
||||
config: normalized,
|
||||
rootConfig: resolvedConfig,
|
||||
enabledByDefault: manifest.enabledByDefault,
|
||||
});
|
||||
if (!enableState.enabled || manifest.format === "bundle") {
|
||||
continue;
|
||||
}
|
||||
const memoryDecision = resolveMemorySlotDecision({
|
||||
id: manifest.id,
|
||||
kind: manifest.kind,
|
||||
slot: normalized.slots.memory,
|
||||
selectedId: selectedMemoryPluginId,
|
||||
});
|
||||
if (!memoryDecision.enabled) {
|
||||
continue;
|
||||
}
|
||||
if (memoryDecision.selected && manifest.kind === "memory") {
|
||||
selectedMemoryPluginId = manifest.id;
|
||||
}
|
||||
|
||||
const opened = openBoundaryFileSync({
|
||||
absolutePath: manifest.source,
|
||||
rootPath: manifest.rootDir,
|
||||
boundaryLabel: "plugin root",
|
||||
rejectHardlinks: manifest.origin !== "bundled",
|
||||
skipLexicalRootCheck: true,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const safeSource = opened.path;
|
||||
fs.closeSync(opened.fd);
|
||||
|
||||
let mod: OpenClawPluginModule | null = null;
|
||||
try {
|
||||
mod = getJiti(safeSource)(safeSource) as OpenClawPluginModule;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { definition, register } = resolvePluginModuleExport(mod);
|
||||
if (typeof register !== "function") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const captured = createCapturedPluginRegistration({
|
||||
config: resolvedConfig,
|
||||
registrationMode: isChannelPluginDefinition(definition) ? "setup-only" : "full",
|
||||
});
|
||||
try {
|
||||
void register(captured.api);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of captured.cliRegistrars) {
|
||||
const seen = new Set<string>();
|
||||
const descriptors: OpenClawPluginCliCommandDescriptor[] = [];
|
||||
for (const entry of registry.cliRegistrars) {
|
||||
for (const descriptor of entry.descriptors) {
|
||||
if (seen.has(descriptor.name)) {
|
||||
continue;
|
||||
|
|
@ -237,17 +85,7 @@ function getPluginCliCommandDescriptorsFromMetadata(
|
|||
descriptors.push(descriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
export function getPluginCliCommandDescriptors(
|
||||
cfg?: OpenClawConfig,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): OpenClawPluginCliCommandDescriptor[] {
|
||||
try {
|
||||
return getPluginCliCommandDescriptorsFromMetadata(cfg, env);
|
||||
return descriptors;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2597,6 +2597,193 @@ module.exports = {
|
|||
expect(registry.channels).toHaveLength(expectedChannels);
|
||||
});
|
||||
|
||||
it("passes validated plugin config into non-activating CLI metadata loads", () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
id: "config-cli",
|
||||
filename: "config-cli.cjs",
|
||||
body: `module.exports = {
|
||||
id: "config-cli",
|
||||
register(api) {
|
||||
if (!api.pluginConfig || api.pluginConfig.token !== "ok") {
|
||||
throw new Error("missing plugin config");
|
||||
}
|
||||
api.registerCli(() => {}, {
|
||||
descriptors: [
|
||||
{
|
||||
name: "cfg",
|
||||
description: "Config-backed CLI command",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};`,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(plugin.dir, "openclaw.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: "config-cli",
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
},
|
||||
required: ["token"],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
activate: false,
|
||||
captureCliMetadataOnly: true,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [plugin.file] },
|
||||
allow: ["config-cli"],
|
||||
entries: {
|
||||
"config-cli": {
|
||||
config: {
|
||||
token: "ok",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain("cfg");
|
||||
expect(registry.plugins.find((entry) => entry.id === "config-cli")?.status).toBe("loaded");
|
||||
});
|
||||
|
||||
it("uses the real channel entry in setup-only mode for CLI metadata capture", () => {
|
||||
useNoBundledPlugins();
|
||||
const pluginDir = makeTempDir();
|
||||
const fullMarker = path.join(pluginDir, "full-loaded.txt");
|
||||
const setupMarker = path.join(pluginDir, "setup-loaded.txt");
|
||||
const modeMarker = path.join(pluginDir, "registration-mode.txt");
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "@openclaw/cli-metadata-channel",
|
||||
openclaw: {
|
||||
extensions: ["./index.cjs"],
|
||||
setupEntry: "./setup-entry.cjs",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: "cli-metadata-channel",
|
||||
configSchema: EMPTY_PLUGIN_SCHEMA,
|
||||
channels: ["cli-metadata-channel"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "index.cjs"),
|
||||
`require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");
|
||||
module.exports = {
|
||||
id: "cli-metadata-channel",
|
||||
register(api) {
|
||||
require("node:fs").writeFileSync(
|
||||
${JSON.stringify(modeMarker)},
|
||||
String(api.registrationMode),
|
||||
"utf-8",
|
||||
);
|
||||
api.registerChannel({
|
||||
plugin: {
|
||||
id: "cli-metadata-channel",
|
||||
meta: {
|
||||
id: "cli-metadata-channel",
|
||||
label: "CLI Metadata Channel",
|
||||
selectionLabel: "CLI Metadata Channel",
|
||||
docsPath: "/channels/cli-metadata-channel",
|
||||
blurb: "cli metadata channel",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({ accountId: "default" }),
|
||||
},
|
||||
outbound: { deliveryMode: "direct" },
|
||||
},
|
||||
});
|
||||
api.registerCli(() => {}, {
|
||||
descriptors: [
|
||||
{
|
||||
name: "cli-metadata-channel",
|
||||
description: "Channel CLI metadata",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
};`,
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "setup-entry.cjs"),
|
||||
`require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8");
|
||||
module.exports = {
|
||||
plugin: {
|
||||
id: "cli-metadata-channel",
|
||||
meta: {
|
||||
id: "cli-metadata-channel",
|
||||
label: "CLI Metadata Channel",
|
||||
selectionLabel: "CLI Metadata Channel",
|
||||
docsPath: "/channels/cli-metadata-channel",
|
||||
blurb: "setup entry",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({ accountId: "default" }),
|
||||
},
|
||||
outbound: { deliveryMode: "direct" },
|
||||
},
|
||||
};`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
activate: false,
|
||||
captureCliMetadataOnly: true,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [pluginDir] },
|
||||
allow: ["cli-metadata-channel"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(fs.existsSync(fullMarker)).toBe(true);
|
||||
expect(fs.existsSync(setupMarker)).toBe(false);
|
||||
expect(fs.readFileSync(modeMarker, "utf-8")).toBe("setup-only");
|
||||
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain(
|
||||
"cli-metadata-channel",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
|
|
|
|||
|
|
@ -88,6 +88,15 @@ export type PluginLoadOptions = {
|
|||
* via package metadata because their setup entry covers the pre-listen startup surface.
|
||||
*/
|
||||
preferSetupRuntimeForChannelPlugins?: boolean;
|
||||
/**
|
||||
* Capture CLI descriptors without activating plugin runtime side effects.
|
||||
*
|
||||
* Enabled channel plugins still load from their real entry file so
|
||||
* descriptor registration can follow the normal plugin entry contract, but
|
||||
* they register in setup-only mode to keep `registerFull(...)` work out of
|
||||
* parse-time help paths.
|
||||
*/
|
||||
captureCliMetadataOnly?: boolean;
|
||||
activate?: boolean;
|
||||
throwOnLoadError?: boolean;
|
||||
};
|
||||
|
|
@ -202,6 +211,7 @@ function buildCacheKey(params: {
|
|||
onlyPluginIds?: string[];
|
||||
includeSetupOnlyChannelPlugins?: boolean;
|
||||
preferSetupRuntimeForChannelPlugins?: boolean;
|
||||
captureCliMetadataOnly?: boolean;
|
||||
runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable";
|
||||
pluginSdkResolution?: PluginSdkResolutionPreference;
|
||||
coreGatewayMethodNames?: string[];
|
||||
|
|
@ -231,12 +241,13 @@ function buildCacheKey(params: {
|
|||
const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime";
|
||||
const startupChannelMode =
|
||||
params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full";
|
||||
const cliMetadataMode = params.captureCliMetadataOnly === true ? "cli-metadata" : "default";
|
||||
const gatewayMethodsKey = JSON.stringify(params.coreGatewayMethodNames ?? []);
|
||||
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
|
||||
...params.plugins,
|
||||
installs,
|
||||
loadPaths,
|
||||
})}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${params.runtimeSubagentMode ?? "default"}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
|
||||
})}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${cliMetadataMode}::${params.runtimeSubagentMode ?? "default"}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`;
|
||||
}
|
||||
|
||||
function normalizeScopedPluginIds(ids?: string[]): string[] | undefined {
|
||||
|
|
@ -269,7 +280,8 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean {
|
|||
options.pluginSdkResolution !== undefined ||
|
||||
options.coreGatewayHandlers !== undefined ||
|
||||
options.includeSetupOnlyChannelPlugins === true ||
|
||||
options.preferSetupRuntimeForChannelPlugins === true,
|
||||
options.preferSetupRuntimeForChannelPlugins === true ||
|
||||
options.captureCliMetadataOnly === true,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -280,6 +292,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
|
|||
const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds);
|
||||
const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true;
|
||||
const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true;
|
||||
const captureCliMetadataOnly = options.captureCliMetadataOnly === true;
|
||||
const coreGatewayMethodNames = Object.keys(options.coreGatewayHandlers ?? {}).toSorted();
|
||||
const cacheKey = buildCacheKey({
|
||||
workspaceDir: options.workspaceDir,
|
||||
|
|
@ -289,6 +302,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
|
|||
onlyPluginIds,
|
||||
includeSetupOnlyChannelPlugins,
|
||||
preferSetupRuntimeForChannelPlugins,
|
||||
captureCliMetadataOnly,
|
||||
runtimeSubagentMode: resolveRuntimeSubagentMode(options.runtimeOptions),
|
||||
pluginSdkResolution: options.pluginSdkResolution,
|
||||
coreGatewayMethodNames,
|
||||
|
|
@ -300,6 +314,7 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
|
|||
onlyPluginIds,
|
||||
includeSetupOnlyChannelPlugins,
|
||||
preferSetupRuntimeForChannelPlugins,
|
||||
captureCliMetadataOnly,
|
||||
shouldActivate: options.activate !== false,
|
||||
runtimeSubagentMode: resolveRuntimeSubagentMode(options.runtimeOptions),
|
||||
cacheKey,
|
||||
|
|
@ -793,6 +808,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||
onlyPluginIds,
|
||||
includeSetupOnlyChannelPlugins,
|
||||
preferSetupRuntimeForChannelPlugins,
|
||||
captureCliMetadataOnly,
|
||||
shouldActivate,
|
||||
cacheKey,
|
||||
runtimeSubagentMode,
|
||||
|
|
@ -1066,18 +1082,20 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||
};
|
||||
|
||||
const registrationMode = enableState.enabled
|
||||
? !validateOnly &&
|
||||
shouldLoadChannelPluginInSetupRuntime({
|
||||
manifestChannels: manifestRecord.channels,
|
||||
setupSource: manifestRecord.setupSource,
|
||||
startupDeferConfiguredChannelFullLoadUntilAfterListen:
|
||||
manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen,
|
||||
cfg,
|
||||
env,
|
||||
preferSetupRuntimeForChannelPlugins,
|
||||
})
|
||||
? "setup-runtime"
|
||||
: "full"
|
||||
? captureCliMetadataOnly && manifestRecord.channels.length > 0
|
||||
? "setup-only"
|
||||
: !validateOnly &&
|
||||
shouldLoadChannelPluginInSetupRuntime({
|
||||
manifestChannels: manifestRecord.channels,
|
||||
setupSource: manifestRecord.setupSource,
|
||||
startupDeferConfiguredChannelFullLoadUntilAfterListen:
|
||||
manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen,
|
||||
cfg,
|
||||
env,
|
||||
preferSetupRuntimeForChannelPlugins,
|
||||
})
|
||||
? "setup-runtime"
|
||||
: "full"
|
||||
: includeSetupOnlyChannelPlugins && !validateOnly && manifestRecord.channels.length > 0
|
||||
? "setup-only"
|
||||
: null;
|
||||
|
|
@ -1183,9 +1201,10 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||
}
|
||||
|
||||
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
|
||||
const loadSource =
|
||||
(registrationMode === "setup-only" || registrationMode === "setup-runtime") &&
|
||||
manifestRecord.setupSource
|
||||
const loadSource = captureCliMetadataOnly
|
||||
? candidate.source
|
||||
: (registrationMode === "setup-only" || registrationMode === "setup-runtime") &&
|
||||
manifestRecord.setupSource
|
||||
? manifestRecord.setupSource
|
||||
: candidate.source;
|
||||
const opened = openBoundaryFileSync({
|
||||
|
|
@ -1221,6 +1240,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
|||
}
|
||||
|
||||
if (
|
||||
!captureCliMetadataOnly &&
|
||||
(registrationMode === "setup-only" || registrationMode === "setup-runtime") &&
|
||||
manifestRecord.setupSource
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -988,7 +988,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
registerWebSearchProvider: (provider) => registerWebSearchProvider(record, provider),
|
||||
registerGatewayMethod: (method, handler, opts) =>
|
||||
registerGatewayMethod(record, method, handler, opts),
|
||||
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
|
||||
registerService: (service) => registerService(record, service),
|
||||
registerCliBackend: (backend) => registerCliBackend(record, backend),
|
||||
registerInteractiveHandler: (registration) => {
|
||||
|
|
@ -1097,6 +1096,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
|||
registerTypedHook(record, hookName, handler, opts, params.hookPolicy),
|
||||
}
|
||||
: {}),
|
||||
// Allow setup-only/setup-runtime paths to surface parse-time CLI metadata
|
||||
// without opting into the wider full-registration surface.
|
||||
registerCli: (registrar, opts) => registerCli(record, registrar, opts),
|
||||
registerChannel: (registration) => registerChannel(record, registration, registrationMode),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue