From 6be14ab388eb74cd100e43bf975aad78146ac220 Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Sat, 28 Mar 2026 19:35:32 +0100 Subject: [PATCH] fix(cli): defer zsh compdef registration until compinit is available (#56555) The generated zsh completion script called compdef at source time, which fails with 'command not found: compdef' when loaded before compinit. Replace with a deferred registration that tries immediately, and if compdef is not yet available, queues a self-removing precmd hook that retries on first prompt. Handles repeated sourcing (deduped hook entry) and shells that never run compinit (completion simply never registers, matching zsh model). Add real zsh integration test verifying no compdef error on source and successful registration after compinit. Fixes #14289 --- src/cli/completion-cli.test.ts | 54 ++++++++++++++++++++++++++++++++++ src/cli/completion-cli.ts | 18 +++++++++++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/cli/completion-cli.test.ts b/src/cli/completion-cli.test.ts index d2f34b0e8cb..efad2b34de7 100644 --- a/src/cli/completion-cli.test.ts +++ b/src/cli/completion-cli.test.ts @@ -1,3 +1,7 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { Command } from "commander"; import { describe, expect, it } from "vitest"; import { getCompletionScript } from "./completion-cli.js"; @@ -27,6 +31,56 @@ describe("completion-cli", () => { expect(script).toContain("--force[Force the action]"); }); + it("defers zsh registration until compinit is available", async () => { + if (process.platform === "win32") { + return; + } + + const probe = spawnSync("zsh", ["-fc", "exit 0"], { encoding: "utf8" }); + if (probe.error) { + if ("code" in probe.error && probe.error.code === "ENOENT") { + return; + } + throw probe.error; + } + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-zsh-completion-")); + try { + const scriptPath = path.join(tempDir, "openclaw.zsh"); + await fs.writeFile(scriptPath, getCompletionScript("zsh", createCompletionProgram()), "utf8"); + + const result = spawnSync( + "zsh", + [ + "-fc", + ` + source ${JSON.stringify(scriptPath)} + [[ -z "\${_comps[openclaw]-}" ]] || exit 10 + [[ "\${precmd_functions[(r)_openclaw_register_completion]}" = "_openclaw_register_completion" ]] || exit 11 + autoload -Uz compinit + compinit -C + _openclaw_register_completion + [[ -z "\${precmd_functions[(r)_openclaw_register_completion]}" ]] || exit 12 + [[ "\${_comps[openclaw]-}" = "_openclaw_root_completion" ]] + `, + ], + { + encoding: "utf8", + env: { + ...process.env, + HOME: tempDir, + ZDOTDIR: tempDir, + }, + }, + ); + + expect(result.stderr).not.toContain("command not found: compdef"); + expect(result.status).toBe(0); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it("generates PowerShell command paths without the executable prefix", () => { const script = getCompletionScript("powershell", createCompletionProgram()); diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts index f25cd63dcdf..0d9fd947013 100644 --- a/src/cli/completion-cli.ts +++ b/src/cli/completion-cli.ts @@ -411,7 +411,23 @@ _${rootCmd}_root_completion() { ${generateZshSubcommands(program, rootCmd)} -compdef _${rootCmd}_root_completion ${rootCmd} +_${rootCmd}_register_completion() { + if (( ! $+functions[compdef] )); then + return 0 + fi + + compdef _${rootCmd}_root_completion ${rootCmd} + precmd_functions=(\${precmd_functions:#_${rootCmd}_register_completion}) + unfunction _${rootCmd}_register_completion 2>/dev/null +} + +_${rootCmd}_register_completion +if (( ! $+functions[compdef] )); then + typeset -ga precmd_functions + if [[ -z "\${precmd_functions[(r)_${rootCmd}_register_completion]}" ]]; then + precmd_functions+=(_${rootCmd}_register_completion) + fi +fi `; return script; }