/** * Shell Completion Support * Provides tab completion suggestions for bash/zsh */ import { eq } from 'drizzle-orm'; import { getDb } from '../db/client'; import { capabilities, modules } from '../db/schema'; import type { ModuleManifest } from '../manifest/schema'; import { SCHEDULABLE_BUILTIN_CHECKS } from '../services/alerting/builtin-source'; import { HEALTH_COVERAGE_CHECK } from '../services/alerting/health-coverage'; import { HOOK_JAIL_CHECK } from '../services/alerting/hook-jail'; import { listPrincipals } from '../services/api-access'; import { listBackups } from '../services/backup-metadata'; import { listBackupStorages } from '../services/backup-storage'; import { listContainerServices } from '../services/container-service'; import { listMachines } from '../services/machine-pool'; import { FRAMEWORK_CONFIG_KEYS } from './commands/module-config'; /** * Get completion suggestions based on current command context * * @param words - Array of words typed so far * @param current - Index of word being completed * @returns Array of completion suggestions */ export async function getCompletions(words: string[], current: number): Promise { // Remove 'celilo' from words if present const args = words[0] === 'celilo' ? words.slice(1) : words; const currentIndex = words[0] === 'celilo' ? current - 1 : current; // Complete top-level commands // currentIndex === 0 means we're completing the first word (the command) if (currentIndex === 0) { const commands = [ 'api', 'apt-upgrade', 'audit', 'backup', 'capability', 'commands', 'console', 'dns', 'completion', 'alerts', 'escalation-policy', 'events', 'firewall', 'help', 'hook', 'ipam', 'machine', 'module', 'monitor', 'package', 'person', 'proxmox', 'publish', 'registry', 'route', 'restore', 'service', 'status', 'storage', 'subscribers', 'system', 'token', 'version', ]; return filterSuggestions(commands, args[0] || ''); } const command = args[0]; // DNS subcommands if (command === 'dns' && currentIndex === 1) { return filterSuggestions(['registrations'], args[1] || ''); } // Capability subcommands if (command === 'capability' && currentIndex === 1) { const subcommands = ['list', 'info']; return filterSuggestions(subcommands, args[1] || ''); } // Registry subcommands if (command === 'registry' && currentIndex === 1) { return filterSuggestions(['token', 'owner'], args[1] || ''); } if (command === 'registry' && args[1] === 'token' && currentIndex === 2) { return filterSuggestions(['add', 'rm'], args[2] || ''); } if (command === 'registry' && args[1] === 'owner' && currentIndex === 2) { return filterSuggestions(['list', 'show', 'set'], args[2] || ''); } // Token subcommands (contributor identity tokens) if (command === 'token' && currentIndex === 1) { return filterSuggestions(['obtain', 'list', 'revoke'], args[1] || ''); } // Capability info - complete with capability names if (command === 'capability' && args[1] === 'info' && currentIndex === 2) { const db = getDb(); const capabilityRows = db .select({ name: capabilities.capabilityName }) .from(capabilities) .all(); const capabilityNames = capabilityRows.map((c) => c.name); return filterSuggestions(capabilityNames, args[2] || ''); } // Events subcommands — keep this list in sync with command-registry.ts. if (command === 'events' && currentIndex === 1) { const subcommands = [ 'status', 'tail', 'list-subscribers', 'resync-subscriptions', 'list-pending', 'list-failed', 'list-unanswered', 'drain', 'run', 'run-hook', 'emit', 'reply', 'ack', 'fail', 'repair', 'resume', 'respond', 'install-daemon', 'uninstall-daemon', 'restart-daemon', 'show-daemon', ]; return filterSuggestions(subcommands, args[1] || ''); } // Hook subcommands if (command === 'hook' && currentIndex === 1) { const subcommands = ['run']; return filterSuggestions(subcommands, args[1] || ''); } // Hook run - complete with module IDs if (command === 'hook' && args[1] === 'run' && currentIndex === 2) { const db = getDb(); const moduleRows = db.select({ id: modules.id }).from(modules).all(); const moduleIds = moduleRows.map((p) => p.id); return filterSuggestions(moduleIds, args[2] || ''); } // Hook run - complete with hook names from manifest if (command === 'hook' && args[1] === 'run' && currentIndex === 3) { const db = getDb(); const module = db .select() .from(modules) .where(eq(modules.id, args[2] || '')) .get(); if (module?.manifestData) { const manifest = module.manifestData as ModuleManifest; const hookNames = Object.keys(manifest.hooks || {}); return filterSuggestions(hookNames, args[3] || ''); } } // Module subcommands if (command === 'module' && currentIndex === 1) { const subcommands = [ 'check', 'changeset', 'import', 'list', 'publish', 'remove', 'search', 'update', 'upgrade', 'verify', 'audit', 'backup', 'config', 'show-config', 'show-zone', 'build', 'generate', 'deploy', 'health', 'logs', 'journal', 'run-hook', 'secret', 'status', 'where', 'operations', 'terraform-unlock', 'types', 'validate', 'pause', 'unpause', 'jail', ]; return filterSuggestions(subcommands, args[1] || ''); } // Module config subcommands (celilo module config set/get/unset) if (command === 'module' && args[1] === 'config' && currentIndex === 2) { const subcommands = ['set', 'get', 'unset']; return filterSuggestions(subcommands, args[2] || ''); } // Module operations subcommands (celilo module operations list/clear) if (command === 'module' && args[1] === 'operations' && currentIndex === 2) { const subcommands = ['list', 'clear']; return filterSuggestions(subcommands, args[2] || ''); } // Module types subcommands (celilo module types generate/check) if (command === 'module' && args[1] === 'types' && currentIndex === 2) { const subcommands = ['generate', 'check']; return filterSuggestions(subcommands, args[2] || ''); } // Module config set/get/unset - complete with module IDs if ( command === 'module' && args[1] === 'config' && (args[2] === 'set' || args[2] === 'get' || args[2] === 'unset') && currentIndex === 3 ) { const db = getDb(); const modulesList = db.select({ id: modules.id }).from(modules).all(); const moduleIds = modulesList.map((p) => p.id); return filterSuggestions(moduleIds, args[3] || ''); } // Module config set/get/unset - complete with config key names. // Framework keys (backup cadence, upgrade policy…) are offered on EVERY // module: they describe how celilo treats a module, so a module never // declares them and completion built from the manifest alone could not see // them — which is why `auto_upgrade` and `upgrade_policy` were uncompletable // for as long as they have existed. if ( command === 'module' && args[1] === 'config' && (args[2] === 'set' || args[2] === 'get' || args[2] === 'unset') && currentIndex === 4 ) { const db = getDb(); const module = db .select() .from(modules) .where(eq(modules.id, args[3] || '')) .get(); const frameworkKeys = Object.keys(FRAMEWORK_CONFIG_KEYS); if (module?.manifestData) { const manifest = module.manifestData as ModuleManifest; const varNames = (manifest.variables?.owns || []) .filter((v) => v.source === 'user' || !v.source) .map((v) => v.name); return filterSuggestions([...varNames, ...frameworkKeys], args[4] || ''); } return filterSuggestions(frameworkKeys, args[4] || ''); } // Module secret subcommands (celilo module secret set/get/list) if (command === 'module' && args[1] === 'secret' && currentIndex === 2) { const subcommands = ['set', 'get', 'list']; return filterSuggestions(subcommands, args[2] || ''); } // Module secret set/list - complete with module IDs if ( command === 'module' && args[1] === 'secret' && (args[2] === 'set' || args[2] === 'get' || args[2] === 'list') && currentIndex === 3 ) { const db = getDb(); const modulesList = db.select({ id: modules.id }).from(modules).all(); const moduleIds = modulesList.map((p) => p.id); return filterSuggestions(moduleIds, args[3] || ''); } // Module secret set/get - complete with secret names if ( command === 'module' && args[1] === 'secret' && (args[2] === 'set' || args[2] === 'get') && currentIndex === 4 ) { const db = getDb(); const module = db .select() .from(modules) .where(eq(modules.id, args[3] || '')) .get(); if (module?.manifestData) { const manifest = module.manifestData as ModuleManifest; const secretNames = (manifest.secrets?.declares || []).map((s) => s.name); return filterSuggestions(secretNames, args[4] || ''); } } // Service subcommands // Proxmox subcommands if (command === 'proxmox' && currentIndex === 1) { return filterSuggestions(['node', 'vm', 'ct'], args[1] || ''); } if (command === 'proxmox' && args[1] === 'node' && currentIndex === 2) { return filterSuggestions(['list'], args[2] || ''); } if (command === 'proxmox' && (args[1] === 'vm' || args[1] === 'ct') && currentIndex === 2) { return filterSuggestions(['list', 'resize'], args[2] || ''); } // proxmox list — proxmox services only if ( command === 'proxmox' && (args[1] === 'node' || args[1] === 'vm' || args[1] === 'ct') && args[2] === 'list' && currentIndex === 3 ) { const services = await listContainerServices(); const serviceIds = services.filter((s) => s.providerName === 'proxmox').map((s) => s.serviceId); return filterSuggestions(serviceIds, args[3] || ''); } if (command === 'service' && currentIndex === 1) { const subcommands = [ 'add', 'list', 'verify', 'reconfigure', 'remove', 'config', 'set-credentials', ]; return filterSuggestions(subcommands, args[1] || ''); } // Service add providers if (command === 'service' && args[1] === 'add' && currentIndex === 2) { const providers = ['proxmox', 'digitalocean']; return filterSuggestions(providers, args[2] || ''); } // Service verify - complete with service IDs if (command === 'service' && args[1] === 'verify' && currentIndex === 2) { const services = await listContainerServices(); const serviceIds = services.map((s) => s.serviceId); return filterSuggestions(serviceIds, args[2] || ''); } // Service reconfigure - complete with service IDs if (command === 'service' && args[1] === 'reconfigure' && currentIndex === 2) { const services = await listContainerServices(); const serviceIds = services.map((s) => s.serviceId); return filterSuggestions(serviceIds, args[2] || ''); } // Service remove - complete with service IDs if (command === 'service' && args[1] === 'remove' && currentIndex === 2) { const services = await listContainerServices(); const serviceIds = services.map((s) => s.serviceId); return filterSuggestions(serviceIds, args[2] || ''); } // Service set-credentials - complete with service IDs if (command === 'service' && args[1] === 'set-credentials' && currentIndex === 2) { const services = await listContainerServices(); const serviceIds = services.map((s) => s.serviceId); return filterSuggestions(serviceIds, args[2] || ''); } // Service config operations if (command === 'service' && args[1] === 'config' && currentIndex === 2) { const operations = ['get', 'set']; return filterSuggestions(operations, args[2] || ''); } // Service config get - complete with service IDs if (command === 'service' && args[1] === 'config' && args[2] === 'get' && currentIndex === 3) { const services = await listContainerServices(); const serviceIds = services.map((s) => s.serviceId); return filterSuggestions(serviceIds, args[3] || ''); } // Service config get - complete with config keys if (command === 'service' && args[1] === 'config' && args[2] === 'get' && currentIndex === 4) { const configKeys = ['name', 'zones', 'providerConfig']; return filterSuggestions(configKeys, args[4] || ''); } // Service config set - complete with service IDs if (command === 'service' && args[1] === 'config' && args[2] === 'set' && currentIndex === 3) { const services = await listContainerServices(); const serviceIds = services.map((s) => s.serviceId); return filterSuggestions(serviceIds, args[3] || ''); } // Service config set - complete with config keys if (command === 'service' && args[1] === 'config' && args[2] === 'set' && currentIndex === 4) { const configKeys = ['name', 'zones', 'providerConfig']; return filterSuggestions(configKeys, args[4] || ''); } // API subcommands if (command === 'api' && currentIndex === 1) { const subcommands = ['grant', 'list', 'revoke', 'authorized-keys', 'key']; return filterSuggestions(subcommands, args[1] || ''); } // API revoke - complete with principal names if (command === 'api' && args[1] === 'revoke' && currentIndex === 2) { const principals = await listPrincipals(); return filterSuggestions( principals.map((p) => p.name), args[2] || '', ); } // API key subcommands if (command === 'api' && args[1] === 'key' && currentIndex === 2) { return filterSuggestions(['new'], args[2] || ''); } // Machine subcommands if (command === 'firewall' && currentIndex === 1) { // `interface` is the only group. `acknowledge` and `enforce` were deleted in // the design amendments — there is no policy toggle to complete. return filterSuggestions(['interface'], args[1] || ''); } if (command === 'firewall' && args[1] === 'interface' && currentIndex === 2) { return filterSuggestions(['list'], args[2] || ''); } if (command === 'machine' && currentIndex === 1) { const subcommands = ['add', 'list', 'status', 'remove', 'earmark', 'detect']; return filterSuggestions(subcommands, args[1] || ''); } // Machine remove/earmark - complete with machine hostnames and IPs if ( command === 'machine' && (args[1] === 'remove' || args[1] === 'earmark') && currentIndex === 2 ) { const machineList = await listMachines(); const identifiers = [ ...machineList.map((m) => m.hostname), ...machineList.map((m) => m.ipAddress), ...machineList.flatMap((m) => m.interfaces.map((i) => i.ipAddress)), ]; return filterSuggestions(identifiers, args[2] || ''); } // Machine earmark - complete with module IDs at position 3 if (command === 'machine' && args[1] === 'earmark' && currentIndex === 3) { const db = getDb(); const moduleRows = db.select().from(modules).all(); const moduleIds = moduleRows.map((p: { id: string }) => p.id); return filterSuggestions(moduleIds, args[3] || ''); } // Module commands that take module ID directly at position 2 if (command === 'module' && currentIndex === 2) { const moduleCommands = [ 'generate', 'deploy', 'logs', 'journal', 'remove', 'build', 'backup', 'run-hook', 'status', 'terraform-unlock', 'verify', 'audit', // deprecation alias for `verify` 'pause', 'unpause', 'jail', ]; if (moduleCommands.includes(args[1] || '')) { const db = getDb(); const moduleRows = db.select().from(modules).all(); const moduleIds = moduleRows.map((p: { id: string }) => p.id); return filterSuggestions(moduleIds, args[2] || ''); } } // Module jail - complete with policy values if (command === 'module' && args[1] === 'jail' && currentIndex === 3) { return filterSuggestions(['auto', 'off', 'required'], args[3] || ''); } // Module run-hook - complete with hook names from manifest if (command === 'module' && args[1] === 'run-hook' && currentIndex === 3) { const db = getDb(); const module = db .select() .from(modules) .where(eq(modules.id, args[2] || '')) .get(); if (module?.manifestData) { const manifest = module.manifestData as ModuleManifest; const hookNames = Object.keys(manifest.hooks || {}); return filterSuggestions(hookNames, args[3] || ''); } } // Subscribers subcommands if (command === 'subscribers' && currentIndex === 1) { const subcommands = [ 'list', 'add', 'remove', 'test', 'serve', 'status', 'install-daemon', 'uninstall-daemon', ]; return filterSuggestions(subcommands, args[1] || ''); } // Person / route / escalation-policy subcommands if (command === 'person' && currentIndex === 1) { return filterSuggestions(['list', 'add', 'remove'], args[1] || ''); } if (command === 'route' && currentIndex === 1) { return filterSuggestions(['list', 'add', 'remove'], args[1] || ''); } if (command === 'escalation-policy' && currentIndex === 1) { return filterSuggestions(['list', 'add', 'step', 'assign', 'remove'], args[1] || ''); } // Monitor subcommands if (command === 'monitor' && currentIndex === 1) { return filterSuggestions( ['list', 'add', 'run', 'set-interval', 'enable', 'disable'], args[1] || '', ); } // Monitor targets - a module ID or one of celilo's own schedulable checks. // Sourced from SCHEDULABLE_BUILTIN_CHECKS rather than a hand-copied list, so // a new built-in check is completable the moment it is schedulable. The two // local-state checks (health coverage, hook jail) are deliberately not in // that list — it is the audit categories — so they ride alongside by name. if ( command === 'monitor' && (args[1] === 'add' || args[1] === 'run' || args[1] === 'set-interval' || args[1] === 'enable' || args[1] === 'disable') && currentIndex === 2 ) { const db = getDb(); const moduleIds = db .select({ id: modules.id }) .from(modules) .all() .map((m) => m.id); return filterSuggestions( [...SCHEDULABLE_BUILTIN_CHECKS, HEALTH_COVERAGE_CHECK, HOOK_JAIL_CHECK, ...moduleIds], args[2] || '', ); } // Alerts subcommands if (command === 'alerts' && currentIndex === 1) { return filterSuggestions(['list', 'ack', 'silence', 'resolve', 'sweep', 'poll'], args[1] || ''); } // Storage subcommands if (command === 'storage' && currentIndex === 1) { const subcommands = ['add', 'list', 'remove', 'verify', 'set-default', 'set-path']; return filterSuggestions(subcommands, args[1] || ''); } // Storage add providers if (command === 'storage' && args[1] === 'add' && currentIndex === 2) { const providers = ['local', 's3']; return filterSuggestions(providers, args[2] || ''); } // Storage remove/verify/set-default/set-path - complete with storage IDs if ( command === 'storage' && (args[1] === 'remove' || args[1] === 'verify' || args[1] === 'set-default' || args[1] === 'set-path') && currentIndex === 2 ) { const storages = listBackupStorages(); const storageIds = storages.map((s) => s.storageId); return filterSuggestions(storageIds, args[2] || ''); } // Backup subcommands if (command === 'backup' && currentIndex === 1) { const subcommands = [ 'create', 'sweep', 'list', 'restore', 'delete', 'prune', 'name', 'import', 'pull', ]; return filterSuggestions(subcommands, args[1] || ''); } // Backup create/list/prune - complete with module IDs if ( command === 'backup' && (args[1] === 'create' || args[1] === 'list' || args[1] === 'prune') && currentIndex === 2 ) { const db = getDb(); const moduleRows = db.select({ id: modules.id }).from(modules).all(); const moduleIds = moduleRows.map((p: { id: string }) => p.id); return filterSuggestions(moduleIds, args[2] || ''); } // Backup restore/delete/name - complete with short backup IDs and names if ( command === 'backup' && (args[1] === 'restore' || args[1] === 'delete' || args[1] === 'name') && currentIndex === 2 ) { const backupList = listBackups({ limit: 50 }); const suggestions = backupList.map((b) => b.id.substring(0, 8)); for (const b of backupList) { if (b.name) suggestions.push(b.name); } return filterSuggestions(suggestions, args[2] || ''); } // IPAM subcommands if (command === 'ipam' && currentIndex === 1) { const subcommands = ['show', 'list-allocations', 'vmid', 'ip']; return filterSuggestions(subcommands, args[1] || ''); } // IPAM VMID actions if (command === 'ipam' && args[1] === 'vmid' && currentIndex === 2) { const actions = ['reserve', 'unreserve', 'list-reservations']; return filterSuggestions(actions, args[2] || ''); } // IPAM IP actions if (command === 'ipam' && args[1] === 'ip' && currentIndex === 2) { const actions = ['exclude', 'include', 'edit', 'list-exclusions']; return filterSuggestions(actions, args[2] || ''); } // The address argument to `ipam ip edit` / `ipam ip include` — both act on an // exclusion that already exists, and nobody remembers which address it was. if ( command === 'ipam' && args[1] === 'ip' && (args[2] === 'edit' || args[2] === 'include') && currentIndex === 3 ) { const { listReservations } = await import('../ipam/allocator'); const reservations = await listReservations(getDb()); return filterSuggestions( reservations.map((r: { ipStart: string }) => r.ipStart), args[3] || '', ); } // Completion subcommands if (command === 'console' && currentIndex === 1) { return filterSuggestions(['status', 'get'], args[1] || ''); } if (command === 'completion' && currentIndex === 1) { const subcommands = ['bash', 'zsh']; return filterSuggestions(subcommands, args[1] || ''); } // System subcommands if (command === 'system' && currentIndex === 1) { const subcommands = [ 'init', 'apply-config', 'discover-network', 'ensure-fleet-key', 'config', 'secret', 'vault-password', 'audit', 'update', 'doctor', 'migrate', ]; return filterSuggestions(subcommands, args[1] || ''); } // System config operations if (command === 'system' && args[1] === 'config' && currentIndex === 2) { const operations = ['get', 'set']; return filterSuggestions(operations, args[2] || ''); } // System secret operations if (command === 'system' && args[1] === 'secret' && currentIndex === 2) { const operations = ['get', 'set']; return filterSuggestions(operations, args[2] || ''); } return []; } /** * Filter suggestions based on current input */ function filterSuggestions(options: string[], current: string): string[] { if (!current) return options; return options.filter((opt) => opt.startsWith(current)); } /** * Generate bash completion script */ export function generateBashCompletion(): string { return `# Celilo bash completion _celilo_completion() { local cur prev words cword _init_completion || return # Get completions from celilo local completions=$(celilo --get-completions "\${COMP_WORDS[@]}" "\${COMP_CWORD}") # Apply completions COMPREPLY=( $(compgen -W "$completions" -- "$cur") ) return 0 } complete -F _celilo_completion celilo `; } /** * Generate fish completion script. * * Fish doesn't have an equivalent of bash's `compgen -W` or zsh's `compadd`, * so we register a single dynamic completer that calls the CLI's * --get-completions hook on each TAB. The CLI emits one completion per line; * fish picks them up as candidates. * * The shell context fish exposes is `commandline -opc` (tokens before the * in-progress word) plus `commandline -ct` (the in-progress word). We * recombine them into the same words + cword shape the bash/zsh wrappers * use, so the same TypeScript completion logic serves all three shells. */ export function generateFishCompletion(): string { return `# Celilo fish completion function __celilo_complete set -l tokens (commandline -opc) set -l current (commandline -ct) set -l words celilo $tokens[2..-1] $current set -l cword (math (count $words) - 1) celilo --get-completions $words $cword 2>/dev/null end complete -c celilo -f -a '(__celilo_complete)' `; }