Spaces:
Paused
Paused
| import { Command, Option } from "commander"; | |
| import fs from "node:fs/promises"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| import { resolveStateDir } from "../config/paths.js"; | |
| import { routeLogsToStderr } from "../logging/console.js"; | |
| import { pathExists } from "../utils.js"; | |
| import { getSubCliEntries, registerSubCliByName } from "./program/register.subclis.js"; | |
| const COMPLETION_SHELLS = ["zsh", "bash", "powershell", "fish"] as const; | |
| type CompletionShell = (typeof COMPLETION_SHELLS)[number]; | |
| function isCompletionShell(value: string): value is CompletionShell { | |
| return COMPLETION_SHELLS.includes(value as CompletionShell); | |
| } | |
| export function resolveShellFromEnv(env: NodeJS.ProcessEnv = process.env): CompletionShell { | |
| const shellPath = env.SHELL?.trim() ?? ""; | |
| const shellName = shellPath ? path.basename(shellPath).toLowerCase() : ""; | |
| if (shellName === "zsh") { | |
| return "zsh"; | |
| } | |
| if (shellName === "bash") { | |
| return "bash"; | |
| } | |
| if (shellName === "fish") { | |
| return "fish"; | |
| } | |
| if (shellName === "pwsh" || shellName === "powershell") { | |
| return "powershell"; | |
| } | |
| return "zsh"; | |
| } | |
| function sanitizeCompletionBasename(value: string): string { | |
| const trimmed = value.trim(); | |
| if (!trimmed) { | |
| return "openclaw"; | |
| } | |
| return trimmed.replace(/[^a-zA-Z0-9._-]/g, "-"); | |
| } | |
| function resolveCompletionCacheDir(env: NodeJS.ProcessEnv = process.env): string { | |
| const stateDir = resolveStateDir(env, os.homedir); | |
| return path.join(stateDir, "completions"); | |
| } | |
| export function resolveCompletionCachePath(shell: CompletionShell, binName: string): string { | |
| const basename = sanitizeCompletionBasename(binName); | |
| const extension = | |
| shell === "powershell" ? "ps1" : shell === "fish" ? "fish" : shell === "bash" ? "bash" : "zsh"; | |
| return path.join(resolveCompletionCacheDir(), `${basename}.${extension}`); | |
| } | |
| /** Check if the completion cache file exists for the given shell. */ | |
| export async function completionCacheExists( | |
| shell: CompletionShell, | |
| binName = "openclaw", | |
| ): Promise<boolean> { | |
| const cachePath = resolveCompletionCachePath(shell, binName); | |
| return pathExists(cachePath); | |
| } | |
| function getCompletionScript(shell: CompletionShell, program: Command): string { | |
| if (shell === "zsh") { | |
| return generateZshCompletion(program); | |
| } | |
| if (shell === "bash") { | |
| return generateBashCompletion(program); | |
| } | |
| if (shell === "powershell") { | |
| return generatePowerShellCompletion(program); | |
| } | |
| return generateFishCompletion(program); | |
| } | |
| async function writeCompletionCache(params: { | |
| program: Command; | |
| shells: CompletionShell[]; | |
| binName: string; | |
| }): Promise<void> { | |
| const cacheDir = resolveCompletionCacheDir(); | |
| await fs.mkdir(cacheDir, { recursive: true }); | |
| for (const shell of params.shells) { | |
| const script = getCompletionScript(shell, params.program); | |
| const targetPath = resolveCompletionCachePath(shell, params.binName); | |
| await fs.writeFile(targetPath, script, "utf-8"); | |
| } | |
| } | |
| function formatCompletionSourceLine( | |
| shell: CompletionShell, | |
| binName: string, | |
| cachePath: string, | |
| ): string { | |
| if (shell === "fish") { | |
| return `source "${cachePath}"`; | |
| } | |
| return `source "${cachePath}"`; | |
| } | |
| function isCompletionProfileHeader(line: string): boolean { | |
| return line.trim() === "# OpenClaw Completion"; | |
| } | |
| function isCompletionProfileLine(line: string, binName: string, cachePath: string | null): boolean { | |
| if (line.includes(`${binName} completion`)) { | |
| return true; | |
| } | |
| if (cachePath && line.includes(cachePath)) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| /** Check if a line uses the slow dynamic completion pattern (source <(...)) */ | |
| function isSlowDynamicCompletionLine(line: string, binName: string): boolean { | |
| // Matches patterns like: source <(openclaw completion --shell zsh) | |
| return ( | |
| line.includes(`<(${binName} completion`) || | |
| (line.includes(`${binName} completion`) && line.includes("| source")) | |
| ); | |
| } | |
| function updateCompletionProfile( | |
| content: string, | |
| binName: string, | |
| cachePath: string | null, | |
| sourceLine: string, | |
| ): { next: string; changed: boolean; hadExisting: boolean } { | |
| const lines = content.split("\n"); | |
| const filtered: string[] = []; | |
| let hadExisting = false; | |
| for (let i = 0; i < lines.length; i += 1) { | |
| const line = lines[i] ?? ""; | |
| if (isCompletionProfileHeader(line)) { | |
| hadExisting = true; | |
| i += 1; | |
| continue; | |
| } | |
| if (isCompletionProfileLine(line, binName, cachePath)) { | |
| hadExisting = true; | |
| continue; | |
| } | |
| filtered.push(line); | |
| } | |
| const trimmed = filtered.join("\n").trimEnd(); | |
| const block = `# OpenClaw Completion\n${sourceLine}`; | |
| const next = trimmed ? `${trimmed}\n\n${block}\n` : `${block}\n`; | |
| return { next, changed: next !== content, hadExisting }; | |
| } | |
| function getShellProfilePath(shell: CompletionShell): string { | |
| const home = process.env.HOME || os.homedir(); | |
| if (shell === "zsh") { | |
| return path.join(home, ".zshrc"); | |
| } | |
| if (shell === "bash") { | |
| return path.join(home, ".bashrc"); | |
| } | |
| if (shell === "fish") { | |
| return path.join(home, ".config", "fish", "config.fish"); | |
| } | |
| // PowerShell | |
| if (process.platform === "win32") { | |
| return path.join( | |
| process.env.USERPROFILE || home, | |
| "Documents", | |
| "PowerShell", | |
| "Microsoft.PowerShell_profile.ps1", | |
| ); | |
| } | |
| return path.join(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"); | |
| } | |
| export async function isCompletionInstalled( | |
| shell: CompletionShell, | |
| binName = "openclaw", | |
| ): Promise<boolean> { | |
| const profilePath = getShellProfilePath(shell); | |
| if (!(await pathExists(profilePath))) { | |
| return false; | |
| } | |
| const cachePathCandidate = resolveCompletionCachePath(shell, binName); | |
| const cachedPath = (await pathExists(cachePathCandidate)) ? cachePathCandidate : null; | |
| const content = await fs.readFile(profilePath, "utf-8"); | |
| const lines = content.split("\n"); | |
| return lines.some( | |
| (line) => isCompletionProfileHeader(line) || isCompletionProfileLine(line, binName, cachedPath), | |
| ); | |
| } | |
| /** | |
| * Check if the profile uses the slow dynamic completion pattern. | |
| * Returns true if profile has `source <(openclaw completion ...)` instead of cached file. | |
| */ | |
| export async function usesSlowDynamicCompletion( | |
| shell: CompletionShell, | |
| binName = "openclaw", | |
| ): Promise<boolean> { | |
| const profilePath = getShellProfilePath(shell); | |
| if (!(await pathExists(profilePath))) { | |
| return false; | |
| } | |
| const cachePath = resolveCompletionCachePath(shell, binName); | |
| const content = await fs.readFile(profilePath, "utf-8"); | |
| const lines = content.split("\n"); | |
| // Check if any line has dynamic completion but NOT the cached path | |
| for (const line of lines) { | |
| if (isSlowDynamicCompletionLine(line, binName) && !line.includes(cachePath)) { | |
| return true; | |
| } | |
| } | |
| return false; | |
| } | |
| export function registerCompletionCli(program: Command) { | |
| program | |
| .command("completion") | |
| .description("Generate shell completion script") | |
| .addOption( | |
| new Option("-s, --shell <shell>", "Shell to generate completion for (default: zsh)").choices( | |
| COMPLETION_SHELLS, | |
| ), | |
| ) | |
| .option("-i, --install", "Install completion script to shell profile") | |
| .option( | |
| "--write-state", | |
| "Write completion scripts to $OPENCLAW_STATE_DIR/completions (no stdout)", | |
| ) | |
| .option("-y, --yes", "Skip confirmation (non-interactive)", false) | |
| .action(async (options) => { | |
| // Route logs to stderr so plugin loading messages do not corrupt | |
| // the completion script written to stdout. | |
| routeLogsToStderr(); | |
| const shell = options.shell ?? "zsh"; | |
| // 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); | |
| } | |
| if (options.writeState) { | |
| const writeShells = options.shell ? [shell] : [...COMPLETION_SHELLS]; | |
| await writeCompletionCache({ | |
| program, | |
| shells: writeShells, | |
| binName: program.name(), | |
| }); | |
| } | |
| if (options.install) { | |
| const targetShell = options.shell ?? resolveShellFromEnv(); | |
| await installCompletion(targetShell, Boolean(options.yes), program.name()); | |
| return; | |
| } | |
| if (options.writeState) { | |
| return; | |
| } | |
| if (!isCompletionShell(shell)) { | |
| throw new Error(`Unsupported shell: ${shell}`); | |
| } | |
| const script = getCompletionScript(shell, program); | |
| process.stdout.write(script + "\n"); | |
| }); | |
| } | |
| export async function installCompletion(shell: string, yes: boolean, binName = "openclaw") { | |
| const home = process.env.HOME || os.homedir(); | |
| let profilePath = ""; | |
| let sourceLine = ""; | |
| const isShellSupported = isCompletionShell(shell); | |
| if (!isShellSupported) { | |
| console.error(`Automated installation not supported for ${shell} yet.`); | |
| return; | |
| } | |
| // Get the cache path - cache MUST exist for fast shell startup | |
| const cachePath = resolveCompletionCachePath(shell, binName); | |
| const cacheExists = await pathExists(cachePath); | |
| if (!cacheExists) { | |
| console.error( | |
| `Completion cache not found at ${cachePath}. Run \`${binName} completion --write-state\` first.`, | |
| ); | |
| return; | |
| } | |
| if (shell === "zsh") { | |
| profilePath = path.join(home, ".zshrc"); | |
| sourceLine = formatCompletionSourceLine("zsh", binName, cachePath); | |
| } 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 = formatCompletionSourceLine("bash", binName, cachePath); | |
| } else if (shell === "fish") { | |
| profilePath = path.join(home, ".config", "fish", "config.fish"); | |
| sourceLine = formatCompletionSourceLine("fish", binName, cachePath); | |
| } 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"); | |
| const update = updateCompletionProfile(content, binName, cachePath, sourceLine); | |
| if (!update.changed) { | |
| if (!yes) { | |
| console.log(`Completion already installed in ${profilePath}`); | |
| } | |
| return; | |
| } | |
| if (!yes) { | |
| const action = update.hadExisting ? "Updating" : "Installing"; | |
| console.log(`${action} completion in ${profilePath}...`); | |
| } | |
| await fs.writeFile(profilePath, update.next, "utf-8"); | |
| if (!yes) { | |
| console.log(`Completion installed. Restart your shell or run: source ${profilePath}`); | |
| } | |
| } catch (err) { | |
| console.error(`Failed to install completion: ${err as string}`); | |
| } | |
| } | |
| function generateZshCompletion(program: Command): string { | |
| const rootCmd = program.name(); | |
| const script = ` | |
| #compdef ${rootCmd} | |
| _${rootCmd}_root_completion() { | |
| local -a commands | |
| local -a options | |
| _arguments -C \\ | |
| ${generateZshArgs(program)} \\ | |
| ${generateZshSubcmdList(program)} \\ | |
| "*::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}_root_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, "\\\\") | |
| .replace(/"/g, '\\"') | |
| .replace(/'/g, "'\\''") | |
| .replace(/\[/g, "\\[") | |
| .replace(/\]/g, "\\]"); | |
| if (short) { | |
| 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, "\\[") | |
| .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) { | |
| 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)} \\ | |
| ${generateZshSubcmdList(cmd)} \\ | |
| "*::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... | |
| // 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: | |
| // 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; | |
| } | |