mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-30 16:40:23 +00:00
fix(cli): harden generated completions
This commit is contained in:
@@ -14,6 +14,7 @@ function createCompletionProgram(): Command {
|
||||
|
||||
const gateway = program.command("gateway").description("Gateway commands");
|
||||
gateway.option("--force", "Force the action");
|
||||
gateway.option("-t, --token <token>", "Gateway token");
|
||||
|
||||
gateway.command("status").description("Show gateway status").option("--json", "JSON output");
|
||||
gateway.command("restart").description("Restart gateway");
|
||||
@@ -90,7 +91,8 @@ describe("completion-cli", () => {
|
||||
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')");
|
||||
expect(script).toContain("$completions = @('status','restart','--force','--token')");
|
||||
expect(script).not.toContain("'-t,'");
|
||||
});
|
||||
|
||||
it("generates fish completions for root and nested command contexts", () => {
|
||||
@@ -100,10 +102,21 @@ describe("completion-cli", () => {
|
||||
'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\'',
|
||||
'complete -c openclaw -n "__openclaw_command_path_matches 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'",
|
||||
"complete -c openclaw -n \"__openclaw_command_path_matches gateway\" -l force -d 'Force the action'",
|
||||
);
|
||||
expect(script).toContain(
|
||||
"complete -c openclaw -n \"__openclaw_command_path_matches gateway status\" -l json -d 'JSON output'",
|
||||
);
|
||||
expect(script).toContain("set -l root_boolean_options -v --verbose");
|
||||
});
|
||||
|
||||
it("generates Bash completions without comma-suffixed short flags", () => {
|
||||
const script = getCompletionScript("bash", createCompletionProgram());
|
||||
|
||||
expect(script).toContain("--token");
|
||||
expect(script).not.toContain("-t,");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,75 @@ export function getCompletionScript(shell: CompletionShell, program: Command): s
|
||||
return generateFishCompletion(program);
|
||||
}
|
||||
|
||||
function splitOptionFlags(flags: string): string[] {
|
||||
return flags.split(/[ ,|]+/u).filter(Boolean);
|
||||
}
|
||||
|
||||
function preferredCompletionFlag(flags: string): string {
|
||||
const parts = splitOptionFlags(flags);
|
||||
return parts.find((flag) => flag.startsWith("--")) ?? parts[0] ?? flags;
|
||||
}
|
||||
|
||||
function fishWords(values: readonly string[]): string {
|
||||
return values.join(" ");
|
||||
}
|
||||
|
||||
function fishOptionFlags(options: Command["options"], wantsValue: boolean): string[] {
|
||||
return options.flatMap((option) => {
|
||||
if (Boolean(option.required || option.optional) !== wantsValue) {
|
||||
return [];
|
||||
}
|
||||
return splitOptionFlags(option.flags).filter((flag) => flag.startsWith("-"));
|
||||
});
|
||||
}
|
||||
|
||||
function generateFishPathHelper(program: Command, rootCmd: string): string {
|
||||
const rootValueOptions = fishOptionFlags(program.options, true);
|
||||
const rootBooleanOptions = fishOptionFlags(program.options, false);
|
||||
return `
|
||||
function __${rootCmd}_command_path_matches
|
||||
set -l tokens (commandline -opc)
|
||||
set -e tokens[1]
|
||||
set -l root_value_options ${fishWords(rootValueOptions)}
|
||||
set -l root_boolean_options ${fishWords(rootBooleanOptions)}
|
||||
set -l command_tokens
|
||||
set -l skip_next 0
|
||||
for token in $tokens
|
||||
if test $skip_next -eq 1
|
||||
set skip_next 0
|
||||
continue
|
||||
end
|
||||
if test (count $command_tokens) -eq 0
|
||||
set -l flag (string split -m1 "=" -- $token)[1]
|
||||
if contains -- $flag $root_boolean_options
|
||||
continue
|
||||
end
|
||||
if contains -- $flag $root_value_options
|
||||
if not string match -q -- "*=*" $token
|
||||
set skip_next 1
|
||||
end
|
||||
continue
|
||||
end
|
||||
end
|
||||
if string match -q -- "-*" $token
|
||||
continue
|
||||
end
|
||||
set -a command_tokens $token
|
||||
end
|
||||
for i in (seq (count $argv))
|
||||
if test "$command_tokens[$i]" != "$argv[$i]"
|
||||
return 1
|
||||
end
|
||||
end
|
||||
return 0
|
||||
end
|
||||
`;
|
||||
}
|
||||
|
||||
function fishCommandPathCondition(rootCmd: string, parents: readonly string[]): string {
|
||||
return `__${rootCmd}_command_path_matches ${parents.join(" ")}`;
|
||||
}
|
||||
|
||||
async function writeCompletionCache(params: {
|
||||
program: Command;
|
||||
shells: CompletionShell[];
|
||||
@@ -285,7 +354,7 @@ _${rootCmd}_completion() {
|
||||
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
# Simple top-level completion for now
|
||||
opts="${program.commands.map((c) => c.name()).join(" ")} ${program.options.map((o) => o.flags.split(" ")[0]).join(" ")}"
|
||||
opts="${program.commands.map((c) => c.name()).join(" ")} ${program.options.map((o) => preferredCompletionFlag(o.flags)).join(" ")}"
|
||||
|
||||
case "\${prev}" in
|
||||
${program.commands.map((cmd) => generateBashSubcommand(cmd)).join("\n ")}
|
||||
@@ -307,7 +376,7 @@ function generateBashSubcommand(cmd: Command): string {
|
||||
// This is a naive implementation; fully recursive bash completion is complex to generate as a single string without improved state tracking.
|
||||
// For now, let's provide top-level command recognition.
|
||||
return `${cmd.name()})
|
||||
opts="${cmd.commands.map((c) => c.name()).join(" ")} ${cmd.options.map((o) => o.flags.split(" ")[0]).join(" ")}"
|
||||
opts="${cmd.commands.map((c) => c.name()).join(" ")} ${cmd.options.map((o) => preferredCompletionFlag(o.flags)).join(" ")}"
|
||||
COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) )
|
||||
return 0
|
||||
;;`;
|
||||
@@ -322,7 +391,7 @@ function generatePowerShellCompletion(program: Command): string {
|
||||
|
||||
// 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 options = cmd.options.map((o) => preferredCompletionFlag(o.flags));
|
||||
const allCompletions = [...subCommands, ...options].map((s) => `'${s}'`).join(",");
|
||||
|
||||
if (fullPath.length > 0 && allCompletions.length > 0) {
|
||||
@@ -363,7 +432,7 @@ Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock {
|
||||
|
||||
# Root command
|
||||
if ($commandPath -eq "") {
|
||||
$completions = @(${program.commands.map((c) => `'${c.name()}'`).join(",")}, ${program.options.map((o) => `'${o.flags.split(" ")[0]}'`).join(",")})
|
||||
$completions = @(${program.commands.map((c) => `'${c.name()}'`).join(",")}, ${program.options.map((o) => `'${preferredCompletionFlag(o.flags)}'`).join(",")})
|
||||
$completions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
|
||||
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_)
|
||||
}
|
||||
@@ -376,11 +445,9 @@ Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock {
|
||||
|
||||
function generateFishCompletion(program: Command): string {
|
||||
const rootCmd = program.name();
|
||||
const segments: string[] = [];
|
||||
const segments: string[] = [generateFishPathHelper(program, rootCmd)];
|
||||
|
||||
const visit = (cmd: Command, parents: string[]) => {
|
||||
const cmdName = cmd.name();
|
||||
|
||||
// Root logic
|
||||
if (parents.length === 0) {
|
||||
// Subcommands of root
|
||||
@@ -406,12 +473,13 @@ function generateFishCompletion(program: Command): string {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const condition = fishCommandPathCondition(rootCmd, parents);
|
||||
// Subcommands
|
||||
for (const sub of cmd.commands) {
|
||||
segments.push(
|
||||
buildFishSubcommandCompletionLine({
|
||||
rootCmd,
|
||||
condition: `__fish_seen_subcommand_from ${cmdName}`,
|
||||
condition,
|
||||
name: sub.name(),
|
||||
description: sub.description(),
|
||||
}),
|
||||
@@ -422,7 +490,7 @@ function generateFishCompletion(program: Command): string {
|
||||
segments.push(
|
||||
buildFishOptionCompletionLine({
|
||||
rootCmd,
|
||||
condition: `__fish_seen_subcommand_from ${cmdName}`,
|
||||
condition,
|
||||
flags: opt.flags,
|
||||
description: opt.description,
|
||||
}),
|
||||
@@ -431,7 +499,7 @@ function generateFishCompletion(program: Command): string {
|
||||
}
|
||||
|
||||
for (const sub of cmd.commands) {
|
||||
visit(sub, [...parents, cmdName]);
|
||||
visit(sub, parents.length === 0 ? [sub.name()] : [...parents, sub.name()]);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user