// Package complete provides shell completion generation for kportal. // It supports bash, zsh, and fish shells with context-aware completions // for flags, subcommands, config values, and Kubernetes resources. package complete import ( "fmt" "os" "path/filepath" "strings" ) // Shell represents a supported shell type type Shell string const ( ShellBash Shell = "bash" ShellZsh Shell = "zsh" ShellFish Shell = "fish" ) // Installer handles shell completion installation type Installer struct { shell Shell prefixDir string } // NewInstaller creates a new completion installer for the specified shell func NewInstaller(shell Shell) *Installer { return &Installer{ shell: shell, prefixDir: getDefaultPrefixDir(shell), } } // getDefaultPrefixDir returns the default completion directory for a shell func getDefaultPrefixDir(shell Shell) string { home, err := os.UserHomeDir() if err != nil { home = os.Getenv("HOME") } if home == "" { home = "/tmp" } switch shell { case ShellBash: // Check common bash completion directories dirs := []string{ "/etc/bash_completion.d", filepath.Join(home, ".local", "share", "bash-completion", "completions"), filepath.Join(home, ".bash_completion.d"), } for _, dir := range dirs { if pathExists(dir) { return dir } } // Fallback to user dir (best-effort create; Install reports write errors) userDir := filepath.Join(home, ".local", "share", "bash-completion", "completions") ensureDir(userDir) return userDir case ShellZsh: dirs := []string{ filepath.Join(home, ".zsh", "completions"), filepath.Join(home, ".oh-my-zsh", "completions"), } for _, dir := range dirs { if pathExists(dir) { return dir } } // Fallback to standard zsh site-functions usrShare := "/usr/local/share/zsh/site-functions" if pathExists(usrShare) { return usrShare } userDir := filepath.Join(home, ".zsh", "completions") ensureDir(userDir) return userDir case ShellFish: dir := filepath.Join(home, ".config", "fish", "completions") ensureDir(dir) return dir } return "" } // Install installs the completion script for the shell func (i *Installer) Install() error { script, err := Generate(i.shell) if err != nil { return fmt.Errorf("failed to generate completion script: %w", err) } filename := i.getCompletionFilename() filepath := filepath.Join(i.prefixDir, filename) // Check if already installed if pathExists(filepath) { return fmt.Errorf("completion file already exists: %s (remove it first to reinstall)", filepath) } // #nosec G306 -- completion scripts are non-secret and must be world-readable by the shell if err := os.WriteFile(filepath, []byte(script), 0o644); err != nil { return fmt.Errorf("failed to write completion file: %w", err) } return nil } // Uninstall removes the completion script func (i *Installer) Uninstall() error { filename := i.getCompletionFilename() filepath := filepath.Join(i.prefixDir, filename) if err := os.Remove(filepath); err != nil { return fmt.Errorf("failed to remove completion file: %w", err) } return nil } // getCompletionFilename returns the filename for the completion script func (i *Installer) getCompletionFilename() string { switch i.shell { case ShellBash: return "_kportal" case ShellZsh: return "_kportal" case ShellFish: return "kportal.fish" } return "_kportal" } // Print prints the completion script to stdout func Print(shell Shell) error { script, err := Generate(shell) if err != nil { return err } fmt.Print(script) return nil } // Generate generates the completion script for the specified shell func Generate(shell Shell) (string, error) { switch shell { case ShellBash: return generateBash() case ShellZsh: return generateZsh() case ShellFish: return generateFish() default: return "", fmt.Errorf("unsupported shell: %s", shell) } } // generateBash generates bash completion script func generateBash() (string, error) { var sb strings.Builder bashScript := `# kportal shell completion - bash # Generated by kportal # Don't interfere with other completions if [[ -n "${BASH_COMPLETION_VERSINFO:-}" ]]; then return fi _kportal() { local cur prev words cword split=false _init_completion -s || return # Complete the value expected after the previous flag case "$prev" in -c|--config) _filedir yaml return ;; --log-format) COMPREPLY=( $(compgen -W "text json" -- "$cur") ) return ;; --convert) _filedir json return ;; --convert-output) _filedir yaml return ;; --context) # Complete from kubectl contexts if command -v kubectl &> /dev/null; then COMPREPLY=( $(compgen -W "$(kubectl config get-contexts -o name 2>/dev/null)" -- "$cur") ) fi return ;; --shell) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ) return ;; esac # Handle option splitting (e.g., -c=value) [[ "$cur" == -*=* ]] && split=true [[ "$split" == true ]] && prev="${cur%%=*}" # Subcommand-specific completion case "${words[1]}" in generate) if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "--context --config --dry-run" -- "$cur") ) elif command -v kubectl &> /dev/null; then COMPREPLY=( $(compgen -W "$(kubectl config get-contexts -o name 2>/dev/null)" -- "$cur") ) fi return ;; completion) COMPREPLY=( $(compgen -W "--install --uninstall --shell" -- "$cur") ) return ;; esac # Top-level options if [[ "$cur" == -* ]]; then COMPREPLY=( $(compgen -W "-c -v --check --headless --log-format --version --update --convert --convert-output" -- "$cur") ) return fi # Top-level subcommands COMPREPLY=( $(compgen -W "generate completion" -- "$cur") ) } # Register completion complete -F _kportal kportal # Also complete for common aliases complete -F _kportal kp ` sb.WriteString(bashScript) return sb.String(), nil } // generateZsh generates zsh completion script func generateZsh() (string, error) { var sb strings.Builder // Per-flag zsh _arguments descriptors. Completion scripts are static text, so // these are literals (single source of truth — keep in sync with the CLI flags). flagDescs := []string{ `'-c[Path to configuration file]:config file:_files -g "*.yaml"'`, `'-v[Enable verbose logging]'`, `'--version[Show version and exit]'`, `'--update[Check for updates]'`, `'--check[Validate configuration]'`, `'--headless[Run without UI]'`, `'--log-format[Log format: text or json]:format:(text json)'`, `'--convert[Convert kftray config]:input file:_files -g "*.json"'`, `'--convert-output[Output file]:output file:_files -g "*.yaml"'`, } sb.WriteString(`#compdef kportal # kportal shell completion - zsh # Generated by kportal _kportal() { local -a commands flags generate_flags completion_flags commands=( 'generate:Interactively generate forwards from cluster' 'completion:Generate shell completion scripts' ) flags=( `) for _, desc := range flagDescs { fmt.Fprintf(&sb, " %s\n", desc) } sb.WriteString(` ) generate_flags=( '--context[Kubernetes context]:context:->ctx' '--config[Config file]:file:_files -g "*.yaml"' '--dry-run[Print without saving]' ) completion_flags=( '--install[Install completions for the shell]' '--uninstall[Remove installed completions]' '--shell[Shell type]:shell:(bash zsh fish)' ) _arguments -s $flags '1: :->command' '*:: :->args' case $state in command) _describe 'command' commands ;; args) case ${words[1]} in generate) _arguments -s $generate_flags if [[ "$words[CURRENT]" == --context ]]; then if command -v kubectl &> /dev/null; then local -a contexts contexts=(${(f)"$(kubectl config get-contexts -o name 2>/dev/null)"}) _describe 'context' contexts fi fi ;; completion) _arguments -s $completion_flags ;; esac ;; esac } _kportal "$@" `) return sb.String(), nil } // generateFish generates fish completion script func generateFish() (string, error) { var sb strings.Builder sb.WriteString(`# kportal shell completion - fish # Generated by kportal # Main completion complete -c kportal -f # Subcommands complete -c kportal -n '__fish_use_subcommand' -a 'generate' -d 'Interactively generate forwards from cluster' complete -c kportal -n '__fish_use_subcommand' -a 'completion' -d 'Generate shell completion scripts' # Global flags (main command uses single-dash -c and -v; words accept --) complete -c kportal -s c -r -f -a '( __fish_complete_suffix .yaml )' -d 'Path to configuration file' complete -c kportal -s v -d 'Enable verbose logging' complete -c kportal -l version -d 'Show version and exit' complete -c kportal -l update -d 'Check for updates' complete -c kportal -l check -d 'Validate configuration' complete -c kportal -l headless -d 'Run without UI' complete -c kportal -l log-format -d 'Log format' -a 'text json' -f complete -c kportal -l convert -r -f -a '( __fish_complete_suffix .json )' -d 'Convert kftray config' complete -c kportal -l convert-output -r -f -a '( __fish_complete_suffix .yaml )' -d 'Output file' # generate subcommand flags complete -c kportal -n '__fish_seen_subcommand_from generate' -l context -d 'Kubernetes context' -a '(kubectl config get-contexts -o name 2>/dev/null)' -f complete -c kportal -n '__fish_seen_subcommand_from generate' -l config -r -f -a '( __fish_complete_suffix .yaml )' -d 'Config file' complete -c kportal -n '__fish_seen_subcommand_from generate' -l dry-run -d 'Print without saving' # completion subcommand flags complete -c kportal -n '__fish_seen_subcommand_from completion' -l install -d 'Install completions for the shell' complete -c kportal -n '__fish_seen_subcommand_from completion' -l uninstall -d 'Remove installed completions' complete -c kportal -n '__fish_seen_subcommand_from completion' -l shell -d 'Shell type' -a 'bash zsh fish' -f # Aliases complete -c kp -f `) return sb.String(), nil } // InstallCompletions installs completions for the specified shell // Prints instructions for manual installation if auto-install fails func InstallCompletions(shell Shell) error { installer := NewInstaller(shell) if err := installer.Install(); err != nil { // Check if it's just "already exists" error if strings.Contains(err.Error(), "already exists") { fmt.Printf("Completion already installed at: %s/%s\n", installer.prefixDir, installer.getCompletionFilename()) fmt.Println("Remove it first to reinstall, or source it manually:") return nil } // Try to print the script instead fmt.Println("Could not auto-install completions. To install manually, run:") fmt.Printf(" # %s\n", getInstallInstructions(shell)) return nil } fmt.Printf("✅ Completions installed to: %s/%s\n", installer.prefixDir, installer.getCompletionFilename()) fmt.Printf("\nTo enable %s completions, restart your shell or run:\n", shell) fmt.Printf(" source ~/%s\n", getSourceInstruction(shell)) return nil } func getInstallInstructions(shell Shell) string { return fmt.Sprintf("kportal completion %s > /_kportal", shell) } func getSourceInstruction(shell Shell) string { switch shell { case ShellBash: return ".bashrc" case ShellZsh: return ".zshrc" case ShellFish: return ".config/fish/config.fish # completions load automatically" } return "shell config" } // AutoDetectShell detects the current shell func AutoDetectShell() Shell { shell := os.Getenv("SHELL") if shell == "" { return ShellBash // Default } if strings.HasSuffix(shell, "/bash") { return ShellBash } if strings.HasSuffix(shell, "/zsh") { return ShellZsh } if strings.HasSuffix(shell, "/fish") { return ShellFish } return ShellBash } // pathExists reports whether path exists on disk. // #nosec G703 -- callers pass fixed completion paths derived from the user's own // HOME and constant subdirectory names, never external/untrusted input. func pathExists(path string) bool { _, err := os.Stat(path) return err == nil } // ensureDir best-effort creates a completion directory. Errors are intentionally // ignored: Install() surfaces any later write failure with a clear message. // #nosec G703 -- path is derived from the user's own HOME and constant // subdirectory names; 0o750 is appropriate for user-owned completion dirs. func ensureDir(path string) { _ = os.MkdirAll(path, 0o750) }