/** * Shell completion commands - output scripts or auto-install. * * @module src/cli/commands/completion/completion */ // node:fs/promises - no Bun equivalent for mkdir/appendFile/writeFile import { appendFile, mkdir, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { CLI_NAME } from "../../../app/constants.js"; import { CliError } from "../../errors.js"; import { getCompletionScript, SUPPORTED_SHELLS, type Shell, } from "./scripts.js"; // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── export interface OutputOptions { shell: Shell; } export interface InstallOptions { /** Override shell detection */ shell?: Shell; /** JSON output */ json?: boolean; } interface InstallResult { shell: Shell; path: string; action: "installed" | "already_installed"; } // ───────────────────────────────────────────────────────────────────────────── // Shell Detection // ───────────────────────────────────────────────────────────────────────────── /** * Detect user's current shell. */ function detectShell(): Shell | undefined { // Check $SHELL env const shellEnv = process.env.SHELL || ""; if (shellEnv.includes("zsh")) return "zsh"; if (shellEnv.includes("bash")) return "bash"; if (shellEnv.includes("fish")) return "fish"; // Check parent process name on Unix // This is less reliable but can help const parentName = process.env._ || ""; if (parentName.includes("zsh")) return "zsh"; if (parentName.includes("bash")) return "bash"; if (parentName.includes("fish")) return "fish"; return undefined; } /** * Get the appropriate rc file for a shell. */ function getShellRcPath(shell: Shell): string { const home = homedir(); switch (shell) { case "bash": // Prefer .bashrc, but .bash_profile on macOS return process.platform === "darwin" ? join(home, ".bash_profile") : join(home, ".bashrc"); case "zsh": return join(home, ".zshrc"); case "fish": return join(home, ".config", "fish", "completions", `${CLI_NAME}.fish`); } } /** * Check if completion is already installed. */ async function isCompletionInstalled(shell: Shell): Promise { const path = getShellRcPath(shell); // For fish, check if file exists if (shell === "fish") { return await Bun.file(path).exists(); } // For bash/zsh, check for shell-specific patterns try { const content = await Bun.file(path).text(); // Generic header comment const header = `# ${CLI_NAME}`; // Bash function name const bashFn = `_${CLI_NAME}_completions`; // Zsh-specific patterns (function definition or completion header) const zshFn = `_${CLI_NAME}()`; if (shell === "bash") { return content.includes(header) || content.includes(bashFn); } if (shell === "zsh") { return content.includes(header) || content.includes(zshFn); } return false; } catch { return false; } } // ───────────────────────────────────────────────────────────────────────────── // Commands // ───────────────────────────────────────────────────────────────────────────── /** * Output completion script to stdout. */ export function completionOutput(options: OutputOptions): string { const { shell } = options; if (!SUPPORTED_SHELLS.includes(shell)) { throw new CliError( "VALIDATION", `Unsupported shell: ${shell}. Supported: ${SUPPORTED_SHELLS.join(", ")}` ); } return getCompletionScript(shell); } /** * Auto-install completion to user's shell config. */ export async function completionInstall( options: InstallOptions ): Promise { const { json = false } = options; let { shell } = options; // Auto-detect shell if not specified if (!shell) { shell = detectShell(); if (!shell) { throw new CliError( "VALIDATION", "Could not detect shell. Please specify: gno completion install --shell " ); } } if (!SUPPORTED_SHELLS.includes(shell)) { throw new CliError( "VALIDATION", `Unsupported shell: ${shell}. Supported: ${SUPPORTED_SHELLS.join(", ")}` ); } // Check if already installed const alreadyInstalled = await isCompletionInstalled(shell); const rcPath = getShellRcPath(shell); const script = getCompletionScript(shell); let result: InstallResult; if (alreadyInstalled) { result = { shell, path: rcPath, action: "already_installed" }; } else { // Install completion if (shell === "fish") { // Fish uses a separate file in completions dir const dir = join(homedir(), ".config", "fish", "completions"); await mkdir(dir, { recursive: true }); await writeFile(rcPath, script, "utf-8"); } else { // Bash/zsh append to rc file const separator = "\n\n"; await appendFile(rcPath, separator + script, "utf-8"); } result = { shell, path: rcPath, action: "installed" }; } // Output result if (json) { process.stdout.write(JSON.stringify(result, null, 2) + "\n"); } else { if (result.action === "already_installed") { process.stdout.write(`Completion already installed for ${shell}.\n`); process.stdout.write(`Config: ${result.path}\n`); } else { process.stdout.write(`Completion installed for ${shell}.\n`); process.stdout.write(`Config: ${result.path}\n`); process.stdout.write(`\nRestart your shell or run:\n`); process.stdout.write(` source ${result.path}\n`); } } }