mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(cli): scope Fish completions to active commands (#115180)
This commit is contained in:
committed by
GitHub
parent
e4907b92ec
commit
c44eb5348b
@@ -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());
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user