/** * Shell completion script generation. * * Loads shell-native template files and replaces placeholders with * dynamic values. This approach keeps the shell scripts readable * and auditable in their native format. * * The generated scripts use the `xo-complete` helper binary for dynamic * completions (invitation IDs, template names, resources, etc.). * * Usage: * eval "$(xo-cli completions bash)" * eval "$(xo-cli completions zsh)" * xo-cli completions fish | source * * Install to shell config: * xo-cli completions bash --install * xo-cli completions zsh --install * xo-cli completions fish --install */ import { existsSync, readFileSync, appendFileSync, writeFileSync, } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; /** * Single source of truth for the CLI command tree. * Each top-level key is a command, and its value is an array of sub-commands. * * IMPORTANT: Keep this in sync with actual switch statements in command handlers: * - mnemonic.ts: create, import, list, expose * - template.ts: import, list, inspect, set-default * - invitation.ts: create, append, sign, broadcast, requirements, import, inspect, list * - resource.ts: list, unreserve, unreserve-all */ /** Subcommands for the mnemonic command */ const MNEMONIC_SUBS = ["create", "import", "list", "expose"]; /** Subcommands for the template command */ const TEMPLATE_SUBS = ["import", "list", "inspect", "set-default"]; /** Subcommands for the invitation command */ const INVITATION_SUBS = [ "create", "append", "sign", "broadcast", "requirements", "import", "inspect", "list", ]; /** Subcommands for the resource command */ const RESOURCE_SUBS = ["list", "unreserve", "unreserve-all"]; /** Subcommands for the completions command */ const COMPLETIONS_SUBS = ["bash", "zsh", "fish"]; export const COMMAND_TREE = { mnemonic: MNEMONIC_SUBS, template: TEMPLATE_SUBS, invitation: INVITATION_SUBS, receive: [], resource: RESOURCE_SUBS, help: [], completions: COMPLETIONS_SUBS, } as const; /** Global option flags available on every command. */ const GLOBAL_OPTIONS = [ "-h", "--help", "-v", "--verbose", "-m", "--mnemonic-file", "-o", "--output", ]; /** * Gets the path to the scripts directory containing shell templates. */ function getScriptsDir(): string { const currentFile = fileURLToPath(import.meta.url); return join(dirname(currentFile), "scripts"); } /** * Loads a shell template file and replaces placeholders. * @param templateName - The template file name (e.g., "bash.sh") * @param binName - The CLI binary name */ function loadAndProcessTemplate(templateName: string, binName: string): string { const scriptsDir = getScriptsDir(); const templatePath = join(scriptsDir, templateName); if (!existsSync(templatePath)) { throw new Error(`Template file not found: ${templatePath}`); } let content = readFileSync(templatePath, "utf8"); const funcName = binName.replace(/-/g, "_"); const commands = Object.keys(COMMAND_TREE).join(" "); const options = GLOBAL_OPTIONS.join(" "); // Replace all placeholders content = content.replace(/\{\{BIN_NAME\}\}/g, binName); content = content.replace(/\{\{FUNC_NAME\}\}/g, funcName); content = content.replace(/\{\{COMMANDS\}\}/g, commands); content = content.replace(/\{\{OPTIONS\}\}/g, options); content = content.replace(/\{\{MNEMONIC_SUBS\}\}/g, MNEMONIC_SUBS.join(" ")); content = content.replace(/\{\{TEMPLATE_SUBS\}\}/g, TEMPLATE_SUBS.join(" ")); content = content.replace( /\{\{INVITATION_SUBS\}\}/g, INVITATION_SUBS.join(" "), ); content = content.replace(/\{\{RESOURCE_SUBS\}\}/g, RESOURCE_SUBS.join(" ")); // Fish-specific placeholders if (templateName.endsWith(".fish")) { content = content.replace( /\{\{TOP_LEVEL_COMMANDS\}\}/g, generateFishTopLevelCommands(binName), ); content = content.replace( /\{\{STATIC_SUBCOMMANDS\}\}/g, generateFishStaticSubcommands(binName), ); } return content; } /** * Generates fish top-level command completions. */ function generateFishTopLevelCommands(binName: string): string { const lines: string[] = []; for (const cmd of Object.keys(COMMAND_TREE)) { lines.push( `complete -c ${binName} -n "__fish_use_subcommand" -a "${cmd}" -d "${cmd} command"`, ); } return lines.join("\n"); } /** * Generates fish static subcommand completions. */ function generateFishStaticSubcommands(binName: string): string { const lines: string[] = []; for (const [cmd, subs] of Object.entries(COMMAND_TREE)) { for (const sub of subs) { lines.push( `complete -c ${binName} -n "__fish_seen_subcommand_from ${cmd}; and not __fish_seen_subcommand_from ${subs.join(" ")}" -a "${sub}" -d "${cmd} ${sub}"`, ); } } return lines.join("\n"); } /** * Generates a bash completion script. * @param binName - The name of the CLI binary. */ export function generateBashCompletions(binName: string): string { return loadAndProcessTemplate("bash.sh", binName); } /** * Generates a zsh completion script. * @param binName - The name of the CLI binary. */ export function generateZshCompletions(binName: string): string { return loadAndProcessTemplate("zsh.zsh", binName); } /** * Generates a fish completion script. * @param binName - The name of the CLI binary. */ export function generateFishCompletions(binName: string): string { return loadAndProcessTemplate("fish.fish", binName); } type ShellType = "bash" | "zsh" | "fish"; const generators: Record string> = { bash: generateBashCompletions, zsh: generateZshCompletions, fish: generateFishCompletions, }; /** * Shell config file paths and eval commands for each shell type. */ const shellConfigs: Record< ShellType, { configFile: string; evalCommand: (binName: string) => string } > = { bash: { configFile: join(homedir(), ".bashrc"), evalCommand: (binName) => `eval "$(${binName} completions bash)"`, }, zsh: { configFile: join(homedir(), ".zshrc"), evalCommand: (binName) => `eval "$(${binName} completions zsh)"`, }, fish: { configFile: join(homedir(), ".config", "fish", "config.fish"), evalCommand: (binName) => `${binName} completions fish | source`, }, }; /** * Installs completions to the user's shell config file. * Adds the eval command if not already present. * @param shell - The shell type * @param binName - The CLI binary name * @returns true if installed, false if already present */ function installCompletions(shell: ShellType, binName: string): boolean { const config = shellConfigs[shell]; const evalCommand = config.evalCommand(binName); // Check if config file exists and already has the completion line let existingContent = ""; if (existsSync(config.configFile)) { existingContent = readFileSync(config.configFile, "utf8"); if (existingContent.includes(evalCommand)) { return false; // Already installed } } // Append the completion line const newLine = existingContent.endsWith("\n") || existingContent === "" ? "" : "\n"; const completionBlock = `${newLine}\n# ${binName} shell completions\n${evalCommand}\n`; appendFileSync(config.configFile, completionBlock); return true; } /** * Handles the `completions` command. * Prints the generated completion script for the given shell to stdout, * or installs it to the shell config file with --install. * @param args - Positional args after "completions", e.g. ["bash"]. * @param options - Parsed command options (may include "install"). * @param binName - The CLI binary name to use in the completion script. */ export function handleCompletionsCommand( args: string[], options: Record = {}, binName: string = "xo-cli", ): void { const shell = args[0] as ShellType | undefined; const installFlag = options["install"] === "true"; if (!shell || !generators[shell]) { const supported = Object.keys(generators).join(", "); console.error(`Usage: ${binName} completions <${supported}> [--install]`); console.error(""); console.error("Examples:"); console.error( ` eval "$(${binName} completions bash)" # Output to stdout (add to ~/.bashrc)`, ); console.error( ` eval "$(${binName} completions zsh)" # Output to stdout (add to ~/.zshrc)`, ); console.error( ` ${binName} completions fish | source # Output to stdout (add to fish config)`, ); console.error(""); console.error("Install directly to shell config:"); console.error( ` ${binName} completions bash --install # Appends to ~/.bashrc`, ); console.error( ` ${binName} completions zsh --install # Appends to ~/.zshrc`, ); console.error( ` ${binName} completions fish --install # Appends to ~/.config/fish/config.fish`, ); process.exit(1); } if (installFlag) { const config = shellConfigs[shell]; const installed = installCompletions(shell, binName); if (installed) { console.log(`Completions installed to ${config.configFile}`); console.log(`Restart your shell or run: source ${config.configFile}`); } else { console.log(`Completions already installed in ${config.configFile}`); } return; } process.stdout.write(generators[shell](binName)); }