From eb6dd2c65d1147225dcf2d42f4e80fd9580c5747 Mon Sep 17 00:00:00 2001 From: Josh Avant <830519+joshavant@users.noreply.github.com> Date: Mon, 18 May 2026 20:35:55 -0500 Subject: [PATCH] Fix memory plugin CLI help dispatch (#83841) * fix cli help for active memory plugin * docs add changelog for memory cli help * test fix root help mock type --- CHANGELOG.md | 1 + docs/cli/memory.md | 4 +- docs/plugins/memory-lancedb.md | 8 +- extensions/memory-lancedb/cli-metadata.ts | 10 +- openclaw.mjs | 71 +++++++++- src/cli/command-registration-policy.test.ts | 2 +- src/cli/command-registration-policy.ts | 4 +- src/cli/root-help-live-config.test.ts | 51 ++++++++ src/cli/root-help-live-config.ts | 53 ++++++++ src/cli/run-main.exit.test.ts | 34 +++++ src/cli/run-main.ts | 16 ++- src/entry.test.ts | 35 +++++ src/entry.ts | 31 +++-- test/openclaw-launcher.e2e.test.ts | 138 ++++++++++++++++++++ 14 files changed, 434 insertions(+), 24 deletions(-) create mode 100644 src/cli/root-help-live-config.test.ts create mode 100644 src/cli/root-help-live-config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 41784c8c31c..bf379b2265d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -137,6 +137,7 @@ Docs: https://docs.openclaw.ai - Mac app: render channel quick config as aligned Settings rows and hide schema-only variants that cannot be edited safely from the quick pane. - Gateway/webchat: hide internal runtime-context and other `display: false` transcript messages from Chat history and live message events. Fixes #83216. Thanks @EmpireCreator. - CLI/help: keep `gateway`, `doctor`, `status`, and `health` help registration out of action/runtime imports so subcommand `--help` stays lightweight in constrained terminals. Fixes #83228. Thanks @dfguerrerom. +- CLI/help: show plugin-owned command help based on the active memory slot so LanceDB memory users see `ltm` instead of unavailable `memory` commands. Fixes #83745. (#83841) Thanks @joshavant. - Cron/Discord: keep explicit announce runs in message-tool-only source-reply mode so scheduled agent turns post once instead of also echoing through automatic visible replies. Fixes #83261. Thanks @Theralley. - Telegram: preserve forum-topic origin targets in inbound, audio-preflight, and skipped-message hook contexts so follow-up delivery stays bound to the originating topic. Fixes #83302. Thanks @M00zyx. - Telegram: retry HTTP 421 Misdirected Request send failures on a fresh fallback transport so transient edge-node routing errors no longer drop outbound replies. Fixes #48892. (#48908) Thanks @MarsDoge. diff --git a/docs/cli/memory.md b/docs/cli/memory.md index 852bf44fcec..aa519b16032 100644 --- a/docs/cli/memory.md +++ b/docs/cli/memory.md @@ -10,7 +10,9 @@ title: "Memory" # `openclaw memory` Manage semantic memory indexing and search. -Provided by the active memory plugin (default: `memory-core`; set `plugins.slots.memory = "none"` to disable). +Provided by the bundled `memory-core` plugin. The command is available when +`plugins.slots.memory` selects `memory-core` (the default); other memory plugins +expose their own CLI namespaces. Related: diff --git a/docs/plugins/memory-lancedb.md b/docs/plugins/memory-lancedb.md index caf25491437..b48b378ea92 100644 --- a/docs/plugins/memory-lancedb.md +++ b/docs/plugins/memory-lancedb.md @@ -238,12 +238,12 @@ openclaw ltm search "project preferences" openclaw ltm stats ``` -The plugin also extends `openclaw memory` with a non-vector `query` subcommand -that runs against the LanceDB table directly: +The `query` subcommand runs a non-vector query against the LanceDB table +directly: ```bash -openclaw memory query --cols id,text,createdAt --limit 20 -openclaw memory query --filter "category = 'preference'" --order-by createdAt:desc +openclaw ltm query --cols id,text,createdAt --limit 20 +openclaw ltm query --filter "category = 'preference'" --order-by createdAt:desc ``` - `--cols `: comma-separated column allowlist (defaults to `id`, `text`, `importance`, `category`, `createdAt`). diff --git a/extensions/memory-lancedb/cli-metadata.ts b/extensions/memory-lancedb/cli-metadata.ts index ecee32649c6..e0d12481a87 100644 --- a/extensions/memory-lancedb/cli-metadata.ts +++ b/extensions/memory-lancedb/cli-metadata.ts @@ -5,6 +5,14 @@ export default definePluginEntry({ name: "Memory LanceDB", description: "LanceDB-backed memory provider", register(api) { - api.registerCli(() => {}, { commands: ["ltm"] }); + api.registerCli(() => {}, { + descriptors: [ + { + name: "ltm", + description: "Inspect and query LanceDB-backed memory", + hasSubcommands: true, + }, + ], + }); }, }); diff --git a/openclaw.mjs b/openclaw.mjs index 0afab74fbab..e5e6662ed2c 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -334,6 +334,72 @@ const isBrowserHelpInvocation = (argv) => const isHelpFastPathDisabled = () => process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH === "1"; +const normalizeLauncherHomeValue = (value) => { + const trimmed = value?.trim(); + return trimmed && trimmed !== "undefined" && trimmed !== "null" ? trimmed : undefined; +}; + +const resolveLauncherOsHomeDir = () => + normalizeLauncherHomeValue(process.env.HOME) ?? + normalizeLauncherHomeValue(process.env.USERPROFILE) ?? + os.homedir(); + +const resolveLauncherHomeDir = () => { + const explicit = normalizeLauncherHomeValue(process.env.OPENCLAW_HOME); + const rawHome = + explicit && (explicit === "~" || explicit.startsWith("~/") || explicit.startsWith("~\\")) + ? explicit.replace(/^~(?=$|[\\/])/, resolveLauncherOsHomeDir()) + : (explicit ?? resolveLauncherOsHomeDir()); + return path.resolve(rawHome); +}; + +const resolveLauncherUserPath = (input) => { + if (input === "~") { + return resolveLauncherHomeDir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(resolveLauncherHomeDir(), input.slice(2)); + } + return path.resolve(input); +}; + +const resolveLauncherConfigPaths = () => { + const explicit = process.env.OPENCLAW_CONFIG_PATH?.trim(); + if (explicit) { + return [resolveLauncherUserPath(explicit)]; + } + const stateOverride = process.env.OPENCLAW_STATE_DIR?.trim(); + if (stateOverride) { + const stateDir = resolveLauncherUserPath(stateOverride); + return [path.join(stateDir, "openclaw.json"), path.join(stateDir, "clawdbot.json")]; + } + const homeDir = resolveLauncherHomeDir(); + return [ + path.join(homeDir, ".openclaw", "openclaw.json"), + path.join(homeDir, ".openclaw", "clawdbot.json"), + path.join(homeDir, ".clawdbot", "openclaw.json"), + path.join(homeDir, ".clawdbot", "clawdbot.json"), + ]; +}; + +const shouldDeferRootHelpToRuntimeEntry = () => { + if ( + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR?.trim() || + process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS?.trim() + ) { + return true; + } + for (const configPath of resolveLauncherConfigPaths()) { + try { + const raw = readFileSync(configPath, "utf8"); + return /\bplugins\b|\$include\b/.test(raw); + } catch { + continue; + } + } + return false; +}; + const loadPrecomputedHelpText = (key) => { try { const raw = readFileSync(new URL("./dist/cli-startup-metadata.json", import.meta.url), "utf8"); @@ -349,6 +415,9 @@ const tryOutputBareRootHelp = async () => { if (!isBareRootHelpInvocation(process.argv)) { return false; } + if (shouldDeferRootHelpToRuntimeEntry()) { + return false; + } const precomputed = loadPrecomputedHelpText("rootHelpText"); if (precomputed) { process.stdout.write(precomputed); @@ -358,7 +427,7 @@ const tryOutputBareRootHelp = async () => { try { const mod = await import(specifier); if (typeof mod.outputRootHelp === "function") { - mod.outputRootHelp(); + await mod.outputRootHelp(); return true; } } catch (err) { diff --git a/src/cli/command-registration-policy.test.ts b/src/cli/command-registration-policy.test.ts index bb67e5285d9..11bc7db54a0 100644 --- a/src/cli/command-registration-policy.test.ts +++ b/src/cli/command-registration-policy.test.ts @@ -35,7 +35,7 @@ describe("command-registration-policy", () => { primary: "voicecall", hasBuiltinPrimary: false, }), - ).toBe(true); + ).toBe(false); expect( shouldSkipPluginCommandRegistration({ argv: ["node", "openclaw", "help", "--help"], diff --git a/src/cli/command-registration-policy.ts b/src/cli/command-registration-policy.ts index c3851e3f1a2..94af2e2f9ec 100644 --- a/src/cli/command-registration-policy.ts +++ b/src/cli/command-registration-policy.ts @@ -22,7 +22,9 @@ export function shouldSkipPluginCommandRegistration(params: { return invocation.hasHelpOrVersion && invocation.commandPath.length <= 1; } if (invocation.hasHelpOrVersion) { - return true; + return ( + !params.primary || params.hasBuiltinPrimary || isReservedNonPluginCommandRoot(params.primary) + ); } if (params.hasBuiltinPrimary) { return true; diff --git a/src/cli/root-help-live-config.test.ts b/src/cli/root-help-live-config.test.ts new file mode 100644 index 00000000000..701fe86f2aa --- /dev/null +++ b/src/cli/root-help-live-config.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { loadRootHelpRenderOptionsForConfigSensitivePlugins } from "./root-help-live-config.js"; + +const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); + +vi.mock("../config/config.js", () => ({ + readConfigFileSnapshot: readConfigFileSnapshotMock, +})); + +describe("root help live config", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses precomputed help when plugin-sensitive config is invalid", async () => { + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: false, + sourceConfig: { + plugins: { + slots: { + memory: "memory-lancedb", + }, + }, + }, + runtimeConfig: {}, + }); + + await expect(loadRootHelpRenderOptionsForConfigSensitivePlugins({})).resolves.toBeNull(); + }); + + it("uses snapshot runtime config when plugin config affects help", async () => { + const runtimeConfig = { + plugins: { + slots: { + memory: "memory-lancedb", + }, + }, + }; + const env = {}; + readConfigFileSnapshotMock.mockResolvedValueOnce({ + valid: true, + sourceConfig: runtimeConfig, + runtimeConfig, + }); + + await expect(loadRootHelpRenderOptionsForConfigSensitivePlugins(env)).resolves.toEqual({ + config: runtimeConfig, + env, + }); + }); +}); diff --git a/src/cli/root-help-live-config.ts b/src/cli/root-help-live-config.ts new file mode 100644 index 00000000000..4c8d4b04397 --- /dev/null +++ b/src/cli/root-help-live-config.ts @@ -0,0 +1,53 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { RootHelpRenderOptions } from "./program/root-help.js"; + +function hasEntries(value: object | undefined): boolean { + return !!value && Object.keys(value).length > 0; +} + +function hasListEntries(value: string[] | undefined): boolean { + return Array.isArray(value) && value.length > 0; +} + +export function hasPluginHelpAffectingConfig(config: OpenClawConfig | null | undefined): boolean { + const plugins = config?.plugins; + if (!plugins) { + return false; + } + return ( + plugins.enabled === false || + hasListEntries(plugins.allow) || + hasListEntries(plugins.deny) || + plugins.bundledDiscovery !== undefined || + hasListEntries(plugins.load?.paths) || + hasEntries(plugins.slots) || + hasEntries(plugins.entries) || + hasEntries(plugins.installs) + ); +} + +export function hasPluginHelpAffectingEnv(env: NodeJS.ProcessEnv): boolean { + return Boolean( + env.OPENCLAW_BUNDLED_PLUGINS_DIR?.trim() || env.OPENCLAW_DISABLE_BUNDLED_PLUGINS?.trim(), + ); +} + +export async function loadRootHelpRenderOptionsForConfigSensitivePlugins( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const configModule = await import("../config/config.js"); + const snapshot = await configModule.readConfigFileSnapshot({ + observe: false, + skipPluginValidation: true, + }); + if (!snapshot.valid) { + return null; + } + if (!hasPluginHelpAffectingEnv(env) && !hasPluginHelpAffectingConfig(snapshot.sourceConfig)) { + return null; + } + return { + config: snapshot.runtimeConfig, + env, + }; +} diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index b1e239f57ac..8f5d716a5f5 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -2,6 +2,7 @@ import process from "node:process"; import { CommanderError } from "commander"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { loggingState } from "../logging/state.js"; +import type { RootHelpRenderOptions } from "./program/root-help.js"; import { runCli, shouldStartProxyForCli } from "./run-main.js"; const tryRouteCliMock = vi.hoisted(() => vi.fn()); @@ -18,6 +19,9 @@ const startTaskRegistryMaintenanceMock = vi.hoisted(() => vi.fn()); const outputRootHelpMock = vi.hoisted(() => vi.fn()); const outputPrecomputedRootHelpTextMock = vi.hoisted(() => vi.fn(() => false)); const outputPrecomputedBrowserHelpTextMock = vi.hoisted(() => vi.fn(() => false)); +const loadRootHelpRenderOptionsForConfigSensitivePluginsMock = vi.hoisted(() => + vi.fn<() => Promise>(async () => null), +); const buildProgramMock = vi.hoisted(() => vi.fn()); const getProgramContextMock = vi.hoisted(() => vi.fn(() => null)); const registerCoreCliByNameMock = vi.hoisted(() => vi.fn()); @@ -168,6 +172,11 @@ vi.mock("./root-help-metadata.js", () => ({ outputPrecomputedRootHelpText: outputPrecomputedRootHelpTextMock, })); +vi.mock("./root-help-live-config.js", () => ({ + loadRootHelpRenderOptionsForConfigSensitivePlugins: + loadRootHelpRenderOptionsForConfigSensitivePluginsMock, +})); + vi.mock("./program.js", () => ({ buildProgram: buildProgramMock, })); @@ -242,6 +251,7 @@ describe("runCli exit behavior", () => { listAgentHarnessIdsMock.mockReturnValue([]); outputPrecomputedBrowserHelpTextMock.mockReturnValue(false); outputPrecomputedRootHelpTextMock.mockReturnValue(false); + loadRootHelpRenderOptionsForConfigSensitivePluginsMock.mockResolvedValue(null); hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(false); loadConfigMock.mockReturnValue({}); startProxyMock.mockResolvedValue(null); @@ -401,6 +411,7 @@ describe("runCli exit behavior", () => { await runCli(["node", "openclaw", "--help"]); + expect(loadRootHelpRenderOptionsForConfigSensitivePluginsMock).toHaveBeenCalledTimes(1); expect(outputPrecomputedRootHelpTextMock).toHaveBeenCalledTimes(1); expect(hasEnvHttpProxyAgentConfiguredMock).not.toHaveBeenCalled(); expect(ensureGlobalUndiciEnvProxyDispatcherMock).not.toHaveBeenCalled(); @@ -416,6 +427,7 @@ describe("runCli exit behavior", () => { expect(maybeRunCliInContainerMock).toHaveBeenCalledWith(["node", "openclaw", "--help"]); expect(tryRouteCliMock).not.toHaveBeenCalled(); + expect(loadRootHelpRenderOptionsForConfigSensitivePluginsMock).toHaveBeenCalledTimes(1); expect(outputPrecomputedRootHelpTextMock).toHaveBeenCalledTimes(1); expect(outputRootHelpMock).toHaveBeenCalledTimes(1); expect(buildProgramMock).not.toHaveBeenCalled(); @@ -424,6 +436,28 @@ describe("runCli exit behavior", () => { exitSpy.mockRestore(); }); + it("renders config-sensitive root help live instead of precomputed metadata", async () => { + const liveOptions: RootHelpRenderOptions = { + config: { + plugins: { + slots: { + memory: "memory-lancedb", + }, + }, + }, + env: process.env, + }; + loadRootHelpRenderOptionsForConfigSensitivePluginsMock.mockResolvedValueOnce(liveOptions); + outputPrecomputedRootHelpTextMock.mockReturnValueOnce(true); + + await runCli(["node", "openclaw", "--help"]); + + expect(loadRootHelpRenderOptionsForConfigSensitivePluginsMock).toHaveBeenCalledTimes(1); + expect(outputPrecomputedRootHelpTextMock).not.toHaveBeenCalled(); + expect(outputRootHelpMock).toHaveBeenCalledWith(liveOptions); + expect(buildProgramMock).not.toHaveBeenCalled(); + }); + it("does not start the managed proxy for local gateway client commands", async () => { tryRouteCliMock.mockResolvedValueOnce(true); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 669251adf52..1a678d4b7ef 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -534,11 +534,19 @@ export async function runCli(argv: string[] = process.argv) { try { if (shouldUseRootHelpFastPath(normalizedArgv)) { - const { outputPrecomputedRootHelpText } = await import("./root-help-metadata.js"); - if (!outputPrecomputedRootHelpText()) { - const { outputRootHelp } = await import("./program/root-help.js"); - await outputRootHelp(); + const { loadRootHelpRenderOptionsForConfigSensitivePlugins } = + await import("./root-help-live-config.js"); + const liveRootHelpOptions = await loadRootHelpRenderOptionsForConfigSensitivePlugins( + process.env, + ); + if (!liveRootHelpOptions) { + const { outputPrecomputedRootHelpText } = await import("./root-help-metadata.js"); + if (outputPrecomputedRootHelpText()) { + return; + } } + const { outputRootHelp } = await import("./program/root-help.js"); + await outputRootHelp(liveRootHelpOptions ?? undefined); return; } diff --git a/src/entry.test.ts b/src/entry.test.ts index 19858963a3b..bc2561ef713 100644 --- a/src/entry.test.ts +++ b/src/entry.test.ts @@ -11,6 +11,7 @@ describe("entry root help fast path", () => { outputPrecomputedRootHelpTextCalls += 1; return true; }, + loadRootHelpRenderOptionsForConfigSensitivePlugins: async () => null, }); expect(handled).toBe(true); @@ -24,6 +25,7 @@ describe("entry root help fast path", () => { outputRootHelp: () => { outputRootHelpCalls += 1; }, + loadRootHelpRenderOptionsForConfigSensitivePlugins: async () => null, env: {}, }); @@ -31,6 +33,37 @@ describe("entry root help fast path", () => { expect(outputRootHelpCalls).toBe(1); }); + it("renders live root help when plugin config changes command descriptors", async () => { + let outputPrecomputedRootHelpTextCalls = 0; + const outputRootHelpOptions: unknown[] = []; + const liveOptions = { + config: { + plugins: { + slots: { + memory: "memory-lancedb", + }, + }, + }, + env: {}, + }; + + const handled = await tryHandleRootHelpFastPath(["node", "openclaw", "--help"], { + env: {}, + outputPrecomputedRootHelpText: () => { + outputPrecomputedRootHelpTextCalls += 1; + return true; + }, + outputRootHelp: (options) => { + outputRootHelpOptions.push(options); + }, + loadRootHelpRenderOptionsForConfigSensitivePlugins: async () => liveOptions, + }); + + expect(handled).toBe(true); + expect(outputPrecomputedRootHelpTextCalls).toBe(0); + expect(outputRootHelpOptions).toEqual([liveOptions]); + }); + it("ignores non-root help invocations", async () => { let outputRootHelpCalls = 0; @@ -38,6 +71,7 @@ describe("entry root help fast path", () => { outputRootHelp: () => { outputRootHelpCalls += 1; }, + loadRootHelpRenderOptionsForConfigSensitivePlugins: async () => null, env: {}, }); @@ -54,6 +88,7 @@ describe("entry root help fast path", () => { outputRootHelp: () => { outputRootHelpCalls += 1; }, + loadRootHelpRenderOptionsForConfigSensitivePlugins: async () => null, env: {}, }, ); diff --git a/src/entry.ts b/src/entry.ts index a72c849b6ab..31c20d54ce7 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { isRootHelpInvocation } from "./cli/argv.js"; import { parseCliContainerArgs, resolveCliContainerTarget } from "./cli/container-target.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; +import type { RootHelpRenderOptions } from "./cli/program/root-help.js"; import { normalizeWindowsArgv } from "./cli/windows-argv.js"; import { enableOpenClawCompileCache, @@ -157,7 +158,10 @@ export async function tryHandleRootHelpFastPath( argv: string[], deps: { outputPrecomputedRootHelpText?: () => boolean; - outputRootHelp?: () => void | Promise; + outputRootHelp?: (options?: RootHelpRenderOptions) => void | Promise; + loadRootHelpRenderOptionsForConfigSensitivePlugins?: ( + env?: NodeJS.ProcessEnv, + ) => Promise; onError?: (error: unknown) => void; env?: NodeJS.ProcessEnv; } = {}, @@ -178,17 +182,22 @@ export async function tryHandleRootHelpFastPath( process.exitCode = 1; }); try { - if (deps.outputRootHelp) { - await deps.outputRootHelp(); - return true; - } - const outputPrecomputedRootHelpText = - deps.outputPrecomputedRootHelpText ?? - (await import("./cli/root-help-metadata.js")).outputPrecomputedRootHelpText; - if (!outputPrecomputedRootHelpText()) { - const { outputRootHelp } = await import("./cli/program/root-help.js"); - await outputRootHelp(); + const loadRootHelpRenderOptionsForConfigSensitivePlugins = + deps.loadRootHelpRenderOptionsForConfigSensitivePlugins ?? + (await import("./cli/root-help-live-config.js")) + .loadRootHelpRenderOptionsForConfigSensitivePlugins; + const liveRootHelpOptions = await loadRootHelpRenderOptionsForConfigSensitivePlugins(deps.env); + if (!liveRootHelpOptions) { + const outputPrecomputedRootHelpText = + deps.outputPrecomputedRootHelpText ?? + (await import("./cli/root-help-metadata.js")).outputPrecomputedRootHelpText; + if (outputPrecomputedRootHelpText()) { + return true; + } } + const outputRootHelp = + deps.outputRootHelp ?? (await import("./cli/program/root-help.js")).outputRootHelp; + await outputRootHelp(liveRootHelpOptions ?? undefined); return true; } catch (error) { handleError(error); diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index 96fc12db0ef..99cbcebd724 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -203,6 +203,144 @@ describe("openclaw launcher", () => { expect(result.stderr).toContain("missing dist/entry.(m)js"); }); + it("uses precomputed root help when plugin config does not invalidate it", async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + await fs.writeFile( + path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), + JSON.stringify({ rootHelpText: "PRECOMPUTED help\n" }), + "utf8", + ); + + const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], { + cwd: fixtureRoot, + env: launcherEnv(), + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("PRECOMPUTED help\n"); + }); + + it("defers root help to the runtime entry when plugin config can change help", async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const configPath = path.join(fixtureRoot, "openclaw.json"); + await fs.writeFile( + path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), + JSON.stringify({ rootHelpText: "PRECOMPUTED memory help\n" }), + "utf8", + ); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + "process.stdout.write('RUNTIME ENTRY\\n');\n", + "utf8", + ); + await fs.writeFile( + configPath, + JSON.stringify({ plugins: { slots: { memory: "memory-lancedb" } } }), + "utf8", + ); + + const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], { + cwd: fixtureRoot, + env: launcherEnv({ OPENCLAW_CONFIG_PATH: configPath }), + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("RUNTIME ENTRY\n"); + expect(result.stdout).not.toContain("PRECOMPUTED"); + }); + + it("checks the OPENCLAW_HOME default config path before using precomputed root help", async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const openclawHome = path.join(fixtureRoot, "home"); + const configDir = path.join(openclawHome, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), + JSON.stringify({ rootHelpText: "PRECOMPUTED memory help\n" }), + "utf8", + ); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + "process.stdout.write('RUNTIME ENTRY\\n');\n", + "utf8", + ); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify({ plugins: { slots: { memory: "memory-lancedb" } } }), + "utf8", + ); + + const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], { + cwd: fixtureRoot, + env: launcherEnv({ OPENCLAW_HOME: openclawHome }), + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("RUNTIME ENTRY\n"); + expect(result.stdout).not.toContain("PRECOMPUTED"); + }); + + it("checks legacy config candidates before using precomputed root help", async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const home = path.join(fixtureRoot, "home"); + const legacyConfigDir = path.join(home, ".clawdbot"); + await fs.mkdir(legacyConfigDir, { recursive: true }); + await fs.writeFile( + path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), + JSON.stringify({ rootHelpText: "PRECOMPUTED memory help\n" }), + "utf8", + ); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + "process.stdout.write('RUNTIME ENTRY\\n');\n", + "utf8", + ); + await fs.writeFile( + path.join(legacyConfigDir, "clawdbot.json"), + JSON.stringify({ plugins: { slots: { memory: "memory-lancedb" } } }), + "utf8", + ); + + const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], { + cwd: fixtureRoot, + env: launcherEnv({ HOME: home, OPENCLAW_HOME: undefined }), + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("RUNTIME ENTRY\n"); + expect(result.stdout).not.toContain("PRECOMPUTED"); + }); + + it("defers root help when the active config has includes", async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const configPath = path.join(fixtureRoot, "openclaw.json"); + await fs.writeFile( + path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), + JSON.stringify({ rootHelpText: "PRECOMPUTED memory help\n" }), + "utf8", + ); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + "process.stdout.write('RUNTIME ENTRY\\n');\n", + "utf8", + ); + await fs.writeFile(configPath, JSON.stringify({ $include: "memory.json" }), "utf8"); + + const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], { + cwd: fixtureRoot, + env: launcherEnv({ OPENCLAW_CONFIG_PATH: configPath }), + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toBe("RUNTIME ENTRY\n"); + expect(result.stdout).not.toContain("PRECOMPUTED"); + }); + it("explains how to recover from an unbuilt source install", async () => { const fixtureRoot = await makeLauncherFixture(fixtureRoots); await addSourceTreeMarker(fixtureRoot);