refactor(cli): extract fish completion line builders

This commit is contained in:
Peter Steinberger
2026-02-21 21:55:52 +00:00
parent fc54e3eabd
commit fb73c0034e
3 changed files with 117 additions and 34 deletions

View File

@@ -5,6 +5,10 @@ import { Command, Option } from "commander";
import { resolveStateDir } from "../config/paths.js";
import { routeLogsToStderr } from "../logging/console.js";
import { pathExists } from "../utils.js";
import {
buildFishOptionCompletionLine,
buildFishSubcommandCompletionLine,
} from "./completion-fish.js";
import { getCoreCliCommandNames, registerCoreCliByName } from "./program/command-registry.js";
import { getProgramContext } from "./program/program-context.js";
import { getSubCliEntries, registerSubCliByName } from "./program/register.subclis.js";
@@ -598,26 +602,21 @@ function generateFishCompletion(program: Command): string {
if (parents.length === 0) {
// Subcommands of root
for (const sub of cmd.commands) {
const desc = sub.description().replace(/'/g, "'\\''");
script += `complete -c ${rootCmd} -n "__fish_use_subcommand" -a "${sub.name()}" -d '${desc}'\n`;
script += buildFishSubcommandCompletionLine({
rootCmd,
condition: "__fish_use_subcommand",
name: sub.name(),
description: sub.description(),
});
}
// Options of root
for (const opt of cmd.options) {
const flags = opt.flags.split(/[ ,|]+/);
const long = flags.find((f) => f.startsWith("--"))?.replace(/^--/, "");
const short = flags
.find((f) => f.startsWith("-") && !f.startsWith("--"))
?.replace(/^-/, "");
const desc = opt.description.replace(/'/g, "'\\''");
let line = `complete -c ${rootCmd} -n "__fish_use_subcommand"`;
if (short) {
line += ` -s ${short}`;
}
if (long) {
line += ` -l ${long}`;
}
line += ` -d '${desc}'\n`;
script += line;
script += buildFishOptionCompletionLine({
rootCmd,
condition: "__fish_use_subcommand",
flags: opt.flags,
description: opt.description,
});
}
} else {
// Nested commands
@@ -631,26 +630,21 @@ function generateFishCompletion(program: Command): string {
// Subcommands
for (const sub of cmd.commands) {
const desc = sub.description().replace(/'/g, "'\\''");
script += `complete -c ${rootCmd} -n "__fish_seen_subcommand_from ${cmdName}" -a "${sub.name()}" -d '${desc}'\n`;
script += buildFishSubcommandCompletionLine({
rootCmd,
condition: `__fish_seen_subcommand_from ${cmdName}`,
name: sub.name(),
description: sub.description(),
});
}
// Options
for (const opt of cmd.options) {
const flags = opt.flags.split(/[ ,|]+/);
const long = flags.find((f) => f.startsWith("--"))?.replace(/^--/, "");
const short = flags
.find((f) => f.startsWith("-") && !f.startsWith("--"))
?.replace(/^-/, "");
const desc = opt.description.replace(/'/g, "'\\''");
let line = `complete -c ${rootCmd} -n "__fish_seen_subcommand_from ${cmdName}"`;
if (short) {
line += ` -s ${short}`;
}
if (long) {
line += ` -l ${long}`;
}
line += ` -d '${desc}'\n`;
script += line;
script += buildFishOptionCompletionLine({
rootCmd,
condition: `__fish_seen_subcommand_from ${cmdName}`,
flags: opt.flags,
description: opt.description,
});
}
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
buildFishOptionCompletionLine,
buildFishSubcommandCompletionLine,
escapeFishDescription,
} from "./completion-fish.js";
describe("completion-fish helpers", () => {
it("escapes single quotes in descriptions", () => {
expect(escapeFishDescription("Bob's plugin")).toBe("Bob'\\''s plugin");
});
it("builds a subcommand completion line", () => {
const line = buildFishSubcommandCompletionLine({
rootCmd: "openclaw",
condition: "__fish_use_subcommand",
name: "plugins",
description: "Manage Bob's plugins",
});
expect(line).toBe(
`complete -c openclaw -n "__fish_use_subcommand" -a "plugins" -d 'Manage Bob'\\''s plugins'\n`,
);
});
it("builds option line with short and long flags", () => {
const line = buildFishOptionCompletionLine({
rootCmd: "openclaw",
condition: "__fish_use_subcommand",
flags: "-s, --shell <shell>",
description: "Shell target",
});
expect(line).toBe(
`complete -c openclaw -n "__fish_use_subcommand" -s s -l shell -d 'Shell target'\n`,
);
});
it("builds option line with long-only flags", () => {
const line = buildFishOptionCompletionLine({
rootCmd: "openclaw",
condition: "__fish_seen_subcommand_from completion",
flags: "--write-state",
description: "Write cache",
});
expect(line).toBe(
`complete -c openclaw -n "__fish_seen_subcommand_from completion" -l write-state -d 'Write cache'\n`,
);
});
});

View File

@@ -0,0 +1,41 @@
export function escapeFishDescription(value: string): string {
return value.replace(/'/g, "'\\''");
}
function parseOptionFlags(flags: string): { long?: string; short?: string } {
const parts = flags.split(/[ ,|]+/);
const long = parts.find((flag) => flag.startsWith("--"))?.replace(/^--/, "");
const short = parts
.find((flag) => flag.startsWith("-") && !flag.startsWith("--"))
?.replace(/^-/, "");
return { long, short };
}
export function buildFishSubcommandCompletionLine(params: {
rootCmd: string;
condition: string;
name: string;
description: string;
}): string {
const desc = escapeFishDescription(params.description);
return `complete -c ${params.rootCmd} -n "${params.condition}" -a "${params.name}" -d '${desc}'\n`;
}
export function buildFishOptionCompletionLine(params: {
rootCmd: string;
condition: string;
flags: string;
description: string;
}): string {
const { short, long } = parseOptionFlags(params.flags);
const desc = escapeFishDescription(params.description);
let line = `complete -c ${params.rootCmd} -n "${params.condition}"`;
if (short) {
line += ` -s ${short}`;
}
if (long) {
line += ` -l ${long}`;
}
line += ` -d '${desc}'\n`;
return line;
}