fix(cli): make commands, completion, and JSON output reliable (#116033)

* fix(cli): make commands, completion, and JSON output reliable

* fix(cli): reconcile completion coverage with current main

* test(cli): keep test routing stable across isolation lanes
This commit is contained in:
Peter Steinberger
2026-07-31 16:20:34 -07:00
committed by GitHub
parent 4645d4a487
commit 433bb3f954
70 changed files with 2789 additions and 551 deletions
@@ -160,6 +160,23 @@ describe("browser action input batch command", () => {
expect(getLastActionBody()).toMatchObject({ kind: "batch", actions: SAMPLE_ACTIONS });
});
it("rejects conflicting inline and file actions before reading either source", async () => {
const program = createActionInputProgram();
await expect(
program.parseAsync(
["browser", "batch", "--actions", "[]", "--actions-file", "/tmp/browser-actions.json"],
{ from: "user" },
),
).rejects.toThrow("__exit__:1");
expect(getBrowserCliRuntimeCapture().runtimeErrors.join("\n")).toContain(
"Specify only one of --actions or --actions-file",
);
expect(mocks.readActionsPayload).not.toHaveBeenCalled();
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
});
it("rejects malformed actions JSON before dispatch", async () => {
mocks.readActionsPayload.mockResolvedValueOnce("NOT JSON {{{");
const program = createActionInputProgram();
@@ -28,6 +28,11 @@ export function registerBrowserBatchCommands(
.option("--target-id <id>", BROWSER_TAB_REFERENCE_HELP)
.action(async (opts, cmd) => {
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
if (opts.actions !== undefined && opts.actionsFile !== undefined) {
defaultRuntime.error(danger("Specify only one of --actions or --actions-file"));
defaultRuntime.exit(1);
return;
}
if (!opts.actions && !opts.actionsFile) {
defaultRuntime.error(danger("Provide --actions, --actions-file, or --actions-file -"));
defaultRuntime.exit(1);
@@ -84,6 +84,22 @@ describe("browser action input fill command", () => {
);
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
});
it("rejects conflicting inline and file fields before dispatch", async () => {
const program = createActionInputProgram();
await expect(
program.parseAsync(
["browser", "fill", "--fields", "[]", "--fields-file", "/tmp/browser-fields.json"],
{ from: "user" },
),
).rejects.toThrow("__exit__:1");
expect(getBrowserCliRuntimeCapture().runtimeErrors.join("\n")).toContain(
"Specify only one of --fields or --fields-file",
);
expect(mocks.callBrowserRequest).not.toHaveBeenCalled();
});
});
describe("browser action input wait command", () => {
@@ -1,6 +1,6 @@
// Browser tests cover shared plugin behavior.
import { describe, expect, it } from "vitest";
import { readFields } from "./shared.js";
import { readActionsPayload, readFields } from "./shared.js";
describe("readFields", () => {
it.each([
@@ -38,4 +38,18 @@ describe("readFields", () => {
it("throws descriptive error on empty fields", async () => {
await expect(readFields({ fields: "" })).rejects.toThrow("fields are required");
});
it("rejects conflicting inline and file form fields", async () => {
await expect(
readFields({ fields: "[]", fieldsFile: "/tmp/openclaw-browser-fields.json" }),
).rejects.toThrow("Specify only one of --fields or --fields-file");
});
});
describe("readActionsPayload", () => {
it("rejects conflicting inline and file actions before reading the file", async () => {
await expect(
readActionsPayload({ actions: "[]", actionsFile: "/tmp/openclaw-browser-actions.json" }),
).rejects.toThrow("Specify only one of --actions or --actions-file");
});
});
@@ -93,6 +93,9 @@ export async function readFields(opts: {
fields?: string;
fieldsFile?: string;
}): Promise<BrowserFormField[]> {
if (opts.fields !== undefined && opts.fieldsFile !== undefined) {
throw new Error("Specify only one of --fields or --fields-file");
}
const payload = opts.fieldsFile ? await readFile(opts.fieldsFile) : (opts.fields ?? "");
if (!payload.trim()) {
throw new Error("fields are required");
@@ -152,6 +155,9 @@ export async function readActionsPayload(opts: {
actions?: string;
actionsFile?: string;
}): Promise<string> {
if (opts.actions !== undefined && opts.actionsFile !== undefined) {
throw new Error("Specify only one of --actions or --actions-file");
}
if (opts.actionsFile) {
return opts.actionsFile === "-" ? await readStdinText() : await readFile(opts.actionsFile);
}
@@ -186,6 +186,26 @@ describe("browser cli snapshot defaults", () => {
expect(params?.query?.depth).toBe(0);
});
it.each([
{
args: ["screenshot", "tab-1", "--type", "webp"],
error: "Invalid --type: expected png or jpeg",
},
{
args: ["snapshot", "--format", "html"],
error: "Invalid --format: expected aria or ai",
},
{
args: ["snapshot", "--mode", "full"],
error: "Invalid --mode: expected efficient",
},
])("rejects unsupported inspect option values before dispatch", async ({ args, error }) => {
await expect(runBrowserInspect(args)).rejects.toThrow("__exit__:1");
expect(runtime.error.mock.calls.at(-1)?.[0]).toContain(error);
expect(sharedMocks.callBrowserRequest).not.toHaveBeenCalled();
});
it("sends screenshot request with trimmed target id and jpeg type", async () => {
const params = await runBrowserInspect(["screenshot", " tab-1 ", "--type", "jpeg"], true);
expect(params?.path).toBe("/screenshot");
@@ -39,6 +39,19 @@ function parseOptionalIntegerOption(
return parsed;
}
function parseBrowserChoiceOption<const T extends string>(
value: string,
label: string,
choices: readonly T[],
): T | undefined {
if ((choices as readonly string[]).includes(value)) {
return value as T;
}
defaultRuntime.error(danger(`Invalid ${label}: expected ${choices.join(" or ")}`));
defaultRuntime.exit(1);
return undefined;
}
/** Registers Browser screenshot and snapshot commands. */
export function registerBrowserInspectCommands(
browser: Command,
@@ -60,6 +73,10 @@ export function registerBrowserInspectCommands(
.action(async (targetId: string | undefined, opts, cmd) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
const type = parseBrowserChoiceOption(opts.type, "--type", ["png", "jpeg"]);
if (type === undefined) {
return;
}
try {
const result = await callBrowserRequest<{ path: string }>(
parent,
@@ -73,7 +90,7 @@ export function registerBrowserInspectCommands(
ref: normalizeOptionalString(opts.ref),
element: normalizeOptionalString(opts.element),
labels: Boolean(opts.labels),
type: opts.type === "jpeg" ? "jpeg" : "png",
type,
},
},
{ timeoutMs: 20000 },
@@ -108,7 +125,17 @@ export function registerBrowserInspectCommands(
.action(async (opts, cmd: Command) => {
const parent = parentOpts(cmd);
const profile = parent?.browserProfile;
const format = opts.format === "aria" ? "aria" : "ai";
const format = parseBrowserChoiceOption(opts.format, "--format", ["aria", "ai"]);
if (format === undefined) {
return;
}
const explicitMode =
opts.mode === undefined
? undefined
: parseBrowserChoiceOption(opts.mode, "--mode", ["efficient"]);
if (opts.mode !== undefined && explicitMode === undefined) {
return;
}
const formatWasExplicit = cmd.getOptionValueSource("format") === "cli";
const configMode =
!formatWasExplicit &&
@@ -116,7 +143,8 @@ export function registerBrowserInspectCommands(
getRuntimeConfig().browser?.snapshotDefaults?.mode === "efficient"
? "efficient"
: undefined;
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : configMode;
const mode =
opts.efficient === true || explicitMode === "efficient" ? "efficient" : configMode;
const limit = parseOptionalIntegerOption(opts.limit, "--limit", { min: 1 });
const depth = parseOptionalIntegerOption(opts.depth, "--depth", { min: 0 });
if (
@@ -475,6 +475,21 @@ describe("browser manage output", () => {
);
});
it("rejects unsupported profile drivers before creating a profile", async () => {
const program = createBrowserManageProgram();
await expect(
program.parseAsync(["browser", "create-profile", "--name", "test", "--driver", "chromium"], {
from: "user",
}),
).rejects.toThrow("__exit__:1");
expect(getBrowserCliRuntimeCapture().runtimeErrors.at(-1)).toContain(
"--driver must be openclaw or existing-session",
);
expect(getBrowserManageCallBrowserRequestMock()).not.toHaveBeenCalled();
});
it("prints a readable browser doctor report", async () => {
getBrowserManageCallBrowserRequestMock().mockImplementation(async (_opts: unknown, req) => {
if (req.path === "/") {
@@ -837,6 +837,13 @@ export function registerBrowserManageCommands(
) => {
const parent = parentOpts(cmd);
await runBrowserCommand(async () => {
if (
opts.driver !== undefined &&
opts.driver !== "openclaw" &&
opts.driver !== "existing-session"
) {
throw new Error("--driver must be openclaw or existing-session");
}
const result = await callBrowserRequest<BrowserCreateProfileResult>(
parent,
{
@@ -69,6 +69,26 @@ describe("memory-lancedb CLI embedding lifecycle", () => {
expect(harness.close).toHaveBeenCalledTimes(1);
});
it("rejects an invalid limit before generating an embedding", async () => {
const harness = createHarness();
await expect(
harness.program.parseAsync([
"node",
"openclaw",
"ltm",
"search",
"hello",
"--limit",
"5items",
]),
).rejects.toThrow("--limit must be a positive integer");
expect(harness.embed).not.toHaveBeenCalled();
expect(harness.search).not.toHaveBeenCalled();
expect(harness.close).toHaveBeenCalledTimes(1);
});
it("preserves a falsy search rejection over cleanup failure", async () => {
const harness = createHarness({
embedError: null,
+1 -1
View File
@@ -140,8 +140,8 @@ export function registerMemoryCli(
let operationFailed = false;
try {
const agentId = resolveCliAgentId(opts.agent);
const vector = await embeddings.embed(normalizeRecallQuery(query, recallMaxChars));
const limit = parsePositiveIntegerOption(opts.limit, "--limit");
const vector = await embeddings.embed(normalizeRecallQuery(query, recallMaxChars));
const results = await db.search(agentId, vector, limit, 0.3);
const output = results.map((r) => ({
id: r.entry.id,
+34
View File
@@ -17,6 +17,7 @@ const listRawChannelPluginCatalogEntriesMock = vi.hoisted(() =>
vi.fn<() => ChannelPluginCatalogEntry[]>(() => []),
);
const channelsAddCommandMock = vi.hoisted(() => vi.fn(async () => undefined));
const channelsResolveCommandMock = vi.hoisted(() => vi.fn(async () => undefined));
const runtimeMock = vi.hoisted(() => ({
log: vi.fn(),
error: vi.fn(),
@@ -33,6 +34,7 @@ vi.mock("../channels/plugins/catalog.js", () => ({
vi.mock("../commands/channels.js", () => ({
channelsAddCommand: channelsAddCommandMock,
channelsResolveCommand: channelsResolveCommandMock,
}));
vi.mock("../runtime.js", () => ({
@@ -89,6 +91,38 @@ describe("registerChannelsCli", () => {
expect(getChannelSubcommandNames(program, "dead-letters")).toEqual(["list", "resubmit"]);
});
it.each(["auto", "user", "group", "channel"])(
"forwards the supported %s resolve target kind",
async (kind) => {
const program = new Command().name("openclaw").exitOverride();
const args = ["channels", "resolve", "--kind", kind, "room"];
await registerChannelsCli(program, ["node", "openclaw", ...args]);
await program.parseAsync(args, { from: "user" });
expect(channelsResolveCommandMock).toHaveBeenCalledWith(
expect.objectContaining({ kind, entries: ["room"] }),
runtimeMock,
);
},
);
it("rejects unsupported resolve target kinds before dispatching", async () => {
const writeErr = vi.fn();
const program = new Command().name("openclaw").exitOverride().configureOutput({ writeErr });
const args = ["channels", "resolve", "--kind", "person", "room"];
await registerChannelsCli(program, ["node", "openclaw", ...args]);
await expect(program.parseAsync(args, { from: "user" })).rejects.toMatchObject({
code: "commander.invalidArgument",
});
expect(writeErr).toHaveBeenCalledWith(
expect.stringContaining("Allowed choices are auto, user, group, channel."),
);
expect(channelsResolveCommandMock).not.toHaveBeenCalled();
});
it("registers ClickClack setup options before an external channel plugin is installed", async () => {
listBundledPackageChannelMetadataMock.mockReturnValueOnce([
{
+7 -3
View File
@@ -1,5 +1,5 @@
// Commander registration for channel discovery, setup, status, auth, and diagnostics commands.
import type { Command } from "commander";
import { Option, type Command } from "commander";
import { formatDocsLink } from "../../packages/terminal-core/src/links.js";
import { theme } from "../../packages/terminal-core/src/theme.js";
import { danger } from "../globals.js";
@@ -221,7 +221,11 @@ export async function registerChannelsCli(
.argument("<entries...>", "Entries to resolve (names or ids)")
.option("--channel <name>", `Channel (${channelNames})`)
.option("--account <id>", "Account id (accountId)")
.option("--kind <kind>", "Target kind (auto|user|group)", "auto")
.addOption(
new Option("--kind <kind>", "Target kind (auto|user|group|channel)")
.choices(["auto", "user", "group", "channel"])
.default("auto"),
)
.option("--json", "Output JSON", false)
.action(async (entries, opts) => {
await runChannelsCommand(async () => {
@@ -230,7 +234,7 @@ export async function registerChannelsCli(
{
channel: opts.channel as string | undefined,
account: opts.account as string | undefined,
kind: opts.kind as "auto" | "user" | "group",
kind: opts.kind as "auto" | "user" | "group" | "channel",
json: Boolean(opts.json),
entries: Array.isArray(entries) ? entries : [String(entries)],
},
+67 -1
View File
@@ -1,7 +1,7 @@
// Command option tests cover shared CLI option registration and parsing.
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import { inheritOptionFromParent } from "./command-options.js";
import { hasExplicitOptions, inheritOptionFromParent } from "./command-options.js";
function attachRunCommandAndCaptureInheritedToken(command: Command) {
let inherited: string | undefined;
@@ -14,6 +14,43 @@ function attachRunCommandAndCaptureInheritedToken(command: Command) {
return () => inherited;
}
describe("hasExplicitOptions", () => {
it.each([
{ source: "cli", expected: true },
{ source: "config", expected: false },
{ source: "env", expected: false },
{ source: "implied", expected: false },
{ source: "default", expected: false },
{ source: undefined, expected: false },
] as const)("recognizes only cli option sources ($source)", ({ source, expected }) => {
const command = new Command().option("--token <token>", "Token");
command.setOptionValueWithSource("token", "test-token", source);
expect(hasExplicitOptions(command, ["token"])).toBe(expected);
});
it("recognizes an explicitly negated option", async () => {
const command = new Command().option("--no-color", "Disable color");
await command.parseAsync(["--no-color"], { from: "user" });
expect(command.getOptionValue("color")).toBe(false);
expect(hasExplicitOptions(command, ["color"])).toBe(true);
});
it("checks every requested option", async () => {
const command = new Command()
.option("--token <token>", "Token")
.option("--force", "Force", false);
await command.parseAsync(["--force"], { from: "user" });
expect(hasExplicitOptions(command, ["token", "force"])).toBe(true);
expect(hasExplicitOptions(command, ["token"])).toBe(false);
expect(hasExplicitOptions(command, [])).toBe(false);
});
});
describe("inheritOptionFromParent", () => {
it.each([
{
@@ -51,6 +88,35 @@ describe("inheritOptionFromParent", () => {
expect(inheritOptionFromParent<string>(run, "token")).toBeUndefined();
});
it("inherits explicitly negated ancestor values", async () => {
const program = new Command();
const gateway = program.command("gateway").option("--no-color", "Disable color");
const run = gateway
.command("run")
.option("--no-color", "Disable color")
.action(() => {});
await program.parseAsync(["gateway", "--no-color", "run"], { from: "user" });
expect(inheritOptionFromParent<boolean>(run, "color")).toBe(false);
});
it("does not override an explicitly negated child value", async () => {
const program = new Command();
const gateway = program.command("gateway").option("--force", "Force");
const run = gateway
.command("run")
.option("--no-force", "Disable force")
.action(() => {});
await program.parseAsync(["gateway", "--force", "run", "--no-force"], {
from: "user",
});
expect(run.getOptionValue("force")).toBe(false);
expect(inheritOptionFromParent<boolean>(run, "force")).toBeUndefined();
});
it("does not inherit from ancestors beyond the bounded traversal depth", async () => {
const program = new Command().option("--token <token>", "Root token");
const level1 = program.command("level1");
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import { getCompletionScript } from "./completion-cli.js";
import {
createAliasedCompletionProgram,
itWithFish,
itWithPowerShell,
runGeneratedBashCompletion,
runGeneratedFishCompletion,
runGeneratedPowerShellCompletion,
} from "./completion-cli.test-support.js";
// Aliases are typeable commands, so every shell must preserve their nested command paths.
describe("completion-cli command aliases", () => {
itWithFish.each([
["a canonical root command", "openclaw --profile work inf", "infer"],
["an aliased root command", "openclaw --profile work cap", "capability"],
["an inline profile and alias", "openclaw --profile=work cap", "capability"],
["an alias-shaped profile value", "openclaw --profile capability cap", "capability"],
["a repeated profile and alias", "openclaw --profile first --profile second cap", "capability"],
])("completes real Fish root aliases after %s", (_name, commandLine, expected) => {
expect(runGeneratedFishCompletion(createAliasedCompletionProgram(), commandLine)).toContain(
expected,
);
});
it("completes root and nested aliases in zsh lists and dispatch", () => {
const script = getCompletionScript("zsh", createAliasedCompletionProgram());
expect(script).toContain("'capability[Run inference]'");
expect(script).toContain("(infer|capability) _openclaw_infer ;;");
expect(script).toContain("'create[Add a job]'");
expect(script).toContain("(add|create) _openclaw_cron_add ;;");
});
it("completes root and nested aliases in bash command paths", () => {
const script = getCompletionScript("bash", createAliasedCompletionProgram());
expect(script).toContain('opts="infer capability cron --profile"');
expect(script).toContain('"infer"|"capability")');
expect(script).toContain('"cron")');
expect(script).toContain('opts="add create"');
expect(script).toContain('"cron add"|"cron create")');
expect(script).toContain('opts="--at"');
});
it.skipIf(process.platform === "win32")("offers options after a nested alias in bash", () => {
expect(
runGeneratedBashCompletion(createAliasedCompletionProgram(), [
"openclaw",
"--profile",
"work",
"cron",
"create",
"--a",
]),
).toEqual(["--at"]);
});
it("completes aliases and their subtrees in fish", () => {
const script = getCompletionScript("fish", createAliasedCompletionProgram());
expect(script).toContain(
'complete -c openclaw -n "__openclaw_command_path_matches -- --profile" -a "capability" -d \'Run inference\'',
);
expect(script).toContain(
'complete -c openclaw -n "__openclaw_command_path_matches capability -- --profile" -a "embed" -d \'Embed text\'',
);
expect(script).toContain(
'complete -c openclaw -n "__openclaw_command_path_matches cron -- --profile" -a "create" -d \'Add a job\'',
);
expect(script).toContain(
"complete -c openclaw -n \"__openclaw_command_path_matches cron create -- --profile --at\" -l at -r -d 'Schedule time'",
);
});
itWithFish.each([
["an aliased nested command", "openclaw cron create -"],
["a canonical nested command", "openclaw cron add -"],
["a global profile", "openclaw --profile work cron create -"],
["an inline global profile", "openclaw --profile=work cron create -"],
["repeated global profiles", "openclaw --profile first --profile second cron create -"],
["an inherited global profile", "openclaw cron --profile work create -"],
["a parent long option", "openclaw cron --timezone UTC create -"],
["a parent short option", "openclaw cron -z UTC create -"],
["an inline parent option", "openclaw cron --timezone=UTC create -"],
["a parent boolean option", "openclaw cron --verbose create -"],
])("keeps real Fish alias completions scoped after %s", (_name, commandLine) => {
const program = createAliasedCompletionProgram();
const cron = program.commands.find((command) => command.name() === "cron");
if (!cron) {
throw new Error("Cron command is unavailable");
}
cron.option("-z, --timezone <zone>", "Time zone").option("--verbose", "Verbose output");
expect(runGeneratedFishCompletion(program, commandLine)).toEqual(["--at"]);
});
itWithFish.each([
["an aliased positional argument", "openclaw cron create meeting -"],
["a canonical positional argument", "openclaw cron add meeting -"],
["a profiled positional argument", "openclaw --profile work cron create meeting -"],
["a parent option and positional argument", "openclaw cron -z UTC create meeting -"],
])("keeps real Fish alias options after %s", (_name, commandLine) => {
const program = createAliasedCompletionProgram();
const cron = program.commands.find((command) => command.name() === "cron");
const add = cron?.commands.find((command) => command.name() === "add");
if (!cron || !add) {
throw new Error("Cron add command is unavailable");
}
cron.option("-z, --timezone <zone>", "Time zone");
add.argument("[label...]", "Job label");
expect(runGeneratedFishCompletion(program, commandLine)).toEqual(["--at"]);
});
it("completes aliases and alias command paths in PowerShell", () => {
const script = getCompletionScript("powershell", createAliasedCompletionProgram());
expect(script).toContain("$completions = @('infer','capability','cron','--profile')");
expect(script).toContain("if ($commandPath -eq 'capability') {");
expect(script).toContain("if ($commandPath -eq 'cron create') {");
});
it("tracks PowerShell command paths past inherited value-taking flags", () => {
const script = getCompletionScript("powershell", createAliasedCompletionProgram());
expect(script).toContain("$valueOptions = @('--profile')");
expect(script).toContain("switch ($candidatePath)");
expect(script).toContain("'cron create'");
expect(script).toContain("'--profile','--at'");
});
itWithPowerShell.each([
["a global option", "openclaw --profile work cron create --a"],
["an inline global option", "openclaw --profile=work cron create --a"],
["repeated global options", "openclaw --profile first --profile second cron create --a"],
["an inherited option after the parent", "openclaw cron --profile work create --a"],
["the canonical nested command", "openclaw --profile work cron add --a"],
])("completes real PowerShell nested aliases after %s", (_name, commandLine) => {
expect(runGeneratedPowerShellCompletion(createAliasedCompletionProgram(), commandLine)).toEqual(
["--at"],
);
});
});
+133
View File
@@ -0,0 +1,133 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import path from "node:path";
import { Command } from "commander";
import { expect, it } from "vitest";
import { getCompletionScript } from "./completion-cli.js";
import { quoteCliArg } from "./quote-cli-arg.js";
export function createAliasedCompletionProgram(): Command {
const program = new Command();
program.name("openclaw");
program.option("--profile <name>", "Profile");
const infer = program.command("infer").alias("capability").description("Run inference");
infer.command("embed").description("Embed text").option("--model <id>", "Model id");
const cron = program.command("cron").description("Cron commands");
cron
.command("add")
.alias("create")
.description("Add a job")
.option("--at <time>", "Schedule time");
return program;
}
export function runGeneratedBashCompletion(program: Command, words: readonly string[]): string[] {
const script = getCompletionScript("bash", program);
const result = spawnSync(
"bash",
[
"--noprofile",
"--norc",
"-c",
`${script}
COMP_WORDS=(${words.map(quoteCliArg).join(" ")})
COMP_CWORD=${words.length - 1}
_openclaw_completion
printf '%s\\n' "\${COMPREPLY[@]}"
`,
],
{ encoding: "utf8" },
);
if (result.error) {
throw result.error;
}
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
return result.stdout.split("\n").filter(Boolean);
}
function findFish(): string | null {
const executable = process.platform === "win32" ? "fish.exe" : "fish";
const candidates = (process.env.PATH ?? "")
.split(path.delimiter)
.filter(Boolean)
.map((directory) => path.join(directory, executable));
return candidates.find((candidate) => existsSync(candidate)) ?? null;
}
const fishPath = findFish();
export const itWithFish = fishPath ? it : it.skip;
export function runGeneratedFishCompletion(program: Command, commandLine: string): string[] {
if (!fishPath) {
throw new Error("Fish is unavailable");
}
const script = getCompletionScript("fish", program);
const quotedCommandLine = commandLine.replaceAll("'", "\\'");
const result = spawnSync(
fishPath,
["--no-config", "--command", `${script}\ncomplete --do-complete '${quotedCommandLine}'`],
{ encoding: "utf8", timeout: 15_000 },
);
if (result.error) {
throw result.error;
}
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
return result.stdout
.split(/\r?\n/)
.filter(Boolean)
.map((completion) => completion.split("\t")[0] ?? completion);
}
function findPowerShell(): string | null {
const executable = process.platform === "win32" ? "pwsh.exe" : "pwsh";
const candidates = [
process.env.OPENCLAW_TEST_PWSH,
...(process.env.PATH ?? "")
.split(path.delimiter)
.filter(Boolean)
.map((directory) => path.join(directory, executable)),
];
return (
candidates.find((candidate): candidate is string =>
Boolean(candidate && existsSync(candidate)),
) ?? null
);
}
const powerShellPath = findPowerShell();
export const itWithPowerShell = powerShellPath ? it : it.skip;
export function runGeneratedPowerShellCompletion(program: Command, commandLine: string): string[] {
if (!powerShellPath) {
throw new Error("PowerShell is unavailable");
}
const script = getCompletionScript("powershell", program);
const quotedCommandLine = commandLine.replaceAll("'", "''");
const result = spawnSync(
powerShellPath,
[
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
`${script}
$line = '${quotedCommandLine}'
[System.Management.Automation.CommandCompletion]::CompleteInput($line, $line.Length, $null).CompletionMatches | ForEach-Object { $_.CompletionText }
`,
],
{ encoding: "utf8" },
);
if (result.error) {
throw result.error;
}
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
return result.stdout.split(/\r?\n/).filter(Boolean);
}
+369 -289
View File
@@ -1,12 +1,19 @@
// Completion CLI tests cover shell completion command generation and install output.
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command, Option } from "commander";
import { describe, expect, it } from "vitest";
import { getCompletionScript, registerCompletionCli } from "./completion-cli.js";
import {
createAliasedCompletionProgram,
itWithFish,
itWithPowerShell,
runGeneratedBashCompletion,
runGeneratedFishCompletion,
runGeneratedPowerShellCompletion,
} from "./completion-cli.test-support.js";
function createCompletionProgram(): Command {
const program = new Command();
@@ -41,115 +48,11 @@ function createDocumentedCompletionProgram(): Command {
return program;
}
function runGeneratedBashCompletion(program: Command, words: readonly string[]): string[] {
const script = getCompletionScript("bash", program);
const result = spawnSync(
"bash",
[
"--noprofile",
"--norc",
"-c",
`${script}
COMP_WORDS=(${words.map((word) => JSON.stringify(word)).join(" ")})
COMP_CWORD=${words.length - 1}
_openclaw_completion
printf '%s\\n' "\${COMPREPLY[@]}"
`,
],
{ encoding: "utf8" },
);
if (result.error) {
throw result.error;
}
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
return result.stdout.split("\n").filter(Boolean);
}
function findFish(): string | null {
const executable = process.platform === "win32" ? "fish.exe" : "fish";
const candidates = (process.env.PATH ?? "")
.split(path.delimiter)
.filter(Boolean)
.map((directory) => path.join(directory, executable));
return candidates.find((candidate) => existsSync(candidate)) ?? null;
}
const fishPath = findFish();
const itWithFish = fishPath ? it : it.skip;
function runGeneratedFishCompletion(program: Command, commandLine: string): string[] {
if (!fishPath) {
throw new Error("Fish is unavailable");
}
const script = getCompletionScript("fish", program);
const quotedCommandLine = commandLine.replaceAll("'", "\\'");
const result = spawnSync(
fishPath,
["--no-config", "--command", `${script}\ncomplete --do-complete '${quotedCommandLine}'`],
{ encoding: "utf8", timeout: 15_000 },
);
if (result.error) {
throw result.error;
}
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
return result.stdout
.split(/\r?\n/)
.filter(Boolean)
.map((completion) => completion.split("\t")[0] ?? completion);
}
function findPowerShell(): string | null {
const executable = process.platform === "win32" ? "pwsh.exe" : "pwsh";
const candidates = [
process.env.OPENCLAW_TEST_PWSH,
...(process.env.PATH ?? "")
.split(path.delimiter)
.filter(Boolean)
.map((directory) => path.join(directory, executable)),
];
return (
candidates.find((candidate): candidate is string =>
Boolean(candidate && existsSync(candidate)),
) ?? null
);
}
const powerShellPath = findPowerShell();
const itWithPowerShell = powerShellPath ? it : it.skip;
function runGeneratedPowerShellCompletion(program: Command, commandLine: string): string[] {
if (!powerShellPath) {
throw new Error("PowerShell is unavailable");
}
const script = getCompletionScript("powershell", program);
const quotedCommandLine = commandLine.replaceAll("'", "''");
const result = spawnSync(
powerShellPath,
[
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
`${script}
$line = '${quotedCommandLine}'
[System.Management.Automation.CommandCompletion]::CompleteInput($line, $line.Length, $null).CompletionMatches | ForEach-Object { $_.CompletionText }
`,
],
{ encoding: "utf8" },
);
if (result.error) {
throw result.error;
}
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
return result.stdout.split(/\r?\n/).filter(Boolean);
function createOptionalChoiceCompletionProgram(): Command {
const program = new Command().name("openclaw");
program.addOption(new Option("--mode [mode]", "Mode").choices(["auto", "manual", "-legacy"]));
program.option("--json", "JSON output");
return program;
}
describe("completion-cli", () => {
@@ -177,6 +80,55 @@ describe("completion-cli", () => {
expect(script).not.toContain("John'\\''s");
});
it("marks zsh option arguments and completes validated shell choices", () => {
const script = getCompletionScript("zsh", createDocumentedCompletionProgram());
expect(script).toContain('"[Gateway token]:token:"');
expect(script).toContain(
'"[Shell to generate completion for (default: zsh)]:shell:(zsh bash powershell fish)"',
);
});
it.skipIf(process.platform === "win32")(
"keeps zsh completion choices literal and preserves candidate boundaries",
() => {
const program = new Command().name("openclaw");
program.addOption(
new Option("--value <value>", "Value").choices([
"two words",
'say "hello"',
"it's literal",
"literal $(printf OPENCLAW_COMPLETION_VALUE_EXECUTED >&2)",
"literal `printf OPENCLAW_COMPLETION_VALUE_EXECUTED >&2`",
]),
);
const result = spawnSync(
"zsh",
[
"-fc",
`${getCompletionScript("zsh", program)}
_arguments() { printf '%s\\n' "$@"; }
_openclaw_root_completion
`,
],
{ encoding: "utf8" },
);
if (result.error) {
if ("code" in result.error && result.error.code === "ENOENT") {
return;
}
throw result.error;
}
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
expect(result.stdout).toContain("two\\ words");
expect(result.stdout).toContain('say\\ \\"hello\\"');
expect(result.stdout).toContain("OPENCLAW_COMPLETION_VALUE_EXECUTED");
},
);
it("defers zsh registration until compinit is available", async () => {
if (process.platform === "win32") {
return;
@@ -259,6 +211,130 @@ describe("completion-cli", () => {
expect(script).toContain("'-s','--shell','-i','--install','--write-state','-y','--yes'");
});
it("generates PowerShell value choices for both completion shell flags", () => {
const script = getCompletionScript("powershell", createDocumentedCompletionProgram());
expect(script).toContain("if ($choiceFlag -in @('-s','--shell')) {");
expect(script).toContain("@('zsh','bash','powershell','fish')");
expect(script).toContain("'ParameterValue'");
});
it("escapes apostrophes in PowerShell completion choices", () => {
const program = new Command().name("openclaw");
program.addOption(new Option("--profile <name>", "Profile").choices(["Jane's", "work"]));
expect(getCompletionScript("powershell", program)).toContain("@('Jane''s','work')");
});
it("matches PowerShell value prefixes literally and case-insensitively", () => {
const program = new Command().name("openclaw");
program.addOption(new Option("--value <value>", "Value").choices(["alpha", "a*literal"]));
expect(getCompletionScript("powershell", program)).toContain(
"StartsWith($choicePrefix, [StringComparison]::OrdinalIgnoreCase)",
);
});
itWithPowerShell.each([
["a long shell flag", "openclaw completion --shell f"],
["a short shell flag", "openclaw completion -s f"],
])("completes validated values in real PowerShell after %s", (_name, commandLine) => {
expect(
runGeneratedPowerShellCompletion(createDocumentedCompletionProgram(), commandLine),
).toEqual(["fish"]);
});
itWithPowerShell.each([
{
name: "an omitted optional value",
commandLine: "openclaw --mode --j",
expected: ["--json"],
},
{
name: "an inline optional value",
commandLine: "openclaw --mode=a",
expected: ["--mode=auto"],
},
{
name: "a hyphen-prefixed optional choice",
commandLine: "openclaw --mode -l",
expected: ["-legacy"],
},
])("preserves real PowerShell completion after $name", ({ commandLine, expected }) => {
expect(
runGeneratedPowerShellCompletion(createOptionalChoiceCompletionProgram(), commandLine),
).toEqual(expected);
});
itWithPowerShell.each([
{
name: "an ordinary prefix",
commandLine: "openclaw --value al",
expected: ["alpha"],
},
{
name: "a literal asterisk",
commandLine: "openclaw --value a*",
expected: ["'a*literal'"],
},
{
name: "a literal opening bracket",
commandLine: "openclaw --value a[",
expected: ["'a[bracket]'"],
},
{
name: "a case-insensitive literal asterisk",
commandLine: "openclaw --value A*",
expected: ["'a*literal'"],
},
{
name: "an inline literal asterisk",
commandLine: "openclaw --value=a*",
expected: ["--value='a*literal'"],
},
])("matches real PowerShell choices with $name", ({ commandLine, expected }) => {
const program = new Command().name("openclaw");
program.addOption(
new Option("--value <value>", "Value").choices(["alpha", "a*literal", "a[bracket]"]),
);
expect(runGeneratedPowerShellCompletion(program, commandLine)).toEqual(expected);
});
itWithPowerShell.each([
{ name: "ordinary choices", value: "alpha", prefix: "al" },
{ name: "whitespace", value: "two words", prefix: "tw" },
{ name: "apostrophes", value: "Jane's", prefix: "Ja" },
{
name: "literal command substitution",
value: "literal $(Write-Error OPENCLAW_COMPLETION_VALUE_EXECUTED)",
prefix: "literal",
},
{
name: "literal backtick metacharacters",
value: "literal `$(Write-Error OPENCLAW_COMPLETION_VALUE_EXECUTED)",
prefix: "literal",
},
{
name: "literal statement separators",
value: "literal; Write-Error OPENCLAW_COMPLETION_VALUE_EXECUTED",
prefix: "literal",
},
])("inserts PowerShell $name as one safe argument", ({ value, prefix }) => {
const program = new Command().name("openclaw");
program.addOption(new Option("--value <value>", "Value").choices([value]));
const safeValue = /^[A-Za-z0-9_./:+-]+$/.test(value)
? value
: `'${value.replaceAll("'", "''")}'`;
expect(runGeneratedPowerShellCompletion(program, `openclaw --value ${prefix}`)).toEqual([
safeValue,
]);
expect(runGeneratedPowerShellCompletion(program, `openclaw --value=${prefix}`)).toEqual([
`--value=${safeValue}`,
]);
});
itWithPowerShell("completes root short and long flags in real PowerShell", () => {
const completions = runGeneratedPowerShellCompletion(
createDocumentedCompletionProgram(),
@@ -412,6 +488,56 @@ describe("completion-cli", () => {
expect(runGeneratedFishCompletion(program, commandLine)).toContain(expected);
});
itWithFish.each([
["a long shell flag", "openclaw completion --shell f"],
["a short shell flag", "openclaw completion -s f"],
])("completes validated values in real Fish after %s", (_name, commandLine) => {
expect(runGeneratedFishCompletion(createDocumentedCompletionProgram(), commandLine)).toEqual([
"fish",
]);
});
it("registers validated Fish option choices without filesystem fallback", () => {
const script = getCompletionScript("fish", createDocumentedCompletionProgram());
expect(script).toContain(" -s s -l shell -r -f -a ");
expect(script).toContain("'zsh' 'bash' 'powershell' 'fish'");
});
itWithFish.each([
{ name: "whitespace", value: "two words", prefix: "tw" },
{ name: "double quotes", value: 'say "hello"', prefix: "sa" },
{ name: "apostrophes", value: "it's literal", prefix: "it" },
{
name: "literal command substitution",
value: "literal $(printf OPENCLAW_COMPLETION_VALUE_EXECUTED >&2)",
prefix: "literal",
},
{
name: "literal backtick substitution",
value: "literal `printf OPENCLAW_COMPLETION_VALUE_EXECUTED >&2`",
prefix: "literal",
},
])("preserves Fish choice $name as one inert candidate", ({ value, prefix }) => {
const program = new Command().name("openclaw");
program.addOption(new Option("--value <value>", "Value").choices([value]));
expect(runGeneratedFishCompletion(program, `openclaw --value ${prefix}`)).toEqual([value]);
});
it("does not require optional Fish option choices", () => {
const program = new Command().name("openclaw");
program.addOption(new Option("--mode [mode]", "Mode").choices(["auto", "manual"]));
const optionLine = getCompletionScript("fish", program)
.split("\n")
.find((line) => line.includes(" -l mode "));
expect(optionLine).toContain(" -f -a ");
expect(optionLine).not.toContain(" -r ");
expect(optionLine).toContain("'auto' 'manual'");
});
it("scopes fish value-taking option skips to the active command path", () => {
const script = getCompletionScript("fish", createCompletionProgram());
@@ -510,6 +636,133 @@ describe("completion-cli", () => {
},
);
it.skipIf(process.platform === "win32").each([
{
name: "a long shell flag",
words: ["openclaw", "completion", "--shell", "f"],
expected: ["fish"],
},
{
name: "a short shell flag",
words: ["openclaw", "completion", "-s", "f"],
expected: ["fish"],
},
{
name: "an inline long shell flag",
words: ["openclaw", "completion", "--shell=f"],
expected: ["--shell=fish"],
},
{
name: "an inline short shell flag",
words: ["openclaw", "completion", "-s=f"],
expected: ["-s=fish"],
},
])("completes validated values in real Bash after $name", ({ words, expected }) => {
expect(runGeneratedBashCompletion(createDocumentedCompletionProgram(), words)).toEqual(
expected,
);
});
it.skipIf(process.platform === "win32").each([
{
name: "an omitted optional value",
words: ["openclaw", "--mode", "--j"],
expected: ["--json"],
},
{
name: "a separate optional value",
words: ["openclaw", "--mode", "a"],
expected: ["auto"],
},
{
name: "an inline optional value",
words: ["openclaw", "--mode=a"],
expected: ["--mode=auto"],
},
{
name: "a hyphen-prefixed optional choice",
words: ["openclaw", "--mode", "-l"],
expected: ["-legacy"],
},
])("preserves real Bash completion after $name", ({ words, expected }) => {
expect(runGeneratedBashCompletion(createOptionalChoiceCompletionProgram(), words)).toEqual(
expected,
);
});
it.skipIf(process.platform === "win32").each([
{
name: "whitespace",
value: "two words",
prefix: "two ",
},
{
name: "double quotes",
value: 'say "hello"',
prefix: 'say "',
},
{
name: "apostrophes",
value: "it's literal",
prefix: "it's",
},
{
name: "literal command substitution",
value: "$(printf OPENCLAW_COMPLETION_VALUE_EXECUTED >&2)",
prefix: "$(",
},
{
name: "literal backtick substitution",
value: "`printf OPENCLAW_COMPLETION_VALUE_EXECUTED >&2`",
prefix: "`",
},
])("keeps Bash choice $name literal without executing it", ({ value, prefix }) => {
const program = new Command().name("openclaw");
program.addOption(new Option("--value <value>", "Value").choices([value]));
expect(runGeneratedBashCompletion(program, ["openclaw", "--value", prefix])).toEqual([value]);
expect(runGeneratedBashCompletion(program, ["openclaw", `--value=${prefix}`])).toEqual([
`--value=${value}`,
]);
});
it.skipIf(process.platform === "win32").each([
{
name: "a root option",
words: ["openclaw", "--channel", "b"],
expected: ["beta"],
},
{
name: "an inherited parent option",
words: ["openclaw", "cron", "create", "--channel", "pre"],
expected: ["preview"],
},
{
name: "an inline inherited parent option",
words: ["openclaw", "cron", "create", "--channel=pre"],
expected: ["--channel=preview"],
},
{
name: "a differently prefixed inherited parent choice",
words: ["openclaw", "cron", "create", "--channel", "pro"],
expected: ["production"],
},
])("uses the nearest validated Bash choices for $name", ({ words, expected }) => {
const program = createAliasedCompletionProgram();
program.addOption(
new Option("--channel <channel>", "Update channel").choices(["stable", "beta"]),
);
const cron = program.commands.find((command) => command.name() === "cron");
if (!cron) {
throw new Error("Cron command is unavailable");
}
cron.addOption(
new Option("--channel <channel>", "Cron channel").choices(["production", "preview"]),
);
expect(runGeneratedBashCompletion(program, words)).toEqual(expected);
});
it("preserves documented short and long completion flags in Fish and Zsh", () => {
const program = createDocumentedCompletionProgram();
const fishScript = getCompletionScript("fish", program);
@@ -531,7 +784,7 @@ describe("completion-cli", () => {
expect(fishScript).toContain(" -s s -l shell -r -f -a ");
expect(fishScript).toContain(`"'zsh' 'bash' 'powershell' 'fish'"`);
expect(zshScript).toContain(
`{--shell,-s}"[Shell to generate completion for (default: zsh)]:shell:('zsh' 'bash' 'powershell' 'fish')"`,
`{--shell,-s}"[Shell to generate completion for (default: zsh)]:shell:(zsh bash powershell fish)"`,
);
expect(zshScript).toContain('{--token,-t}"[Gateway token]:token:"');
});
@@ -595,7 +848,7 @@ describe("completion-cli", () => {
expect(script).toContain("$choiceFlag -in @('-s','--shell')");
expect(script).toContain("$wordToComplete -match '^(--[^=]+)=(.*)$'");
expect(script).toContain("$wordToComplete -match '^-[^-].+$'");
expect(script).toContain("[WildcardPattern]::Escape($choicePrefix)");
expect(script).toContain("StartsWith($choicePrefix, [StringComparison]::OrdinalIgnoreCase)");
expect(script).toContain("@('zsh','bash','powershell','fish')");
});
@@ -614,8 +867,8 @@ describe("completion-cli", () => {
const script = getCompletionScript("powershell", program);
expect(script).toContain('$completionText = "$choiceCompletionPrefix$_"');
expect(script).toContain('$completionText.Replace("\'", "\'\'")');
expect(script).toContain('$completionText = "$choiceCompletionPrefix$choiceValue"');
expect(script).toContain('$_.Replace("\'", "\'\'")');
expect(script).toContain(
"[System.Management.Automation.CompletionResult]::new($completionText, $_, 'ParameterValue', $_)",
);
@@ -644,7 +897,7 @@ describe("completion-cli", () => {
itWithPowerShell.each([
["a spaced choice", "openclaw --theme l", "'light blue'"],
["an attached spaced choice", "openclaw --theme=l", "'--theme=light blue'"],
["an attached spaced choice", "openclaw --theme=l", "--theme='light blue'"],
["an apostrophe", "openclaw --theme Bob", "'Bob''s green'"],
["a backtick", "openclaw --theme p", "'path`name'"],
])("quotes %s in real PowerShell completion text", (_name, commandLine, expected) => {
@@ -690,7 +943,7 @@ describe("completion-cli", () => {
"Bob's green",
]);
expect(getCompletionScript("fish", program)).toContain(`"'light blue' 'dark'`);
expect(getCompletionScript("zsh", program)).toContain("('light blue' 'dark' 'Bob");
expect(getCompletionScript("zsh", program)).toContain(":theme:(light");
},
);
@@ -709,176 +962,3 @@ describe("completion-cli", () => {
expect(result.status).toBe(0);
});
});
// Commander aliases are typeable commands (`openclaw capability` == `openclaw infer`),
// so every shell must complete alias names and keep completing after an alias.
function createAliasedCompletionProgram(): Command {
const program = new Command();
program.name("openclaw");
program.option("--profile <name>", "Profile");
const infer = program.command("infer").alias("capability").description("Run inference");
infer.command("embed").description("Embed text").option("--model <id>", "Model id");
const cron = program.command("cron").description("Cron commands");
cron
.command("add")
.alias("create")
.description("Add a job")
.option("--at <time>", "Schedule time");
return program;
}
describe("completion-cli command aliases", () => {
itWithFish.each([
["a canonical root command", "openclaw --profile work inf", "infer"],
["an aliased root command", "openclaw --profile work cap", "capability"],
["an inline profile and alias", "openclaw --profile=work cap", "capability"],
["an alias-shaped profile value", "openclaw --profile capability cap", "capability"],
["a repeated profile and alias", "openclaw --profile first --profile second cap", "capability"],
])("completes real Fish root aliases after %s", (_name, commandLine, expected) => {
expect(runGeneratedFishCompletion(createAliasedCompletionProgram(), commandLine)).toContain(
expected,
);
});
it("completes root and nested aliases in zsh lists and dispatch", () => {
const script = getCompletionScript("zsh", createAliasedCompletionProgram());
expect(script).toContain("'capability[Run inference]'");
expect(script).toContain("(infer|capability) _openclaw_infer ;;");
expect(script).toContain("'create[Add a job]'");
expect(script).toContain("(add|create) _openclaw_cron_add ;;");
});
it("completes root and nested aliases in bash command paths", () => {
const script = getCompletionScript("bash", createAliasedCompletionProgram());
expect(script).toContain('opts="infer capability cron --profile"');
expect(script).toContain('"infer"|"capability")');
expect(script).toContain('"cron")');
expect(script).toContain('opts="add create"');
expect(script).toContain('"cron add"|"cron create")');
expect(script).toContain('opts="--at"');
});
it("offers options after a nested alias in bash", () => {
if (process.platform === "win32") {
return;
}
const script = getCompletionScript("bash", createAliasedCompletionProgram());
const result = spawnSync(
"bash",
[
"--noprofile",
"--norc",
"-c",
`${script}
COMP_WORDS=(openclaw --profile work cron create --a)
COMP_CWORD=5
_openclaw_completion
printf '%s\\n' "\${COMPREPLY[@]}"
`,
],
{ encoding: "utf8" },
);
if (result.error) {
if (
"code" in result.error &&
(result.error.code === "ENOENT" || result.error.code === "EACCES")
) {
return;
}
throw result.error;
}
expect(result.stderr).toBe("");
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe("--at");
});
it("completes aliases and their subtrees in fish", () => {
const script = getCompletionScript("fish", createAliasedCompletionProgram());
expect(script).toContain(
'complete -c openclaw -n "__openclaw_command_path_matches -- --profile" -a "capability" -d \'Run inference\'',
);
expect(script).toContain(
'complete -c openclaw -n "__openclaw_command_path_matches capability -- --profile" -a "embed" -d \'Embed text\'',
);
expect(script).toContain(
'complete -c openclaw -n "__openclaw_command_path_matches cron -- --profile" -a "create" -d \'Add a job\'',
);
expect(script).toContain(
"complete -c openclaw -n \"__openclaw_command_path_matches cron create -- --profile --at\" -l at -r -d 'Schedule time'",
);
});
itWithFish.each([
["an aliased nested command", "openclaw cron create -"],
["a canonical nested command", "openclaw cron add -"],
["a global profile", "openclaw --profile work cron create -"],
["an inline global profile", "openclaw --profile=work cron create -"],
["repeated global profiles", "openclaw --profile first --profile second cron create -"],
["an inherited global profile", "openclaw cron --profile work create -"],
["a parent long option", "openclaw cron --timezone UTC create -"],
["a parent short option", "openclaw cron -z UTC create -"],
["an inline parent option", "openclaw cron --timezone=UTC create -"],
["a parent boolean option", "openclaw cron --verbose create -"],
])("keeps real Fish alias completions scoped after %s", (_name, commandLine) => {
const program = createAliasedCompletionProgram();
const cron = program.commands.find((command) => command.name() === "cron");
if (!cron) {
throw new Error("Cron command is unavailable");
}
cron.option("-z, --timezone <zone>", "Time zone").option("--verbose", "Verbose output");
expect(runGeneratedFishCompletion(program, commandLine)).toEqual(["--at"]);
});
itWithFish.each([
["an aliased positional argument", "openclaw cron create meeting -"],
["a canonical positional argument", "openclaw cron add meeting -"],
["a profiled positional argument", "openclaw --profile work cron create meeting -"],
["a parent option and positional argument", "openclaw cron -z UTC create meeting -"],
])("keeps real Fish alias options after %s", (_name, commandLine) => {
const program = createAliasedCompletionProgram();
const cron = program.commands.find((command) => command.name() === "cron");
const add = cron?.commands.find((command) => command.name() === "add");
if (!cron || !add) {
throw new Error("Cron add command is unavailable");
}
cron.option("-z, --timezone <zone>", "Time zone");
add.argument("[label...]", "Job label");
expect(runGeneratedFishCompletion(program, commandLine)).toEqual(["--at"]);
});
it("completes aliases and alias command paths in PowerShell", () => {
const script = getCompletionScript("powershell", createAliasedCompletionProgram());
expect(script).toContain("$completions = @('infer','capability','cron','--profile')");
expect(script).toContain("if ($commandPath -eq 'capability') {");
expect(script).toContain("if ($commandPath -eq 'cron create') {");
});
it("tracks PowerShell command paths past inherited value-taking flags", () => {
const script = getCompletionScript("powershell", createAliasedCompletionProgram());
expect(script).toContain("$valueOptions = @('--profile')");
expect(script).toContain("switch ($candidatePath)");
expect(script).toContain("'cron create'");
expect(script).toContain("'--profile','--at'");
});
itWithPowerShell.each([
["a global option", "openclaw --profile work cron create --a"],
["an inline global option", "openclaw --profile=work cron create --a"],
["repeated global options", "openclaw --profile first --profile second cron create --a"],
["an inherited option after the parent", "openclaw cron --profile work create --a"],
["the canonical nested command", "openclaw --profile work cron add --a"],
])("completes real PowerShell nested aliases after %s", (_name, commandLine) => {
expect(runGeneratedPowerShellCompletion(createAliasedCompletionProgram(), commandLine)).toEqual(
["--at"],
);
});
});
+34 -26
View File
@@ -26,6 +26,7 @@ import {
import { getCoreCliCommandNames, registerCoreCliByName } from "./program/command-registry-core.js";
import { getProgramContext } from "./program/program-context.js";
import { getSubCliEntries, registerSubCliByName } from "./program/register.subclis-core.js";
import { quoteCliArg } from "./quote-cli-arg.js";
export function getCompletionScript(shell: CompletionShell, program: Command): string {
if (shell === "zsh") {
@@ -304,9 +305,7 @@ function generateZshArgs(cmd: Command): string {
const name = preferredCompletionFlag(opt);
const alternate = flags.find((flag) => flag !== name);
const desc = escapeZshDoubleQuotedDescription(opt.description);
const choices = opt.argChoices
?.map((choice) => escapeZshDoubleQuotedDescription(`'${choice.replaceAll("'", "'\\''")}'`))
.join(" ");
const choices = opt.argChoices?.map(escapeZshCompletionChoice).join(" ");
const argument =
opt.required || opt.optional
? `${opt.optional ? "::" : ":"}${opt.attributeName()}:${choices ? `(${choices})` : ""}`
@@ -319,6 +318,11 @@ function generateZshArgs(cmd: Command): string {
.join(" \\\n ");
}
function escapeZshCompletionChoice(choice: string): string {
// `_arguments` parses this list after zsh parses the surrounding double-quoted spec.
return escapeZshDoubleQuotedDescription(choice.replace(/([\\\s:()[\]{}*?!|&;<>"'$`])/g, "\\$1"));
}
function generateZshSubcmdList(cmd: Command): string {
const list = cmd.commands
.flatMap((c) => {
@@ -431,7 +435,7 @@ ${commandPathUpdate}
choice_flag="\${COMP_WORDS[COMP_CWORD-1]}"
choice_prefix="\${cur}"
choice_completion_prefix=""
if [[ "\${cur}" == --*=* ]]; then
if [[ "\${cur}" == -*=* ]]; then
choice_flag="\${cur%%=*}"
choice_prefix="\${cur#*=}"
choice_completion_prefix="\${choice_flag}="
@@ -450,7 +454,7 @@ ${commandPathUpdate}
fi
done
fi
if [[ "\${cur}" == -??* && "\${cur}" != --* ]]; then
if [[ "\${cur}" == -??* && "\${cur}" != --* && "\${cur}" != *=* ]]; then
short_group="\${cur#-}"
for ((short_index = 0; short_index < \${#short_group}; short_index++)); do
short_flag="-\${short_group:short_index:1}"
@@ -464,7 +468,7 @@ ${commandPathUpdate}
fi
${choiceCompletion}
COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) )
COMPREPLY=( $(compgen -W "\${opts}" -- "\${cur}") )
}
complete -F _${rootCmd}_completion ${rootCmd}
@@ -479,21 +483,19 @@ function generateBashOptionChoiceCompletion(contexts: ShellCompletionContext[]):
const optionCases = valueChoices
.map(({ flags, choices, requiresValue }) => {
const optionFlags = flags.map((flag) => `"${flag}"`).join("|");
const escapedChoices = choices
.map((choice) => `'${choice.replaceAll("'", "'\\''")}'`)
.join(" ");
const shouldComplete = requiresValue
const escapedChoices = choices.map(quoteCliArg).join(" ");
const shouldReturn = requiresValue
? "true"
: `[[ -n "\${choice_completion_prefix}" || "\${choice_prefix}" != -* ]]`;
: `[[ \${#COMPREPLY[@]} -gt 0 || -n "\${choice_completion_prefix}" || "\${choice_prefix}" != -* ]]`;
return ` ${optionFlags})
if ${shouldComplete}; then
local -a choice_values=(${escapedChoices})
local choice
for choice in "\${choice_values[@]}"; do
if [[ "\${choice}" == "\${choice_prefix}"* ]]; then
COMPREPLY+=("\${choice_completion_prefix}\${choice}")
fi
done
local -a choice_values=(${escapedChoices})
local choice
for choice in "\${choice_values[@]}"; do
if [[ "\${choice}" == "\${choice_prefix}"* ]]; then
COMPREPLY+=("\${choice_completion_prefix}\${choice}")
fi
done
if ${shouldReturn}; then
return
fi
;;`;
@@ -596,16 +598,22 @@ ${commandPathCases}
flags,
choices,
requiresValue,
}) => ` if ($choiceFlag -in ${formatPowerShellArray(flags)} -and (${requiresValue ? "$true" : "$choiceCompletionPrefix -ne '' -or $choicePrefix -notlike '-*'"})) {
$escapedChoicePrefix = [WildcardPattern]::Escape($choicePrefix)
${formatPowerShellArray(choices)} | Where-Object { $_ -like "$escapedChoicePrefix*" } | ForEach-Object {
$completionText = "$choiceCompletionPrefix$_"
if ($completionText -notmatch '^[\\p{L}\\p{N}_./:=+-]+$') {
$completionText = "'" + $completionText.Replace("'", "''") + "'"
}) => ` if ($choiceFlag -in ${formatPowerShellArray(flags)}) {
$matchingChoices = @(${formatPowerShellArray(choices)} | Where-Object {
$_.StartsWith($choicePrefix, [StringComparison]::OrdinalIgnoreCase)
})
$matchingChoices | ForEach-Object {
$choiceValue = if ($_ -match '^[A-Za-z0-9_./:+-]+$') {
$_
} else {
"'" + $_.Replace("'", "''") + "'"
}
$completionText = "$choiceCompletionPrefix$choiceValue"
[System.Management.Automation.CompletionResult]::new($completionText, $_, 'ParameterValue', $_)
}
return
if (${requiresValue ? "$true" : "$matchingChoices.Count -gt 0 -or $choiceCompletionPrefix -ne '' -or $choicePrefix -notlike '-*'"}) {
return
}
}`,
)
.join("\n");
+27 -6
View File
@@ -1,5 +1,4 @@
import { isRecord as isPlainRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import JSON5 from "json5";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import { parseConfigPathArrayIndex } from "../shared/path-array-index.js";
@@ -56,6 +55,28 @@ function assertNotWhitespaceSegment(current: string, raw: string): void {
}
}
function findBracketPathClose(path: string, open: number): number {
let quote: '"' | "'" | undefined;
for (let index = open + 1; index < path.length; index += 1) {
const character = path[index];
if (quote) {
if (character === "\\") {
index += 1;
} else if (character === quote) {
quote = undefined;
}
continue;
}
if (character === "]") {
return index;
}
if ((character === '"' || character === "'") && !path.slice(open + 1, index).trim()) {
quote = character;
}
}
return -1;
}
function parsePath(raw: string): PathSegment[] {
const trimmed = raw.trim();
if (!trimmed) {
@@ -82,7 +103,7 @@ function parsePath(raw: string): PathSegment[] {
throw new Error(`Invalid path (empty segment): ${raw}`);
}
if (current) {
parts.push(current);
parts.push(current.trim());
}
current = "";
segmentEmitted = false;
@@ -95,10 +116,10 @@ function parsePath(raw: string): PathSegment[] {
throw new Error(`Invalid path (empty segment): ${raw}`);
}
if (current) {
parts.push(current);
parts.push(current.trim());
}
current = "";
const close = trimmed.indexOf("]", i);
const close = findBracketPathClose(trimmed, i);
if (close === -1) {
throw new Error(`Invalid path (missing "]"): ${raw}`);
}
@@ -122,9 +143,9 @@ function parsePath(raw: string): PathSegment[] {
throw new Error(`Invalid path (empty segment): ${raw}`);
}
if (current) {
parts.push(current);
parts.push(current.trim());
}
return normalizeStringEntries(parts);
return parts;
}
export function parseConfigSetPath(path: string): string[] {
+131
View File
@@ -1322,6 +1322,10 @@ describe("config cli", () => {
path: "agents.list[0]id",
error: "Invalid path (missing separator after bracket): agents.list[0]id",
},
{
path: "gateway.port\\",
error: "Invalid path (trailing escape): gateway.port\\",
},
])(
"returns a JSON error without reading configuration for malformed $path",
async (testCase) => {
@@ -3517,10 +3521,137 @@ describe("config cli", () => {
"channels.discord.guilds.prod\\\\.channels",
["channels", "discord", "guilds", "prod\\", "channels"],
],
[
'channels.discord.guilds["prod]guild"].channels',
["channels", "discord", "guilds", "prod]guild", "channels"],
],
[
"channels.discord.guilds['prod]guild'].channels",
["channels", "discord", "guilds", "prod]guild", "channels"],
],
[
'channels.discord.guilds["prod\\"]guild"].channels',
["channels", "discord", "guilds", 'prod"]guild', "channels"],
],
[
"channels.discord.guilds['prod\\']guild'].channels",
["channels", "discord", "guilds", "prod']guild", "channels"],
],
[
'channels.discord.guilds[" prod.guild "].channels',
["channels", "discord", "guilds", " prod.guild ", "channels"],
],
])("preserves valid bracket path %s", (configPath, expected) => {
expect(parseConfigSetPath(configPath)).toEqual(expected);
});
it("reads quoted bracket keys containing closing brackets", async () => {
const resolved = {
channels: {
discord: {
guilds: {
"prod]guild": { channels: ["alerts"] },
},
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"get",
'channels.discord.guilds["prod]guild"].channels',
"--json",
]);
expect(parseLastLogPayload()).toEqual(["alerts"]);
expect(mockReadConfigFileSnapshot).toHaveBeenCalledWith({ observe: false });
expect(mockWriteConfigFile).not.toHaveBeenCalled();
});
it("updates only the quoted bracket key containing a closing bracket", async () => {
const resolved = {
channels: {
discord: {
guilds: {
"prod]guild": { channels: ["alerts"] },
staging: { channels: ["chat"] },
},
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
'channels.discord.guilds["prod]guild"].channels',
'["alerts","ops"]',
"--strict-json",
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig() as {
channels?: { discord?: { guilds?: Record<string, { channels?: string[] }> } };
};
expect(written.channels?.discord?.guilds?.["prod]guild"]?.channels).toEqual([
"alerts",
"ops",
]);
expect(written.channels?.discord?.guilds?.staging?.channels).toEqual(["chat"]);
});
it("removes only the quoted bracket key containing a closing bracket", async () => {
const resolved = {
channels: {
discord: {
guilds: {
"prod]guild": { channels: ["alerts"] },
staging: { channels: ["chat"] },
},
},
},
} as unknown as OpenClawConfig;
setSnapshot(resolved, resolved);
await runConfigCommand(["config", "unset", 'channels.discord.guilds["prod]guild"].channels']);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = firstWrittenConfig() as {
channels?: { discord?: { guilds?: Record<string, { channels?: string[] }> } };
};
expect(written.channels?.discord?.guilds?.["prod]guild"]).not.toHaveProperty("channels");
expect(written.channels?.discord?.guilds?.staging?.channels).toEqual(["chat"]);
expect(firstWriteConfigOptions()).toEqual({
auditOrigin: "cli",
unsetPaths: [["channels", "discord", "guilds", "prod]guild", "channels"]],
});
});
it("rejects trailing escapes in config patch replacement paths", async () => {
const pathname = writeTempJson5File("openclaw-config-patch-dangling-escape", {
gateway: { port: 23456 },
});
try {
await expect(
runConfigCommand([
"config",
"patch",
"--file",
pathname,
"--replace-path",
"gateway.port\\",
]),
).rejects.toThrow(ExitError);
} finally {
fs.rmSync(pathname, { force: true });
}
expectErrorIncludes("Invalid path (trailing escape): gateway.port\\");
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
expect(mockWriteConfigFile).not.toHaveBeenCalled();
});
it("preserves valid bracket path forms", async () => {
const resolved: OpenClawConfig = {
agents: { list: [{ id: "main" }, { id: "other" }] },
+14
View File
@@ -989,6 +989,13 @@ describe("cron cli", () => {
});
});
it.each(["", " "])("rejects a blank cron list agent filter %j", async (agent) => {
await expectCronCommandExit(["cron", "list", "--agent", agent]);
expectRuntimeErrorContaining("--agent must not be blank");
expect(callGatewayFromCli.mock.calls.some(([method]) => method === "cron.list")).toBe(false);
});
it("routes cron get to cron.get with the provided id", async () => {
await runCronCommand(["cron", "get", "job-1"]);
@@ -1007,6 +1014,13 @@ describe("cron cli", () => {
);
});
it.each(["", " "])("rejects a blank cron run history filter %j", async (runId) => {
await expectCronCommandExit(["cron", "runs", "--id", "job-1", "--run-id", runId]);
expectRuntimeErrorContaining("--run-id must not be blank");
expect(callGatewayFromCli.mock.calls.some(([method]) => method === "cron.runs")).toBe(false);
});
it("paginates cron show lookups", async () => {
resetGatewayMock();
callGatewayFromCli.mockImplementation(
+3
View File
@@ -61,6 +61,9 @@ export function registerCronListCommand(cron: Command) {
includeDisabled: Boolean(opts.all),
};
const agentId = normalizeOptionalString(opts.agent);
if (typeof opts.agent === "string" && !agentId) {
throw new Error("--agent must not be blank");
}
if (agentId) {
listParams.agentId = sanitizeAgentId(agentId);
}
@@ -206,3 +206,37 @@ describe("cron disable hint", () => {
}
});
});
describe("cron scheduler status warnings", () => {
beforeEach(() => {
callGatewayFromCli.mockReset();
vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => {});
vi.spyOn(defaultRuntime, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it.each([
{ status: undefined, disabled: false },
{ status: null, disabled: false },
{ status: {}, disabled: false },
{ status: { enabled: true }, disabled: false },
{ status: { enabled: false }, disabled: true },
])("warns only when scheduler disabled is known ($status)", async ({ status, disabled }) => {
callGatewayFromCli.mockImplementation(async (method: string) =>
method === "cron.status" ? status : { ok: true },
);
await runCronToggle("enable");
if (disabled) {
expect(defaultRuntime.error).toHaveBeenCalledWith(
expect.stringContaining("scheduler is disabled"),
);
} else {
expect(defaultRuntime.error).not.toHaveBeenCalled();
}
});
});
+3
View File
@@ -211,6 +211,9 @@ export function registerCronSimpleCommands(cron: Command) {
throw new Error("Invalid --limit (must be a positive integer).");
}
const id = String(opts.id);
if (typeof opts.runId === "string" && !opts.runId.trim()) {
throw new Error("--run-id must not be blank");
}
const res = await callGatewayFromCli("cron.runs", opts, {
id,
...(typeof opts.runId === "string" && opts.runId.trim() ? { runId: opts.runId } : {}),
+1 -1
View File
@@ -205,7 +205,7 @@ export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
storage?: string;
sqlitePath?: string;
};
if (res?.enabled === true) {
if (res?.enabled !== false) {
return;
}
const store =
+80
View File
@@ -46,6 +46,9 @@ describe("runDaemonStatus", () => {
beforeEach(() => {
gatherDaemonStatus.mockClear();
printDaemonStatus.mockClear();
defaultRuntime.error.mockClear();
defaultRuntime.exit.mockClear();
defaultRuntime.writeJson.mockClear();
resetRuntimeCapture();
});
@@ -79,6 +82,7 @@ describe("runDaemonStatus", () => {
json: false,
deep: false,
});
expect(defaultRuntime.exit).toHaveBeenCalledTimes(1);
});
it("forwards require-rpc to daemon status gathering", async () => {
@@ -111,5 +115,81 @@ describe("runDaemonStatus", () => {
expect(runtimeErrors[0]).toBe(
"Gateway status failed: --require-rpc needs probing enabled. Remove --no-probe or drop --require-rpc.",
);
expect(defaultRuntime.exit).toHaveBeenCalledTimes(1);
});
it("renders disabled-probe validation failures as JSON in JSON mode", async () => {
await expect(
runDaemonStatus({
rpc: {},
probe: false,
requireRpc: true,
json: true,
}),
).rejects.toThrow("__exit__:1");
expect(gatherDaemonStatus).not.toHaveBeenCalled();
expect(defaultRuntime.writeJson).toHaveBeenCalledWith({
ok: false,
error:
"Gateway status failed: --require-rpc needs probing enabled. Remove --no-probe or drop --require-rpc.",
});
expect(defaultRuntime.error).not.toHaveBeenCalled();
expect(defaultRuntime.exit).toHaveBeenCalledTimes(1);
});
it("renders service-inspection failures as JSON in JSON mode", async () => {
gatherDaemonStatus.mockRejectedValueOnce(new Error("service manager unavailable"));
await expect(
runDaemonStatus({
rpc: {},
probe: true,
requireRpc: false,
json: true,
}),
).rejects.toThrow("__exit__:1");
expect(printDaemonStatus).not.toHaveBeenCalled();
expect(defaultRuntime.writeJson).toHaveBeenCalledWith({
ok: false,
error: "Gateway status failed: Error: service manager unavailable",
});
expect(defaultRuntime.error).not.toHaveBeenCalled();
expect(defaultRuntime.exit).toHaveBeenCalledTimes(1);
});
it("exits only once after printing a failed required RPC probe", async () => {
gatherDaemonStatus.mockResolvedValueOnce({
service: {
label: "LaunchAgent",
loaded: true,
loadedText: "loaded",
notLoadedText: "not loaded",
},
rpc: {
ok: false,
url: "ws://127.0.0.1:18789",
error: "gateway closed",
},
extraServices: [],
});
await expect(
runDaemonStatus({
rpc: {},
probe: true,
requireRpc: true,
json: true,
}),
).rejects.toThrow("__exit__:1");
expect(printDaemonStatus).toHaveBeenCalledTimes(1);
expect(printDaemonStatus).toHaveBeenCalledWith(expect.any(Object), {
json: true,
deep: false,
});
expect(defaultRuntime.error).not.toHaveBeenCalled();
expect(defaultRuntime.exit).toHaveBeenCalledTimes(1);
});
});
+24 -13
View File
@@ -5,29 +5,40 @@ import { gatherDaemonStatus } from "./status.gather.js";
import { printDaemonStatus } from "./status.print.js";
import type { DaemonStatusOptions } from "./types.js";
function failDaemonStatus(opts: DaemonStatusOptions, message: string): void {
if (opts.json) {
defaultRuntime.writeJson({ ok: false, error: message });
} else {
defaultRuntime.error(colorize(isRich(), theme.error, message));
}
defaultRuntime.exit(1);
}
/** Run Gateway status diagnostics and apply --require-rpc exit behavior. */
export async function runDaemonStatus(opts: DaemonStatusOptions) {
if (opts.requireRpc && !opts.probe) {
failDaemonStatus(
opts,
"Gateway status failed: --require-rpc needs probing enabled. Remove --no-probe or drop --require-rpc.",
);
return;
}
let status: Awaited<ReturnType<typeof gatherDaemonStatus>>;
try {
if (opts.requireRpc && !opts.probe) {
defaultRuntime.error(
"Gateway status failed: --require-rpc needs probing enabled. Remove --no-probe or drop --require-rpc.",
);
defaultRuntime.exit(1);
return;
}
const status = await gatherDaemonStatus({
status = await gatherDaemonStatus({
rpc: opts.rpc,
probe: opts.probe,
requireRpc: opts.requireRpc,
deep: opts.deep === true,
});
printDaemonStatus(status, { json: opts.json, deep: opts.deep === true });
if (opts.requireRpc && !status.rpc?.ok) {
defaultRuntime.exit(1);
}
} catch (err) {
const rich = isRich();
defaultRuntime.error(colorize(rich, theme.error, `Gateway status failed: ${String(err)}`));
failDaemonStatus(opts, `Gateway status failed: ${String(err)}`);
return;
}
if (opts.requireRpc && !status.rpc?.ok) {
defaultRuntime.exit(1);
}
}
+11 -1
View File
@@ -1163,7 +1163,17 @@ export async function runDevicesRejectCommand(
requestId: string,
opts: DevicesRpcOpts,
): Promise<void> {
const result = await callGatewayCli("device.pair.reject", opts, { requestId });
const normalizedRequestId = normalizeOptionalString(requestId);
if (!normalizedRequestId) {
defaultRuntime.error(
`requestId is required. Run ${formatCliCommand("openclaw devices list")} to choose a pending request.`,
);
defaultRuntime.exit(1);
return;
}
const result = await callGatewayCli("device.pair.reject", opts, {
requestId: normalizedRequestId,
});
if (opts.json) {
defaultRuntime.writeJson(result);
return;
+23
View File
@@ -695,6 +695,29 @@ describe("devices cli remove", () => {
});
});
describe("devices cli reject", () => {
it("normalizes a pending request id before rejecting it", async () => {
callGateway.mockResolvedValueOnce({ requestId: "req-1", deviceId: "device-1" });
await runDevicesCommand(["reject", " req-1 "]);
expect(callGateway).toHaveBeenCalledTimes(1);
expectGatewayCall(0, {
method: "device.pair.reject",
params: { requestId: "req-1" },
});
});
it("explains blank pending request ids without calling the gateway", async () => {
await runDevicesCommand(["reject", " "]);
expect(callGateway).not.toHaveBeenCalled();
expect(readRuntimeErrorOutput()).toContain("requestId is required.");
expect(readRuntimeErrorOutput()).toContain("openclaw devices list");
expect(runtime.exit).toHaveBeenCalledWith(1);
});
});
describe("devices cli clear", () => {
it("requires --yes before clearing", async () => {
await runDevicesCommand(["clear"]);
+27
View File
@@ -35,4 +35,31 @@ describe("formatCliFailureLines", () => {
]);
expect(lines.join("\n")).toContain("Error: boom");
});
it.each(["--debug", "--verbose"])("prints stack details for the root %s option", (debugFlag) => {
const lines = formatCliFailureLines({
title: "The CLI command failed.",
error: new Error("boom"),
argv: ["node", "openclaw", "proxy", "run", debugFlag],
env: {},
});
expect(lines).toContain("[openclaw] Stack:");
expect(lines).toContain("[openclaw] Error: boom");
});
it.each(["--debug", "--verbose"])(
"does not enable root stack traces for a child %s option",
(debugFlag) => {
const lines = formatCliFailureLines({
title: "The CLI command failed.",
error: new Error("boom"),
argv: ["node", "openclaw", "proxy", "run", "--", "child", debugFlag],
env: {},
});
expect(lines).not.toContain("[openclaw] Stack:");
expect(lines).toContain("[openclaw] Debug: set OPENCLAW_DEBUG=1 to include the stack trace.");
},
);
});
+10 -1
View File
@@ -12,7 +12,16 @@ type FormatCliFailureOptions = {
};
function hasDebugArg(argv: string[] | undefined): boolean {
return Boolean(argv?.some((arg) => arg === "--debug" || arg === "--verbose"));
for (const arg of argv ?? []) {
// Arguments after the terminator belong to the child, not root stack-trace policy.
if (arg === "--") {
return false;
}
if (arg === "--debug" || arg === "--verbose") {
return true;
}
}
return false;
}
function shouldShowStack(argv: string[] | undefined, env: NodeJS.ProcessEnv): boolean {
+64
View File
@@ -460,6 +460,37 @@ describe("gateway-cli coverage", () => {
expect(runtimeErrors.join("\n")).not.toContain("gateway health requires credentials");
});
it.each([
{
name: "probe",
args: ["gateway", "probe", "--json"],
reject: (error: Error) => gatewayStatusCommand.mockRejectedValueOnce(error),
},
{
name: "discovery",
args: ["gateway", "discover", "--json"],
reject: (error: Error) => discoverGatewayBeacons.mockRejectedValueOnce(error),
},
])("writes JSON for gateway $name transport failures", async ({ args, reject }) => {
const error = new Error("gateway transport unavailable");
const payload = {
ok: false,
error: {
type: "gateway_transport_error",
kind: "closed",
message: "gateway transport unavailable",
},
};
reject(error);
formatGatewayTransportErrorJson.mockReturnValueOnce(payload);
await expectGatewayExit(args);
expect(formatGatewayTransportErrorJson).toHaveBeenCalledWith(error);
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(payload);
expect(runtimeErrors).toHaveLength(0);
});
it("prints the latest stability bundle without calling Gateway", async () => {
callGateway.mockClear();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gateway-cli-bundle-"));
@@ -637,6 +668,39 @@ describe("gateway-cli coverage", () => {
expect(out).toContain("ws://");
});
it.each([
{
name: "uses the secure scheme advertised by a TLS gateway",
beacon: {
instanceName: "Secure gateway",
host: "secure.openclaw.internal",
port: 18789,
gatewayTls: true,
} satisfies DiscoveredBeacon,
wsUrl: "wss://secure.openclaw.internal:18789",
},
{
name: "does not construct a URL from unresolved TXT hints",
beacon: {
instanceName: "Unresolved gateway",
lanHost: "unresolved.openclaw.internal",
gatewayPort: 18789,
} satisfies DiscoveredBeacon,
wsUrl: null,
},
])("gateway discovery JSON $name", async ({ beacon, wsUrl }) => {
discoverGatewayBeacons.mockResolvedValueOnce([beacon]);
await runGatewayCommand(["gateway", "discover", "--json"]);
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(
expect.objectContaining({
count: 1,
beacons: [expect.objectContaining({ wsUrl })],
}),
);
});
it("validates gateway discover timeout", async () => {
discoverGatewayBeacons.mockClear();
await expectGatewayExit(["gateway", "discover", "--timeout", "0"]);
+105 -92
View File
@@ -842,17 +842,21 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie
.option("--no-stability-bundle", "Skip persisted stability bundle lookup")
.option("--json", "Output JSON", false)
.action(async (opts, command) => {
await runGatewayCommand(async () => {
const rpcOpts = resolveGatewayRpcOptions(opts, command);
await writeSupportExportFromCli({
json: opts.json,
output: opts.output,
logLines: opts.logLines,
logBytes: opts.logBytes,
stabilityBundle: opts.stabilityBundle === false ? false : "latest",
rpc: rpcOpts,
});
}, "Gateway diagnostics export failed");
await runGatewayCommand(
async () => {
const rpcOpts = resolveGatewayRpcOptions(opts, command);
await writeSupportExportFromCli({
json: opts.json,
output: opts.output,
logLines: opts.logLines,
logBytes: opts.logBytes,
stabilityBundle: opts.stabilityBundle === false ? false : "latest",
rpc: rpcOpts,
});
},
"Gateway diagnostics export failed",
{ json: Boolean(opts.json) },
);
});
gateway
@@ -870,17 +874,21 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie
.option("--timeout <ms>", "Overall probe budget in ms", "3000")
.option("--json", "Output JSON", false)
.action(async (opts, command) => {
await runGatewayCommand(async () => {
const rpcOpts = resolveGatewayRpcOptions(opts, command);
const { gatewayStatusCommand } = await loadGatewayStatusModule();
await gatewayStatusCommand(
{
...rpcOpts,
port: opts.port ?? inheritOptionFromParent(command, "port"),
},
defaultRuntime,
);
});
await runGatewayCommand(
async () => {
const rpcOpts = resolveGatewayRpcOptions(opts, command);
const { gatewayStatusCommand } = await loadGatewayStatusModule();
await gatewayStatusCommand(
{
...rpcOpts,
port: opts.port ?? inheritOptionFromParent(command, "port"),
},
defaultRuntime,
);
},
undefined,
{ json: Boolean(opts.json) },
);
});
gateway
@@ -889,80 +897,85 @@ export function registerGatewayCli(program: Command, deps: GatewayCliDependencie
.option("--timeout <ms>", "Per-command timeout in ms", "2000")
.option("--json", "Output JSON", false)
.action(async (opts: GatewayDiscoverOpts) => {
await runGatewayCommand(async () => {
const [
{ readSourceConfigBestEffort },
{ discoverGatewayBeacons },
{ resolveWideAreaDiscoveryDomain },
{
dedupeBeacons,
parseDiscoverTimeoutMs,
pickBeaconHost,
pickGatewayPort,
renderBeaconLines,
},
{ withProgress },
] = await Promise.all([
loadConfigModule(),
loadBonjourDiscoveryModule(),
loadWideAreaDnsModule(),
import("./discover.js"),
import("../progress.js"),
]);
const cfg = await readSourceConfigBestEffort();
const wideAreaDomain = resolveWideAreaDiscoveryDomain({
configDomain: cfg.discovery?.wideArea?.domain,
});
const timeoutMs = parseDiscoverTimeoutMs(opts.timeout, 2000);
const domains = ["local.", ...(wideAreaDomain ? [wideAreaDomain] : [])];
const beacons = await withProgress(
{
label: "Scanning for gateways…",
indeterminate: true,
enabled: opts.json !== true,
delayMs: 0,
},
async () => await discoverGatewayBeacons({ timeoutMs, wideAreaDomain }),
);
const deduped = dedupeBeacons(beacons).toSorted((a, b) =>
(a.displayName || a.instanceName).localeCompare(b.displayName || b.instanceName),
);
if (opts.json) {
const enriched = deduped.map((b) => {
const host = pickBeaconHost(b);
const port = pickGatewayPort(b);
return { ...b, wsUrl: host ? `ws://${host}:${port}` : null };
await runGatewayCommand(
async () => {
const [
{ readSourceConfigBestEffort },
{ discoverGatewayBeacons },
{ resolveWideAreaDiscoveryDomain },
{
dedupeBeacons,
parseDiscoverTimeoutMs,
pickBeaconHost,
pickGatewayPort,
renderBeaconLines,
},
{ withProgress },
] = await Promise.all([
loadConfigModule(),
loadBonjourDiscoveryModule(),
loadWideAreaDnsModule(),
import("./discover.js"),
import("../progress.js"),
]);
const cfg = await readSourceConfigBestEffort();
const wideAreaDomain = resolveWideAreaDiscoveryDomain({
configDomain: cfg.discovery?.wideArea?.domain,
});
defaultRuntime.writeJson({
timeoutMs,
domains,
count: enriched.length,
beacons: enriched,
});
return;
}
const timeoutMs = parseDiscoverTimeoutMs(opts.timeout, 2000);
const domains = ["local.", ...(wideAreaDomain ? [wideAreaDomain] : [])];
const beacons = await withProgress(
{
label: "Scanning for gateways…",
indeterminate: true,
enabled: opts.json !== true,
delayMs: 0,
},
async () => await discoverGatewayBeacons({ timeoutMs, wideAreaDomain }),
);
const rich = isRich();
defaultRuntime.log(colorize(rich, theme.heading, "Gateway Discovery"));
defaultRuntime.log(
colorize(
rich,
theme.muted,
`Found ${deduped.length} gateway(s) · domains: ${domains.join(", ")}`,
),
);
if (deduped.length === 0) {
return;
}
const deduped = dedupeBeacons(beacons).toSorted((a, b) =>
(a.displayName || a.instanceName).localeCompare(b.displayName || b.instanceName),
);
for (const beacon of deduped) {
for (const line of renderBeaconLines(beacon, rich)) {
defaultRuntime.log(line);
if (opts.json) {
const enriched = deduped.map((b) => {
const host = pickBeaconHost(b);
const port = pickGatewayPort(b);
const scheme = b.gatewayTls === true ? "wss" : "ws";
return { ...b, wsUrl: host ? `${scheme}://${host}:${port}` : null };
});
defaultRuntime.writeJson({
timeoutMs,
domains,
count: enriched.length,
beacons: enriched,
});
return;
}
}
}, "gateway discover failed");
const rich = isRich();
defaultRuntime.log(colorize(rich, theme.heading, "Gateway Discovery"));
defaultRuntime.log(
colorize(
rich,
theme.muted,
`Found ${deduped.length} gateway(s) · domains: ${domains.join(", ")}`,
),
);
if (deduped.length === 0) {
return;
}
for (const beacon of deduped) {
for (const line of renderBeaconLines(beacon, rich)) {
defaultRuntime.log(line);
}
}
},
"gateway discover failed",
{ json: Boolean(opts.json) },
);
});
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
+45
View File
@@ -1,5 +1,8 @@
// Gateway RPC runtime tests cover CLI gateway RPC calls and runtime error handling.
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { addGatewayClientOptions } from "./gateway-rpc.js";
import type { GatewayRpcOpts } from "./gateway-rpc.types.js";
const callGatewayMock = vi.fn(async () => ({ ok: true }));
vi.mock("../gateway/call.js", () => ({
@@ -12,6 +15,31 @@ vi.mock("./progress.js", () => ({
const { callGatewayFromCliRuntime } = await import("./gateway-rpc.runtime.js");
describe("addGatewayClientOptions", () => {
it.each([
{ name: "token", flag: "--token", value: "test-gateway-token" },
{ name: "password", flag: "--password", value: "test-gateway-password" },
])(
"registers and parses explicit gateway $name authentication",
async ({ name, flag, value }) => {
const program = new Command().exitOverride();
const action = vi.fn((_opts: GatewayRpcOpts) => {});
addGatewayClientOptions(program.command("gateway-command")).action(action);
await program.parseAsync(
["gateway-command", "--url", "wss://gateway.example/ws", flag, value],
{ from: "user" },
);
expect(action).toHaveBeenCalledOnce();
expect(action.mock.calls[0]?.[0]).toMatchObject({
url: "wss://gateway.example/ws",
[name]: value,
});
},
);
});
describe("callGatewayFromCliRuntime", () => {
beforeEach(() => {
callGatewayMock.mockClear().mockResolvedValue({ ok: true });
@@ -28,6 +56,23 @@ describe("callGatewayFromCliRuntime", () => {
);
});
it.each([
{ name: "token", auth: { token: "test-gateway-token" } },
{ name: "password", auth: { password: "test-gateway-password" } },
])("forwards explicit gateway $name authentication", async ({ auth }) => {
await callGatewayFromCliRuntime("cron.status", {
url: "wss://gateway.example/ws",
...auth,
});
expect(callGatewayMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "wss://gateway.example/ws",
...auth,
}),
);
});
it.each([
["cron status", "cron.status"],
["cron list", "cron.list"],
+1
View File
@@ -41,6 +41,7 @@ export async function callGatewayFromCliRuntime(
await callGateway({
url: opts.url,
token: opts.token,
password: opts.password,
method,
params,
deviceIdentity: extra?.deviceIdentity,
+1
View File
@@ -25,6 +25,7 @@ export function addGatewayClientOptions(cmd: Command, defaults?: { timeoutMs?: n
return cmd
.option("--url <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (if required)")
.option("--timeout <ms>", "Timeout in ms", String(defaults?.timeoutMs ?? 30_000))
.option("--expect-final", "Wait for final response (agent)", false);
}
+1
View File
@@ -3,6 +3,7 @@
export type GatewayRpcOpts = {
url?: string;
token?: string;
password?: string;
timeout?: string;
expectFinal?: boolean;
json?: boolean;
+234
View File
@@ -0,0 +1,234 @@
// Hook command tests cover metadata config keys and missing-hook exit status.
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HookStatusEntry, HookStatusReport } from "../hooks/hooks-status.js";
import { createEmptyInstallChecks } from "./requirements-test-fixtures.js";
import { createCliRuntimeCapture } from "./test-runtime-capture.js";
const mocks = vi.hoisted(() => ({
buildWorkspaceHookStatus: vi.fn(),
readConfigFileSnapshot: vi.fn(),
replaceConfigFile: vi.fn(),
requestExitAfterOneShotOutput: vi.fn(),
}));
const capture = createCliRuntimeCapture();
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: () => "/tmp/openclaw-hook-workspace",
resolveDefaultAgentId: () => "main",
}));
vi.mock("../config/config.js", () => ({
getRuntimeConfig: () => sourceConfig,
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
replaceConfigFile: mocks.replaceConfigFile,
}));
vi.mock("../hooks/hooks-status.js", () => ({
buildWorkspaceHookStatus: mocks.buildWorkspaceHookStatus,
}));
vi.mock("../hooks/policy.js", () => ({
resolveHookEntries: (entries: unknown[]) => entries,
}));
vi.mock("../hooks/workspace.js", () => ({
loadWorkspaceHookEntries: () => [],
}));
vi.mock("../plugins/status.js", () => ({
buildPluginDiagnosticsReport: () => ({ hooks: [] }),
}));
vi.mock("../runtime.js", () => ({
defaultRuntime: capture.defaultRuntime,
}));
vi.mock("./one-shot-exit.js", () => ({
requestExitAfterOneShotOutput: mocks.requestExitAfterOneShotOutput,
}));
vi.mock("./native-hook-relay-cli.js", () => ({
runNativeHookRelayCli: vi.fn(),
}));
vi.mock("./plugins-install-command.js", () => ({
runPluginInstallCommand: vi.fn(),
}));
vi.mock("./plugins-update-command.js", () => ({
runPluginUpdateCommand: vi.fn(),
}));
const sourceConfig = {
hooks: {
internal: {
enabled: true,
entries: {
"metadata-key": {
env: { HOOK_ENV: "preserved" },
},
},
},
},
};
const hook: HookStatusEntry = {
name: "display-name",
description: "Hook with a metadata config-key override",
source: "openclaw-workspace",
filePath: "/tmp/openclaw-hook-workspace/HOOK.md",
baseDir: "/tmp/openclaw-hook-workspace",
handlerPath: "/tmp/openclaw-hook-workspace/handler.js",
hookKey: "metadata-key",
events: [],
unknownEvents: [],
always: false,
enabledByConfig: true,
requirementsSatisfied: true,
loadable: true,
managedByPlugin: false,
...createEmptyInstallChecks(),
};
const report: HookStatusReport = {
workspaceDir: "/tmp/openclaw-hook-workspace",
managedHooksDir: "/tmp/openclaw-managed-hooks",
hooks: [hook],
};
const { registerHooksCli } = await import("./hooks-cli.js");
function createHooksProgram(): Command {
const program = new Command();
registerHooksCli(program);
return program;
}
describe("hooks CLI metadata config keys", () => {
beforeEach(() => {
vi.clearAllMocks();
capture.resetRuntimeCapture();
mocks.buildWorkspaceHookStatus.mockReturnValue(report);
mocks.readConfigFileSnapshot.mockResolvedValue({ sourceConfig, hash: "config-hash" });
mocks.replaceConfigFile.mockResolvedValue(undefined);
});
it.each([
{ action: "enable", identifier: "display-name", enabled: true },
{ action: "enable", identifier: "metadata-key", enabled: true },
{ action: "disable", identifier: "display-name", enabled: false },
{ action: "disable", identifier: "metadata-key", enabled: false },
])("$action resolves $identifier to its metadata config key", async (testCase) => {
await createHooksProgram().parseAsync(["hooks", testCase.action, testCase.identifier], {
from: "user",
});
expect(mocks.replaceConfigFile).toHaveBeenCalledWith({
nextConfig: {
hooks: {
internal: {
enabled: true,
entries: {
"metadata-key": {
env: { HOOK_ENV: "preserved" },
enabled: testCase.enabled,
},
},
},
},
},
baseHash: "config-hash",
});
expect(capture.runtimeLogs.at(-1)).toContain("display-name");
expect(mocks.requestExitAfterOneShotOutput).toHaveBeenCalledWith(capture.defaultRuntime, 0);
});
it.each(["key-first", "name-first"])(
"prefers an exact hook name over a colliding config key (%s)",
async (order) => {
const exactNameHook: HookStatusEntry = {
...hook,
name: "shared",
hookKey: "metadata-key",
};
const collidingKeyHook: HookStatusEntry = {
...hook,
name: "another-hook",
hookKey: "shared",
};
mocks.buildWorkspaceHookStatus.mockReturnValue({
...report,
hooks:
order === "key-first"
? [collidingKeyHook, exactNameHook]
: [exactNameHook, collidingKeyHook],
});
await createHooksProgram().parseAsync(["hooks", "disable", "shared"], {
from: "user",
});
expect(mocks.replaceConfigFile).toHaveBeenCalledWith({
nextConfig: {
hooks: {
internal: {
enabled: true,
entries: {
"metadata-key": {
env: { HOOK_ENV: "preserved" },
enabled: false,
},
},
},
},
},
baseHash: "config-hash",
});
expect(capture.runtimeLogs.at(-1)).toContain("shared");
},
);
it.each([
{
identifier: "shared-name",
hooks: [
{ ...hook, name: "shared-name", hookKey: "first-key" },
{ ...hook, name: "shared-name", hookKey: "second-key" },
],
},
{
identifier: "shared-key",
hooks: [
{ ...hook, name: "first-hook", hookKey: "shared-key" },
{ ...hook, name: "second-hook", hookKey: "shared-key" },
],
},
])("rejects the ambiguous hook identifier $identifier without mutation", async (testCase) => {
mocks.buildWorkspaceHookStatus.mockReturnValue({ ...report, hooks: testCase.hooks });
await expect(
createHooksProgram().parseAsync(["hooks", "disable", testCase.identifier], {
from: "user",
}),
).rejects.toThrow("__exit__:1");
expect(capture.runtimeErrors.at(-1)).toContain(
`Hook "${testCase.identifier}" is ambiguous; use a unique hook name or hook key`,
);
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
});
it("preserves machine-readable missing-hook output and requests a failing exit", async () => {
await createHooksProgram().parseAsync(["hooks", "info", "missing-hook", "--json"], {
from: "user",
});
expect(capture.defaultRuntime.writeStdout).toHaveBeenCalledWith(
expect.stringContaining('"error": "not found"'),
);
expect(mocks.requestExitAfterOneShotOutput).toHaveBeenCalledWith(capture.defaultRuntime, 1);
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
});
});
+16 -5
View File
@@ -65,7 +65,13 @@ function resolveHookForToggle(
hookName: string,
opts?: { requireEligible?: boolean },
): HookStatusEntry {
const hook = report.hooks.find((h) => h.name === hookName);
const nameMatches = report.hooks.filter((hook) => hook.name === hookName);
const matches =
nameMatches.length > 0 ? nameMatches : report.hooks.filter((hook) => hook.hookKey === hookName);
if (matches.length > 1) {
throw new Error(`Hook "${hookName}" is ambiguous; use a unique hook name or hook key`);
}
const hook = matches[0];
if (!hook) {
throw new Error(`Hook "${hookName}" not found`);
}
@@ -447,7 +453,7 @@ async function enableHook(hookName: string): Promise<void> {
const hook = resolveHookForToggle(buildHooksReport(config), hookName, { requireEligible: true });
const nextConfig = buildConfigWithHookEnabled({
config,
hookName,
hookName: hook.hookKey,
enabled: true,
ensureHooksEnabled: true,
});
@@ -457,7 +463,7 @@ async function enableHook(hookName: string): Promise<void> {
...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}),
});
defaultRuntime.log(
`${theme.success("✓")} Enabled hook: ${hook.emoji ? `${hook.emoji} ${theme.command(hookName)}` : decorativePrefix("🔗", theme.command(hookName))}`,
`${theme.success("✓")} Enabled hook: ${hook.emoji ? `${hook.emoji} ${theme.command(hook.name)}` : decorativePrefix("🔗", theme.command(hook.name))}`,
);
}
@@ -465,14 +471,18 @@ async function disableHook(hookName: string): Promise<void> {
const snapshot = await readConfigFileSnapshot();
const config = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
const hook = resolveHookForToggle(buildHooksReport(config), hookName);
const nextConfig = buildConfigWithHookEnabled({ config, hookName, enabled: false });
const nextConfig = buildConfigWithHookEnabled({
config,
hookName: hook.hookKey,
enabled: false,
});
await replaceConfigFile({
nextConfig,
...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}),
});
defaultRuntime.log(
`${theme.warn(decorativePrefix("⏸", "Disabled hook:"))} ${hook.emoji ? `${hook.emoji} ${theme.command(hookName)}` : decorativePrefix("🔗", theme.command(hookName))}`,
`${theme.warn(decorativePrefix("⏸", "Disabled hook:"))} ${hook.emoji ? `${hook.emoji} ${theme.command(hook.name)}` : decorativePrefix("🔗", theme.command(hook.name))}`,
);
}
@@ -509,6 +519,7 @@ export function registerHooksCli(program: Command): void {
const config = getRuntimeConfig();
const report = buildHooksReport(config);
writeHooksOutput(formatHookInfo(report, name, opts), opts.json);
return report.hooks.some((hook) => hook.name === name || hook.hookKey === name) ? 0 : 1;
}),
);
+33
View File
@@ -216,6 +216,39 @@ describe("nodes-cli coverage", () => {
expect(defaultRuntime.writeJson).toHaveBeenCalledWith({ approved: true });
});
it("explains unknown nodes reject request ids without leaking connection credentials", async () => {
callGateway.mockRejectedValueOnce(
Object.assign(new Error("unknown requestId"), {
name: "GatewayClientRequestError",
gatewayCode: "INVALID_REQUEST",
}),
);
await expect(
sharedProgram.parseAsync(
[
"nodes",
"reject",
"stale-request",
"--url",
"wss://gateway.example.test",
"--token",
"secret-token",
],
{ from: "user" },
),
).rejects.toThrow("__exit__:1");
const output = runtimeErrors.join("\n");
expect(output).toContain("Unknown node pairing requestId: stale-request");
expect(output).toContain("openclaw nodes pending");
expect(output).toContain("Reuse the same connection options when rerunning: --url, --token.");
expect(output).not.toContain("gateway.example.test");
expect(output).not.toContain("secret-token");
expect(output).not.toContain("GatewayClientRequestError: unknown requestId");
expect(callGateway.mock.calls.map(([call]) => call.method)).toEqual(["node.pair.reject"]);
});
it("blocks system.run on nodes invoke", async () => {
await expect(
sharedProgram.parseAsync(["nodes", "invoke", "--node", "mac-1", "--command", "system.run"], {
+23 -10
View File
@@ -107,6 +107,20 @@ function buildUnknownNodePairRequestIdMessage(
return lines.join("\n");
}
function rethrowUnknownNodePairRequestId(
error: unknown,
requestId: string,
opts: NodesRpcOpts,
): never {
if (!isUnknownNodePairRequestIdError(error)) {
throw error;
}
// Reuse the gateway error so generic formatting does not append its raw cause.
error.name = "Error";
error.message = buildUnknownNodePairRequestIdMessage(requestId, opts);
throw error;
}
/** Register node pairing management commands. */
export function registerNodesPairingCommands(nodes: Command) {
nodesCallOpts(
@@ -162,13 +176,7 @@ export function registerNodesPairingCommands(nodes: Command) {
},
);
} catch (error) {
if (!isUnknownNodePairRequestIdError(error)) {
throw error;
}
// Reuse the gateway error so generic formatting does not append its raw cause.
error.name = "Error";
error.message = buildUnknownNodePairRequestIdMessage(requestId, opts);
throw error;
rethrowUnknownNodePairRequestId(error, requestId, opts);
}
defaultRuntime.writeJson(result);
});
@@ -182,9 +190,14 @@ export function registerNodesPairingCommands(nodes: Command) {
.argument("<requestId>", "Pending request id")
.action(async (requestId: string, opts: NodesRpcOpts) => {
await runNodesCommand("reject", async () => {
const result = await callGatewayCli("node.pair.reject", opts, {
requestId,
});
let result: unknown;
try {
result = await callGatewayCli("node.pair.reject", opts, {
requestId,
});
} catch (error) {
rethrowUnknownNodePairRequestId(error, requestId, opts);
}
defaultRuntime.writeJson(result);
});
}),
+16
View File
@@ -189,6 +189,22 @@ describe("pairing cli", () => {
expect(listChannelPairingRequests).toHaveBeenCalledWith("telegram");
});
it("rejects conflicting positional and option channels for list", async () => {
await expect(
runPairing(["pairing", "list", "discord", "--channel", "telegram"]),
).rejects.toThrow(
'Conflicting pairing channels: "telegram" and "discord". Pass the channel either positionally or with --channel.',
);
expect(listChannelPairingRequests).not.toHaveBeenCalled();
});
it("accepts matching positional and option channel aliases for list", async () => {
await runPairing(["pairing", "list", "imsg", "--channel", "imessage"]);
expect(listChannelPairingRequests).toHaveBeenCalledWith("imessage");
});
it("forwards --account for list", async () => {
listChannelPairingRequests.mockResolvedValueOnce([]);
+9
View File
@@ -83,6 +83,15 @@ export function registerPairingCli(program: Command) {
throw new Error(`Channel required (expected one of: ${channelHint}).`);
}
const channel = parseChannel(channelRaw, channels);
if (opts.channel && channelArg) {
const positionalChannel = parseChannel(channelArg, channels);
if (channel !== positionalChannel) {
throw new Error(
`Conflicting pairing channels: "${channel}" and "${positionalChannel}". ` +
`Pass the channel either positionally or with --channel.`,
);
}
}
const accountId = normalizeStringifiedOptionalString(opts.account) ?? "";
const requests = accountId
? await listChannelPairingRequests(channel, process.env, accountId)
+41
View File
@@ -45,6 +45,47 @@ describe("plugins cli lazy runtime boundary", () => {
expect(runtimeLoaded).not.toHaveBeenCalled();
});
it.each([
{
name: "plugins",
argv: ["plugins"],
description: "Manage OpenClaw plugins and extensions",
},
{
name: "plugins marketplace",
argv: ["plugins", "marketplace"],
description: "Inspect Claude-compatible plugin marketplaces",
},
])("renders $name parent help successfully without importing the runtime", async (testCase) => {
const runtimeLoaded = vi.fn();
vi.doMock("./plugins-cli.runtime.js", () => {
runtimeLoaded();
return {};
});
const { registerPluginsCli } = await import("./plugins-cli.js");
const program = new Command();
const helpOutput: string[] = [];
program.exitOverride();
program.configureOutput({
writeErr: (value) => helpOutput.push(value),
writeOut: (value) => helpOutput.push(value),
});
registerPluginsCli(program);
const originalExitCode = process.exitCode;
try {
process.exitCode = undefined;
await program.parseAsync(testCase.argv, { from: "user" });
expect(process.exitCode).toBe(0);
expect(helpOutput.join("")).toContain(testCase.description);
expect(runtimeLoaded).not.toHaveBeenCalled();
} finally {
process.exitCode = originalExitCode;
}
});
it("loads the plugins runtime for runtime-backed actions", async () => {
const runPluginsRegistryCommand = vi.fn().mockResolvedValue(undefined);
const runtimeLoaded = vi.fn();
+1
View File
@@ -329,5 +329,6 @@ export function registerPluginsCli(program: Command) {
await runPluginMarketplaceListCommand(source, opts);
});
applyParentDefaultHelpAction(marketplace);
applyParentDefaultHelpAction(plugins);
}
+109
View File
@@ -61,6 +61,30 @@ describe("reparseProgramFromActionCommand", () => {
});
});
it("hoists a lazy-parent short option with an attached required value", async () => {
const root = new Command().name("openclaw");
const browser = root.command("browser").option("-p, --browser-profile <name>");
const tabs = browser.command("tabs");
await expectReparseArgv({
parent: browser,
action: tabs,
argv: ["node", "openclaw", "browser", "tabs", "-premote"],
expected: ["node", "openclaw", "browser", "-premote", "tabs"],
});
});
it("hoists a lazy-parent short option with an attached optional value", async () => {
const root = new Command().name("openclaw");
const browser = root.command("browser").option("-p, --browser-profile [name]");
const tabs = browser.command("tabs");
await expectReparseArgv({
parent: browser,
action: tabs,
argv: ["node", "openclaw", "browser", "tabs", "-premote"],
expected: ["node", "openclaw", "browser", "-premote", "tabs"],
});
});
it("skips root option values that match the parent command name", async () => {
const root = new Command().name("openclaw").option("--profile <name>");
const browser = root.command("browser").option("--browser-profile <name>");
@@ -91,6 +115,18 @@ describe("reparseProgramFromActionCommand", () => {
});
});
it("skips an attached root option value that matches the parent command name", async () => {
const root = new Command().name("openclaw").option("-p, --profile <name>");
const browser = root.command("browser").option("--browser-profile <name>");
const tabs = browser.command("tabs");
await expectReparseArgv({
parent: browser,
action: tabs,
argv: ["node", "openclaw", "-pbrowser", "browser", "tabs", "--browser-profile", "remote"],
expected: ["node", "openclaw", "-pbrowser", "browser", "--browser-profile", "remote", "tabs"],
});
});
it("hoists parent options after nested lazy commands", async () => {
const root = new Command().name("openclaw");
const browser = root.command("browser").option("--browser-profile <name>");
@@ -114,6 +150,79 @@ describe("reparseProgramFromActionCommand", () => {
await expectReparseArgv({ parent: browser, action: extension, argv, expected: argv });
});
it("leaves a child-owned attached short option after the child command", async () => {
const root = new Command().name("openclaw");
const browser = root.command("browser").option("-p, --browser-profile <name>");
const extension = browser.command("extension");
extension.command("pair").option("-p, --pairing-profile <name>");
const argv = ["node", "openclaw", "browser", "extension", "pair", "-premote"];
await expectReparseArgv({ parent: browser, action: extension, argv, expected: argv });
});
it("preserves an unknown suffix after a child-owned boolean short flag", async () => {
const root = new Command().name("openclaw");
const browser = root.command("browser").option("-p, --browser-profile <name>");
const extension = browser.command("extension");
const pair = extension.command("pair").option("-p, --preview");
const parsed = pair.parseOptions(["-pfoo"]);
expect(parsed.unknown).toEqual(["-foo"]);
expect(pair.opts()).toEqual({ preview: true });
const argv = ["node", "openclaw", "browser", "extension", "pair", "-pfoo"];
await expectReparseArgv({ parent: browser, action: extension, argv, expected: argv });
});
it.each([
{
label: "boolean flags",
retryFlags: "-r, --retry",
tokens: ["-pr"],
expected: { preview: true, retry: true },
},
{
label: "a required attached value",
retryFlags: "-r, --retry <value>",
tokens: ["-prremote"],
expected: { preview: true, retry: "remote" },
},
{
label: "a required separate value",
retryFlags: "-r, --retry <value>",
tokens: ["-pr", "remote"],
expected: { preview: true, retry: "remote" },
},
{
label: "an optional attached value",
retryFlags: "-r, --retry [value]",
tokens: ["-prremote"],
expected: { preview: true, retry: "remote" },
},
{
label: "an optional separate value",
retryFlags: "-r, --retry [value]",
tokens: ["-pr", "remote"],
expected: { preview: true, retry: "remote" },
},
] as const)(
"preserves child-owned short groups with $label",
async ({ retryFlags, tokens, expected }) => {
const root = new Command().name("openclaw");
const browser = root.command("browser").option("-p, --browser-profile <name>");
const extension = browser.command("extension");
const pair = extension.command("pair").option("-p, --preview").option(retryFlags);
const parsed = pair.parseOptions([...tokens]);
expect(parsed.unknown).toEqual([]);
expect(pair.opts()).toEqual(expected);
const argv = ["node", "openclaw", "browser", "extension", "pair", ...tokens];
await expectReparseArgv({ parent: browser, action: extension, argv, expected: argv });
},
);
it("hoists a parent option when only a sibling command owns the same flag", async () => {
const root = new Command().name("openclaw");
const browser = root.command("browser").option("--url <url>");
+23 -2
View File
@@ -25,11 +25,27 @@ function findRootCommand(cmd: Command): Command {
function findOption(command: Command, token: string): Option | undefined {
const equalsIndex = token.indexOf("=");
const flag = equalsIndex === -1 ? token : token.slice(0, equalsIndex);
return command.options.find(
const exactOption = command.options.find(
(candidate) =>
(candidate.short === flag || candidate.long === flag) &&
(equalsIndex === -1 || candidate.required || candidate.optional),
);
if (exactOption || !token.startsWith("-") || token.startsWith("--") || token.length <= 2) {
return exactOption;
}
// Once a child claims a short group, preserve its unknown suffix for Commander.
let claimedOption: Option | undefined;
for (let index = 1; index < token.length; index += 1) {
const option = command.options.find((candidate) => candidate.short === `-${token[index]}`);
if (!option) {
return claimedOption;
}
claimedOption ??= option;
if (option.required || option.optional || index === token.length - 1) {
return option;
}
}
return undefined;
}
function findNearestOption(commands: readonly Command[], token: string): Option | undefined {
@@ -50,7 +66,12 @@ function matchesCommandName(command: Command, token: string): boolean {
// Returns 0 for a missing required value, otherwise the number of consumed tokens.
function optionTokenCount(option: Option, argv: readonly string[], index: number): number {
const token = argv[index] ?? "";
if (token.includes("=") || (!option.required && !option.optional)) {
const shortFlagIndex =
option.short !== undefined && !token.startsWith("--")
? token.indexOf(option.short.slice(1), 1)
: -1;
const hasAttachedShortValue = shortFlagIndex !== -1 && shortFlagIndex < token.length - 1;
if (token.includes("=") || hasAttachedShortValue || (!option.required && !option.optional)) {
return 1;
}
const next = argv[index + 1];
+23
View File
@@ -309,6 +309,29 @@ describe("runMessageAction", () => {
expect(exitMock).not.toHaveBeenCalledWith(0);
});
it("rejects conflicting poll visibility flags before loading channel plugins", async () => {
const runMessageAction = createRunMessageAction();
await expect(
runMessageAction("poll", {
channel: "telegram",
target: "123",
pollQuestion: "Ship it?",
pollOption: ["Yes", "No"],
pollAnonymous: true,
pollPublic: true,
}),
).rejects.toThrow("exit");
expect(errorMock).toHaveBeenCalledWith(
"Error: --poll-anonymous and --poll-public are mutually exclusive.",
);
expect(ensurePluginRegistryLoaded).not.toHaveBeenCalled();
expect(messageCommandMock).not.toHaveBeenCalled();
expect(exitMock).toHaveBeenCalledWith(1);
expect(exitMock).not.toHaveBeenCalledWith(0);
});
it.each([
[
"poll duration hours",
+3
View File
@@ -167,6 +167,9 @@ export function createMessageCliHelpers(
defaultRuntime,
async () => {
validateMessageNumericOptions(opts);
if (action === "poll" && opts.pollAnonymous === true && opts.pollPublic === true) {
throw new Error("--poll-anonymous and --poll-public are mutually exclusive.");
}
const preloadPlan = resolveMessagePluginPreloadPlan(action, opts);
if (preloadPlan.preload) {
ensurePluginRegistryLoaded(preloadPlan.loadOptions);
+11
View File
@@ -287,6 +287,17 @@ describe("agent command registration", () => {
expect(betaFlags).toEqual({ hasFlags: true });
});
it("keeps JSON-only agent creation non-interactive", async () => {
await runCli(["agents", "add", "alpha", "--json"]);
const [options, callRuntime, flags] = commandCall(agentsAddCommandMock);
expect(options).toEqual(
expect.objectContaining({ name: "alpha", json: true, nonInteractive: false }),
);
expect(callRuntime).toBe(runtime);
expect(flags).toEqual({ hasFlags: true });
});
it("runs agents list when root agents command is invoked", async () => {
await runCli(["agents"]);
expect(agentsListCommandMock).toHaveBeenCalledWith({}, runtime);
+1
View File
@@ -177,6 +177,7 @@ export function registerAgentsCommands(program: Command): void {
"agentDir",
"bind",
"nonInteractive",
"json",
]);
const agentsAddCommand = await loadAgentsAddCommand();
await agentsAddCommand(
+47 -6
View File
@@ -127,15 +127,56 @@ describe("registerMessageCommands", () => {
}
});
it("shows command help when root message command is invoked", async () => {
it("shows root message help without reporting a command failure", async () => {
const program = new Command().exitOverride();
registerMessageCommands(program, ctx);
const message = requireProgramCommand(program, "message");
const helpSpy = vi.spyOn(message, "help").mockImplementation(() => {
throw new Error("help-called");
});
const helpSpy = vi.spyOn(message, "outputHelp").mockImplementation(() => {});
const originalExitCode = process.exitCode;
await expect(program.parseAsync(["message"], { from: "user" })).rejects.toThrow("help-called");
expect(helpSpy).toHaveBeenCalledWith({ error: true });
try {
process.exitCode = undefined;
await expect(program.parseAsync(["message"], { from: "user" })).resolves.toBe(program);
expect(helpSpy).toHaveBeenCalledOnce();
expect(process.exitCode).toBe(0);
} finally {
process.exitCode = originalExitCode;
}
});
it.each([
["thread", registerMessageThreadCommandsMock],
["emoji", registerMessageEmojiCommandsMock],
["sticker", registerMessageStickerCommandsMock],
["role", registerMessageDiscordAdminCommandsMock],
["channel", registerMessageDiscordAdminCommandsMock],
["member", registerMessageDiscordAdminCommandsMock],
["voice", registerMessageDiscordAdminCommandsMock],
["event", registerMessageDiscordAdminCommandsMock],
])("shows message %s help without reporting a command failure", async (name, registerCommand) => {
registerCommand.mockImplementationOnce((message: Command) => {
message
.command(name)
.command("action")
.action(() => {});
});
const program = new Command().exitOverride();
registerMessageCommands(program, ctx);
const parent = requireProgramCommand(requireProgramCommand(program, "message"), name);
const helpSpy = vi.spyOn(parent, "outputHelp").mockImplementation(() => {});
const originalExitCode = process.exitCode;
try {
process.exitCode = undefined;
await expect(program.parseAsync(["message", name], { from: "user" })).resolves.toBe(program);
expect(helpSpy).toHaveBeenCalledOnce();
expect(process.exitCode).toBe(0);
} finally {
process.exitCode = originalExitCode;
}
});
});
+9 -4
View File
@@ -21,6 +21,7 @@ import { registerMessageReactionsCommands } from "./message/register.reactions.j
import { registerMessageReadEditDeleteCommands } from "./message/register.read-edit-delete.js";
import { registerMessageSendCommand } from "./message/register.send.js";
import { registerMessageThreadCommands } from "./message/register.thread.js";
import { applyParentDefaultHelpAction } from "./parent-default-help.js";
/** Register the `message` command group with shared channel option helpers. */
export function registerMessageCommands(program: Command, ctx: ProgramContext) {
@@ -49,10 +50,7 @@ ${formatHelpExamples([
])}
${theme.muted("Docs:")} ${formatDocsLink("/cli/message", "docs.openclaw.ai/cli/message")}`,
)
.action(() => {
message.help({ error: true });
});
);
const helpers = createMessageCliHelpers(message, ctx.messageChannelOptions);
registerMessageSendCommand(message, helpers);
@@ -67,4 +65,11 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/message", "docs.openclaw.ai/cli/m
registerMessageEmojiCommands(message, helpers);
registerMessageStickerCommands(message, helpers);
registerMessageDiscordAdminCommands(message, helpers);
for (const command of message.commands) {
if (command.commands.length > 0) {
applyParentDefaultHelpAction(command);
}
}
applyParentDefaultHelpAction(message);
}
@@ -412,6 +412,17 @@ describe("registerStatusHealthSessionsCommands", () => {
});
});
it.each([
{ flag: "--active", value: "5" },
{ flag: "--limit", value: "1" },
])("rejects inherited $flag before running session cleanup", async ({ flag, value }) => {
await runCli(["sessions", flag, value, "cleanup", "--enforce"]);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining(flag));
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(sessionsCleanupCommand).not.toHaveBeenCalled();
});
it("runs sessions tail with forwarded progress options", async () => {
await runCli([
"sessions",
@@ -437,6 +448,14 @@ describe("registerStatusHealthSessionsCommands", () => {
});
});
it("rejects inherited JSON mode for human-readable session tail", async () => {
await runCli(["sessions", "--json", "tail"]);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("--json"));
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(sessionsTailCommand).not.toHaveBeenCalled();
});
it("runs sessions export-trajectory with owner-routable export options", async () => {
await runCli([
"sessions",
@@ -476,6 +495,20 @@ describe("registerStatusHealthSessionsCommands", () => {
});
});
it("rejects inherited all-agent scope for single-session trajectory exports", async () => {
await runCli([
"sessions",
"--all-agents",
"export-trajectory",
"--session-key",
"agent:main:main",
]);
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("--all-agents"));
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(exportTrajectoryCommand).not.toHaveBeenCalled();
});
it("runs tasks list from the parent command", async () => {
await runCli(["tasks", "--json", "--runtime", "acp", "--status", "running"]);
@@ -22,6 +22,39 @@ type SessionsListCliOptions = {
limit?: string;
};
const SESSIONS_PARENT_OPTION_FLAGS = {
json: "--json",
verbose: "--verbose",
store: "--store",
agent: "--agent",
allAgents: "--all-agents",
active: "--active",
limit: "--limit",
} satisfies Record<keyof SessionsListCliOptions, string>;
function rejectUnsupportedSessionsParentOptions(
subcommand: string,
parentOpts: SessionsListCliOptions | undefined,
unsupportedOptions: readonly (keyof SessionsListCliOptions)[],
reason: string,
): boolean {
const unsupportedFlags = unsupportedOptions
.filter((option) => {
const value = parentOpts?.[option];
return typeof value === "boolean" ? value : value !== undefined;
})
.map((option) => SESSIONS_PARENT_OPTION_FLAGS[option]);
if (unsupportedFlags.length === 0) {
return false;
}
const plural = unsupportedFlags.length > 1 ? "options" : "option";
defaultRuntime.error(
`\`sessions ${subcommand}\` does not support the parent \`sessions\` ${plural} ${unsupportedFlags.join(", ")}; ${reason}.`,
);
defaultRuntime.exit(1);
return true;
}
function createModuleLoader<T>(load: () => Promise<T>): () => Promise<T> {
let promise: Promise<T> | undefined;
return () => (promise ??= load());
@@ -261,14 +294,17 @@ export function registerStatusHealthSessionsCommands(program: Command) {
])}`,
)
.action(async (opts, command) => {
const parentOpts = command.parent?.opts() as
| {
store?: string;
agent?: string;
allAgents?: boolean;
json?: boolean;
}
| undefined;
const parentOpts = command.parent?.opts() as SessionsListCliOptions | undefined;
if (
rejectUnsupportedSessionsParentOptions(
"cleanup",
parentOpts,
["active", "limit", "verbose"],
"session-list filters cannot scope session maintenance",
)
) {
return;
}
await runCommandWithRuntime(defaultRuntime, async () => {
const { sessionsCleanupCommand } = await import("../../commands/sessions-cleanup.js");
await sessionsCleanupCommand(
@@ -298,13 +334,17 @@ export function registerStatusHealthSessionsCommands(program: Command) {
.option("--agent <id>", "Agent id to inspect (default: configured default agent)")
.option("--all-agents", "Aggregate sessions across all configured agents", false)
.action(async (opts, command) => {
const parentOpts = command.parent?.opts() as
| {
store?: string;
agent?: string;
allAgents?: boolean;
}
| undefined;
const parentOpts = command.parent?.opts() as SessionsListCliOptions | undefined;
if (
rejectUnsupportedSessionsParentOptions(
"tail",
parentOpts,
["json", "active", "limit", "verbose"],
"trajectory tail emits human-readable progress and selects sessions separately",
)
) {
return;
}
await runCommandWithRuntime(defaultRuntime, async () => {
const { sessionsTailCommand } = await import("../../commands/sessions-tail.js");
await sessionsTailCommand(
@@ -332,13 +372,17 @@ export function registerStatusHealthSessionsCommands(program: Command) {
.option("--request-json-base64 <payload>", "Base64url-encoded export request")
.option("--json", "Output JSON", false)
.action(async (opts, command) => {
const parentOpts = command.parent?.opts() as
| {
store?: string;
agent?: string;
json?: boolean;
}
| undefined;
const parentOpts = command.parent?.opts() as SessionsListCliOptions | undefined;
if (
rejectUnsupportedSessionsParentOptions(
"export-trajectory",
parentOpts,
["allAgents", "active", "limit", "verbose"],
"trajectory export targets one session and cannot apply session-list filters",
)
) {
return;
}
await runCommandWithRuntime(defaultRuntime, async () => {
const { exportTrajectoryCommand } = await import("../../commands/export-trajectory.js");
await exportTrajectoryCommand(
@@ -403,30 +447,15 @@ export function registerStatusHealthSessionsCommands(program: Command) {
// Silently dropping `--store` is the dangerous case — the user could
// believe they targeted one store while the gateway compacts another — so
// reject any unsupported inherited option instead of ignoring it.
const parentOpts = command.parent?.opts() as
| {
agent?: string;
json?: boolean;
store?: string;
allAgents?: boolean;
active?: string;
limit?: string;
verbose?: boolean;
}
| undefined;
const unsupportedParentOptions = [
parentOpts?.store !== undefined ? "--store" : undefined,
parentOpts?.allAgents ? "--all-agents" : undefined,
parentOpts?.active !== undefined ? "--active" : undefined,
parentOpts?.limit !== undefined ? "--limit" : undefined,
parentOpts?.verbose ? "--verbose" : undefined,
].filter((flag): flag is string => flag !== undefined);
if (unsupportedParentOptions.length > 0) {
const plural = unsupportedParentOptions.length > 1 ? "options" : "option";
defaultRuntime.error(
`\`sessions compact\` does not support the parent \`sessions\` ${plural} ${unsupportedParentOptions.join(", ")}; the gateway resolves the target store from <key> and --agent.`,
);
defaultRuntime.exit(1);
const parentOpts = command.parent?.opts() as SessionsListCliOptions | undefined;
if (
rejectUnsupportedSessionsParentOptions(
"compact",
parentOpts,
["store", "allAgents", "active", "limit", "verbose"],
"the gateway resolves the target store from <key> and --agent",
)
) {
return;
}
const maxLines = parseStrictPositiveIntOrUndefined(opts.maxLines);
+83
View File
@@ -84,6 +84,89 @@ describe("cli progress", () => {
expect(write).not.toHaveBeenCalled();
});
it("does not render progress updates after the reporter is finished", () => {
const writes: string[] = [];
const stream = {
isTTY: false,
write: vi.fn((chunk: string) => {
writes.push(chunk);
}),
} as unknown as NodeJS.WriteStream;
const progress = createCliProgress({
label: "Indexing memory...",
total: 10,
stream,
fallback: "log",
});
progress.done();
progress.setLabel("Late progress");
progress.setPercent(50);
progress.tick();
expect(writes).toEqual(["Indexing memory... 0%\n"]);
});
it("does not stop an interactive spinner more than once", () => {
const stream = {
isTTY: true,
write: vi.fn(),
} as unknown as NodeJS.WriteStream;
const progress = createCliProgress({ label: "Loading", stream });
progress.done();
progress.done();
expect(clackMocks.spinnerInstance.stop).toHaveBeenCalledTimes(1);
});
it("does not let a finished reporter clear or unlock a newer progress line", () => {
const firstStream = {
isTTY: true,
write: vi.fn(),
} as unknown as NodeJS.WriteStream;
const secondWrite = vi.fn();
const secondStream = {
isTTY: true,
write: secondWrite,
} as unknown as NodeJS.WriteStream;
const thirdWrite = vi.fn();
const thirdStream = {
isTTY: true,
write: thirdWrite,
} as unknown as NodeJS.WriteStream;
const first = createCliProgress({
label: "First",
stream: firstStream,
fallback: "line",
});
first.done();
const second = createCliProgress({
label: "Second",
stream: secondStream,
fallback: "line",
});
try {
secondWrite.mockClear();
first.done();
expect(secondWrite).not.toHaveBeenCalled();
const third = createCliProgress({
label: "Third",
stream: thirdStream,
fallback: "line",
});
third.done();
expect(thirdWrite).not.toHaveBeenCalled();
} finally {
second.done();
}
});
it("does not use readline-backed spinners while raw TUI input is active", () => {
expect(
shouldUseInteractiveProgressSpinner({
+7 -1
View File
@@ -89,6 +89,7 @@ export function createCliProgress(options: ProgressOptions): ProgressReporter {
}
let started = false;
let finished = false;
let label = options.label;
const total = options.total ?? null;
let completed = 0;
@@ -144,7 +145,7 @@ export function createCliProgress(options: ProgressOptions): ProgressReporter {
let timer: NodeJS.Timeout | null = null;
const applyState = () => {
if (!started) {
if (!started || finished) {
return;
}
if (controller) {
@@ -203,6 +204,11 @@ export function createCliProgress(options: ProgressOptions): ProgressReporter {
};
const done = () => {
// A finally block may finish an already-stopped reporter; never clear a newer owner's line.
if (finished) {
return;
}
finished = true;
if (timer) {
clearTimeout(timer);
timer = null;
+3
View File
@@ -67,6 +67,7 @@ import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-ackn
import { resolveOptionFromCommand } from "./cli-utils.js";
import { parseStrictPositiveIntOption } from "./program/helpers.js";
import { setCommandJsonMode } from "./program/json-mode.js";
import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
import { formatSkillInfo, formatSkillsCheck, formatSkillsList } from "./skills-cli.format.js";
import { isSkillsMachineOutput } from "./skills-output-mode.js";
@@ -1286,6 +1287,8 @@ export function registerSkillsCli(program: Command) {
},
);
applyParentDefaultHelpAction(workshop);
skills
.command("list")
.description("List all available skills")
+26
View File
@@ -126,6 +126,32 @@ describe("skills workshop cli", () => {
await tempDirs.cleanup();
});
it("renders workshop parent help successfully without creating workshop state", async () => {
const helpOutput: string[] = [];
const program = new Command();
program.exitOverride();
program.configureOutput({
writeErr: (value) => helpOutput.push(value),
writeOut: (value) => helpOutput.push(value),
});
registerSkillsCli(program);
const originalExitCode = process.exitCode;
try {
process.exitCode = undefined;
await program.parseAsync(["skills", "workshop"], { from: "user" });
expect(process.exitCode).toBe(0);
expect(helpOutput.join("")).toContain("Manage pending skill proposals");
expect(helpOutput.join("")).toContain("propose-create");
expect(mocks.runtimeStdout).toEqual([]);
expect(mocks.runtimeErrors).toEqual([]);
await expect(fs.access(path.join(stateDir, "skill-workshop"))).rejects.toThrow();
} finally {
process.exitCode = originalExitCode;
}
});
it("creates, lists, inspects, and applies a skill proposal", async () => {
const draftPath = path.join(mocks.workspaceDir, "proposal-draft");
await fs.mkdir(path.join(draftPath, "references"), { recursive: true });
+133 -2
View File
@@ -13,6 +13,29 @@ async function makeFixtureRoot(prefix: string): Promise<string> {
return await fs.mkdtemp(path.join(os.tmpdir(), `doctor-post-upgrade-${prefix}-`));
}
async function writeDeclaredPackageFixture(root: string, packageContents: string): Promise<string> {
const pluginDir = path.join(root, "user-plugins", "broken");
await fs.mkdir(pluginDir, { recursive: true });
await fs.writeFile(path.join(pluginDir, "package.json"), packageContents, "utf-8");
const installsPath = path.join(root, "plugins", "installs.json");
await fs.mkdir(path.dirname(installsPath), { recursive: true });
await fs.writeFile(
installsPath,
JSON.stringify({
plugins: [
{
pluginId: "broken",
rootDir: pluginDir,
enabled: true,
packageJson: { path: "package.json" },
},
],
}),
"utf-8",
);
return installsPath;
}
describe("runPostUpgradeProbes — plugin.index_unavailable", () => {
it("returns a structured finding when the installed plugin index is missing", async () => {
const root = await makeFixtureRoot("index-missing");
@@ -76,7 +99,7 @@ describe("runPostUpgradeProbes — plugin.index_unavailable", () => {
});
describe("runPostUpgradeProbes — plugin.entry_unresolved", () => {
it("structures unreadable package diagnostics for JSON console output", async () => {
it("reports unreadable plugin packages as structured errors without losing JSON console diagnostics", async () => {
const root = await makeFixtureRoot("entry-unreadable-json");
const stderrSpy = vi
.spyOn(process.stderr, "write")
@@ -102,7 +125,15 @@ describe("runPostUpgradeProbes — plugin.entry_unresolved", () => {
const report = await runPostUpgradeProbes({ installsPath });
expect(report.findings).toEqual([]);
expect(report.findings).toEqual([
expect.objectContaining({
level: "error",
code: "plugin.entry_unresolved",
plugin: "broken",
entry: "missing-package.json",
message: expect.stringContaining("openclaw plugins registry --refresh"),
}),
]);
const line = stderrSpy.mock.calls.map(([value]) => String(value)).join("");
expect(JSON.parse(line)).toMatchObject({
level: "warn",
@@ -115,6 +146,106 @@ describe("runPostUpgradeProbes — plugin.entry_unresolved", () => {
}
});
it("reports malformed declared plugin packages as entry resolution errors", async () => {
const root = await makeFixtureRoot("entry-malformed-package");
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true as unknown as ReturnType<typeof process.stderr.write>);
try {
const installsPath = await writeDeclaredPackageFixture(root, "{ not json");
const report = await runPostUpgradeProbes({ installsPath });
expect(report.findings).toEqual([
expect.objectContaining({
level: "error",
code: "plugin.entry_unresolved",
plugin: "broken",
entry: "package.json",
message: expect.stringContaining("openclaw plugins registry --refresh"),
}),
]);
expect(stderrSpy).toHaveBeenCalled();
} finally {
stderrSpy.mockRestore();
await fs.rm(root, { recursive: true, force: true });
}
});
it.each([
{ label: "null", packageJson: null },
{ label: "array", packageJson: [] },
{ label: "string", packageJson: "not a package" },
])("rejects a $label declared package manifest", async ({ label, packageJson }) => {
const root = await makeFixtureRoot(`entry-non-object-${label}`);
const stderrSpy = vi
.spyOn(process.stderr, "write")
.mockImplementation(() => true as unknown as ReturnType<typeof process.stderr.write>);
try {
const installsPath = await writeDeclaredPackageFixture(root, JSON.stringify(packageJson));
const report = await runPostUpgradeProbes({ installsPath });
expect(report.findings).toEqual([
expect.objectContaining({
level: "error",
code: "plugin.entry_unresolved",
plugin: "broken",
entry: "package.json",
message: expect.stringContaining("package.json must contain a JSON object"),
}),
]);
} finally {
stderrSpy.mockRestore();
await fs.rm(root, { recursive: true, force: true });
}
});
it.each([
{
label: "non-object metadata",
openclaw: "invalid",
reason: "package.json openclaw must be an object",
},
{
label: "non-array entries",
openclaw: { extensions: "./dist/index.js" },
reason: "package.json openclaw.extensions must be an array",
},
{
label: "blank entries",
openclaw: { extensions: [" "] },
reason: "package.json openclaw.extensions[0] must be a non-empty string",
},
{
label: "non-string entries",
openclaw: { extensions: [42] },
reason: "package.json openclaw.extensions[0] must be a non-empty string",
},
])(
"reports $label through the canonical package contract",
async ({ label, openclaw, reason }) => {
const root = await makeFixtureRoot(`entry-invalid-${label.replaceAll(" ", "-")}`);
try {
const installsPath = await writeDeclaredPackageFixture(
root,
JSON.stringify({ name: "broken", openclaw }),
);
const report = await runPostUpgradeProbes({ installsPath });
expect(report.findings).toEqual([
expect.objectContaining({
level: "error",
code: "plugin.entry_unresolved",
plugin: "broken",
entry: "package.json",
message: expect.stringContaining(reason),
}),
]);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
},
);
it("reads the canonical SQLite plugin index by default", async () => {
const root = await makeFixtureRoot("entry-sqlite");
try {
+29 -5
View File
@@ -3,9 +3,10 @@ import crypto from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { formatConsoleDiagnosticLine } from "../logging/json-console-line.js";
import { readPersistedInstalledPluginIndex } from "../plugins/installed-plugin-index-store.js";
import type { PackageManifest } from "../plugins/manifest.js";
import { resolvePackageExtensionEntries, type PackageManifest } from "../plugins/manifest.js";
import { validatePackageExtensionEntriesForInstall } from "../plugins/package-entry-resolution.js";
import {
POST_UPGRADE_PROBE_CODES,
@@ -123,7 +124,11 @@ async function readInstalledPackageJson(
): Promise<PackageManifest> {
const absPath = path.join(rootDir, packageJsonRelPath);
const raw = await fs.readFile(absPath, "utf-8");
return JSON.parse(raw) as PackageManifest;
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed)) {
throw new Error("package.json must contain a JSON object");
}
return parsed as PackageManifest;
}
async function resolvePackageJsonRelPath(
@@ -176,12 +181,31 @@ export async function runPostUpgradeProbes(params: {
try {
pkg = await readInstalledPackageJson(record.rootDir, pkgRelPath);
} catch (err) {
const message = `[doctor-post-upgrade] could not read package.json for ${record.pluginId} at ${record.rootDir}: ${err instanceof Error ? err.message : String(err)}`;
const reason = err instanceof Error ? err.message : String(err);
const message = `[doctor-post-upgrade] could not read package.json for ${record.pluginId} at ${record.rootDir}: ${reason}`;
process.stderr.write(`${formatConsoleDiagnosticLine({ level: "warn", message })}\n`);
// A declared package is required to validate its runtime entry; logging
// alone otherwise makes a broken enabled plugin exit as healthy.
findings.push({
level: "error",
code: "plugin.entry_unresolved",
message: `Plugin ${record.pluginId}: could not read package.json (${pkgRelPath}): ${reason}. Reinstall the plugin or run \`openclaw plugins registry --refresh\`.`,
plugin: record.pluginId,
entry: pkgRelPath,
});
continue;
}
const entries = pkg.openclaw?.extensions ?? [];
if (entries.length > 0) {
const resolvedEntries = resolvePackageExtensionEntries(pkg);
if (resolvedEntries.status === "invalid") {
findings.push({
level: "error",
code: "plugin.entry_unresolved",
message: `Plugin ${record.pluginId}: ${resolvedEntries.error}. Reinstall the plugin or run \`openclaw plugins registry --refresh\`.`,
plugin: record.pluginId,
entry: pkgRelPath,
});
} else if (resolvedEntries.status === "ok") {
const entries = resolvedEntries.entries;
// Delegate to the install-time resolver so the probe enforces the same
// contract as plugin install/discovery: runtimeExtensions shape, plugin-root
// boundary, and inferred-built-output / TypeScript-source-only handling.
+68
View File
@@ -47,6 +47,74 @@ function snapshot(sourceConfig: OpenClawConfig) {
};
}
describe("modelsAliasesListCommand", () => {
beforeEach(() => {
mocks.loadModelsConfig.mockReset();
});
it.each([
{
label: "plain",
opts: { plain: true },
lines: ["alpha anthropic/claude-sonnet-4-6", "zeta openai/gpt-5.6-sol"],
},
{
label: "human-readable",
opts: {},
lines: [
"Aliases (2):",
"- alpha -> anthropic/claude-sonnet-4-6",
"- zeta -> openai/gpt-5.6-sol",
],
},
])("sorts $label aliases independently of model insertion order", async ({ opts, lines }) => {
mocks.loadModelsConfig.mockResolvedValue({
agents: {
defaults: {
models: {
"openai/gpt-5.6-sol": { alias: "zeta" },
"anthropic/claude-sonnet-4-6": { alias: "alpha" },
},
},
},
});
const runtime = makeRuntime();
await modelsAliasesListCommand(opts, runtime);
expect(runtime.logs).toEqual(lines);
});
it("preserves safely named prototype aliases in deterministic JSON output", async () => {
mocks.loadModelsConfig.mockResolvedValue({
agents: {
defaults: {
models: {
"openai/gpt-5.6-sol": { alias: "zeta" },
"google/gemini-3.1-pro-preview": { alias: "__proto__" },
"anthropic/claude-sonnet-4-6": { alias: "alpha" },
},
},
},
});
const runtime = makeRuntime();
await modelsAliasesListCommand({ json: true }, runtime);
expect(runtime.logs).toHaveLength(1);
const payload = JSON.parse(runtime.logs[0] ?? "") as {
aliases: Record<string, string>;
};
expect(Object.keys(payload.aliases)).toEqual(
["zeta", "__proto__", "alpha"].toSorted((left, right) => left.localeCompare(right)),
);
expect(Object.hasOwn(payload.aliases, "__proto__")).toBe(true);
expect(Reflect.get(payload.aliases, "__proto__")).toBe("google/gemini-3.1-pro-preview");
expect(payload.aliases.alpha).toBe("anthropic/claude-sonnet-4-6");
expect(payload.aliases.zeta).toBe("openai/gpt-5.6-sol");
});
});
describe("modelsAliasesRemoveCommand", () => {
beforeEach(() => {
mocks.readConfigFileSnapshot.mockReset();
+12 -13
View File
@@ -16,34 +16,33 @@ export async function modelsAliasesListCommand(
ensureFlagCompatibility(opts);
const cfg = await loadModelsConfig({ commandName: "models aliases list", runtime });
const models = cfg.agents?.defaults?.models ?? {};
const aliases = Object.entries(models).reduce<Record<string, string>>(
(acc, [modelKey, entry]) => {
const aliases = Object.fromEntries(
Object.entries(models).flatMap(([modelKey, entry]) => {
const alias = entry?.alias?.trim();
if (alias) {
acc[alias] = modelKey;
}
return acc;
},
{},
return alias ? [[alias, modelKey] as const] : [];
}),
);
const aliasEntries = Object.entries(aliases).toSorted(([left], [right]) =>
left.localeCompare(right),
);
if (opts.json) {
writeRuntimeJson(runtime, { aliases });
writeRuntimeJson(runtime, { aliases: Object.fromEntries(aliasEntries) });
return;
}
if (opts.plain) {
for (const [alias, target] of Object.entries(aliases)) {
for (const [alias, target] of aliasEntries) {
runtime.log(`${alias} ${target}`);
}
return;
}
runtime.log(`Aliases (${Object.keys(aliases).length}):`);
if (Object.keys(aliases).length === 0) {
runtime.log(`Aliases (${aliasEntries.length}):`);
if (aliasEntries.length === 0) {
runtime.log("- none");
return;
}
for (const [alias, target] of Object.entries(aliases)) {
for (const [alias, target] of aliasEntries) {
runtime.log(`- ${alias} -> ${target}`);
}
}
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../runtime.js";
import { listFallbacksCommand } from "./fallbacks-shared.js";
const mocks = vi.hoisted(() => ({
loadModelsConfig: vi.fn(),
}));
vi.mock("./load-config.js", () => ({
loadModelsConfig: mocks.loadModelsConfig,
}));
describe("listFallbacksCommand", () => {
beforeEach(() => {
mocks.loadModelsConfig.mockReset();
});
it.each([
{
label: "Fallbacks",
key: "model" as const,
commandName: "models fallbacks list",
model: "anthropic/claude-sonnet-4-6",
},
{
label: "Image fallbacks",
key: "imageModel" as const,
commandName: "models image-fallbacks list",
model: "openai/gpt-image-1",
},
])("attributes $label diagnostics to the real CLI command", async (testCase) => {
mocks.loadModelsConfig.mockResolvedValue({
agents: {
defaults: {
[testCase.key]: { fallbacks: [testCase.model] },
},
},
});
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} satisfies RuntimeEnv;
await listFallbacksCommand(
{ label: testCase.label, key: testCase.key },
{ json: true },
runtime,
);
expect(mocks.loadModelsConfig).toHaveBeenCalledWith({
commandName: testCase.commandName,
runtime,
});
expect(runtime.log).toHaveBeenCalledOnce();
expect(JSON.parse(runtime.log.mock.calls[0]?.[0] as string)).toEqual({
fallbacks: [testCase.model],
});
});
});
+6 -5
View File
@@ -21,9 +21,7 @@ import {
type DefaultsFallbackKey = "model" | "imageModel";
function listCommandForFallbackKey(key: DefaultsFallbackKey): string {
return key === "imageModel"
? "openclaw models image-fallbacks list"
: "openclaw models fallbacks list";
return key === "imageModel" ? "models image-fallbacks list" : "models fallbacks list";
}
function getFallbacks(cfg: OpenClawConfig, key: DefaultsFallbackKey): string[] {
@@ -55,7 +53,10 @@ export async function listFallbacksCommand(
runtime: RuntimeEnv,
) {
ensureFlagCompatibility(opts);
const cfg = await loadModelsConfig({ commandName: `models ${params.key} list`, runtime });
const cfg = await loadModelsConfig({
commandName: listCommandForFallbackKey(params.key),
runtime,
});
const fallbacks = getFallbacks(cfg, params.key);
if (opts.json) {
@@ -147,7 +148,7 @@ export async function removeFallbackCommand(
if (filtered.length === existing.length) {
throw new Error(
`${params.notFoundLabel} not found: ${targetKey}. Run ${formatCliCommand(listCommandForFallbackKey(params.key))} to see configured fallbacks.`,
`${params.notFoundLabel} not found: ${targetKey}. Run ${formatCliCommand(`openclaw ${listCommandForFallbackKey(params.key)}`)} to see configured fallbacks.`,
);
}
+55
View File
@@ -89,6 +89,61 @@ describe("models scan command", () => {
expect(runtime.lines.join("\n")).toContain("skip");
});
it("prints metadata-only JSON in the same deterministic ranking as the table", async () => {
const runtime = createRuntime();
mocks.scanOpenRouterModels.mockResolvedValue([
scanResult({
id: "zeta/free:free",
modelRef: "openrouter/zeta/free:free",
contextLength: 128_000,
}),
scanResult({
id: "alpha/free:free",
modelRef: "openrouter/alpha/free:free",
contextLength: 128_000,
}),
scanResult({
id: "larger/free:free",
modelRef: "openrouter/larger/free:free",
contextLength: 256_000,
}),
]);
await modelsScanCommand({ probe: false, json: true }, runtime);
expect(runtime.lines).toHaveLength(1);
const results = JSON.parse(runtime.lines[0] ?? "") as ModelScanResult[];
expect(results.map((result) => result.modelRef)).toEqual([
"openrouter/larger/free:free",
"openrouter/alpha/free:free",
"openrouter/zeta/free:free",
]);
expect(mocks.loadModelsConfig).not.toHaveBeenCalled();
expect(mocks.resolveApiKeyForProvider).not.toHaveBeenCalled();
});
it("sanitizes provider-controlled model refs and modality in scan tables", async () => {
const runtime = createRuntime();
mocks.scanOpenRouterModels.mockResolvedValue([
scanResult({
modelRef: "openrouter/evil\u001b[31m\nmodel:free",
modality: "text\u001b[31m\nnext\tvalue\u0007",
}),
]);
await modelsScanCommand({ probe: false }, runtime);
const row = runtime.lines.find((line) => line.includes("openrouter/evil"));
expect(row).toContain("openrouter/evil\\nmodel:free");
expect(row).toContain("modality:text\\nnext\\tvalue");
expect(
Array.from(row ?? "").every((character) => {
const code = character.charCodeAt(0);
return code > 0x1f && (code < 0x7f || code > 0x9f);
}),
).toBe(true);
});
it("downgrades to metadata-only scan when no OpenRouter key is configured", async () => {
await withOpenRouterApiKey(undefined, async () => {
const runtime = createRuntime();
+6 -5
View File
@@ -3,6 +3,7 @@ import { cancel, multiselect as clackMultiselect, isCancel } from "@clack/prompt
import { getEnvApiKey } from "@openclaw/ai/internal/runtime";
import { styleSelectParams } from "../../../packages/terminal-core/src/prompt-select-styled-params.js";
import { stylePromptTitle } from "../../../packages/terminal-core/src/prompt-style.js";
import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js";
import { resolveApiKeyForProvider } from "../../agents/model-auth.js";
import { type ModelScanResult, scanOpenRouterModels } from "../../agents/model-scan.js";
import { formatCliCommand } from "../../cli/command-format.js";
@@ -143,7 +144,7 @@ function printScanTable(results: ModelScanResult[], runtime: RuntimeEnv) {
);
const ctxLabel = pad(formatTokenK(entry.contextLength), CTX_PAD);
const paramsLabel = pad(entry.inferredParamB ? `${entry.inferredParamB}b` : "-", 8);
const notes = entry.modality ? `modality:${entry.modality}` : "";
const notes = entry.modality ? `modality:${sanitizeTerminalText(entry.modality)}` : "";
runtime.log([modelLabel, toolLabel, imageLabel, ctxLabel, paramsLabel, notes].join(" "));
}
@@ -270,6 +271,7 @@ export async function modelsScanCommand(
},
}),
);
const sorted = sortScanResults(results);
if (!probe) {
if (!opts.json) {
@@ -278,9 +280,9 @@ export async function modelsScanCommand(
runtime,
autoDowngraded: requestedProbe,
});
printScanTable(sortScanResults(results), runtime);
printScanTable(sorted, runtime);
} else {
writeRuntimeJson(runtime, results);
writeRuntimeJson(runtime, sorted);
}
return;
}
@@ -292,7 +294,6 @@ export async function modelsScanCommand(
);
}
const sorted = sortScanResults(results);
const toolSorted = sortScanResults(toolOk);
const imageOk = results.filter((entry) => entry.image.ok);
const imageSorted = sortImageResults(imageOk);
@@ -398,7 +399,7 @@ export async function modelsScanCommand(
selectedImages,
setDefault: Boolean(opts.setDefault),
setImage: Boolean(opts.setImage),
results,
results: sorted,
warnings: [],
});
return;
@@ -2,6 +2,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import * as gatewayServiceLayout from "../daemon/service-layout.js";
import type { GatewayServiceEnvArgs } from "../daemon/service-types.js";
import { resolveGatewayService, type GatewayService } from "../daemon/service.js";
import { createMockGatewayService } from "../daemon/service.test-helpers.js";
@@ -66,6 +67,37 @@ describe("readServiceStatusSummary", () => {
expect(summary.loadedText).toBe("disabled");
});
it("preserves running service state when optional layout diagnostics fail", async () => {
const layoutSpy = vi
.spyOn(gatewayServiceLayout, "summarizeGatewayServiceLayout")
.mockRejectedValueOnce(new Error("package metadata is unreadable"));
try {
const summary = await readServiceStatusSummary(
createService({
isLoaded: vi.fn(async () => true),
readCommand: vi.fn(async () => ({ programArguments: ["openclaw", "gateway", "run"] })),
readRuntime: vi.fn(async () => ({ status: "running", pid: 1234 })),
}),
"Daemon",
);
expect(layoutSpy).toHaveBeenCalledOnce();
expect(summary).toMatchObject({
label: "systemd",
installed: true,
loaded: true,
managedByOpenClaw: true,
externallyManaged: false,
loadedText: "enabled",
runtime: { status: "running", pid: 1234 },
});
expect(summary.layout).toBeUndefined();
} finally {
layoutSpy.mockRestore();
}
});
it("keeps unsupported service adapters readable", async () => {
await withMockedPlatform("aix", async () => {
const summary = await readServiceStatusSummary(resolveGatewayService(), "Daemon");
+3 -1
View File
@@ -37,7 +37,9 @@ export async function readServiceStatusSummary(
): Promise<ServiceStatusSummary> {
try {
const state = await readGatewayServiceState(service, { env: process.env, timeoutMs });
const layout = await summarizeGatewayServiceLayout(state.command);
// Layout is optional enrichment; a broken manifest or inaccessible path
// must not erase service-manager evidence that the gateway is running.
const layout = await summarizeGatewayServiceLayout(state.command).catch(() => undefined);
const wrapperPath = normalizeServiceWrapperPath(state.command);
const managedByOpenClaw = state.installed;
// A running unmanaged process still counts as installed for status display.
+13 -8
View File
@@ -228,8 +228,8 @@ describe("scripts/test-projects changed-target routing", () => {
"src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts",
])(
"routes setup inference transcript ownership changes through both regressions for %s",
(path) => {
expect(resolveChangedTestTargetPlan([path])).toEqual({
(targetPath) => {
expect(resolveChangedTestTargetPlan([targetPath])).toEqual({
mode: "targets",
targets: [
"src/agents/embedded-agent-runner/run.overflow-compaction.loop.test.ts",
@@ -3176,12 +3176,17 @@ describe("scripts/test-projects changed-target routing", () => {
it("adds the CLI process project for broad CLI targets", () => {
const plans = buildVitestRunPlans(["src/cli"]);
expect(plans.map((plan) => plan.config)).toEqual([
"test/vitest/vitest.unit-fast.config.ts",
"test/vitest/vitest.cli-process.config.ts",
"test/vitest/vitest.cli.config.ts",
]);
expect(plans[1]?.includePatterns).toContain("src/cli/help-exit.process.test.ts");
expect(plans.map((plan) => plan.config)).toEqual(
expect.arrayContaining([
"test/vitest/vitest.unit-fast.config.ts",
"test/vitest/vitest.cli-process.config.ts",
"test/vitest/vitest.cli.config.ts",
]),
);
const processPlan = plans.find(
(plan) => plan.config === "test/vitest/vitest.cli-process.config.ts",
);
expect(processPlan?.includePatterns).toContain("src/cli/help-exit.process.test.ts");
});
it("rejects broad CLI watch targets that cross shared and process projects", () => {