4 Commits

Author SHA1 Message Date
Sebastian Unterschütz cdf750081e feat: show subcommands after hostnames in shell completion
Release / release (push) Successful in 48s
Use ShellCompDirectiveKeepOrder so fish/zsh/bash preserve the returned
order instead of sorting alphabetically. ValidArgsFunction now appends
subcommand names (with their short descriptions) after all hostname
entries, so hosts always appear first in the completion list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:42:41 +02:00
Sebastian Unterschütz 574c4dbf58 fix: completion install writes non-empty scripts
Release / release (push) Successful in 47s
GenBashCompletionV2/GenZshCompletion/GenFishCompletion write into the
buffer as a side effect; capturing buf.String() in the return statement
before the Gen* call runs means the buffer is always empty. Separate
the call from the return to fix evaluation order.

Also call InitDefaultCompletionCmd() before iterating root.Commands()
so the lazily-initialized completion subtree is visible before Execute().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:50:53 +02:00
Sebastian Unterschütz 8fc7896b35 docs: update README for wizard, token versions, completion install
Release / release (push) Successful in 46s
2026-05-23 13:31:08 +02:00
Sebastian Unterschütz da3a280a43 feat: netssh completion install — user-space shell completion setup
Adds `netssh completion install` subcommand:
- Auto-detects shell from $SHELL, override with --shell bash|zsh|fish
- bash  → ~/.local/share/bash-completion/completions/netssh
- zsh   → ~/.zfunc/_netssh  (prints fpath hint)
- fish  → ~/.config/fish/completions/netssh.fish
- No sudo required

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:28:43 +02:00
2 changed files with 143 additions and 32 deletions
+53 -26
View File
@@ -17,7 +17,8 @@ netssh -p 2222 admin@app-server-03 uptime
- **Flexible IP resolution** — configurable chain of strategies: management subnet, primary IP, or named interface
- **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments)
- **Persistent cache** — successful lookups are cached to `~/.cache/netssh/hosts.json` for instant shell completion
- **Shell completion** — tab-complete hostnames from the cache in zsh, bash, and fish
- **Setup wizard** — interactive first-run onboarding; re-run anytime with `netssh configure`
- **Shell completion** — install without sudo via `netssh completion install`
- **Default SSH user** — set a fallback username once in config instead of typing it every time
## Installation
@@ -46,12 +47,27 @@ go build -o netssh ./cmd/netssh
## Configuration
Create `~/.config/netssh.yaml`:
### Interactive wizard
On first run (when no config exists), `netssh` automatically starts an interactive setup wizard.
Re-run it at any time to change settings without editing the file manually:
```sh
netssh configure
```
The wizard walks through NetBox connection, SSH defaults, resolver strategies, and cache TTL,
then saves to `~/.config/netssh.yaml`.
### Manual config
`~/.config/netssh.yaml`:
```yaml
netbox:
url: https://netbox.example.com
token: your-api-token-here
token: nbt_your-api-token-here # v2 token (nbt_ prefix) recommended
token_version: 2 # auto-detected from token; 1 = legacy, 2 = nbt_
resolver:
# Strategies are tried in order; the first to return an IP wins.
@@ -73,7 +89,19 @@ ssh:
default_user: admin # used when no user is specified on the command line
```
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.) or will be read from the config file.
Any value can be overridden with environment variables (`NETSSH_NETBOX_URL`, `NETSSH_NETBOX_TOKEN`, etc.).
### API tokens
NetBox supports two token formats:
| Format | Example | Notes |
|--------|---------|-------|
| v2 (recommended) | `nbt_abc123…` | Create in NetBox → Admin → API Tokens |
| v1 (legacy) | `abc123def456…` | Older format; still works, but v2 is preferred |
`netssh` auto-detects the version from the token prefix and stores it as `token_version` in the config.
A hint is shown during `netssh configure` if a legacy v1 token is entered.
## Usage
@@ -146,28 +174,26 @@ Strategies are tried in the configured order; the first to succeed wins.
## Shell Completion
### zsh
Install completion for the current user (no sudo required):
```sh
netssh completion zsh > "${fpath[1]}/_netssh"
netssh completion install # auto-detects $SHELL
netssh completion install --shell bash
netssh completion install --shell zsh
netssh completion install --shell fish
```
Or add to `.zshrc`:
| Shell | Install path |
|-------|-------------|
| bash | `~/.local/share/bash-completion/completions/netssh` |
| zsh | `~/.zfunc/_netssh` |
| fish | `~/.config/fish/completions/netssh.fish` |
For zsh, make sure `~/.zfunc` is in your `fpath` (add to `~/.zshrc`):
```zsh
source <(netssh completion zsh)
```
### bash
```sh
netssh completion bash > /etc/bash_completion.d/netssh
```
### fish
```sh
netssh completion fish > ~/.config/fish/completions/netssh.fish
fpath=(~/.zfunc $fpath)
autoload -Uz compinit && compinit
```
Completions are served from the local cache — no network request on every `<Tab>`.
@@ -179,12 +205,13 @@ go test ./... # run all tests
go build ./... # build all packages
```
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, and SSH argument parser.
The test suite covers the cache, NetBox client (via `httptest`), IP resolver chain, SSH argument parser, config loading, and the setup wizard.
## How it works
1. `netssh` checks whether the first argument is a known subcommand (`search`, `cache`, `completion`). If not, it enters SSH wrapper mode.
2. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …).
3. It checks the local cache. If the entry exists and is within the TTL, it connects immediately.
4. Otherwise it queries NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/` in parallel), runs the result through the resolver chain, and caches the IP.
5. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP.
1. `netssh` checks whether the first argument is a known subcommand (`configure`, `search`, `cache`, `completion`). If not, it enters SSH wrapper mode.
2. On first run or when `netbox.url` is empty, the interactive setup wizard starts automatically.
3. It parses the SSH arguments to extract the destination hostname, handling all flags that consume an extra argument (`-p`, `-i`, `-J`, …).
4. It checks the local cache. If the entry exists and is within the TTL, it connects immediately.
5. Otherwise it queries NetBox (`/api/dcim/devices/` and `/api/virtualization/virtual-machines/` in parallel), runs the result through the resolver chain, and caches the IP.
6. It calls `syscall.Exec` to replace itself with `ssh`, substituting the hostname with the resolved IP.
+90 -6
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"text/tabwriter"
@@ -198,20 +199,103 @@ func rootCmd() *cobra.Command {
}
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
_ = c.Load()
entries := c.Search(toComplete)
names := make([]cobra.Completion, len(entries))
for i, e := range entries {
names[i] = cobra.Completion(e.Name)
var completions []cobra.Completion
for _, e := range c.Search(toComplete) {
completions = append(completions, cobra.Completion(e.Name))
}
return names, cobra.ShellCompDirectiveNoFileComp
// Subcommands at the end, after all hostnames.
for _, sub := range cmd.Commands() {
if sub.IsAvailableCommand() && strings.HasPrefix(sub.Name(), toComplete) {
completions = append(completions, cobra.Completion(sub.Name()+"\t"+sub.Short))
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveKeepOrder
},
}
// cobra automatically adds a "completion" subcommand
root.AddCommand(configureCmd(), searchCmd(), cacheCmd())
// cobra builds the "completion" command lazily; force init so we can extend it.
root.InitDefaultCompletionCmd()
for _, cmd := range root.Commands() {
if cmd.Name() == "completion" {
cmd.AddCommand(completionInstallCmd(root))
break
}
}
return root
}
func completionInstallCmd(root *cobra.Command) *cobra.Command {
var shell string
cmd := &cobra.Command{
Use: "install",
Short: "Install shell completion for the current user (no sudo required)",
RunE: func(cmd *cobra.Command, args []string) error {
if shell == "" {
shell = filepath.Base(os.Getenv("SHELL"))
}
var (
dir string
file string
gen func() ([]byte, error)
note string
)
switch shell {
case "bash":
dir = filepath.Join(os.Getenv("HOME"), ".local", "share", "bash-completion", "completions")
file = filepath.Join(dir, "netssh")
gen = func() ([]byte, error) {
var buf strings.Builder
err := root.GenBashCompletionV2(&buf, true)
return []byte(buf.String()), err
}
note = "Reload your shell or run: source " + file
case "zsh":
dir = filepath.Join(os.Getenv("HOME"), ".zfunc")
file = filepath.Join(dir, "_netssh")
gen = func() ([]byte, error) {
var buf strings.Builder
err := root.GenZshCompletion(&buf)
return []byte(buf.String()), err
}
note = "Make sure ~/.zfunc is in your fpath:\n fpath=(~/.zfunc $fpath)\n autoload -Uz compinit && compinit"
case "fish":
configDir, _ := os.UserConfigDir()
dir = filepath.Join(configDir, "fish", "completions")
file = filepath.Join(dir, "netssh.fish")
gen = func() ([]byte, error) {
var buf strings.Builder
err := root.GenFishCompletion(&buf, true)
return []byte(buf.String()), err
}
note = "Reload your shell or start a new fish session."
default:
return fmt.Errorf("unsupported shell %q — use --shell bash|zsh|fish", shell)
}
script, err := gen()
if err != nil {
return fmt.Errorf("generating completion: %w", err)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("creating %s: %w", dir, err)
}
if err := os.WriteFile(file, script, 0o644); err != nil {
return fmt.Errorf("writing %s: %w", file, err)
}
fmt.Printf("Completion installed → %s\n%s\n", file, note)
return nil
},
}
cmd.Flags().StringVar(&shell, "shell", "", "Shell to install for (default: $SHELL). Supported: bash, zsh, fish")
return cmd
}
func configureCmd() *cobra.Command {
return &cobra.Command{
Use: "configure",