fix(cli): align configure and channel wizard behavior

This commit is contained in:
Peter Steinberger
2026-07-20 03:28:08 -04:00
parent a971188a6d
commit 87f6172287
11 changed files with 195 additions and 18 deletions
+8 -1
View File
@@ -100,7 +100,14 @@ Non-interactive add flags shared across channels: `--account <id>`, `--name <nam
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:
When you run `openclaw channels add` with no direct account, credential, or channel-config flags, the interactive wizard can prompt. A positional channel id and `--channel <id>` both preselect that channel without bypassing guidance:
```bash
openclaw channels add telegram
openclaw channels add --channel telegram
```
The wizard can prompt for:
- account ids per selected channel
- optional display names for those accounts
+1 -1
View File
@@ -15,7 +15,7 @@ Setup commands by intent:
- `openclaw setup` and `openclaw onboard` verify inference first, then start OpenClaw for Gateway, workspace, channels, skills, and health setup.
- `openclaw setup --baseline` creates the baseline config and workspace without walking the guided onboarding flow.
- `openclaw configure` changes targeted parts of an existing setup: model auth, gateway, channels, plugins, or skills.
- `openclaw channels add` configures channel accounts after the baseline exists; run without flags for guided setup, or with channel-specific flags for scripts.
- `openclaw channels add` configures channel accounts after the baseline exists; a channel selection alone uses guided setup, while account, credential, or channel-config flags use the direct path for scripts.
## Command pages
+72
View File
@@ -8,11 +8,25 @@ import { registerChannelsCli } from "./channels-cli.js";
const listBundledPackageChannelMetadataMock = vi.hoisted(() =>
vi.fn<() => readonly PluginPackageChannel[]>(() => []),
);
const channelsAddCommandMock = vi.hoisted(() => vi.fn(async () => undefined));
const runtimeMock = vi.hoisted(() => ({
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
}));
vi.mock("../plugins/bundled-package-channel-metadata.js", () => ({
listBundledPackageChannelMetadata: listBundledPackageChannelMetadataMock,
}));
vi.mock("../commands/channels.js", () => ({
channelsAddCommand: channelsAddCommandMock,
}));
vi.mock("../runtime.js", () => ({
defaultRuntime: runtimeMock,
}));
function getChannelAddOptionFlags(program: Command): string[] {
const channels = program.commands.find((command) => command.name() === "channels");
const add = channels?.commands.find((command) => command.name() === "add");
@@ -25,6 +39,12 @@ function getChannelSubcommandNames(program: Command, parentName: string): string
return parent?.commands.map((command) => command.name()) ?? [];
}
async function runChannelsAddCli(args: string[]) {
const program = new Command().name("openclaw");
await registerChannelsCli(program, ["node", "openclaw", ...args]);
await program.parseAsync(args, { from: "user" });
}
describe("registerChannelsCli", () => {
const originalArgv = [...process.argv];
@@ -135,4 +155,56 @@ describe("registerChannelsCli", () => {
expect(listBundledPackageChannelMetadataMock).toHaveBeenCalledTimes(1);
expect(getChannelAddOptionFlags(program)).toContain("--homeserver <url>");
});
it.each([
["positional", ["channels", "add", "telegram"]],
["--channel", ["channels", "add", "--channel", "telegram"]],
])("keeps selection-only %s channel adds on the guided path", async (_label, args) => {
await runChannelsAddCli(args);
expect(channelsAddCommandMock).toHaveBeenCalledWith(
expect.objectContaining({ channel: "telegram" }),
runtimeMock,
{ hasFlags: false },
);
});
it.each([
["token", ["--token", "test-token"]],
["token file", ["--token-file", "/tmp/test-token"]],
["environment", ["--use-env"]],
["account", ["--account", "work"]],
])("keeps explicit %s inputs on the direct path", async (_label, extraArgs) => {
await runChannelsAddCli(["channels", "add", "--channel", "telegram", ...extraArgs]);
expect(channelsAddCommandMock).toHaveBeenCalledWith(
expect.objectContaining({ channel: "telegram" }),
runtimeMock,
{ hasFlags: true },
);
});
it("treats plugin-provided config flags as direct automation inputs", async () => {
listBundledPackageChannelMetadataMock.mockReturnValueOnce([
{
id: "matrix",
cliAddOptions: [{ flags: "--homeserver <url>", description: "Matrix homeserver URL" }],
},
]);
await runChannelsAddCli([
"channels",
"add",
"--channel",
"matrix",
"--homeserver",
"https://matrix.example.org",
]);
expect(channelsAddCommandMock).toHaveBeenCalledWith(
expect.objectContaining({ channel: "matrix", homeserver: "https://matrix.example.org" }),
runtimeMock,
{ hasFlags: true },
);
});
});
+5 -1
View File
@@ -19,6 +19,7 @@ type BundledPackageChannelMetadataModule =
typeof import("../plugins/bundled-package-channel-metadata.js");
const optionNamesRemove = ["channel", "account", "delete"] as const;
const CHANNEL_ADD_SELECTION_OPTION_NAMES = new Set(["channel"]);
type RegisterChannelsCliOptions = {
includeSetupOptions?: boolean;
@@ -282,7 +283,10 @@ export async function registerChannelsCli(
addCommand.action(async (channelArg: string | undefined, opts, command) => {
await runChannelsCommand(async () => {
const { channelsAddCommand } = await loadChannelsCommands();
const hasFlags = hasExplicitOptions(command, getOptionNames(command));
const hasFlags = hasExplicitOptions(
command,
getOptionNames(command).filter((name) => !CHANNEL_ADD_SELECTION_OPTION_NAMES.has(name)),
);
await channelsAddCommand(resolveChannelsAddOptions(channelArg, opts), defaultRuntime, {
hasFlags,
});
+2
View File
@@ -100,6 +100,7 @@ export async function removeChannelConfigWizard(
options,
}),
runtime,
1,
);
if (choice.kind === "done") {
@@ -114,6 +115,7 @@ export async function removeChannelConfigWizard(
initialValue: false,
}),
runtime,
1,
);
if (!confirmed) {
continue;
+16 -3
View File
@@ -83,7 +83,21 @@ describe("configureCommandFromSectionsArg", () => {
);
});
it("fails closed for a mixed valid/invalid section list on a non-interactive terminal", async () => {
it("rejects a lone invalid section before unrestricted wizard dispatch", async () => {
const runtime = makeRuntime();
await expect(
configureCommandFromSectionsArg(["bogus"], runtime, { interactive: true }),
).rejects.toThrow("exit 1");
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(runtime.error.mock.calls[0]?.[0]).toBe(
"Invalid --section: bogus. Expected one of: workspace, model, web, gateway, daemon, channels, plugins, skills, health. Run openclaw configure without --section to use the full wizard.",
);
expect(runConfigureWizardMock).not.toHaveBeenCalled();
});
it("validates invalid sections before the interactive-terminal guard", async () => {
const runtime = makeRuntime();
await expect(
@@ -92,9 +106,8 @@ describe("configureCommandFromSectionsArg", () => {
}),
).rejects.toThrow("exit 1");
// Non-TTY guard fires before any section validation, so the wizard never runs.
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(runtime.error.mock.calls[0]?.[0]).toContain("interactive terminal (TTY)");
expect(runtime.error.mock.calls[0]?.[0]).toContain("Invalid --section: bogus");
expect(runConfigureWizardMock).not.toHaveBeenCalled();
});
});
+9 -9
View File
@@ -60,6 +60,15 @@ export async function configureCommandFromSectionsArg(
runtime: RuntimeEnv = defaultRuntime,
options?: { interactive?: boolean },
): Promise<void> {
const { sections, invalid } = parseConfigureWizardSections(rawSections);
if (invalid.length > 0) {
runtime.error(
`Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}. Run ${formatCliCommand("openclaw configure")} without --section to use the full wizard.`,
);
runtime.exit(1);
return;
}
// Fail closed once at the shared entry: both `openclaw configure` and the
// no-subcommand `openclaw config` route here, so a single guard keeps them
// consistent instead of partially entering the wizard on a non-TTY pipe.
@@ -69,19 +78,10 @@ export async function configureCommandFromSectionsArg(
return;
}
const { sections, invalid } = parseConfigureWizardSections(rawSections);
if (sections.length === 0) {
await configureCommand(runtime);
return;
}
if (invalid.length > 0) {
runtime.error(
`Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}. Run ${formatCliCommand("openclaw configure")} without --section to use the full wizard.`,
);
runtime.exit(1);
return;
}
await configureCommandWithSections(sections as never, runtime);
}
+3 -1
View File
@@ -47,6 +47,7 @@ export async function maybeInstallDaemon(params: {
],
}),
params.runtime,
1,
);
if (action === "restart") {
await withProgress(
@@ -93,6 +94,7 @@ export async function maybeInstallDaemon(params: {
initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME,
}),
params.runtime,
1,
) as GatewayDaemonRuntime;
}
}
@@ -156,7 +158,7 @@ export async function maybeInstallDaemon(params: {
await ensureSystemdUserLingerInteractive({
runtime: params.runtime,
prompter: {
confirm: async (p) => guardCancel(await confirm(p), params.runtime),
confirm: async (p) => guardCancel(await confirm(p), params.runtime, 1),
note,
},
reason:
+14
View File
@@ -55,6 +55,7 @@ export async function promptGatewayConfig(
validate: validateGatewayPortInput,
}),
runtime,
1,
);
const port = parsePort(portRaw) ?? resolveGatewayPort(cfg);
@@ -90,6 +91,7 @@ export async function promptGatewayConfig(
],
}),
runtime,
1,
);
let customBindHost: string | undefined;
@@ -101,6 +103,7 @@ export async function promptGatewayConfig(
validate: validateDottedDecimalIPv4Input,
}),
runtime,
1,
);
customBindHost = readStringValue(input);
}
@@ -120,6 +123,7 @@ export async function promptGatewayConfig(
initialValue: "token",
}),
runtime,
1,
) as GatewayAuthChoice;
let tailscaleMode = guardCancel(
@@ -128,6 +132,7 @@ export async function promptGatewayConfig(
options: [...TAILSCALE_EXPOSURE_OPTIONS],
}),
runtime,
1,
);
// Detect Tailscale binary before proceeding with serve/funnel setup.
@@ -149,6 +154,7 @@ export async function promptGatewayConfig(
initialValue: false,
}),
runtime,
1,
);
}
@@ -201,6 +207,7 @@ export async function promptGatewayConfig(
initialValue: "plaintext",
}),
runtime,
1,
);
if (tokenInputMode === "ref") {
const envVar = guardCancel(
@@ -221,6 +228,7 @@ export async function promptGatewayConfig(
},
}),
runtime,
1,
);
const envVarName = normalizeOptionalString(envVar) ?? "";
gatewayToken = {
@@ -237,6 +245,7 @@ export async function promptGatewayConfig(
message: "Gateway token (blank to generate)",
}),
runtime,
1,
);
gatewayTokenForCalls = normalizeGatewayTokenInput(tokenInput) || randomToken();
gatewayToken = gatewayTokenForCalls;
@@ -250,6 +259,7 @@ export async function promptGatewayConfig(
validate: validateGatewayPasswordInput,
}),
runtime,
1,
);
gatewayPassword = normalizeOptionalString(passwordInput) ?? "";
}
@@ -275,6 +285,7 @@ export async function promptGatewayConfig(
validate: (value) => (value?.trim() ? undefined : "User header is required"),
}),
runtime,
1,
);
const requiredHeadersRaw = guardCancel(
@@ -283,6 +294,7 @@ export async function promptGatewayConfig(
placeholder: "x-forwarded-proto,x-forwarded-host",
}),
runtime,
1,
);
const requiredHeaders = requiredHeadersRaw
? normalizeStringEntries(requiredHeadersRaw.split(","))
@@ -294,6 +306,7 @@ export async function promptGatewayConfig(
placeholder: "nick@example.com,admin@company.com",
}),
runtime,
1,
);
const allowUsers = allowUsersRaw ? normalizeStringEntries(allowUsersRaw.split(",")) : [];
@@ -309,6 +322,7 @@ export async function promptGatewayConfig(
},
}),
runtime,
1,
);
trustedProxies = normalizeStringEntries(trustedProxiesRaw.split(","));
+54 -1
View File
@@ -1,6 +1,7 @@
// Configure wizard tests cover guided setup routing across gateway, auth, channels, skills, and search.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
const mocks = vi.hoisted(() => {
const writeConfigFile = vi.fn();
@@ -48,6 +49,7 @@ const mocks = vi.hoisted(() => {
Boolean(config.auth?.profiles?.["openai:default"]),
),
setupChannels: vi.fn(async (cfg: OpenClawConfig) => cfg),
guardCancel: vi.fn((value: unknown, _runtime: RuntimeEnv, _exitCode?: number) => value),
};
});
@@ -114,7 +116,7 @@ vi.mock("./onboard-helpers.js", () => ({
DEFAULT_WORKSPACE: "~/.openclaw/workspace",
applyWizardMetadata: (cfg: OpenClawConfig) => cfg,
ensureWorkspaceAndSessions: vi.fn(),
guardCancel: <T>(value: T) => value,
guardCancel: mocks.guardCancel,
printWizardHeader: mocks.printWizardHeader,
probeGatewayReachable: mocks.probeGatewayReachable,
resolveAdvertisedControlUiLinks: mocks.resolveAdvertisedControlUiLinks,
@@ -346,6 +348,8 @@ describe("runConfigureWizard", () => {
config: cfg,
port: 18789,
}));
mocks.guardCancel.mockReset();
mocks.guardCancel.mockImplementation((value: unknown) => value);
});
it("persists gateway.mode=local when only the run mode is selected", async () => {
@@ -393,6 +397,37 @@ describe("runConfigureWizard", () => {
expect(remoteProbe?.timeoutMs).toBe(300);
});
it("uses the resolved configured port for the local gateway startup hint", async () => {
setupBaseWizardState({
gateway: {
mode: "local",
port: 18991,
},
});
mocks.resolveGatewayPort.mockReturnValue(18991);
mocks.probeGatewayReachable
.mockResolvedValueOnce({ ok: true })
.mockResolvedValue({ ok: false });
mocks.clackSelect.mockResolvedValue("local");
await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime());
expect(mocks.probeGatewayReachable).toHaveBeenCalledWith(
expect.objectContaining({ url: "ws://127.0.0.1:18991", timeoutMs: 300 }),
);
expect(mocks.clackSelect).toHaveBeenCalledWith(
expect.objectContaining({
message: "Where will the Gateway run?",
options: expect.arrayContaining([
expect.objectContaining({
value: "local",
hint: "Gateway reachable (ws://127.0.0.1:18991)",
}),
]),
}),
);
});
it("advertises LAN Control UI links while probing the local gateway", async () => {
setupBaseWizardState({
gateway: {
@@ -455,6 +490,24 @@ describe("runConfigureWizard", () => {
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("uses nonzero exit semantics for cancellation at the first direct Clack prompt", async () => {
const runtime = createRuntime();
setupBaseWizardState();
mocks.guardCancel.mockImplementationOnce(
(_value: unknown, promptRuntime: RuntimeEnv, exitCode?: number) => {
promptRuntime.exit(exitCode ?? 0);
throw new Error("direct prompt cancelled");
},
);
await expect(runConfigureWizard({ command: "configure" }, runtime)).rejects.toThrow(
"direct prompt cancelled",
);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("does not gate model-only configure behind Gateway run-mode selection", async () => {
setupBaseWizardState();
+11 -1
View File
@@ -165,6 +165,7 @@ async function promptConfigureSection(
initialValue: CONFIGURE_SECTION_OPTIONS[0]?.value,
}),
runtime,
1,
);
}
@@ -187,6 +188,7 @@ async function promptChannelMode(runtime: RuntimeEnv): Promise<ChannelsWizardMod
initialValue: "configure",
}),
runtime,
1,
) as ChannelsWizardMode;
}
@@ -221,6 +223,7 @@ async function promptWebToolsConfig(
initialValue: existingSearch?.enabled ?? hasManagedSearchProviders,
}),
runtime,
1,
);
let nextSearch: WebSearchConfig = {
@@ -252,6 +255,7 @@ async function promptWebToolsConfig(
initialValue: existingSearch?.openaiCodex?.enabled === true,
}),
runtime,
1,
);
if (enableCodexNative) {
@@ -273,6 +277,7 @@ async function promptWebToolsConfig(
initialValue: existingSearch?.openaiCodex?.mode ?? "cached",
}),
runtime,
1,
);
nextSearch = {
...nextSearch,
@@ -288,6 +293,7 @@ async function promptWebToolsConfig(
initialValue: Boolean(existingSearch?.provider),
}),
runtime,
1,
);
} else {
nextSearch = {
@@ -342,6 +348,7 @@ async function promptWebToolsConfig(
initialValue: existingFetch?.enabled ?? true,
}),
runtime,
1,
);
const nextFetch = {
@@ -422,7 +429,7 @@ export async function runConfigureWizard(
selectedSections.includes("daemon") ||
selectedSections.includes("health");
const promptGatewayRunMode = async (): Promise<OnboardMode> => {
const localUrl = "ws://127.0.0.1:18789";
const localUrl = `ws://127.0.0.1:${resolveGatewayPort(baseConfig)}`;
const remoteUrl = normalizeOptionalString(baseConfig.gateway?.remote?.url) ?? "";
const localProbePromise = (async () => {
const [baseLocalProbeToken, baseLocalProbePassword] = await Promise.all([
@@ -482,6 +489,7 @@ export async function runConfigureWizard(
],
}),
runtime,
1,
);
};
@@ -580,6 +588,7 @@ export async function runConfigureWizard(
initialValue: workspaceDir,
}),
runtime,
1,
);
workspaceDir = resolveUserPath(
normalizeOptionalString(workspaceInput ?? "") || DEFAULT_WORKSPACE,
@@ -650,6 +659,7 @@ export async function runConfigureWizard(
validate: validateGatewayPortInput,
}),
runtime,
1,
);
gatewayPort = parsePort(portInput) ?? gatewayPort;
};