diff --git a/CHANGELOG.md b/CHANGELOG.md index 194b5a34b7..27fc08963c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Telegram: render markdown spoilers with `` HTML tags. (#11543) Thanks @ezhikkk. - Telegram: truncate command registration to 100 entries to avoid `BOT_COMMANDS_TOO_MUCH` failures on startup. (#12356) Thanks @arosstale. - Telegram: match DM `allowFrom` against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai. +- Onboarding: QuickStart now auto-installs shell completion (prompt only in Manual). - Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials. - Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh. - Tools/web_search: include provider-specific settings in the web search cache key, and pass `inlineCitations` for Grok. (#12419) Thanks @tmchow. diff --git a/src/wizard/onboarding.completion.test.ts b/src/wizard/onboarding.completion.test.ts new file mode 100644 index 0000000000..27dc4b2f04 --- /dev/null +++ b/src/wizard/onboarding.completion.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; +import { setupOnboardingShellCompletion } from "./onboarding.completion.js"; + +describe("setupOnboardingShellCompletion", () => { + it("QuickStart: installs without prompting", async () => { + const prompter = { + confirm: vi.fn(async () => false), + note: vi.fn(async () => {}), + }; + + const deps = { + resolveCliName: () => "openclaw", + checkShellCompletionStatus: vi.fn(async () => ({ + shell: "zsh", + profileInstalled: false, + cacheExists: false, + cachePath: "/tmp/openclaw.zsh", + usesSlowPattern: false, + })), + ensureCompletionCacheExists: vi.fn(async () => true), + installCompletion: vi.fn(async () => {}), + }; + + await setupOnboardingShellCompletion({ flow: "quickstart", prompter, deps }); + + expect(prompter.confirm).not.toHaveBeenCalled(); + expect(deps.ensureCompletionCacheExists).toHaveBeenCalledWith("openclaw"); + expect(deps.installCompletion).toHaveBeenCalledWith("zsh", true, "openclaw"); + expect(prompter.note).toHaveBeenCalled(); + }); + + it("Advanced: prompts; skip means no install", async () => { + const prompter = { + confirm: vi.fn(async () => false), + note: vi.fn(async () => {}), + }; + + const deps = { + resolveCliName: () => "openclaw", + checkShellCompletionStatus: vi.fn(async () => ({ + shell: "zsh", + profileInstalled: false, + cacheExists: false, + cachePath: "/tmp/openclaw.zsh", + usesSlowPattern: false, + })), + ensureCompletionCacheExists: vi.fn(async () => true), + installCompletion: vi.fn(async () => {}), + }; + + await setupOnboardingShellCompletion({ flow: "advanced", prompter, deps }); + + expect(prompter.confirm).toHaveBeenCalledTimes(1); + expect(deps.ensureCompletionCacheExists).not.toHaveBeenCalled(); + expect(deps.installCompletion).not.toHaveBeenCalled(); + expect(prompter.note).not.toHaveBeenCalled(); + }); +}); diff --git a/src/wizard/onboarding.completion.ts b/src/wizard/onboarding.completion.ts new file mode 100644 index 0000000000..9bea14369d --- /dev/null +++ b/src/wizard/onboarding.completion.ts @@ -0,0 +1,118 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { ShellCompletionStatus } from "../commands/doctor-completion.js"; +import type { WizardFlow } from "./onboarding.types.js"; +import type { WizardPrompter } from "./prompts.js"; +import { resolveCliName } from "../cli/cli-name.js"; +import { installCompletion } from "../cli/completion-cli.js"; +import { + checkShellCompletionStatus, + ensureCompletionCacheExists, +} from "../commands/doctor-completion.js"; + +type CompletionDeps = { + resolveCliName: () => string; + checkShellCompletionStatus: (binName: string) => Promise; + ensureCompletionCacheExists: (binName: string) => Promise; + installCompletion: (shell: string, yes: boolean, binName?: string) => Promise; +}; + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function resolveProfileHint(shell: ShellCompletionStatus["shell"]): Promise { + const home = process.env.HOME || os.homedir(); + if (shell === "zsh") { + return "~/.zshrc"; + } + if (shell === "bash") { + const bashrc = path.join(home, ".bashrc"); + return (await pathExists(bashrc)) ? "~/.bashrc" : "~/.bash_profile"; + } + if (shell === "fish") { + return "~/.config/fish/config.fish"; + } + // Best-effort. PowerShell profile path varies; restart hint is still correct. + return "$PROFILE"; +} + +function formatReloadHint(shell: ShellCompletionStatus["shell"], profileHint: string): string { + if (shell === "powershell") { + return "Restart your shell (or reload your PowerShell profile)."; + } + return `Restart your shell or run: source ${profileHint}`; +} + +export async function setupOnboardingShellCompletion(params: { + flow: WizardFlow; + prompter: Pick; + deps?: Partial; +}): Promise { + const deps: CompletionDeps = { + resolveCliName, + checkShellCompletionStatus, + ensureCompletionCacheExists, + installCompletion, + ...params.deps, + }; + + const cliName = deps.resolveCliName(); + const completionStatus = await deps.checkShellCompletionStatus(cliName); + + if (completionStatus.usesSlowPattern) { + // Case 1: Profile uses slow dynamic pattern - silently upgrade to cached version + const cacheGenerated = await deps.ensureCompletionCacheExists(cliName); + if (cacheGenerated) { + await deps.installCompletion(completionStatus.shell, true, cliName); + } + return; + } + + if (completionStatus.profileInstalled && !completionStatus.cacheExists) { + // Case 2: Profile has completion but no cache - auto-fix silently + await deps.ensureCompletionCacheExists(cliName); + return; + } + + if (!completionStatus.profileInstalled) { + // Case 3: No completion at all + const shouldInstall = + params.flow === "quickstart" + ? true + : await params.prompter.confirm({ + message: `Enable ${completionStatus.shell} shell completion for ${cliName}?`, + initialValue: true, + }); + + if (!shouldInstall) { + return; + } + + // Generate cache first (required for fast shell startup) + const cacheGenerated = await deps.ensureCompletionCacheExists(cliName); + if (!cacheGenerated) { + await params.prompter.note( + `Failed to generate completion cache. Run \`${cliName} completion --install\` later.`, + "Shell completion", + ); + return; + } + + // Install to shell profile + await deps.installCompletion(completionStatus.shell, true, cliName); + + const profileHint = await resolveProfileHint(completionStatus.shell); + await params.prompter.note( + `Shell completion installed. ${formatReloadHint(completionStatus.shell, profileHint)}`, + "Shell completion", + ); + } + // Case 4: Both profile and cache exist (using cached version) - all good, nothing to do +} diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index fb5873f3d8..ca454cc01f 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -6,9 +6,7 @@ import type { RuntimeEnv } from "../runtime.js"; import type { GatewayWizardSettings, WizardFlow } from "./onboarding.types.js"; import type { WizardPrompter } from "./prompts.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../agents/workspace.js"; -import { resolveCliName } from "../cli/cli-name.js"; import { formatCliCommand } from "../cli/command-format.js"; -import { installCompletion } from "../cli/completion-cli.js"; import { buildGatewayInstallPlan, gatewayInstallErrorHint, @@ -17,10 +15,6 @@ import { DEFAULT_GATEWAY_DAEMON_RUNTIME, GATEWAY_DAEMON_RUNTIME_OPTIONS, } from "../commands/daemon-runtime.js"; -import { - checkShellCompletionStatus, - ensureCompletionCacheExists, -} from "../commands/doctor-completion.js"; import { formatHealthCheckFailure } from "../commands/health-format.js"; import { healthCommand } from "../commands/health.js"; import { @@ -37,6 +31,7 @@ import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import { restoreTerminalState } from "../terminal/restore.js"; import { runTui } from "../tui/tui.js"; import { resolveUserPath } from "../utils.js"; +import { setupOnboardingShellCompletion } from "./onboarding.completion.js"; type FinalizeOnboardingOptions = { flow: WizardFlow; @@ -397,50 +392,7 @@ export async function finalizeOnboardingWizard( "Security", ); - // Shell completion setup - const cliName = resolveCliName(); - const completionStatus = await checkShellCompletionStatus(cliName); - - if (completionStatus.usesSlowPattern) { - // Case 1: Profile uses slow dynamic pattern - silently upgrade to cached version - const cacheGenerated = await ensureCompletionCacheExists(cliName); - if (cacheGenerated) { - await installCompletion(completionStatus.shell, true, cliName); - } - } else if (completionStatus.profileInstalled && !completionStatus.cacheExists) { - // Case 2: Profile has completion but no cache - auto-fix silently - await ensureCompletionCacheExists(cliName); - } else if (!completionStatus.profileInstalled) { - // Case 3: No completion at all - prompt to install - const installShellCompletion = await prompter.confirm({ - message: `Enable ${completionStatus.shell} shell completion for ${cliName}?`, - initialValue: true, - }); - if (installShellCompletion) { - // Generate cache first (required for fast shell startup) - const cacheGenerated = await ensureCompletionCacheExists(cliName); - if (cacheGenerated) { - // Install to shell profile - await installCompletion(completionStatus.shell, true, cliName); - const profileHint = - completionStatus.shell === "zsh" - ? "~/.zshrc" - : completionStatus.shell === "bash" - ? "~/.bashrc" - : "~/.config/fish/config.fish"; - await prompter.note( - `Shell completion installed. Restart your shell or run: source ${profileHint}`, - "Shell completion", - ); - } else { - await prompter.note( - `Failed to generate completion cache. Run \`${cliName} completion --install\` later.`, - "Shell completion", - ); - } - } - } - // Case 4: Both profile and cache exist (using cached version) - all good, nothing to do + await setupOnboardingShellCompletion({ flow, prompter }); const shouldOpenControlUi = !opts.skipUi &&