diff --git a/src/commands/doctor-completion.ts b/src/commands/doctor-completion.ts new file mode 100644 index 000000000..403b314ae --- /dev/null +++ b/src/commands/doctor-completion.ts @@ -0,0 +1,179 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import type { RuntimeEnv } from "../runtime.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; +import { resolveCliName } from "../cli/cli-name.js"; +import { + completionCacheExists, + installCompletion, + isCompletionInstalled, + resolveCompletionCachePath, + resolveShellFromEnv, + usesSlowDynamicCompletion, +} from "../cli/completion-cli.js"; +import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js"; +import { note } from "../terminal/note.js"; + +type CompletionShell = "zsh" | "bash" | "fish" | "powershell"; + +/** Generate the completion cache by spawning the CLI. */ +async function generateCompletionCache(): Promise { + const root = await resolveOpenClawPackageRoot({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + }); + if (!root) { + return false; + } + + const binPath = path.join(root, "openclaw.mjs"); + const result = spawnSync(process.execPath, [binPath, "completion", "--write-state"], { + cwd: root, + env: process.env, + encoding: "utf-8", + }); + + return result.status === 0; +} + +export type ShellCompletionStatus = { + shell: CompletionShell; + profileInstalled: boolean; + cacheExists: boolean; + cachePath: string; + /** True if profile uses slow dynamic pattern like `source <(openclaw completion ...)` */ + usesSlowPattern: boolean; +}; + +/** Check the status of shell completion for the current shell. */ +export async function checkShellCompletionStatus( + binName = "openclaw", +): Promise { + const shell = resolveShellFromEnv() as CompletionShell; + const profileInstalled = await isCompletionInstalled(shell, binName); + const cacheExists = await completionCacheExists(shell, binName); + const cachePath = resolveCompletionCachePath(shell, binName); + const usesSlowPattern = await usesSlowDynamicCompletion(shell, binName); + + return { + shell, + profileInstalled, + cacheExists, + cachePath, + usesSlowPattern, + }; +} + +export type DoctorCompletionOptions = { + nonInteractive?: boolean; +}; + +/** + * Doctor check for shell completion. + * - If profile uses slow dynamic pattern: upgrade to cached version + * - If profile has completion but no cache: auto-generate cache and upgrade profile + * - If no completion at all: prompt to install (with user confirmation) + */ +export async function doctorShellCompletion( + runtime: RuntimeEnv, + prompter: DoctorPrompter, + options: DoctorCompletionOptions = {}, +): Promise { + const cliName = resolveCliName(); + const status = await checkShellCompletionStatus(cliName); + + // Profile uses slow dynamic pattern - upgrade to cached version + if (status.usesSlowPattern) { + note( + `Your ${status.shell} profile uses slow dynamic completion (source <(...)).\nUpgrading to cached completion for faster shell startup...`, + "Shell completion", + ); + + // Ensure cache exists first + if (!status.cacheExists) { + const generated = await generateCompletionCache(); + if (!generated) { + note( + `Failed to generate completion cache. Run \`${cliName} completion --write-state\` manually.`, + "Shell completion", + ); + return; + } + } + + // Upgrade profile to use cached file + await installCompletion(status.shell, true, cliName); + note( + `Shell completion upgraded. Restart your shell or run: source ~/.${status.shell === "zsh" ? "zshrc" : status.shell === "bash" ? "bashrc" : "config/fish/config.fish"}`, + "Shell completion", + ); + return; + } + + // Profile has completion but no cache - auto-fix + if (status.profileInstalled && !status.cacheExists) { + note( + `Shell completion is configured in your ${status.shell} profile but the cache is missing.\nRegenerating cache...`, + "Shell completion", + ); + const generated = await generateCompletionCache(); + if (generated) { + note(`Completion cache regenerated at ${status.cachePath}`, "Shell completion"); + } else { + note( + `Failed to regenerate completion cache. Run \`${cliName} completion --write-state\` manually.`, + "Shell completion", + ); + } + return; + } + + // No completion at all - prompt to install + if (!status.profileInstalled) { + if (options.nonInteractive) { + // In non-interactive mode, just note that completion is not installed + return; + } + + const shouldInstall = await prompter.confirm({ + message: `Enable ${status.shell} shell completion for ${cliName}?`, + initialValue: true, + }); + + if (shouldInstall) { + // First generate the cache + const generated = await generateCompletionCache(); + if (!generated) { + note( + `Failed to generate completion cache. Run \`${cliName} completion --write-state\` manually.`, + "Shell completion", + ); + return; + } + + // Then install to profile + await installCompletion(status.shell, true, cliName); + note( + `Shell completion installed. Restart your shell or run: source ~/.${status.shell === "zsh" ? "zshrc" : status.shell === "bash" ? "bashrc" : "config/fish/config.fish"}`, + "Shell completion", + ); + } + } +} + +/** + * Ensure completion cache exists. Used during onboarding/update to fix + * cases where profile has completion but no cache. + * This is a silent fix - no prompts. + */ +export async function ensureCompletionCacheExists(binName = "openclaw"): Promise { + const shell = resolveShellFromEnv() as CompletionShell; + const cacheExists = await completionCacheExists(shell, binName); + + if (cacheExists) { + return true; + } + + return generateCompletionCache(); +}