From ccc8d7146170e1f17bd855ece8b004fc16c3f2f9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 26 Apr 2026 12:58:33 +0100 Subject: [PATCH] fix(cli): keep channel add plugin install noninteractive --- CHANGELOG.md | 1 + docs/cli/channels.md | 2 ++ src/cli/daemon-cli.coverage.test.ts | 10 +++++++-- .../channel-setup/plugin-install.test.ts | 21 +++++++++++++++++++ src/commands/channel-setup/plugin-install.ts | 2 ++ src/commands/channels.add.test.ts | 2 +- src/commands/channels/add.ts | 1 + src/commands/onboarding-plugin-install.ts | 16 ++++++++------ 8 files changed, 46 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58b1578f544..15ffa4dc2ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/CLI: let flag-driven `openclaw channels add` install the selected channel plugin from its default source without opening an interactive prompt, fixing published npm Telegram setup in stdin-closed automation. Thanks @codex. - Plugins/startup: load the default `memory-core` slot during Gateway startup when permitted so active-memory recall can call `memory_search` and `memory_get` without requiring an explicit `plugins.slots.memory` entry, while preserving `plugins.slots.memory: "none"`. Thanks @codex. - Plugins/CLI: prefer native require for compiled bundled plugin JavaScript before jiti so read-only config, status, device, and node commands avoid unnecessary transform overhead on slow hosts. Fixes #62842. Thanks @Effet. - Plugins/compat: inventory doctor-side deprecation migrations separately from runtime plugin compatibility so release sweeps preserve needed repairs while enforcing dated removal windows. Thanks @vincentkoc. diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 2eb834d542a..2b4aa1e07bb 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -59,6 +59,8 @@ Common non-interactive add surfaces include: - Tlon fields: `--ship`, `--url`, `--code`, `--group-channels`, `--dm-allowlist`, `--auto-discover-channels` - `--use-env` for default-account env-backed auth where supported +If a channel plugin needs to be installed during a flag-driven add command, OpenClaw uses the channel's default install source without opening the interactive plugin install prompt. + When you run `openclaw channels add` without flags, the interactive wizard can prompt: - account ids per selected channel diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index a7c837470c7..b50e91d6c1f 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { captureEnv } from "../test-utils/env.js"; @@ -139,17 +142,19 @@ function parseFirstJsonRuntimeLine() { describe("daemon-cli coverage", () => { let envSnapshot: ReturnType; + let tmpDir: string; beforeEach(() => { daemonProgram = createDaemonProgram(); + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-daemon-cli-")); envSnapshot = captureEnv([ "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH", "OPENCLAW_GATEWAY_PORT", "OPENCLAW_PROFILE", ]); - process.env.OPENCLAW_STATE_DIR = "/tmp/openclaw-cli-state"; - process.env.OPENCLAW_CONFIG_PATH = "/tmp/openclaw-cli-state/openclaw.json"; + process.env.OPENCLAW_STATE_DIR = tmpDir; + process.env.OPENCLAW_CONFIG_PATH = path.join(tmpDir, "openclaw.json"); delete process.env.OPENCLAW_GATEWAY_PORT; delete process.env.OPENCLAW_PROFILE; serviceReadCommand.mockResolvedValue(null); @@ -160,6 +165,7 @@ describe("daemon-cli coverage", () => { afterEach(() => { envSnapshot.restore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); }); it("probes gateway status by default", async () => { diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index ebee8943ff4..711486b7253 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -430,6 +430,27 @@ describe("ensureChannelSetupPluginInstalled", () => { ); }); + it("uses the bundled default install source without prompting in non-interactive mode", async () => { + const runtime = makeRuntime(); + const { prompter, select } = makeSkipInstallPrompter(); + const cfg: OpenClawConfig = { update: { channel: "beta" } }; + mockBundledChatSource(); + + const result = await ensureChannelSetupPluginInstalled({ + cfg, + entry: baseEntry, + prompter, + runtime, + promptInstall: false, + }); + + expect(select).not.toHaveBeenCalled(); + expect(result.installed).toBe(true); + expect(result.cfg.plugins?.load?.paths).toContain( + bundledPluginRootAt("/opt/openclaw", "bundled-chat"), + ); + }); + it("does not default to bundled local path when an external catalog overrides the npm spec", async () => { const runtime = makeRuntime(); const { prompter, select } = makeSkipInstallPrompter(); diff --git a/src/commands/channel-setup/plugin-install.ts b/src/commands/channel-setup/plugin-install.ts index 293441a4fee..b6ce164b47d 100644 --- a/src/commands/channel-setup/plugin-install.ts +++ b/src/commands/channel-setup/plugin-install.ts @@ -41,6 +41,7 @@ export async function ensureChannelSetupPluginInstalled(params: { prompter: WizardPrompter; runtime: RuntimeEnv; workspaceDir?: string; + promptInstall?: boolean; }): Promise { const result = await ensureOnboardingPluginInstalled({ cfg: params.cfg, @@ -48,6 +49,7 @@ export async function ensureChannelSetupPluginInstalled(params: { prompter: params.prompter, runtime: params.runtime, workspaceDir: params.workspaceDir, + ...(params.promptInstall !== undefined ? { promptInstall: params.promptInstall } : {}), }); return { cfg: result.cfg, diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index a2ea8912b4a..74775e9ef51 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -501,7 +501,7 @@ describe("channelsAddCommand", () => { ); expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledWith( - expect.objectContaining({ entry: catalogEntry }), + expect.objectContaining({ entry: catalogEntry, promptInstall: false }), ); expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledTimes(1); expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index baad702c9f3..653e26f2d57 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -311,6 +311,7 @@ export async function channelsAddCommand( prompter, runtime, workspaceDir, + promptInstall: false, }); nextConfig = result.cfg; if (!result.installed) { diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index d273fef3cf1..52e107c8891 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -422,6 +422,7 @@ export async function ensureOnboardingPluginInstalled(params: { prompter: WizardPrompter; runtime: RuntimeEnv; workspaceDir?: string; + promptInstall?: boolean; }): Promise { const { entry, prompter, runtime, workspaceDir } = params; let next = params.cfg; @@ -442,12 +443,15 @@ export async function ensureOnboardingPluginInstalled(params: { bundledLocalPath, hasNpmSpec: Boolean(npmSpec), }); - const choice = await promptInstallChoice({ - entry, - localPath, - defaultChoice, - prompter, - }); + const choice = + params.promptInstall === false + ? defaultChoice + : await promptInstallChoice({ + entry, + localPath, + defaultChoice, + prompter, + }); if (choice === "skip") { return {