diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts index e8f9f40d4..8c14f2979 100644 --- a/src/cli/completion-cli.ts +++ b/src/cli/completion-cli.ts @@ -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, + }); } } diff --git a/src/cli/completion-fish.test.ts b/src/cli/completion-fish.test.ts new file mode 100644 index 000000000..b1b15bf0a --- /dev/null +++ b/src/cli/completion-fish.test.ts @@ -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 ", + 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`, + ); + }); +}); diff --git a/src/cli/completion-fish.ts b/src/cli/completion-fish.ts new file mode 100644 index 000000000..7178d059f --- /dev/null +++ b/src/cli/completion-fish.ts @@ -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; +}