fix(cli): scope Fish completions to active commands (#115180)

This commit is contained in:
Peter Steinberger
2026-07-28 08:32:48 -04:00
committed by GitHub
parent e4907b92ec
commit c44eb5348b
2 changed files with 138 additions and 2 deletions
+120
View File
@@ -67,6 +67,42 @@ printf '%s\\n' "\${COMPREPLY[@]}"
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 = [
@@ -283,6 +319,50 @@ describe("completion-cli", () => {
expect(script).toContain("if contains -- $flag $value_options");
});
it("distinguishes Fish child command paths from positional arguments", () => {
const script = getCompletionScript("fish", createCompletionProgram());
expect(script).toContain('switch "$candidate_path"');
expect(script).toContain("'gateway status'");
});
itWithFish.each([
["the exact nested command", "openclaw gateway status -"],
["a separate long option value", "openclaw gateway --token secret status -"],
["a separate short option value", "openclaw gateway -t secret status -"],
["an inline long option value", "openclaw gateway --token=secret status -"],
["an inline short option value", "openclaw gateway -t=secret status -"],
["a parent boolean option", "openclaw gateway --force status -"],
])("keeps real Fish completions scoped after %s", (_name, commandLine) => {
expect(runGeneratedFishCompletion(createCompletionProgram(), commandLine)).toEqual(["--json"]);
});
itWithFish.each([
["a positional argument", "openclaw gateway status query -"],
["multiple positional arguments", "openclaw gateway status first second -"],
["a positional argument named like a sibling", "openclaw gateway status restart -"],
["a long option and positional argument", "openclaw gateway --token secret status query -"],
["an inline option and positional argument", "openclaw gateway --token=secret status query -"],
])("keeps real Fish leaf options after %s", (_name, commandLine) => {
const program = createCompletionProgram();
const gateway = program.commands.find((command) => command.name() === "gateway");
const status = gateway?.commands.find((command) => command.name() === "status");
if (!status) {
throw new Error("Gateway status command is unavailable");
}
status.argument("[query...]", "Search query");
expect(runGeneratedFishCompletion(program, commandLine)).toEqual(["--json"]);
});
itWithFish("preserves documented short and long completion flags in real Fish", () => {
expect(
runGeneratedFishCompletion(createDocumentedCompletionProgram(), "openclaw completion -"),
).toEqual(
expect.arrayContaining(["-s", "--shell", "-i", "--install", "-y", "--yes", "--write-state"]),
);
});
it("scopes fish value-taking option skips to the active command path", () => {
const script = getCompletionScript("fish", createCompletionProgram());
@@ -501,6 +581,46 @@ printf '%s\\n' "\${COMPREPLY[@]}"
);
});
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());
+18 -2
View File
@@ -93,7 +93,22 @@ function collectFishPathOptionFlags(
return [...flags];
}
function generateFishPathHelper(rootCmd: string): string {
function generateFishPathHelper(rootCmd: string, program: Command): string {
const knownCommandPaths = collectBashCompletionContexts(program, [])
.flatMap((context) => context.pathVariants)
.map((pathSegments) => `'${pathSegments.join(" ").replaceAll("'", "'\\''")}'`)
.join(" ");
const rejectDescendantCommands = knownCommandPaths
? `
if test (count $command_tokens) -gt (count $expected)
set -l next_index (math (count $expected) + 1)
set -l candidate_path (string join " " $expected $command_tokens[$next_index])
switch "$candidate_path"
case ${knownCommandPaths}
return 1
end
end`
: "";
// Fish needs a helper to ignore option values while matching nested command paths.
return `
function __${rootCmd}_command_path_matches
@@ -137,6 +152,7 @@ function __${rootCmd}_command_path_matches
return 1
end
end
${rejectDescendantCommands}
return 0
end
`;
@@ -604,7 +620,7 @@ ${commandPathCases}
function generateFishCompletion(program: Command): string {
const rootCmd = program.name();
const segments: string[] = [generateFishPathHelper(rootCmd)];
const segments: string[] = [generateFishPathHelper(rootCmd, program)];
const visit = (cmd: Command, parentVariants: string[][]) => {
// One condition per alias-expanded parent path so completion keeps working