feat: golang-cli v1.0.0 — generic command-tree CLI library
Reusable, generics-based CLI extracted from vpp-evpn's cmd/evpnc: a declarative command tree (Node[C]) from which dispatch, '?'-help and TAB-completion are derived, an interactive Shell[C], dynamic slot resolvers (context-dependent via captured args), text-or-JSON output (Emit), and color helpers (Paint/Label/KV). Builds on Linux and OpenBSD (readline termios override). Includes a self-contained example and a design proposal under docs/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||
// 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(" <no completions>")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user