mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
4645d4a487
commit
433bb3f954
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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)],
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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" }] },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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.");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -41,6 +41,7 @@ export async function callGatewayFromCliRuntime(
|
||||
await callGateway({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
password: opts.password,
|
||||
method,
|
||||
params,
|
||||
deviceIdentity: extra?.deviceIdentity,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
export type GatewayRpcOpts = {
|
||||
url?: string;
|
||||
token?: string;
|
||||
password?: string;
|
||||
timeout?: string;
|
||||
expectFinal?: boolean;
|
||||
json?: boolean;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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"], {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -329,5 +329,6 @@ export function registerPluginsCli(program: Command) {
|
||||
await runPluginMarketplaceListCommand(source, opts);
|
||||
});
|
||||
|
||||
applyParentDefaultHelpAction(marketplace);
|
||||
applyParentDefaultHelpAction(plugins);
|
||||
}
|
||||
|
||||
@@ -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>");
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -177,6 +177,7 @@ export function registerAgentsCommands(program: Command): void {
|
||||
"agentDir",
|
||||
"bind",
|
||||
"nonInteractive",
|
||||
"json",
|
||||
]);
|
||||
const agentsAddCommand = await loadAgentsAddCommand();
|
||||
await agentsAddCommand(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user