CLI/completion: fix generator OOM and harden plugin registries (#45537)

* fix: avoid OOM during completion script generation

* CLI/completion: fix PowerShell nested command paths

* CLI/completion: cover generated shell scripts

* Changelog: note completion generator follow-up

* Plugins: reserve shared registry names

---------

Co-authored-by: Xiaoyi <xiaoyi@example.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
xiaoyi
2026-03-16 03:14:30 +08:00
committed by GitHub
parent dd2eb29038
commit bbb0c3e5d7
5 changed files with 307 additions and 71 deletions

View File

@@ -0,0 +1,52 @@
import { Command } from "commander";
import { describe, expect, it } from "vitest";
import { getCompletionScript } from "./completion-cli.js";
function createCompletionProgram(): Command {
const program = new Command();
program.name("openclaw");
program.description("CLI root");
program.option("-v, --verbose", "Verbose output");
const gateway = program.command("gateway").description("Gateway commands");
gateway.option("--force", "Force the action");
gateway.command("status").description("Show gateway status").option("--json", "JSON output");
gateway.command("restart").description("Restart gateway");
return program;
}
describe("completion-cli", () => {
it("generates zsh functions for nested subcommands", () => {
const script = getCompletionScript("zsh", createCompletionProgram());
expect(script).toContain("_openclaw_gateway()");
expect(script).toContain("(status) _openclaw_gateway_status ;;");
expect(script).toContain("(restart) _openclaw_gateway_restart ;;");
expect(script).toContain("--force[Force the action]");
});
it("generates PowerShell command paths without the executable prefix", () => {
const script = getCompletionScript("powershell", createCompletionProgram());
expect(script).toContain("if ($commandPath -eq 'gateway') {");
expect(script).toContain("if ($commandPath -eq 'gateway status') {");
expect(script).not.toContain("if ($commandPath -eq 'openclaw gateway') {");
expect(script).toContain("$completions = @('status','restart','--force')");
});
it("generates fish completions for root and nested command contexts", () => {
const script = getCompletionScript("fish", createCompletionProgram());
expect(script).toContain(
'complete -c openclaw -n "__fish_use_subcommand" -a "gateway" -d \'Gateway commands\'',
);
expect(script).toContain(
'complete -c openclaw -n "__fish_seen_subcommand_from gateway" -a "status" -d \'Show gateway status\'',
);
expect(script).toContain(
"complete -c openclaw -n \"__fish_seen_subcommand_from gateway\" -l force -d 'Force the action'",
);
});
});

View File

@@ -69,7 +69,7 @@ export async function completionCacheExists(
return pathExists(cachePath);
}
function getCompletionScript(shell: CompletionShell, program: Command): string {
export function getCompletionScript(shell: CompletionShell, program: Command): string {
if (shell === "zsh") {
return generateZshCompletion(program);
}
@@ -442,17 +442,19 @@ function generateZshSubcmdList(cmd: Command): string {
}
function generateZshSubcommands(program: Command, prefix: string): string {
let script = "";
for (const cmd of program.commands) {
const cmdName = cmd.name();
const funcName = `_${prefix}_${cmdName.replace(/-/g, "_")}`;
const segments: string[] = [];
// Recurse first
script += generateZshSubcommands(cmd, `${prefix}_${cmdName.replace(/-/g, "_")}`);
const visit = (current: Command, currentPrefix: string) => {
for (const cmd of current.commands) {
const cmdName = cmd.name();
const nextPrefix = `${currentPrefix}_${cmdName.replace(/-/g, "_")}`;
const funcName = `_${nextPrefix}`;
const subCommands = cmd.commands;
if (subCommands.length > 0) {
script += `
visit(cmd, nextPrefix);
const subCommands = cmd.commands;
if (subCommands.length > 0) {
segments.push(`
${funcName}() {
local -a commands
local -a options
@@ -470,17 +472,21 @@ ${funcName}() {
;;
esac
}
`;
} else {
script += `
`);
continue;
}
segments.push(`
${funcName}() {
_arguments -C \\
${generateZshArgs(cmd)}
}
`;
`);
}
}
return script;
};
visit(program, prefix);
return segments.join("");
}
function generateBashCompletion(program: Command): string {
@@ -528,38 +534,34 @@ function generateBashSubcommand(cmd: Command): string {
function generatePowerShellCompletion(program: Command): string {
const rootCmd = program.name();
const segments: string[] = [];
const visit = (cmd: Command, parents: string[]): string => {
const cmdName = cmd.name();
const fullPath = [...parents, cmdName].join(" ");
let script = "";
const visit = (cmd: Command, pathSegments: string[]) => {
const fullPath = pathSegments.join(" ");
// Command completion for this level
const subCommands = cmd.commands.map((c) => c.name());
const options = cmd.options.map((o) => o.flags.split(/[ ,|]+/)[0]); // Take first flag
const allCompletions = [...subCommands, ...options].map((s) => `'${s}'`).join(",");
if (allCompletions.length > 0) {
script += `
if (fullPath.length > 0 && allCompletions.length > 0) {
segments.push(`
if ($commandPath -eq '${fullPath}') {
$completions = @(${allCompletions})
$completions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_)
}
}
`;
`);
}
// Recurse
for (const sub of cmd.commands) {
script += visit(sub, [...parents, cmdName]);
visit(sub, [...pathSegments, sub.name()]);
}
return script;
};
const rootBody = visit(program, []);
visit(program, []);
const rootBody = segments.join("");
return `
Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock {
@@ -593,65 +595,57 @@ Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock {
function generateFishCompletion(program: Command): string {
const rootCmd = program.name();
let script = "";
const segments: string[] = [];
const visit = (cmd: Command, parents: string[]) => {
const cmdName = cmd.name();
const fullPath = [...parents];
if (parents.length > 0) {
fullPath.push(cmdName);
} // Only push if not root, or consistent root handling
// Fish uses 'seen_subcommand_from' to determine context.
// For root: complete -c openclaw -n "__fish_use_subcommand" -a "subcmd" -d "desc"
// Root logic
if (parents.length === 0) {
// Subcommands of root
for (const sub of cmd.commands) {
script += buildFishSubcommandCompletionLine({
rootCmd,
condition: "__fish_use_subcommand",
name: sub.name(),
description: sub.description(),
});
segments.push(
buildFishSubcommandCompletionLine({
rootCmd,
condition: "__fish_use_subcommand",
name: sub.name(),
description: sub.description(),
}),
);
}
// Options of root
for (const opt of cmd.options) {
script += buildFishOptionCompletionLine({
rootCmd,
condition: "__fish_use_subcommand",
flags: opt.flags,
description: opt.description,
});
segments.push(
buildFishOptionCompletionLine({
rootCmd,
condition: "__fish_use_subcommand",
flags: opt.flags,
description: opt.description,
}),
);
}
} else {
// Nested commands
// Logic: if seen subcommand matches parents...
// But fish completion logic is simpler if we just say "if we haven't seen THIS command yet but seen parent"
// Actually, a robust fish completion often requires defining a function to check current line.
// For simplicity, we'll assume standard fish helper __fish_seen_subcommand_from.
// To properly scope to 'openclaw gateway' and not 'openclaw other gateway', we need to check the sequence.
// A simplified approach:
// Subcommands
for (const sub of cmd.commands) {
script += buildFishSubcommandCompletionLine({
rootCmd,
condition: `__fish_seen_subcommand_from ${cmdName}`,
name: sub.name(),
description: sub.description(),
});
segments.push(
buildFishSubcommandCompletionLine({
rootCmd,
condition: `__fish_seen_subcommand_from ${cmdName}`,
name: sub.name(),
description: sub.description(),
}),
);
}
// Options
for (const opt of cmd.options) {
script += buildFishOptionCompletionLine({
rootCmd,
condition: `__fish_seen_subcommand_from ${cmdName}`,
flags: opt.flags,
description: opt.description,
});
segments.push(
buildFishOptionCompletionLine({
rootCmd,
condition: `__fish_seen_subcommand_from ${cmdName}`,
flags: opt.flags,
description: opt.description,
}),
);
}
}
@@ -661,5 +655,5 @@ function generateFishCompletion(program: Command): string {
};
visit(program, []);
return script;
return segments.join("");
}