/** * Rich Zsh Completion Generator * * Generates a full zsh completion file from the command registry. * Produces native zsh completions with descriptions, flags, dynamic * database-backed argument completions, and nested command hierarchies. * * This is the ONLY source for zsh completions. The generated file * should never be hand-edited. */ import type { ArgDef, CommandDef, FlagDef } from '@celilo/core'; /** * Escape a string for use in zsh completion descriptions. * Descriptions are emitted inside single-quoted zsh strings, so: * - backslash and colon need escaping in _describe / _arguments specs * - a literal apostrophe must close-quote, emit an escaped quote, and reopen * ('...'\''...') — otherwise it terminates the string early and the rest of * the file mis-parses (e.g. "account's" broke completion at the next `->`). */ function escapeZshDescription(text: string): string { return text.replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "'\\''"); } /** * Convert a command path to a zsh function name. * e.g., ["module", "config"] → "_celilo_module_config" */ function functionName(path: string[]): string { return `_celilo${path.length > 0 ? `_${path.join('_')}` : ''}`; } /** * Generate the _arguments line for flags */ function generateFlagArgs(flags: FlagDef[]): string[] { return flags.map((flag) => { const desc = escapeZshDescription(flag.description); if (flag.takesValue) { const hint = flag.valueHint ?? ''; // If valueHint is a zsh completer function (starts with _), use directly // Otherwise treat as space-separated literal values if (hint.startsWith('_')) { return `'--${flag.name}[${desc}]:${flag.name}:${hint}'`; } if (hint) { return `'--${flag.name}[${desc}]:${flag.name}:(${hint})'`; } return `'--${flag.name}[${desc}]:${flag.name}:'`; } return `'--${flag.name}[${desc}]'`; }); } /** * Get the zsh completion action for a dynamic completion source */ function completionAction(completion: string): string { switch (completion) { case 'module_ids': return '_celilo_module_ids'; case 'service_ids': return '_celilo_service_ids'; case 'machine_hostnames': return '_celilo_machine_hostnames'; case 'capability_names': return '_celilo_capability_names'; case 'config_keys': return '_celilo_config_keys'; case 'system_config_keys': return '_celilo_system_config_keys'; case 'system_secret_keys': return '_celilo_system_secret_keys'; case 'storage_ids': return '_celilo_storage_ids'; case 'backup_ids': return '_celilo_backup_ids'; case 'files': return '_files'; case 'directories': return '_files -/'; default: return ''; } } /** * Generate positional argument specs for _arguments */ function generatePositionalArgs(args: ArgDef[]): string[] { return args.map((arg, i) => { const pos = arg.variadic ? '*' : String(i + 1); const desc = escapeZshDescription(arg.description); if (arg.completion) { const action = completionAction(arg.completion); return `'${pos}:${desc}:${action}'`; } return `'${pos}:${desc}:_files'`; }); } /** * Generate a leaf command's completion (args + flags, no subcommands) */ function generateLeafArgs(cmd: CommandDef): string { const parts: string[] = []; if (cmd.flags && cmd.flags.length > 0) { parts.push(...generateFlagArgs(cmd.flags)); } if (cmd.args && cmd.args.length > 0) { parts.push(...generatePositionalArgs(cmd.args)); } if (parts.length === 0) { return ' # No additional arguments'; } if (parts.length === 1) { return ` _arguments ${parts[0]}`; } return ` _arguments \\\n ${parts.join(' \\\n ')}`; } /** * Generate a command function that has subcommands */ function generateCommandFunction(cmd: CommandDef, path: string[], lines: string[]): void { const fnName = functionName(path); const subcommands = cmd.subcommands ?? []; lines.push(`${fnName}() {`); lines.push(' local curcontext="$curcontext" state line'); lines.push(' typeset -A opt_args'); lines.push(''); const flagArgs = cmd.flags && cmd.flags.length > 0 ? generateFlagArgs(cmd.flags) : []; if (flagArgs.length > 0) { lines.push(' _arguments -C \\'); for (const flag of flagArgs) { lines.push(` ${flag} \\`); } lines.push(` '1: :${fnName}_commands' \\`); lines.push(" '*::arg:->args'"); } else { lines.push(' _arguments -C \\'); lines.push(` '1: :${fnName}_commands' \\`); lines.push(" '*::arg:->args'"); } lines.push(''); lines.push(' case $state in'); lines.push(' args)'); lines.push(' case $line[1] in'); for (const sub of subcommands) { if (sub.subcommands && sub.subcommands.length > 0) { // Sub has its own subcommands — dispatch to its function lines.push(` ${sub.name})`); lines.push(` ${functionName([...path, sub.name])}`); lines.push(' ;;'); } else { // Leaf subcommand lines.push(` ${sub.name})`); lines.push(generateLeafArgs(sub)); lines.push(' ;;'); } } lines.push(' esac'); lines.push(' ;;'); lines.push(' esac'); lines.push('}'); lines.push(''); // Generate the _commands list function lines.push(`${fnName}_commands() {`); lines.push(' local commands=('); for (const sub of subcommands) { lines.push(` '${sub.name}:${escapeZshDescription(sub.description)}'`); } lines.push(' )'); lines.push(" _describe 'command' commands"); lines.push('}'); lines.push(''); // Recurse into subcommands that have their own subcommands for (const sub of subcommands) { if (sub.subcommands && sub.subcommands.length > 0) { generateCommandFunction(sub, [...path, sub.name], lines); } } } /** * Collect all dynamic completion sources used across the command tree */ function collectCompletionSources(commands: CommandDef[]): Set { const sources = new Set(); function walk(cmd: CommandDef): void { if (cmd.args) { for (const arg of cmd.args) { if (arg.completion && !['files', 'directories'].includes(arg.completion)) { sources.add(arg.completion); } } } if (cmd.subcommands) { for (const sub of cmd.subcommands) { walk(sub); } } } for (const cmd of commands) { walk(cmd); } return sources; } /** * Generate the dynamic completion helper functions */ function generateDynamicCompletions(sources: Set): string[] { const lines: string[] = []; if (sources.has('module_ids')) { lines.push(`# Dynamic completions - module IDs _celilo_module_ids() { local -a module_ids local db_path="\${CELILO_DB_PATH:-$HOME/Library/Application Support/celilo/celilo.db}" if [[ -f "$db_path" ]] && command -v sqlite3 >/dev/null 2>&1; then module_ids=(\${(f)"$(sqlite3 "$db_path" "SELECT id FROM modules;" 2>/dev/null)"}) if [[ \${#module_ids[@]} -gt 0 ]]; then _describe 'module id' module_ids return fi fi _message 'module-id' } `); } if (sources.has('service_ids')) { lines.push(`# Dynamic completions - service IDs _celilo_service_ids() { local -a service_ids local db_path="\${CELILO_DB_PATH:-$HOME/Library/Application Support/celilo/celilo.db}" if [[ -f "$db_path" ]] && command -v sqlite3 >/dev/null 2>&1; then while IFS='|' read -r id name; do service_ids+=("$id:$name") done < <(sqlite3 "$db_path" "SELECT service_id, name FROM container_services;" 2>/dev/null) if [[ \${#service_ids[@]} -gt 0 ]]; then _describe 'service id' service_ids return fi fi _message 'service-id' } `); } if (sources.has('machine_hostnames')) { lines.push(`# Dynamic completions - machine hostnames _celilo_machine_hostnames() { local -a hostnames local db_path="\${CELILO_DB_PATH:-$HOME/Library/Application Support/celilo/celilo.db}" if [[ -f "$db_path" ]] && command -v sqlite3 >/dev/null 2>&1; then hostnames=(\${(f)"$(sqlite3 "$db_path" "SELECT hostname FROM machines;" 2>/dev/null)"}) if [[ \${#hostnames[@]} -gt 0 ]]; then _describe 'hostname' hostnames return fi fi _message 'hostname' } `); } if (sources.has('capability_names')) { lines.push(`# Dynamic completions - capability names _celilo_capability_names() { local -a capability_names local db_path="\${CELILO_DB_PATH:-$HOME/Library/Application Support/celilo/celilo.db}" if [[ -f "$db_path" ]] && command -v sqlite3 >/dev/null 2>&1; then while IFS='|' read -r name module_id; do capability_names+=("$name:provided by $module_id") done < <(sqlite3 "$db_path" "SELECT capability_name, module_id FROM capabilities;" 2>/dev/null) if [[ \${#capability_names[@]} -gt 0 ]]; then _describe 'capability name' capability_names return fi fi _message 'capability-name' } `); } if (sources.has('config_keys')) { lines.push(`# Dynamic completions - module config keys (from manifest) _celilo_config_keys() { local module_id=$line[1] local db_path="\${CELILO_DB_PATH:-$HOME/Library/Application Support/celilo/celilo.db}" if [[ -n "$module_id" ]] && [[ -f "$db_path" ]] && command -v sqlite3 >/dev/null 2>&1 && command -v jq >/dev/null 2>&1; then local manifest_json=$(sqlite3 "$db_path" "SELECT manifest_data FROM modules WHERE id = '$module_id';" 2>/dev/null) if [[ -n "$manifest_json" ]]; then local -a config_keys while IFS='|' read -r key desc required; do local desc_text="$desc" if [[ "$required" == "true" ]]; then desc_text="$desc (required)" fi config_keys+=("$key:$desc_text") done < <(echo "$manifest_json" | jq -r '.variables.owns[]? | "\\(.name)|\\(.description // "No description")|\\(.required // false)"' 2>/dev/null) # Celilo-managed keys apply to EVERY module regardless of its manifest # (#515), so offer them even when the manifest declares none. config_keys+=("auto_upgrade:Let the registry poll upgrade this module unattended (true|false)") config_keys+=("upgrade_policy:Deploy posture floor for upgrades (by-semver|always-safe|always-fast)") if [[ \${#config_keys[@]} -gt 0 ]]; then _describe 'config key' config_keys return 0 fi fi fi _message 'config-key' } `); } if (sources.has('system_config_keys')) { lines.push(`# Dynamic completions - system config keys _celilo_system_config_keys() { local config_keys=( 'proxmox.api_url:Proxmox API URL' 'proxmox.default_target_node:Default Proxmox node' 'proxmox.lxc_template:LXC container template' 'network.bridge:Network bridge name' 'network.dmz.subnet:DMZ subnet' 'network.dmz.vlan:DMZ VLAN ID' 'network.dmz.gateway:DMZ gateway IP' 'network.app.subnet:App subnet' 'network.app.vlan:App VLAN ID' 'network.app.gateway:App gateway IP' 'network.secure.subnet:Secure subnet' 'network.secure.vlan:Secure VLAN ID' 'network.secure.gateway:Secure gateway IP' 'network.internal.subnet:Internal subnet' 'network.internal.gateway:Internal gateway IP' 'network.secure-mgmt.subnet:Control-plane subnet (celilo-mgr own network)' 'network.secure-mgmt.gateway:Control-plane gateway IP' 'network.control-plane-vpn.subnet:Administrative VPN client subnet (WireGuard remote access)' 'firewall.trusted_subnets:Extra subnets that reach every managed zone (comma-separated CIDRs or JSON array)' 'dns.primary:Primary DNS server' 'dns.fallback:Fallback DNS servers' 'routing.internal_gateway:Internal gateway IP' 'ssh.public_key:SSH public key' ) _describe 'config key' config_keys } `); } if (sources.has('system_secret_keys')) { lines.push(`# Dynamic completions - system secret keys _celilo_system_secret_keys() { local secret_keys=( 'proxmox.root_password:Proxmox root password' ) _describe 'secret key' secret_keys } `); } if (sources.has('storage_ids')) { lines.push(`# Dynamic completions - backup storage IDs _celilo_storage_ids() { local -a storage_ids local db_path="\${CELILO_DB_PATH:-$HOME/Library/Application Support/celilo/celilo.db}" if [[ -f "$db_path" ]] && command -v sqlite3 >/dev/null 2>&1; then while IFS='|' read -r id name; do storage_ids+=("$id:$name") done < <(sqlite3 "$db_path" "SELECT storage_id, name FROM backup_storages;" 2>/dev/null) if [[ \${#storage_ids[@]} -gt 0 ]]; then _describe 'storage id' storage_ids return fi fi _message 'storage-id' } `); } if (sources.has('backup_ids')) { lines.push(`# Dynamic completions - backup IDs and names _celilo_backup_ids() { local -a backup_ids local db_path="\${CELILO_DB_PATH:-$HOME/Library/Application Support/celilo/celilo.db}" if [[ -f "$db_path" ]] && command -v sqlite3 >/dev/null 2>&1; then while IFS='|' read -r id module_id backup_type started_at name; do local short_id="\${id:0:8}" local label="\${module_id:-system}" if [[ "$backup_type" == "system_state" ]]; then label="system"; fi local date="\${started_at}" backup_ids+=("$short_id:$label $date") if [[ -n "$name" ]]; then backup_ids+=("$name:$label $date") fi done < <(sqlite3 "$db_path" "SELECT id, module_id, backup_type, datetime(started_at, 'unixepoch'), name FROM backups ORDER BY started_at DESC LIMIT 50;" 2>/dev/null) if [[ \${#backup_ids[@]} -gt 0 ]]; then _describe 'backup id' backup_ids return fi fi _message 'backup-id or name' } `); } return lines; } /** * Generate the complete rich zsh completion script * * @param commands - Command tree from the registry * @returns Complete zsh completion script as a string */ export function generateRichZshCompletion(commands: CommandDef[]): string { const lines: string[] = []; // Header lines.push('#compdef celilo'); lines.push(''); lines.push('# Auto-generated by: celilo completion zsh'); lines.push('# Do not edit manually. Regenerate with:'); lines.push('# celilo completion zsh > ~/.zsh/completions/_celilo'); lines.push(''); // Main _celilo function lines.push('_celilo() {'); lines.push(' local curcontext="$curcontext" state line'); lines.push(' typeset -A opt_args'); lines.push(''); lines.push(' _arguments -C \\'); lines.push(" '1: :_celilo_commands' \\"); lines.push(" '*::arg:->args'"); lines.push(''); lines.push(' case $state in'); lines.push(' args)'); lines.push(' case $line[1] in'); for (const cmd of commands) { if (cmd.subcommands && cmd.subcommands.length > 0) { lines.push(` ${cmd.name})`); lines.push(` _celilo_${cmd.name}`); lines.push(' ;;'); } else if (cmd.args || cmd.flags) { lines.push(` ${cmd.name})`); lines.push(generateLeafArgs(cmd)); lines.push(' ;;'); } else { lines.push(` ${cmd.name})`); lines.push(' # No additional arguments'); lines.push(' ;;'); } } lines.push(' esac'); lines.push(' ;;'); lines.push(' esac'); lines.push('}'); lines.push(''); // Top-level commands list lines.push('_celilo_commands() {'); lines.push(' local commands=('); for (const cmd of commands) { lines.push(` '${cmd.name}:${escapeZshDescription(cmd.description)}'`); } lines.push(' )'); lines.push(" _describe 'command' commands"); lines.push('}'); lines.push(''); // Generate functions for each command with subcommands for (const cmd of commands) { if (cmd.subcommands && cmd.subcommands.length > 0) { generateCommandFunction(cmd, [cmd.name], lines); } } // Generate dynamic completion helpers const sources = collectCompletionSources(commands); const dynamicLines = generateDynamicCompletions(sources); lines.push(...dynamicLines); // Footer lines.push('_celilo "$@"'); lines.push(''); return lines.join('\n'); }