diff --git a/CHANGELOG.md b/CHANGELOG.md index 212841ba5751..1353cdda9157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - **WhatsApp restart recovery:** stop automatic restart loops after logged-out or connection-replaced disconnects until the account reconnects. (#78511) Thanks @openperf. - **Local Gateway CLI auth:** keep loopback CLI token/password calls off durable device scopes so read probes cannot block later write/admin commands behind a stale pairing baseline. (#95997) Thanks @vincentkoc. - **Plugin module identity:** keep OpenClaw package chunks on Node's native module graph when jiti transforms plugin entries, preventing duplicate evaluation and class identity drift. (#88384) Thanks @vincentkoc. +- **Shell completion repair:** generate core-only caches during doctor and update repair while preserving full plugin command completion for onboarding and explicit user rebuilds. (#76235) - **iMessage group warnings:** suppress the false drop-all startup warning when an effective group sender allowlist can admit groups, and point true empty-allowlist configurations at the correct remedy. (#100046) - **Control UI mobile login:** keep Gateway recovery guidance visible after connection failures, make the disconnected gate scroll safely on constrained screens, and improve mobile keyboard and tap-target behavior. (#100208) diff --git a/scripts/test-shell-completion.ts b/scripts/test-shell-completion.ts index 7b2b573c5362..d229dd52848b 100644 --- a/scripts/test-shell-completion.ts +++ b/scripts/test-shell-completion.ts @@ -158,7 +158,10 @@ async function main() { // Profile uses slow dynamic pattern - upgrade to cached version if (status.usesSlowPattern) { console.log(theme.warn("Profile uses slow dynamic completion. Upgrading to cached version...")); - const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, { shell: status.shell }); + const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, { + shell: status.shell, + generationMode: "full", + }); if (cacheGenerated) { await installCompletion(status.shell, false, CLI_NAME); console.log(theme.success("Upgraded to cached completion.")); @@ -171,7 +174,10 @@ async function main() { // Profile has completion but no cache - auto-fix if (status.profileInstalled && !status.cacheExists) { console.log(theme.warn("Profile has completion but cache is missing. Regenerating...")); - const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, { shell: status.shell }); + const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, { + shell: status.shell, + generationMode: "full", + }); if (cacheGenerated) { console.log(theme.success("Cache regenerated successfully.")); } else { @@ -208,7 +214,10 @@ async function main() { // Generate cache first (required for fast shell startup) if (!status.cacheExists) { console.log(theme.muted("Generating completion cache...")); - const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, { shell: status.shell }); + const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, { + shell: status.shell, + generationMode: "full", + }); if (!cacheGenerated) { console.log(theme.error("Failed to generate completion cache.")); return; diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index dae2c39e42ce..5cb555892917 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1467,10 +1467,11 @@ async function tryInstallShellCompletion(opts: { } const status = await checkShellCompletionStatus(CLI_NAME); + const generationOptions = { generationMode: "core-only" } as const; if (status.usesSlowPattern) { defaultRuntime.log(theme.muted("Upgrading shell completion to cached version...")); - const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME); + const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, generationOptions); if (cacheGenerated) { await installShellCompletionForUpdate(status.shell, true); } @@ -1479,7 +1480,7 @@ async function tryInstallShellCompletion(opts: { if (status.profileInstalled && !status.cacheExists) { defaultRuntime.log(theme.muted("Regenerating shell completion cache...")); - await ensureCompletionCacheExists(CLI_NAME); + await ensureCompletionCacheExists(CLI_NAME, generationOptions); return; } @@ -1503,7 +1504,7 @@ async function tryInstallShellCompletion(opts: { return; } - const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME); + const cacheGenerated = await ensureCompletionCacheExists(CLI_NAME, generationOptions); if (!cacheGenerated) { defaultRuntime.log(theme.warn("Failed to generate completion cache.")); return; diff --git a/src/commands/doctor-completion.test.ts b/src/commands/doctor-completion.test.ts index bd6ec1c1f309..69d1b290b932 100644 --- a/src/commands/doctor-completion.test.ts +++ b/src/commands/doctor-completion.test.ts @@ -4,16 +4,23 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as noteModule from "../../packages/terminal-core/src/note.js"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { COMPLETION_SKIP_PLUGIN_COMMANDS_ENV } from "../cli/completion-runtime.js"; import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; import { checkShellCompletionStatus, doctorShellCompletion, + ensureCompletionCacheExists, shellCompletionStatusToHealthFindings, shellCompletionStatusToRepairEffects, type ShellCompletionStatus, } from "./doctor-completion.js"; -const originalEnv = captureEnv(["HOME", "OPENCLAW_STATE_DIR", "SHELL"]); +const originalEnv = captureEnv([ + "HOME", + "OPENCLAW_STATE_DIR", + "SHELL", + COMPLETION_SKIP_PLUGIN_COMMANDS_ENV, +]); const tempDirs = useAutoCleanupTempDirTracker(afterEach); afterEach(async () => { @@ -167,6 +174,36 @@ describe("doctorShellCompletion", () => { spawnSyncMock.mockClear(); }); + it.each([ + { generationMode: "core-only" as const, expectedSkipValue: "1" }, + { generationMode: "full" as const, expectedSkipValue: undefined }, + ])( + "uses explicit $generationMode cache generation even with an ambient skip guard", + async ({ generationMode, expectedSkipValue }) => { + const stateDir = tempDirs.make("openclaw-doctor-state-"); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + setTestEnvValue(COMPLETION_SKIP_PLUGIN_COMMANDS_ENV, "1"); + + await expect( + ensureCompletionCacheExists("openclaw", { + shell: "powershell", + generationMode, + }), + ).resolves.toBe(true); + + expect(spawnSyncMock).toHaveBeenCalledWith( + process.execPath, + expect.arrayContaining(["completion", "--write-state", "--shell", "powershell"]), + expect.any(Object), + ); + const spawnCalls = spawnSyncMock.mock.calls as unknown as Array< + [string, string[], { env?: NodeJS.ProcessEnv }] + >; + const spawnOptions = spawnCalls.at(-1)?.[2]; + expect(spawnOptions?.env?.[COMPLETION_SKIP_PLUGIN_COMMANDS_ENV]).toBe(expectedSkipValue); + }, + ); + it.each([ { code: "EACCES", usesSlowPattern: true, action: "upgraded" }, { code: "EPERM", usesSlowPattern: true, action: "upgraded" }, diff --git a/src/commands/doctor-completion.ts b/src/commands/doctor-completion.ts index ca3dd5ce7e86..c5c7bc722df5 100644 --- a/src/commands/doctor-completion.ts +++ b/src/commands/doctor-completion.ts @@ -5,6 +5,7 @@ import { note } from "../../packages/terminal-core/src/note.js"; import { resolveCliName } from "../cli/cli-name.js"; import { completionCacheExists, + COMPLETION_SKIP_PLUGIN_COMMANDS_ENV, formatCompletionReloadCommand, installCompletion, isCompletionInstalled, @@ -26,6 +27,10 @@ export type ShellCompletionStatusOptions = { shell?: CompletionShell; }; +export type CompletionCacheGenerationOptions = ShellCompletionStatusOptions & { + generationMode: "core-only" | "full"; +}; + const PROFILE_WRITE_ERROR_CODES = new Set(["EACCES", "EPERM", "EROFS"]); function findProfileWriteError(err: unknown): NodeJS.ErrnoException | undefined { @@ -74,7 +79,7 @@ async function installCompletionForDoctor( /** Generate the completion cache by spawning the CLI. */ async function generateCompletionCache( - options: ShellCompletionStatusOptions = {}, + options: CompletionCacheGenerationOptions, ): Promise { const root = await resolveOpenClawPackageRoot({ moduleUrl: import.meta.url, @@ -90,9 +95,16 @@ async function generateCompletionCache( if (options.shell) { args.push("--shell", options.shell); } + const env = { ...process.env }; + // The mode is explicit so ambient repair state cannot silently change a full user-facing cache. + if (options.generationMode === "core-only") { + env[COMPLETION_SKIP_PLUGIN_COMMANDS_ENV] = "1"; + } else { + delete env[COMPLETION_SKIP_PLUGIN_COMMANDS_ENV]; + } const result = spawnSync(process.execPath, args, { cwd: root, - env: process.env, + env, encoding: "utf-8", timeout: COMPLETION_CACHE_WRITE_TIMEOUT_MS, }); @@ -217,7 +229,7 @@ export async function doctorShellCompletion( ); if (!status.cacheExists) { - const generated = await generateCompletionCache(); + const generated = await generateCompletionCache({ generationMode: "core-only" }); if (!generated) { note( `Failed to generate completion cache. Run \`${cliName} completion --write-state\` manually.`, @@ -236,7 +248,7 @@ export async function doctorShellCompletion( `Shell completion is configured in your ${status.shell} profile but the cache is missing.\nRegenerating cache...`, "Shell completion", ); - const generated = await generateCompletionCache(); + const generated = await generateCompletionCache({ generationMode: "core-only" }); if (generated) { note(`Completion cache regenerated at ${status.cachePath}`, "Shell completion"); } else { @@ -259,7 +271,7 @@ export async function doctorShellCompletion( }); if (shouldInstall) { - const generated = await generateCompletionCache(); + const generated = await generateCompletionCache({ generationMode: "core-only" }); if (!generated) { note( `Failed to generate completion cache. Run \`${cliName} completion --write-state\` manually.`, @@ -275,8 +287,8 @@ export async function doctorShellCompletion( /** Ensures the shell completion cache exists without prompting during setup/update flows. */ export async function ensureCompletionCacheExists( - binName = "openclaw", - options: ShellCompletionStatusOptions = {}, + binName: string, + options: CompletionCacheGenerationOptions, ): Promise { const shell = options.shell ?? resolveShellFromEnv(); const cacheExists = await completionCacheExists(shell, binName); diff --git a/src/wizard/setup.completion.test.ts b/src/wizard/setup.completion.test.ts index b32ef35153ae..8be9393650aa 100644 --- a/src/wizard/setup.completion.test.ts +++ b/src/wizard/setup.completion.test.ts @@ -49,7 +49,9 @@ describe("setupWizardShellCompletion", () => { await setupWizardShellCompletion({ flow: "quickstart", prompter, deps }); expect(prompter.confirm).not.toHaveBeenCalled(); - expect(deps.ensureCompletionCacheExists).toHaveBeenCalledWith("openclaw"); + expect(deps.ensureCompletionCacheExists).toHaveBeenCalledWith("openclaw", { + generationMode: "full", + }); expect(deps.installCompletion).toHaveBeenCalledWith("zsh", true, "openclaw"); expect(prompter.note).toHaveBeenCalled(); }); diff --git a/src/wizard/setup.completion.ts b/src/wizard/setup.completion.ts index 19378fb84b11..c0b87356eac5 100644 --- a/src/wizard/setup.completion.ts +++ b/src/wizard/setup.completion.ts @@ -7,7 +7,10 @@ import { installCompletion, resolveCompletionProfilePath, } from "../cli/completion-runtime.js"; -import type { ShellCompletionStatus } from "../commands/doctor-completion.js"; +import type { + CompletionCacheGenerationOptions, + ShellCompletionStatus, +} from "../commands/doctor-completion.js"; import { checkShellCompletionStatus, ensureCompletionCacheExists, @@ -20,7 +23,10 @@ import type { WizardFlow } from "./setup.types.js"; type CompletionDeps = { resolveCliName: () => string; checkShellCompletionStatus: (binName: string) => Promise; - ensureCompletionCacheExists: (binName: string) => Promise; + ensureCompletionCacheExists: ( + binName: string, + options: CompletionCacheGenerationOptions, + ) => Promise; installCompletion: (shell: string, yes: boolean, binName?: string) => Promise; }; @@ -63,10 +69,11 @@ export async function setupWizardShellCompletion(params: { const cliName = deps.resolveCliName(); const completionStatus = await deps.checkShellCompletionStatus(cliName); + const generationOptions = { generationMode: "full" } as const; if (completionStatus.usesSlowPattern) { // Case 1: Profile uses slow dynamic pattern - silently upgrade to cached version - const cacheGenerated = await deps.ensureCompletionCacheExists(cliName); + const cacheGenerated = await deps.ensureCompletionCacheExists(cliName, generationOptions); if (cacheGenerated) { await deps.installCompletion(completionStatus.shell, true, cliName); } @@ -75,7 +82,7 @@ export async function setupWizardShellCompletion(params: { if (completionStatus.profileInstalled && !completionStatus.cacheExists) { // Case 2: Profile has completion but no cache - auto-fix silently - await deps.ensureCompletionCacheExists(cliName); + await deps.ensureCompletionCacheExists(cliName, generationOptions); return; } @@ -97,7 +104,7 @@ export async function setupWizardShellCompletion(params: { } // Generate cache first (required for fast shell startup) - const cacheGenerated = await deps.ensureCompletionCacheExists(cliName); + const cacheGenerated = await deps.ensureCompletionCacheExists(cliName, generationOptions); if (!cacheGenerated) { await params.prompter.note( t("wizard.completion.cacheFailed", { command: `${cliName} completion --install` }),