Files
kportal/internal/complete/completion.go
T
2026-06-21 12:59:55 +01:00

459 lines
13 KiB
Go

// 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 > <your-completion-dir>/_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)
}