// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt // SPDX-License-Identifier: Apache-2.0 package cli import ( "context" "errors" "fmt" "io" "strings" "time" "github.com/chzyer/readline" ) // ErrQuit is the sentinel a quit/exit command returns from its Run function to // stop the REPL. Shell.Run treats it (via errors.Is) as a clean exit, not an // error to print. var ErrQuit = errors.New("quit") // Shell runs an interactive REPL over a command tree: readline-based input with // tab-completion, '?'-help, prefix abbreviation, and prompt. Build it with a // Root tree and a Client, then call Run. type Shell[C any] struct { // Root is the command tree. Required. Root *Node[C] // Client is passed unchanged to every Dynamic and Run function. Required. Client C // Prompt is the readline prompt, e.g. "evpn> ". Prompt string // FormatError renders a command error for display. Defaults to err.Error(). // Apps typically use this to unwrap a gRPC status to its message and color it. FormatError func(error) string // CompleteTimeout bounds a single Dynamic lookup for TAB/'?'. Defaults to 1s. CompleteTimeout time.Duration } // Run starts the REPL and blocks until the user quits (a Run returns ErrQuit), // hits EOF (Ctrl-D), or readline errors. ctx is passed to every command. func (s *Shell[C]) Run(ctx context.Context) error { formatErr := s.FormatError if formatErr == nil { formatErr = func(err error) string { return err.Error() } } timeout := s.CompleteTimeout if timeout == 0 { timeout = defaultCompleteTimeout } comp := &completer[C]{root: s.Root, client: s.Client, timeout: timeout} ql := &questionListener[C]{root: s.Root, client: s.Client, timeout: timeout} cfg := &readline.Config{ Prompt: s.Prompt, AutoComplete: comp, InterruptPrompt: "^C", EOFPrompt: "exit", Listener: ql, } // On OpenBSD, route terminal control through golang.org/x/sys/unix; // readline's own termios path is broken there. No-op elsewhere. applyTermFuncs(cfg) rl, err := readline.NewEx(cfg) if err != nil { return fmt.Errorf("readline init: %w", err) } ql.rl = rl defer func() { _ = rl.Close() }() for { line, err := rl.Readline() if err == readline.ErrInterrupt { continue } if err == io.EOF { return nil } if err != nil { return err } tokens := SplitTokens(line) if len(tokens) == 0 { continue } if err := Dispatch(ctx, s.Root, s.Client, tokens); err != nil { if errors.Is(err, ErrQuit) { return nil } _, _ = fmt.Fprintf(rl.Stderr(), "%s\n", formatErr(err)) } } } // Dispatch walks the tree and executes the matched command, or prints the // reachable leaves (help) if the matched node is not runnable. Use it directly // for one-shot, non-interactive invocation. func Dispatch[C any](ctx context.Context, root *Node[C], client C, tokens []string) error { node, args, remaining := Walk(root, tokens) if len(remaining) > 0 { consumed := tokens[:len(tokens)-len(remaining)] return unknownCommandError(consumed, remaining[0]) } if node.Run == nil { showHelpAt(node, strings.Join(tokens, " ")) return nil } return node.Run(ctx, client, args) } func unknownCommandError(consumed []string, bad string) error { if len(consumed) == 0 { return fmt.Errorf("unknown command: %s", bad) } return fmt.Errorf("unknown subcommand %q after %q", bad, strings.Join(consumed, " ")) } // showHelpAt prints the reachable leaves below node, each with the given prefix, // to stdout. func showHelpAt[C any](node *Node[C], prefix string) { lines := ExpandPaths(node, prefix) if len(lines) == 0 { fmt.Println(" ") return } maxLen := 0 for _, l := range lines { if len(l.Path) > maxLen { maxLen = len(l.Path) } } for _, l := range lines { if l.Help != "" { fmt.Printf("%-*s %s\n", maxLen+2, l.Path, l.Help) } else { fmt.Printf("%s\n", l.Path) } } }