From 80d8fe778602c5a2a21632a96134796ba21adef7 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 3 Feb 2026 08:33:40 +0000 Subject: [PATCH 1/4] CLI: cache shell completion scripts --- src/cli/completion-cli.ts | 244 ++++++++++++++++++++++++++++++++++---- 1 file changed, 220 insertions(+), 24 deletions(-) diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts index 0d2aef7bf..0d160f915 100644 --- a/src/cli/completion-cli.ts +++ b/src/cli/completion-cli.ts @@ -2,21 +2,196 @@ 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 { 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"); +} + +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}`); +} + +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 { + 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"); + } +} + +async function pathExists(targetPath: string): Promise { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +} + +function formatCompletionSourceLine( + shell: CompletionShell, + binName: string, + cachePath: string | null, +): string { + if (cachePath) { + return `source "${cachePath}"`; + } + if (shell === "fish") { + return `${binName} completion --shell fish | source`; + } + return `source <(${binName} completion --shell ${shell})`; +} + +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; +} + +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 }; +} + +export async function isCompletionInstalled( + shell: CompletionShell, + binName = "openclaw", +): Promise { + const home = process.env.HOME || os.homedir(); + let profilePath = ""; + if (shell === "zsh") { + profilePath = path.join(home, ".zshrc"); + } else if (shell === "bash") { + profilePath = path.join(home, ".bashrc"); + if (!(await pathExists(profilePath))) { + profilePath = path.join(home, ".bash_profile"); + } + } else if (shell === "fish") { + profilePath = path.join(home, ".config", "fish", "config.fish"); + } else { + return false; + } + + 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), + ); +} + 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"), + new Option("-s, --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) => { - const shell = options.shell; + const shell = options.shell ?? "zsh"; // Eagerly register all subcommands to build the full tree const entries = getSubCliEntries(); for (const entry of entries) { @@ -27,22 +202,29 @@ export function registerCompletionCli(program: Command) { 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) { - await installCompletion(shell, Boolean(options.yes), program.name()); + const targetShell = options.shell ?? resolveShellFromEnv(); + await installCompletion(targetShell, Boolean(options.yes), program.name()); return; } - 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); + if (options.writeState) { + return; } + if (!isCompletionShell(shell)) { + throw new Error(`Unsupported shell: ${shell}`); + } + const script = getCompletionScript(shell, program); console.log(script); }); } @@ -51,10 +233,16 @@ export async function installCompletion(shell: string, yes: boolean, binName = " const home = process.env.HOME || os.homedir(); let profilePath = ""; let sourceLine = ""; + let cachedPath: string | null = null; + const isShellSupported = isCompletionShell(shell); + if (isShellSupported) { + const candidate = resolveCompletionCachePath(shell, binName); + cachedPath = (await pathExists(candidate)) ? candidate : null; + } if (shell === "zsh") { profilePath = path.join(home, ".zshrc"); - sourceLine = `source <(${binName} completion --shell zsh)`; + sourceLine = formatCompletionSourceLine("zsh", binName, cachedPath); } else if (shell === "bash") { // Try .bashrc first, then .bash_profile profilePath = path.join(home, ".bashrc"); @@ -63,10 +251,10 @@ export async function installCompletion(shell: string, yes: boolean, binName = " } catch { profilePath = path.join(home, ".bash_profile"); } - sourceLine = `source <(${binName} completion --shell bash)`; + sourceLine = formatCompletionSourceLine("bash", binName, cachedPath); } else if (shell === "fish") { profilePath = path.join(home, ".config", "fish", "config.fish"); - sourceLine = `${binName} completion --shell fish | source`; + sourceLine = formatCompletionSourceLine("fish", binName, cachedPath); } else { console.error(`Automated installation not supported for ${shell} yet.`); return; @@ -85,7 +273,8 @@ export async function installCompletion(shell: string, yes: boolean, binName = " } const content = await fs.readFile(profilePath, "utf-8"); - if (content.includes(`${binName} completion`)) { + const update = updateCompletionProfile(content, binName, cachedPath, sourceLine); + if (!update.changed) { if (!yes) { console.log(`Completion already installed in ${profilePath}`); } @@ -93,14 +282,15 @@ export async function installCompletion(shell: string, yes: boolean, binName = " } 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}...`); + const action = update.hadExisting ? "Updating" : "Installing"; + console.log(`${action} completion in ${profilePath}...`); } - await fs.appendFile(profilePath, `\n# OpenClaw Completion\n${sourceLine}\n`); + await fs.writeFile(profilePath, update.next, "utf-8"); console.log(`Completion installed. Restart your shell or run: source ${profilePath}`); + if (!yes && cachedPath) { + console.log(`Completion cache: ${cachedPath}`); + } } catch (err) { console.error(`Failed to install completion: ${err as string}`); } @@ -142,7 +332,12 @@ function generateZshArgs(cmd: Command): string { 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, "'\\''"); + const desc = opt.description + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/'/g, "'\\''") + .replace(/\[/g, "\\[") + .replace(/\]/g, "\\]"); if (short) { return `"(${name} ${short})"{${name},${short}}"[${desc}]"`; } @@ -156,6 +351,7 @@ function generateZshSubcmdList(cmd: Command): string { .map((c) => { const desc = c .description() + .replace(/\\/g, "\\\\") .replace(/'/g, "'\\''") .replace(/\[/g, "\\[") .replace(/\]/g, "\\]"); From 9950440cf6dea3cb11da2bfd32bc92c7d6aa0ab8 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 3 Feb 2026 08:37:31 +0000 Subject: [PATCH 2/4] Install: cache completion scripts on install/update --- scripts/postinstall.js | 4 ++-- src/cli/update-cli.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/scripts/postinstall.js b/scripts/postinstall.js index e5adce74e..1fb82ca99 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -293,13 +293,13 @@ function trySetupCompletion(repoRoot) { try { // Run with OPENCLAW_SKIP_POSTINSTALL to avoid any weird recursion, // though distinct from this script. - spawnSync(process.execPath, [binPath, "completion", "--install", "--yes"], { + spawnSync(process.execPath, [binPath, "completion", "--install", "--yes", "--write-state"], { cwd: repoRoot, stdio: "inherit", env: { ...process.env, OPENCLAW_SKIP_POSTINSTALL: "1" }, }); } catch { - // Ignore errors to not break install + // Ignore errors } } diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index d7cd94375..bf7c9bc4d 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { confirm, isCancel, select, spinner } from "@clack/prompts"; +import { spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -200,6 +201,29 @@ async function pathExists(targetPath: string): Promise { } } +async function tryWriteCompletionCache(root: string, jsonMode: boolean): Promise { + const binPath = path.join(root, "openclaw.mjs"); + if (!(await pathExists(binPath))) { + return; + } + const result = spawnSync(resolveNodeRunner(), [binPath, "completion", "--write-state"], { + cwd: root, + env: { ...process.env, OPENCLAW_SKIP_POSTINSTALL: "1" }, + encoding: "utf-8", + }); + if (result.error) { + if (!jsonMode) { + defaultRuntime.log(theme.warn(`Completion cache update failed: ${String(result.error)}`)); + } + return; + } + if (result.status !== 0 && !jsonMode) { + const stderr = (result.stderr ?? "").toString().trim(); + const detail = stderr ? ` (${stderr})` : ""; + defaultRuntime.log(theme.warn(`Completion cache update failed${detail}.`)); + } +} + async function isEmptyDir(targetPath: string): Promise { try { const entries = await fs.readdir(targetPath); @@ -959,6 +983,8 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { defaultRuntime.log(theme.warn("Skipping plugin updates: config is invalid.")); } + await tryWriteCompletionCache(root, Boolean(opts.json)); + // Restart service if requested if (shouldRestart) { if (!opts.json) { From 981de051813ae9b2b406d89d119cd6a8346238a2 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 3 Feb 2026 08:37:40 +0000 Subject: [PATCH 3/4] Onboarding: drop completion prompt --- src/wizard/onboarding.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 544e455ec..e5cab60f6 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -10,7 +10,6 @@ import type { QuickstartGatewayDefaults, WizardFlow } from "./onboarding.types.j import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import { formatCliCommand } from "../cli/command-format.js"; -import { installCompletion } from "../cli/completion-cli.js"; import { promptAuthChoiceGrouped } from "../commands/auth-choice-prompt.js"; import { applyAuthChoice, @@ -468,16 +467,4 @@ export async function runOnboardingWizard( if (launchedTui) { return; } - - 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 3014a91b0779f7629bf1d91675a99629e2798cb3 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 3 Feb 2026 08:39:43 +0000 Subject: [PATCH 4/4] chore: update changelog for completion caching --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15296ddd4..0d9658a1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,10 @@ Docs: https://docs.openclaw.ai - Docs: finish renaming the QMD memory docs to reference the OpenClaw state dir. - Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed). +- Onboarding: drop completion prompt now handled by install/update. - TUI: block onboarding output while TUI is active and restore terminal state on exit. +- CLI: cache shell completion scripts in state dir and source cached files in profiles. +- Zsh completion: escape option descriptions to avoid invalid option errors. - Agents: repair malformed tool calls and session transcripts. (#7473) Thanks @justinhuangcode. - fix(agents): validate AbortSignal instances before calling AbortSignal.any() (#7277) (thanks @Elarwei001) - fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz)