From 34503de68e9b7262dcfdea3ac1746ea33faabdd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Tue, 27 Oct 2026 19:19:42 +0100 Subject: [PATCH] Enable shell completion for configured command aliases --- CHANGELOG.md | 5 + README.md | 8 ++ cmd/netssh/main.go | 10 ++ cmd/netssh/notes_test.go | 1 + internal/hook/aliases.go | 9 +- internal/hook/completion.go | 116 +++++++++++++++++++++ internal/hook/completion_test.go | 171 +++++++++++++++++++++++++++++++ 7 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 internal/hook/completion.go create mode 100644 internal/hook/completion_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 62d8f52..11b09c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.0.16 + +- Enable Bash, Zsh, and Fish completion for the configured command alias, such as `j`. +- Update or remove generated alias completion files when the command alias changes. + ## v0.0.15 - Use `https://git.zb-server.de` for release download links and API requests instead of the runner's internal Gitea service URL. diff --git a/README.md b/README.md index ad3b4f6..a8bdedd 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,14 @@ it for new shells. The command alias takes priority over a host alias with the same name and works even when no servers are cached. Environment override: `NETSSH_SHELL_ALIAS=j`. +The command alias also supports shell completion: `j ser` suggests cached +server names, just like `netssh ser`. The wizard, shell hook, installer, and +`netssh completion install` maintain the alias completion files automatically. +Changing or disabling the alias removes its old generated completion files. +Bash requires bash-completion to load user completion files; for Zsh, include +`~/.zfunc` in `fpath` before running `compinit` as described below. Fish loads +completion files automatically. Open a new terminal after changing the alias. + ### API tokens NetBox supports two token formats: diff --git a/cmd/netssh/main.go b/cmd/netssh/main.go index 1b3ada5..c142d18 100644 --- a/cmd/netssh/main.go +++ b/cmd/netssh/main.go @@ -388,7 +388,17 @@ func completionInstallCmd(root *cobra.Command) *cobra.Command { if err := os.WriteFile(file, script, 0o644); err != nil { return fmt.Errorf("writing %s: %w", file, err) } + cfg, err := config.Load() + if err != nil { + return err + } + if err := hook.SyncCommandCompletion(cfg.Shell.Alias); err != nil { + return err + } fmt.Printf("Completion installed → %s\n%s\n", file, note) + if cfg.Shell.Alias != "" { + fmt.Printf("Completion also enabled for alias %q.\n", cfg.Shell.Alias) + } return nil }, } diff --git a/cmd/netssh/notes_test.go b/cmd/netssh/notes_test.go index f6cecfb..6768d38 100644 --- a/cmd/netssh/notes_test.go +++ b/cmd/netssh/notes_test.go @@ -42,6 +42,7 @@ func TestLocalNotesCLI(t *testing.T) { } func TestShellInitUpdatesAndRemovesCommandAliasWithoutHosts(t *testing.T) { + t.Setenv("HOME", t.TempDir()) t.Setenv("XDG_CONFIG_HOME", t.TempDir()) t.Setenv("XDG_CACHE_HOME", t.TempDir()) t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json")) diff --git a/internal/hook/aliases.go b/internal/hook/aliases.go index 22c605b..738c007 100644 --- a/internal/hook/aliases.go +++ b/internal/hook/aliases.go @@ -49,7 +49,14 @@ func WriteAliasFiles(entries []AliasEntry) error { return err } writeFishAliasFile(entries) // best-effort — never blocks the caller on failure - return nil + alias := "" + for _, entry := range entries { + if entry.Host == "" { + alias = entry.Name + break + } + } + return SyncCommandCompletion(alias) } func writeShAliasFile(entries []AliasEntry) error { diff --git a/internal/hook/completion.go b/internal/hook/completion.go new file mode 100644 index 0000000..1f42980 --- /dev/null +++ b/internal/hook/completion.go @@ -0,0 +1,116 @@ +package hook + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage" +) + +const completionMarker = "# netssh command alias completion — generated automatically" + +// SyncCommandCompletion installs lazy loaders for the configured command alias. +// The loaders obtain the canonical completion from the current netssh binary, +// so aliases configured by the wizard work without a separate completion install. +func SyncCommandCompletion(alias string) error { + if err := config.ValidateCommandAlias(alias); err != nil { + return err + } + statePath := filepath.Join(filepath.Dir(AliasesPath()), "completion_alias") + data, err := os.ReadFile(statePath) + if err != nil && !os.IsNotExist(err) { + return err + } + previous := strings.TrimSpace(string(data)) + if err := config.ValidateCommandAlias(previous); err != nil { + return fmt.Errorf("invalid completion alias state: %w", err) + } + if alias == "" && previous == "" { + return nil + } + + for _, shell := range []string{"bash", "zsh", "fish"} { + if previous != "" && previous != alias { + path := commandCompletionPath(shell, previous) + data, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + // Only delete files owned by netssh; preserve any user replacements. + if strings.HasPrefix(string(data), completionMarker+"\n") || strings.HasPrefix(string(data), "#compdef "+previous+"\n"+completionMarker+"\n") { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + } + } + if alias == "" { + continue + } + if shell == "fish" { + if _, err := os.Stat(FishConfigDir()); os.IsNotExist(err) { + continue + } else if err != nil { + return err + } + } + path := commandCompletionPath(shell, alias) + script := commandCompletionScript(shell, alias) + existing, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return err + } + if string(existing) == script { + continue + } + if err := storage.WriteFile(path, []byte(script), 0o644); err != nil { + return fmt.Errorf("writing %s alias completion: %w", shell, err) + } + } + if alias == "" { + if err := os.Remove(statePath); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + return storage.WriteFile(statePath, []byte(alias+"\n"), 0o600) +} + +func commandCompletionPath(shell, alias string) string { + switch shell { + case "bash": + return filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", alias) + case "zsh": + return filepath.Join(os.Getenv("HOME"), ".zfunc", "_"+alias) + default: + return filepath.Join(FishConfigDir(), "completions", alias+".fish") + } +} + +func commandCompletionScript(shell, alias string) string { + switch shell { + case "bash": + return fmt.Sprintf(`%s +source <(netssh completion bash) +if [[ $(type -t compopt) == builtin ]]; then + complete -o default -F __start_netssh %s +else + complete -o default -o nospace -F __start_netssh %s +fi +`, completionMarker, alias, alias) + case "zsh": + return fmt.Sprintf(`#compdef %s +%s +source <(netssh completion zsh) +compdef _netssh %s +_netssh "$@" +`, alias, completionMarker, alias) + default: + return fmt.Sprintf(`%s +netssh completion fish | source +complete --command %s --wraps netssh +`, completionMarker, alias) + } +} diff --git a/internal/hook/completion_test.go b/internal/hook/completion_test.go new file mode 100644 index 0000000..215175b --- /dev/null +++ b/internal/hook/completion_test.go @@ -0,0 +1,171 @@ +package hook_test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook" + "github.com/spf13/cobra" +) + +func completionEnvironment(t *testing.T) map[string]string { + t.Helper() + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache")) + if err := os.MkdirAll(hook.FishConfigDir(), 0o755); err != nil { + t.Fatal(err) + } + return map[string]string{ + "bash": filepath.Join(home, ".local", "share", "bash-completion", "completions", "j"), + "zsh": filepath.Join(home, ".zfunc", "_j"), + "fish": filepath.Join(hook.FishConfigDir(), "completions", "j.fish"), + } +} + +func TestCommandCompletionTracksAliasChanges(t *testing.T) { + paths := completionEnvironment(t) + if err := hook.WriteAliasFiles([]hook.AliasEntry{{Name: "j"}}); err != nil { + t.Fatal(err) + } + for _, path := range paths { + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } + } + // A manually replaced completion file must survive removal of the old alias. + custom := "# user-owned completion\n" + if err := os.WriteFile(paths["bash"], []byte(custom), 0o644); err != nil { + t.Fatal(err) + } + if err := hook.WriteAliasFiles([]hook.AliasEntry{{Name: "k"}}); err != nil { + t.Fatal(err) + } + for shell, path := range paths { + if shell == "bash" { + data, err := os.ReadFile(path) + if err != nil || string(data) != custom { + t.Fatal("user completion was removed") + } + } else if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("stale %s completion remains: %v", shell, err) + } + } + if err := hook.WriteAliasFiles(nil); err != nil { + t.Fatal(err) + } + for _, path := range []string{ + filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions", "k"), + filepath.Join(os.Getenv("HOME"), ".zfunc", "_k"), + filepath.Join(hook.FishConfigDir(), "completions", "k.fish"), + } { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("disabled completion remains: %s: %v", path, err) + } + } +} + +func TestCommandAliasCompletesServerNamesAcrossShells(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "fish"} { + t.Run(shell, func(t *testing.T) { + executable, err := exec.LookPath(shell) + if err != nil { + t.Skipf("%s unavailable", shell) + } + paths := completionEnvironment(t) + if err := hook.SyncCommandCompletion("j"); err != nil { + t.Fatal(err) + } + fixtures := t.TempDir() + root := &cobra.Command{Use: "netssh"} + var canonical bytes.Buffer + switch shell { + case "bash": + err = root.GenBashCompletionV2(&canonical, true) + case "zsh": + err = root.GenZshCompletion(&canonical) + case "fish": + err = root.GenFishCompletion(&canonical, true) + } + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fixtures, shell), canonical.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + // Exercise Cobra's actual generated shell code with a deterministic backend. + backend := `#!/bin/bash +case "$1" in + completion) cat "$COMPLETION_FIXTURES/$2" ;; + __complete|__completeNoDesc) printf 'server01\n:4\n' ;; + *) exit 1 ;; +esac +` + if err := os.WriteFile(filepath.Join(fixtures, "netssh"), []byte(backend), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("COMPLETION_FIXTURES", fixtures) + t.Setenv("ALIAS_COMPLETION", paths[shell]) + t.Setenv("PATH", fixtures+string(os.PathListSeparator)+os.Getenv("PATH")) + var script string + var args []string + switch shell { + case "bash": + library := "" + for _, path := range []string{"/usr/share/bash-completion/bash_completion", "/etc/bash_completion"} { + if _, err := os.Stat(path); err == nil { + library = path + break + } + } + if library == "" { + t.Skip("bash-completion unavailable") + } + t.Setenv("BASH_COMPLETION_LIBRARY", library) + args = []string{"--noprofile", "--norc", "-c"} + script = `source "$BASH_COMPLETION_LIBRARY" +shopt -s expand_aliases +alias j=netssh +_completion_loader j +COMP_WORDS=(j ser) +COMP_CWORD=1 +COMP_LINE='j ser' +COMP_POINT=5 +COMP_TYPE=9 +COMP_KEY=9 +__start_netssh j ser j +printf '%s\n' "${COMPREPLY[@]}" +` + case "zsh": + args = []string{"-f", "-c"} + script = `fpath=("$HOME/.zfunc" $fpath) +autoload -Uz compinit +compinit -u -D +alias j=netssh +autoload -Uz _j +# Capture matches without requiring an interactive ZLE completion widget. +_describe() { print -rl -- "${completions[@]}"; } +words=(j ser) +CURRENT=2 +_j +` + case "fish": + args = []string{"--no-config", "-c"} + script = `alias j netssh +set -p fish_complete_path "$XDG_CONFIG_HOME/fish/completions" +complete --do-complete 'j ser' +` + } + cmd := exec.Command(executable, append(args, script)...) + out, err := cmd.CombinedOutput() + if err != nil || !strings.Contains(string(out), "server01") { + t.Fatalf("%s completion: %v\n%s", shell, err, out) + } + }) + } +}