package main import ( "context" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "text/tabwriter" "time" tea "charm.land/bubbletea/v2" "github.com/spf13/cobra" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup" internalssh "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/ssh" "git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/tui" ) // managedSubcommands are dispatched to cobra; everything else is treated as SSH wrapper mode. var managedSubcommands = map[string]bool{ "configure": true, "notes": true, "search": true, "cache": true, "alias": true, "hook": true, "shell-init": true, "completion": true, "__complete": true, "help": true, "version": true, "uninstall": true, } func main() { args := os.Args[1:] if len(args) == 0 { runTUI() return } if args[0] == "--version" { fmt.Printf("netssh %s\n", buildVersion()) return } if managedSubcommands[args[0]] { if err := rootCmd().Execute(); err != nil { os.Exit(1) } return } runSSHWrapper(args) } // loadConfigOrSetup loads the config and runs the setup wizard if NetBox is not configured. func loadConfigOrSetup() *config.Config { cfg, err := config.Load() if err != nil { fatalf("config: %v", err) } if !cfg.HasNetBox() { fmt.Fprintln(os.Stderr, "No configuration found. Starting setup…") if err := setup.RunWizard(cfg); err != nil { fatalf("setup: %v", err) } cfg, err = config.Load() if err != nil { fatalf("config: %v", err) } } return cfg } // runSSHWrapper resolves the target hostname via NetBox and execs the native ssh binary. func runSSHWrapper(args []string) { flags, args, err := internalssh.ExtractWrapperFlags(args) if err != nil { fatalf("%v", err) } cfg := loadConfigOrSetup() parsed := internalssh.Parse(args) if parsed == nil { // No destination found — pass arguments straight through to ssh. execSSH(internalssh.Result{Args: args}, flags) return } connect := func(name, kind, ip string, tags []string) { connectHost(cfg, flags, name, kind, ip, tags, parsed.DestIdx, args) } // Inject the configured default user if none was given on the command line. if cfg.SSH.DefaultUser != "" && parsed.User == "" && !internalssh.HasUserFlag(args) { args = append([]string{"-l", cfg.SSH.DefaultUser}, args...) parsed.DestIdx += 2 } // Inject the configured default port if none was given on the command line. if cfg.SSH.DefaultPort > 0 && !internalssh.HasPortFlag(args) { args = append([]string{"-p", strconv.Itoa(cfg.SSH.DefaultPort)}, args...) parsed.DestIdx += 2 } c := cache.New(cfg.CachePath(), cfg.Cache.TTL) _ = c.Load() maybeBackgroundRefresh(cfg, c) source, _ := netbox.SplitTarget(parsed.Host) useCache := len(cfg.NetBoxes) == 0 || source != "" // Cache hit with a fresh TTL — connect directly without querying NetBox. if entry, fresh := c.Get(parsed.Host); fresh && useCache { c.MarkUsed(parsed.Host) _ = c.Save() connect(entry.Name, entry.Kind, entry.IP, entry.Tags) return } // Shortcut cache lookup: scan entries whose normalized name matches the input. // If a stale match is found, use the canonical name for the NetBox re-fetch below. lookupHost := parsed.Host shortcutsEnabled := cfg.Shortcuts.Enabled() if cached, _ := c.Get(lookupHost); shortcutsEnabled && cached.Name == "" && useCache { normalize := normalizeTarget(cfg.Shortcuts, source != "") entry, found, fresh, err := c.GetByShortcut(lookupHost, normalize) if err != nil { fatalf("%v", err) } if found { if fresh { c.MarkUsed(entry.Name) _ = c.Save() connect(entry.Name, entry.Kind, entry.IP, entry.Tags) return } // Stale shortcut match — use canonical name for NetBox re-fetch. lookupHost = entry.Name } } if !cfg.HasNetBox() { fatalf("netbox.url is not configured (~/.config/netssh.yaml)") } nbClient := newNetBoxClient(cfg) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() target, err := findSSHHost(ctx, nbClient, lookupHost, cfg.Shortcuts.Domains) if errors.Is(err, netbox.ErrHostNotFound) && !useCache && shortcutsEnabled { entry, found, _, cacheErr := c.GetByShortcut(parsed.Host, normalizeTarget(cfg.Shortcuts, false)) if cacheErr != nil { fatalf("%v", cacheErr) } if found { target, err = findSSHHost(ctx, nbClient, entry.Name, cfg.Shortcuts.Domains) } } if err != nil { fatalf("NetBox: %v", err) } if cached, fresh := c.Get(target.Target()); fresh && cached.Kind == target.Kind { c.MarkUsed(target.Target()) _ = c.Save() connect(cached.Name, cached.Kind, cached.IP, cached.Tags) return } chain, err := resolver.New(cfg.Resolver) if err != nil { fatalf("resolver: %v", err) } ip, err := chain.Resolve(ctx, &target, nbClient) if err != nil { fatalf("IP resolution for %q: %v", parsed.Host, err) } c.Upsert(cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags}) c.MarkUsed(target.Target()) _ = c.Save() connect(target.Target(), target.Kind, ip, target.Tags) } // findSSHHost uses the paginated, ambiguity-checking lookup for connection // targets. Only a genuine missing host permits trying configured suffixes. func findSSHHost(ctx context.Context, client *netbox.Client, name string, domains []string) (netbox.HostEntry, error) { target, err := client.FindHost(ctx, name, "") if !errors.Is(err, netbox.ErrHostNotFound) || strings.Contains(name, ".") { return target, err } for _, domain := range domains { domain = strings.TrimPrefix(strings.TrimSpace(domain), ".") if domain == "" { continue } target, err = client.FindHost(ctx, name+"."+domain, "") if !errors.Is(err, netbox.ErrHostNotFound) { return target, err } } return netbox.HostEntry{}, fmt.Errorf("%w: %q", netbox.ErrHostNotFound, name) } // connectHost applies the SSH options configured for the host and execs ssh. // target is the cache/selector name, which may carry an instance prefix. func connectHost(cfg *config.Config, flags internalssh.WrapperFlags, target, kind, ip string, tags []string, destIdx int, args []string) { instance, name := netbox.SplitTarget(target) opts := cfg.SSHOptionsFor(config.SSHHost{Instance: instance, Name: name, Kind: kind, Tags: tags}) execSSH(internalssh.Apply(args, destIdx, internalssh.Host{Name: name, IP: ip}, opts, flags), flags) } // execSSH replaces the process with ssh, or prints the command for --dry-run. func execSSH(result internalssh.Result, flags internalssh.WrapperFlags) { if flags.DryRun { fmt.Println(internalssh.Quote(append([]string{"ssh"}, result.Args...))) return } if result.Notice != "" { fmt.Fprintf(os.Stderr, "netssh: %s\n", result.Notice) } if err := internalssh.Exec(result.Args); err != nil { fatalf("%v", err) } } // runTUI starts the interactive host search. func runTUI() { cfg := loadConfigOrSetup() c := cache.New(cfg.CachePath(), cfg.Cache.TTL) _ = c.Load() maybeBackgroundRefresh(cfg, c) var nbClient *netbox.Client if cfg.HasNetBox() { nbClient = newNetBoxClient(cfg) } m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort) m.SetNotes(notes.New(cfg.NetBox.URL, nbClient)) p := tea.NewProgram(m) final, err := p.Run() if err != nil { fatalf("TUI: %v", err) } tuiModel, ok := final.(*tui.Model) if !ok { return } host := tuiModel.Selected() if host == nil { return } // Revalidate stale or live TUI results through the configured resolver chain. var tags []string if cached, fresh := c.Get(host.Name); fresh && cached.Kind == host.Kind { host.IP = cached.IP tags = cached.Tags } else { if nbClient == nil { fatalf("cannot resolve %q: NetBox is not configured", host.Name) } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() target, err := nbClient.FindHost(ctx, host.Name, host.Kind) if err != nil { fatalf("NetBox: %v", err) } chain, err := resolver.New(cfg.Resolver) if err != nil { fatalf("resolver: %v", err) } host.IP, err = chain.Resolve(ctx, &target, nbClient) if err != nil { fatalf("IP resolution: %v", err) } c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags}) tags = target.Tags } fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP) var sshArgs []string // User override from TUI edit mode takes priority, then config default. user := host.User if user == "" { user = cfg.SSH.DefaultUser } if user != "" { sshArgs = append(sshArgs, "-l", user) } port := host.Port if port == "" && cfg.SSH.DefaultPort > 0 { port = strconv.Itoa(cfg.SSH.DefaultPort) } if port != "" { sshArgs = append(sshArgs, "-p", port) } sshArgs = append(sshArgs, host.IP) c.MarkUsed(host.Name) _ = c.Save() connectHost(cfg, internalssh.WrapperFlags{}, host.Name, host.Kind, host.IP, tags, len(sshArgs)-1, sshArgs) } // --- cobra root + subcommands --- func rootCmd() *cobra.Command { root := &cobra.Command{ Use: "netssh", Short: "SSH wrapper with NetBox hostname resolution", DisableFlagParsing: true, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) { cfg, err := config.Load() if err != nil { return nil, cobra.ShellCompDirectiveError } c := cache.New(cfg.CachePath(), cfg.Cache.TTL) _ = c.Load() hosts := hostCompletions(c.All(), toComplete, cfg.Shortcuts) if limit := cfg.Completion.MaxResults; limit > 0 && len(hosts) > limit { hosts = hosts[:limit] } completions := make([]cobra.Completion, 0, len(hosts)) for _, host := range hosts { completions = append(completions, cobra.Completion(host)) } // 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 }, } root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd()) // 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)) // Cobra's zsh script spawns a subshell per line and sends every host // through _describe, which hangs with thousands of cached hosts. for _, sub := range cmd.Commands() { if sub.Name() == "zsh" { sub.RunE = func(cmd *cobra.Command, args []string) error { _, err := io.WriteString(cmd.OutOrStdout(), hook.ZshCompletionScript) return err } sub.Run = nil } } 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) { return []byte(hook.ZshCompletionScript), nil } note = hook.ZshCompletionNote(dir) case "fish": dir = filepath.Join(hook.FishConfigDir(), "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) } 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 }, } 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", Short: "Interactively configure netssh", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return err } return setup.RunWizard(cfg) }, } } func searchCmd() *cobra.Command { return &cobra.Command{ Use: "search ", Short: "Print hostnames from the cache (used for shell completion)", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return err } c := cache.New(cfg.CachePath(), cfg.Cache.TTL) if err := c.Load(); err != nil { return err } q := "" if len(args) > 0 { q = args[0] } for _, e := range c.Search(q) { fmt.Println(e.Name) } return nil }, } } func cacheCmd() *cobra.Command { cmd := &cobra.Command{ Use: "cache", Short: "Manage the local host cache", } cmd.AddCommand(cacheListCmd(), cacheClearCmd(), cacheRefreshCmd()) return cmd } func cacheListCmd() *cobra.Command { return &cobra.Command{ Use: "list", Short: "Show all cached entries", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return err } c := cache.New(cfg.CachePath(), cfg.Cache.TTL) if err := c.Load(); err != nil { return err } entries := c.All() sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "NAME\tIP\tKIND\tCACHED AT") for _, e := range entries { fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.Name, e.IP, e.Kind, e.CachedAt.Format(time.RFC3339)) } return w.Flush() }, } } func cacheClearCmd() *cobra.Command { return &cobra.Command{ Use: "clear", Short: "Remove all cached entries", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return err } c := cache.New(cfg.CachePath(), cfg.Cache.TTL) c.Clear() if err := c.Save(); err != nil { return err } fmt.Println("Cache cleared.") return nil }, } } func cacheRefreshCmd() *cobra.Command { var filterTag, filterKind, filterInstance string cmd := &cobra.Command{ Use: "refresh", Short: "Re-fetch all known hosts from NetBox", RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return err } if !cfg.HasNetBox() { return fmt.Errorf("netbox.url is not configured") } nbClient := newNetBoxClient(cfg) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() opts := netbox.SearchOptions{Tag: filterTag, Kind: filterKind} query := "" if filterInstance != "" { if !nbClient.MultipleSources() { return fmt.Errorf("--instance requires named netboxes configuration") } query = filterInstance + "/" } entries, err := nbClient.SearchAll(ctx, query, opts) if err != nil { return fmt.Errorf("NetBox: %w", err) } c := cache.New(cfg.CachePath(), cfg.Cache.TTL) _ = c.Load() chain, err := resolver.New(cfg.Resolver) if err != nil { return fmt.Errorf("resolver: %w", err) } written := 0 nameCounts := make(map[string]int) for _, e := range entries { nameCounts[strings.ToLower(e.Target())]++ } for i := range entries { e := &entries[i] if nameCounts[strings.ToLower(e.Target())] > 1 { c.Delete(e.Target()) fmt.Fprintf(os.Stderr, "warning: skipping ambiguous hostname %q\n", e.Name) continue } ip, err := chain.Resolve(ctx, e, nbClient) if err != nil { // Do not turn a failed configured strategy into a fresh // primary-IP cache hit that bypasses the resolver. if ctx.Err() != nil { return ctx.Err() } c.Delete(e.Target()) fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err) continue } c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags}) written++ } if err := c.Save(); err != nil { return err } if filterTag == "" && filterKind == "" && filterInstance == "" { if err := c.SetRefreshed(); err != nil { fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err) } } if aliasEntries := buildAliasEntries(c, *cfg); len(aliasEntries) > 0 { if err := hook.WriteAliasFiles(aliasEntries); err != nil { fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err) } } fmt.Printf("%d entries written to cache.\n", written) return nil }, } cmd.Flags().StringVar(&filterTag, "tag", "", "Filter by NetBox tag slug (e.g. prod)") cmd.Flags().StringVar(&filterKind, "kind", "", "Filter by kind: device or vm") cmd.Flags().StringVar(&filterInstance, "instance", "", "Refresh only this named NetBox instance") return cmd } // buildAliasEntries computes the sorted, deduplicated alias list from the cache. func buildAliasEntries(c *cache.Cache, cfg config.Config) []hook.AliasEntry { return hook.BuildAliasEntries(c.All(), cfg) } func aliasCmd() *cobra.Command { var shell, test string cmd := &cobra.Command{ Use: "alias", Short: "Print shell aliases for all cached hosts", Long: `Print shell alias definitions for all cached hosts. The alias name is the shortened form derived from the configured shortcuts (domain suffixes stripped, rewrite rules applied, hyphens optionally stripped). Check a rule with: netssh alias --test fsn1-bx1.example.com Source the output in your shell profile: bash/zsh: eval "$(netssh alias)" fish: netssh alias --shell fish | source Or use in a script: source <(netssh alias)`, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return err } c := cache.New(cfg.CachePath(), cfg.Cache.TTL) if err := c.Load(); err != nil { return err } if test != "" { printShortcutTest(os.Stdout, test, c.All(), cfg.Shortcuts) return nil } if shell == "" { shell = filepath.Base(os.Getenv("SHELL")) } entries := buildAliasEntries(c, *cfg) if len(entries) == 0 { fmt.Fprintln(os.Stderr, "netssh: cache is empty — run 'netssh cache refresh' first") return nil } switch shell { case "fish": fmt.Printf("# netssh aliases (%d hosts) — source with: netssh alias --shell fish | source\n", len(entries)) for _, e := range entries { fmt.Println(e.Definition("fish")) } default: fmt.Printf("# netssh aliases (%d hosts) — source with: eval \"$(netssh alias)\"\n", len(entries)) for _, e := range entries { fmt.Println(e.Definition("bash")) } } return nil }, } cmd.Flags().StringVar(&shell, "shell", "", "Output format: bash, zsh, fish (default: $SHELL)") cmd.Flags().StringVar(&test, "test", "", "Show the shortcut for a hostname and the cached hosts it matches") return cmd } // printShortcutTest helps writing shortcut rules: it shows what a name is // shortened to and which cached hosts share that shortcut. func printShortcutTest(w io.Writer, name string, entries []cache.Entry, sc config.ShortcutsConfig) { normalize := normalizeTarget(sc, strings.Contains(name, "/")) short := normalize(name) fmt.Fprintf(w, "%s → %s\n", name, short) var matches []string for _, e := range entries { if normalize(e.Name) == short { matches = append(matches, e.Name) } } sort.Strings(matches) switch len(matches) { case 0: fmt.Fprintln(w, "no cached host has this shortcut") case 1: fmt.Fprintf(w, "matches %s\n", matches[0]) default: fmt.Fprintf(w, "ambiguous, matches %d hosts:\n", len(matches)) for _, m := range matches { fmt.Fprintf(w, " %s\n", m) } } } // shellInitCmd is called at shell startup to update alias files and trigger a // background cache refresh when stale. Intentionally silent — never disrupts the prompt. func shellInitCmd() *cobra.Command { return &cobra.Command{ Use: "shell-init", Short: "Regenerate alias files and trigger a background cache refresh if stale", Hidden: true, RunE: func(cmd *cobra.Command, args []string) error { cfg, err := config.Load() if err != nil { return nil } c := cache.New(cfg.CachePath(), cfg.Cache.TTL) _ = c.Load() // Regenerate alias files from the current cache every shell start // so aliases are always up-to-date even before a refresh completes. _ = hook.WriteAliasFiles(buildAliasEntries(c, *cfg)) maybeBackgroundRefresh(cfg, c) return nil }, } } func hookCmd() *cobra.Command { cmd := &cobra.Command{ Use: "hook", Short: "Manage shell hooks for automatic cache refresh at shell startup", } cmd.AddCommand(hookInstallCmd(), hookUninstallCmd()) return cmd } func hookInstallCmd() *cobra.Command { var shell string cmd := &cobra.Command{ Use: "install", Short: "Add netssh shell-init to your shell profile", Long: `Adds a marked block (# >>> netssh >>> … # <<< netssh <<<) to your shell profile that runs 'netssh shell-init' on every new shell session and, for zsh, registers the completion. Unmarked entries from older versions are replaced. shell-init checks whether the cache is older than 24 hours and, if so, starts a background refresh — no delay to your prompt. After installation, reload your profile or open a new shell.`, RunE: func(cmd *cobra.Command, args []string) error { if shell == "" { shell = filepath.Base(os.Getenv("SHELL")) } profile, err := hook.ProfilePath(shell) if err != nil { return err } // The managed block also sources the aliases (bash/zsh) and registers // zsh completion; fish uses conf.d auto-sourcing instead. installed, err := hook.Install(profile, shell) if err != nil { return err } if !installed { fmt.Printf("Hook already installed in %s\n", profile) } else { fmt.Printf("Hook installed → %s\n%s\n", profile, hook.ReloadNote(profile)) } // Generate the aliases file immediately so it is ready on the next shell start. cfg, cfgErr := config.Load() if cfgErr == nil && cfg.HasNetBox() { c := cache.New(cfg.CachePath(), cfg.Cache.TTL) if _ = c.Load(); true { if entries := buildAliasEntries(c, *cfg); len(entries) > 0 { if err := hook.WriteAliasFiles(entries); err != nil { fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err) } else { fmt.Printf("%d aliases written to %s\n", len(entries), hook.AliasesPath()) } } else { fmt.Println("Cache is empty — run 'netssh cache refresh' to populate aliases.") } } } return nil }, } cmd.Flags().StringVar(&shell, "shell", "", "Shell to install for (default: $SHELL). Supported: bash, zsh, fish") return cmd } func hookUninstallCmd() *cobra.Command { var shell string cmd := &cobra.Command{ Use: "uninstall", Short: "Remove netssh shell-init from your shell profile", RunE: func(cmd *cobra.Command, args []string) error { if shell == "" { shell = filepath.Base(os.Getenv("SHELL")) } profile, err := hook.ProfilePath(shell) if err != nil { return err } removed, err := hook.Uninstall(profile) if err != nil { return err } if !removed { fmt.Printf("No hook found in %s\n", profile) return nil } fmt.Printf("Hook removed from %s\n", profile) return nil }, } cmd.Flags().StringVar(&shell, "shell", "", "Shell to uninstall from (default: $SHELL). Supported: bash, zsh, fish") return cmd } // maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache // has not been fully refreshed within the last 24 hours. func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) { if !cfg.HasNetBox() { return } if !c.NeedsRefresh(24 * time.Hour) { return } self, err := os.Executable() if err != nil { return } cmd := exec.Command(self, "cache", "refresh") cmd.Stdout = nil cmd.Stderr = nil cmd.Stdin = nil _ = cmd.Start() // fire and forget — becomes orphan after parent execs ssh } func fatalf(format string, args ...any) { fmt.Fprintf(os.Stderr, "netssh: "+format+"\n", args...) os.Exit(1) }