From 14c77f82959e43c90675bcd653769e7af91c4007 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sat, 31 Jan 2026 03:37:12 +0000 Subject: [PATCH 1/5] feat: add completion subcommand to generate shell completion scripts for Zsh, Bash, PowerShell, and Fish. --- src/cli/completion-cli.ts | 306 ++++++++++++++++++++++++++++ src/cli/program/register.subclis.ts | 12 ++ 2 files changed, 318 insertions(+) create mode 100644 src/cli/completion-cli.ts diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts new file mode 100644 index 000000000..fbc52ee16 --- /dev/null +++ b/src/cli/completion-cli.ts @@ -0,0 +1,306 @@ +import { Command, Option } from "commander"; +import { getSubCliEntries, registerSubCliByName } from "./program/register.subclis.js"; + +export function registerCompletionCli(program: Command) { + program + .command("completion") + .description("Generate shell completion script") + .addOption( + new Option("-s, --shell ", "Shell to generate completion for") + .choices(["zsh", "bash", "powershell", "fish"]) + .default("zsh"), + ) + .action(async (options) => { + const shell = options.shell; + // Eagerly register all subcommands to build the full tree + const entries = getSubCliEntries(); + for (const entry of entries) { + // Skip completion command itself to avoid cycle if we were to add it to the list + if (entry.name === "completion") continue; + await registerSubCliByName(program, entry.name); + } + + let script = ""; + if (shell === "zsh") { + script = generateZshCompletion(program); + } else if (shell === "bash") { + script = generateBashCompletion(program); + } else if (shell === "powershell") { + script = generatePowerShellCompletion(program); + } else if (shell === "fish") { + script = generateFishCompletion(program); + } + + console.log(script); + }); +} + +function generateZshCompletion(program: Command): string { + const rootCmd = program.name(); + const script = ` +#compdef ${rootCmd} + +_${rootCmd}_completion() { + local -a commands + local -a options + + _arguments -C \\ + ${generateZshArgs(program)} \\ + "1: :_commands" \\ + "*::arg:->args" + + case $state in + (args) + case $line[1] in + ${program.commands.map((cmd) => `(${cmd.name()}) _${rootCmd}_${cmd.name().replace(/-/g, "_")} ;;`).join("\n ")} + esac + ;; + esac +} + +${generateZshSubcommands(program, rootCmd)} + +compdef _${rootCmd}_completion ${rootCmd} +`; + return script; +} + +function generateZshArgs(cmd: Command): string { + return (cmd.options || []) + .map((opt) => { + const flags = opt.flags.split(/[ ,|]+/); + const name = flags.find((f) => f.startsWith("--")) || flags[0]; + const short = flags.find((f) => f.startsWith("-") && !f.startsWith("--")); + const desc = opt.description.replace(/'/g, "'\\''"); + if (short) { + return `"(${name} ${short})"'{${name},${short}}'[${desc}]"`; + } + return `"${name}[${desc}]"`; + }) + .join(" \\\n "); +} + +function generateZshSubcommands(program: Command, prefix: string): string { + let script = ""; + for (const cmd of program.commands) { + const cmdName = cmd.name(); + const funcName = `_${prefix}_${cmdName.replace(/-/g, "_")}`; + + // Recurse first + script += generateZshSubcommands(cmd, `${prefix}_${cmdName.replace(/-/g, "_")}`); + + const subCommands = cmd.commands; + if (subCommands.length > 0) { + script += ` +${funcName}() { + local -a commands + local -a options + + _arguments -C \\ + ${generateZshArgs(cmd)} \\ + "1: :_commands" \\ + "*::arg:->args" + + case $state in + (args) + case $line[1] in + ${subCommands.map((sub) => `(${sub.name()}) ${funcName}_${sub.name().replace(/-/g, "_")} ;;`).join("\n ")} + esac + ;; + esac +} +`; + } else { + script += ` +${funcName}() { + _arguments -C \\ + ${generateZshArgs(cmd)} +} +`; + } + } + return script; +} + +function generateBashCompletion(program: Command): string { + // Simplified Bash completion using dynamic iteration logic (often hardcoded in static scripts) + // For a robust implementation, usually one maps out the tree. + // This assumes a simple structure. + const rootCmd = program.name(); + + // We can use a recursive function to build the case statements + return ` +_${rootCmd}_completion() { + local cur prev opts + COMPREPLY=() + cur="\${COMP_WORDS[COMP_CWORD]}" + 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(" ")}" + + case "\${prev}" in + ${program.commands.map((cmd) => generateBashSubcommand(cmd)).join("\n ")} + esac + + if [[ \${cur} == -* ]] ; then + COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) ) + return 0 + fi + + COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) ) +} + +complete -F _${rootCmd}_completion ${rootCmd} +`; +} + +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(" ")}" + COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) ) + return 0 + ;;`; +} + +function generatePowerShellCompletion(program: Command): string { + const rootCmd = program.name(); + + const visit = (cmd: Command, parents: string[]): string => { + const cmdName = cmd.name(); + const fullPath = [...parents, cmdName].join(" "); + + let script = ""; + + // 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 allCompletions = [...subCommands, ...options].map((s) => `'${s}'`).join(","); + + if (allCompletions.length > 0) { + script += ` + if ($commandPath -eq '${fullPath}') { + $completions = @(${allCompletions}) + $completions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_) + } + } +`; + } + + // Recurse + for (const sub of cmd.commands) { + script += visit(sub, [...parents, cmdName]); + } + + return script; + }; + + const rootBody = visit(program, []); + + return ` +Register-ArgumentCompleter -Native -CommandName ${rootCmd} -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $commandElements = $commandAst.CommandElements + $commandPath = "" + + # Reconstruct command path (simple approximation) + # Skip the executable name + for ($i = 1; $i -lt $commandElements.Count; $i++) { + $element = $commandElements[$i].Extent.Text + if ($element -like "-*") { break } + if ($i -eq $commandElements.Count - 1 -and $wordToComplete -ne "") { break } # Don't include current word being typed + $commandPath += "$element " + } + $commandPath = $commandPath.Trim() + + # Root command + if ($commandPath -eq "") { + $completions = @(${program.commands.map((c) => `'${c.name()}'`).join(",")}, ${program.options.map((o) => `'${o.flags.split(" ")[0]}'`).join(",")}) + $completions | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_) + } + } + + ${rootBody} +} +`; +} + +function generateFishCompletion(program: Command): string { + const rootCmd = program.name(); + let script = ""; + + const visit = (cmd: Command, parents: string[]) => { + const cmdName = cmd.name(); + const fullPath = [...parents]; + if (parents.length > 0) fullPath.push(cmdName); // Only push if not root, or consistent root handling + + // Fish uses 'seen_subcommand_from' to determine context. + // For root: complete -c openclaw -n "__fish_use_subcommand" -a "subcmd" -d "desc" + + // Root logic + 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`; + } + // 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; + } + } else { + // Nested commands + // Logic: if seen subcommand matches parents... + const seenCondition = `__fish_seen_subcommand_from ${parents.join(" ")}`; + // But fish completion logic is simpler if we just say "if we haven't seen THIS command yet but seen parent" + // Actually, a robust fish completion often requires defining a function to check current line. + // For simplicity, we'll assume standard fish helper __fish_seen_subcommand_from. + + // To properly scope to 'openclaw gateway' and not 'openclaw other gateway', we need to check the sequence. + // A simplified approach: + const parentChain = parents.join(" "); + + // 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`; + } + // 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; + } + } + + for (const sub of cmd.commands) { + visit(sub, [...parents, cmdName]); + } + }; + + visit(program, []); + return script; +} diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index 87eec67e1..34dff02d0 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -227,8 +227,20 @@ const entries: SubCliEntry[] = [ mod.registerUpdateCli(program); }, }, + { + name: "completion", + description: "Generate shell completion script", + register: async (program) => { + const mod = await import("../completion-cli.js"); + mod.registerCompletionCli(program); + }, + }, ]; +export function getSubCliEntries(): SubCliEntry[] { + return entries; +} + function removeCommand(program: Command, command: Command) { const commands = program.commands as Command[]; const index = commands.indexOf(command); From beafaef92f77470201413ecc2e9d519796cb5659 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sat, 31 Jan 2026 04:44:04 +0000 Subject: [PATCH 2/5] feat: implement completion command and shell generators --- src/cli/completion-cli.ts | 91 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts index fbc52ee16..dde644c1f 100644 --- a/src/cli/completion-cli.ts +++ b/src/cli/completion-cli.ts @@ -1,4 +1,7 @@ import { Command, Option } from "commander"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { getSubCliEntries, registerSubCliByName } from "./program/register.subclis.js"; export function registerCompletionCli(program: Command) { @@ -10,6 +13,8 @@ export function registerCompletionCli(program: Command) { .choices(["zsh", "bash", "powershell", "fish"]) .default("zsh"), ) + .option("-i, --install", "Install completion script to shell profile") + .option("-y, --yes", "Skip confirmation (non-interactive)", false) .action(async (options) => { const shell = options.shell; // Eagerly register all subcommands to build the full tree @@ -20,6 +25,11 @@ export function registerCompletionCli(program: Command) { await registerSubCliByName(program, entry.name); } + if (options.install) { + await installCompletion(shell, Boolean(options.yes), program.name()); + return; + } + let script = ""; if (shell === "zsh") { script = generateZshCompletion(program); @@ -35,18 +45,75 @@ export function registerCompletionCli(program: Command) { }); } +export async function installCompletion(shell: string, yes: boolean, binName = "openclaw") { + const home = process.env.HOME || os.homedir(); + let profilePath = ""; + let sourceLine = ""; + + if (shell === "zsh") { + profilePath = path.join(home, ".zshrc"); + sourceLine = `source <(${binName} completion --shell zsh)`; + } else if (shell === "bash") { + // Try .bashrc first, then .bash_profile + profilePath = path.join(home, ".bashrc"); + try { + await fs.access(profilePath); + } catch { + profilePath = path.join(home, ".bash_profile"); + } + sourceLine = `source <(${binName} completion --shell bash)`; + } else if (shell === "fish") { + profilePath = path.join(home, ".config", "fish", "config.fish"); + sourceLine = `${binName} completion --shell fish | source`; + } else { + console.error(`Automated installation not supported for ${shell} yet.`); + return; + } + + try { + // Check if profile exists + try { + await fs.access(profilePath); + } catch { + if (!yes) { + console.warn(`Profile not found at ${profilePath}. Created a new one.`); + } + await fs.mkdir(path.dirname(profilePath), { recursive: true }); + await fs.writeFile(profilePath, "", "utf-8"); + } + + const content = await fs.readFile(profilePath, "utf-8"); + if (content.includes(`${binName} completion`)) { + if (!yes) console.log(`Completion already installed in ${profilePath}`); + return; + } + + if (!yes) { + // Simple confirmation could go here if we had a prompter, + // but for now we assume --yes or manual invocation implies consent or we print info. + // Since we don't have a prompter passed in here easily without adding deps, we'll log. + console.log(`Installing completion to ${profilePath}...`); + } + + await fs.appendFile(profilePath, `\n# OpenClaw Completion\n${sourceLine}\n`); + console.log(`Completion installed. Restart your shell or run: source ${profilePath}`); + } catch (err) { + console.error(`Failed to install completion: ${err}`); + } +} + function generateZshCompletion(program: Command): string { const rootCmd = program.name(); const script = ` #compdef ${rootCmd} -_${rootCmd}_completion() { +_${rootCmd}_root_completion() { local -a commands local -a options _arguments -C \\ ${generateZshArgs(program)} \\ - "1: :_commands" \\ + ${generateZshSubcmdList(program)} \\ "*::arg:->args" case $state in @@ -60,7 +127,7 @@ _${rootCmd}_completion() { ${generateZshSubcommands(program, rootCmd)} -compdef _${rootCmd}_completion ${rootCmd} +compdef _${rootCmd}_root_completion ${rootCmd} `; return script; } @@ -73,13 +140,27 @@ function generateZshArgs(cmd: Command): string { const short = flags.find((f) => f.startsWith("-") && !f.startsWith("--")); const desc = opt.description.replace(/'/g, "'\\''"); if (short) { - return `"(${name} ${short})"'{${name},${short}}'[${desc}]"`; + return `"(${name} ${short})"{${name},${short}}"[${desc}]"`; } return `"${name}[${desc}]"`; }) .join(" \\\n "); } +function generateZshSubcmdList(cmd: Command): string { + const list = cmd.commands + .map((c) => { + const desc = c + .description() + .replace(/'/g, "'\\''") + .replace(/\[/g, "\\[") + .replace(/\]/g, "\\]"); + return `'${c.name()}[${desc}]'`; + }) + .join(" "); + return `"1: :_values 'command' ${list}"`; +} + function generateZshSubcommands(program: Command, prefix: string): string { let script = ""; for (const cmd of program.commands) { @@ -98,7 +179,7 @@ ${funcName}() { _arguments -C \\ ${generateZshArgs(cmd)} \\ - "1: :_commands" \\ + ${generateZshSubcmdList(cmd)} \\ "*::arg:->args" case $state in From 48aaf6ce4eb3f6233391a6a1ba35a4d0555747fd Mon Sep 17 00:00:00 2001 From: Shakker Date: Sat, 31 Jan 2026 04:44:46 +0000 Subject: [PATCH 3/5] fix: suppress banner and doctor checks for completion command --- src/cli/program/preaction.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 310b8faaa..27d196ec0 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -31,6 +31,7 @@ export function registerPreActionHooks(program: Command, programVersion: string) const hideBanner = isTruthyEnvValue(process.env.OPENCLAW_HIDE_BANNER) || commandPath[0] === "update" || + commandPath[0] === "completion" || (commandPath[0] === "plugins" && commandPath[1] === "update"); if (!hideBanner) { emitCliBanner(programVersion); @@ -40,7 +41,7 @@ export function registerPreActionHooks(program: Command, programVersion: string) if (!verbose) { process.env.NODE_NO_WARNINGS ??= "1"; } - if (commandPath[0] === "doctor") return; + if (commandPath[0] === "doctor" || commandPath[0] === "completion") return; await ensureConfigReady({ runtime: defaultRuntime, commandPath }); // Load plugins for commands that need channel access if (PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) { From b1d25ed0dd85a33d7a583b19a8df56764c2c8d35 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sat, 31 Jan 2026 04:45:11 +0000 Subject: [PATCH 4/5] feat: automated completion setup in postinstall and onboarding --- scripts/postinstall.js | 25 +++++++++++++++++++++++++ src/wizard/onboarding.ts | 13 +++++++++++++ 2 files changed, 38 insertions(+) diff --git a/scripts/postinstall.js b/scripts/postinstall.js index d6896d9ac..32b6eb3fa 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -248,12 +248,37 @@ function applyPatchFile({ patchPath, targetDir }) { applyPatchSet({ patchText, targetDir }); } +function trySetupCompletion(repoRoot) { + // Skip in CI or if explicitly disabled + if (process.env.CI || process.env.OPENCLAW_SKIP_COMPLETION_SETUP) return; + + const binPath = path.join(repoRoot, "openclaw.mjs"); + if (!fs.existsSync(binPath)) return; + + // In development, dist might not exist yet during postinstall + const distEntry = path.join(repoRoot, "dist", "index.js"); + if (!fs.existsSync(distEntry)) return; + + try { + // Run with OPENCLAW_SKIP_POSTINSTALL to avoid any weird recursion, + // though distinct from this script. + spawnSync(process.execPath, [binPath, "completion", "--install", "--yes"], { + cwd: repoRoot, + stdio: "inherit", + env: { ...process.env, OPENCLAW_SKIP_POSTINSTALL: "1" }, + }); + } catch (err) { + // Ignore errors to not break install + } +} + function main() { const repoRoot = getRepoRoot(); process.chdir(repoRoot); ensureExecutable(path.join(repoRoot, "dist", "entry.js")); setupGitHooks({ repoRoot }); + trySetupCompletion(repoRoot); if (!shouldApplyPnpmPatchedDependenciesFallback()) { return; diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index ef2e349c6..c2391261a 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -42,6 +42,7 @@ import { finalizeOnboardingWizard } from "./onboarding.finalize.js"; import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js"; import type { QuickstartGatewayDefaults, WizardFlow } from "./onboarding.types.js"; import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; +import { installCompletion } from "../cli/completion-cli.js"; async function requireRiskAcknowledgement(params: { opts: OnboardOptions; @@ -448,4 +449,16 @@ export async function runOnboardingWizard( prompter, runtime, }); + + const installShell = await prompter.confirm({ + message: "Install shell completion script?", + initialValue: true, + }); + + if (installShell) { + const shell = process.env.SHELL?.split("/").pop() || "zsh"; + // We pass 'yes=true' to skip any double-confirmation inside the helper, + // as the wizard prompt above serves as confirmation. + await installCompletion(shell, true); + } } From 3c8fa0f9130bca7fa97ab48d36fcad8c3eb8e13d Mon Sep 17 00:00:00 2001 From: Shakker Date: Sat, 31 Jan 2026 04:54:44 +0000 Subject: [PATCH 5/5] fix: remove unused variables and fix template literal type --- src/cli/completion-cli.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts index dde644c1f..42916c1d4 100644 --- a/src/cli/completion-cli.ts +++ b/src/cli/completion-cli.ts @@ -98,7 +98,7 @@ export async function installCompletion(shell: string, yes: boolean, binName = " await fs.appendFile(profilePath, `\n# OpenClaw Completion\n${sourceLine}\n`); console.log(`Completion installed. Restart your shell or run: source ${profilePath}`); } catch (err) { - console.error(`Failed to install completion: ${err}`); + console.error(`Failed to install completion: ${err as string}`); } } @@ -347,14 +347,12 @@ function generateFishCompletion(program: Command): string { } else { // Nested commands // Logic: if seen subcommand matches parents... - const seenCondition = `__fish_seen_subcommand_from ${parents.join(" ")}`; // But fish completion logic is simpler if we just say "if we haven't seen THIS command yet but seen parent" // Actually, a robust fish completion often requires defining a function to check current line. // For simplicity, we'll assume standard fish helper __fish_seen_subcommand_from. // To properly scope to 'openclaw gateway' and not 'openclaw other gateway', we need to check the sequence. // A simplified approach: - const parentChain = parents.join(" "); // Subcommands for (const sub of cmd.commands) {