Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d35e1f2832 | |||
| 496557858d |
@@ -55,6 +55,10 @@ type App[C any] struct {
|
|||||||
Root *Node[C]
|
Root *Node[C]
|
||||||
// Greeting, if set, is printed after the version banner in interactive mode.
|
// Greeting, if set, is printed after the version banner in interactive mode.
|
||||||
Greeting string
|
Greeting string
|
||||||
|
// JSON, when true, registers a -json flag that switches Emit to JSON output.
|
||||||
|
// Leave it false for CLIs whose commands do not (yet) use cli.Emit, so they
|
||||||
|
// never advertise a flag they cannot honor.
|
||||||
|
JSON bool
|
||||||
|
|
||||||
// DefaultServer is the -server default. If both it and ServerEnv are empty,
|
// DefaultServer is the -server default. If both it and ServerEnv are empty,
|
||||||
// no -server flag is registered (local CLI).
|
// no -server flag is registered (local CLI).
|
||||||
@@ -109,7 +113,10 @@ func (a *App[C]) Run(ctx context.Context, argv []string) error {
|
|||||||
serverFlag = fs.String("server", defaultServer, usage)
|
serverFlag = fs.String("server", defaultServer, usage)
|
||||||
}
|
}
|
||||||
color := fs.Bool("color", true, "colorize output (default: on in the shell, off one-shot)")
|
color := fs.Bool("color", true, "colorize output (default: on in the shell, off one-shot)")
|
||||||
jsonOut := fs.Bool("json", false, "emit JSON instead of text")
|
var jsonOut *bool
|
||||||
|
if a.JSON {
|
||||||
|
jsonOut = fs.Bool("json", false, "emit JSON instead of text")
|
||||||
|
}
|
||||||
showVersion := fs.Bool("version", false, "print version and exit")
|
showVersion := fs.Bool("version", false, "print version and exit")
|
||||||
if a.RegisterFlags != nil {
|
if a.RegisterFlags != nil {
|
||||||
a.RegisterFlags(fs)
|
a.RegisterFlags(fs)
|
||||||
@@ -125,7 +132,8 @@ func (a *App[C]) Run(ctx context.Context, argv []string) error {
|
|||||||
fmt.Println(a.versionLine())
|
fmt.Println(a.versionLine())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if *jsonOut {
|
jsonMode := jsonOut != nil && *jsonOut
|
||||||
|
if jsonMode {
|
||||||
SetFormat(FormatJSON)
|
SetFormat(FormatJSON)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +153,7 @@ func (a *App[C]) Run(ctx context.Context, argv []string) error {
|
|||||||
if colorExplicit {
|
if colorExplicit {
|
||||||
colorOn = *color
|
colorOn = *color
|
||||||
}
|
}
|
||||||
if *jsonOut {
|
if jsonMode {
|
||||||
colorOn = false
|
colorOn = false
|
||||||
}
|
}
|
||||||
SetColor(colorOn)
|
SetColor(colorOn)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const (
|
|||||||
Blue = "\x1b[94m" // bright blue
|
Blue = "\x1b[94m" // bright blue
|
||||||
Yellow = "\x1b[93m" // bright yellow
|
Yellow = "\x1b[93m" // bright yellow
|
||||||
Cyan = "\x1b[96m" // bright cyan
|
Cyan = "\x1b[96m" // bright cyan
|
||||||
|
White = "\x1b[97m" // bright white
|
||||||
)
|
)
|
||||||
|
|
||||||
// colorEnabled is process-global, toggled once at startup via SetColor. It
|
// colorEnabled is process-global, toggled once at startup via SetColor. It
|
||||||
|
|||||||
+13
-3
@@ -7,7 +7,7 @@ SPDX-License-Identifier: Apache-2.0
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **Status** | Describes shipped behavior as of `v1.2.0` |
|
| **Status** | Describes shipped behavior as of `v1.4.0` |
|
||||||
| **Author** | Pim van Pelt `<pim@ipng.ch>` |
|
| **Author** | Pim van Pelt `<pim@ipng.ch>` |
|
||||||
| **Last updated** | 2026-06-05 |
|
| **Last updated** | 2026-06-05 |
|
||||||
| **Audience** | Contributors, and authors of CLIs built on this library |
|
| **Audience** | Contributors, and authors of CLIs built on this library |
|
||||||
@@ -92,11 +92,21 @@ or JSON from the same code.
|
|||||||
- **FR-4.3** With color off or JSON selected, no ANSI escapes MUST be emitted.
|
- **FR-4.3** With color off or JSON selected, no ANSI escapes MUST be emitted.
|
||||||
- **FR-4.4** Color MUST default on in the shell, off one-shot; `-color` overrides;
|
- **FR-4.4** Color MUST default on in the shell, off one-shot; `-color` overrides;
|
||||||
`-json` forces it off.
|
`-json` forces it off.
|
||||||
|
- **FR-4.5** `Render` MUST treat JSON as the model: in JSON mode it prints the
|
||||||
|
value as JSON; otherwise it paints it as text — object scalars on one line as
|
||||||
|
`key=value` (keys blue, values bright white, a purely *structural* distinction),
|
||||||
|
nested objects indented, arrays one block per element, source field order
|
||||||
|
preserved. It MUST NOT apply semantic (red/green) color; that is the caller's
|
||||||
|
job via its own printer (EmitJSON in the JSON arm, a painter otherwise).
|
||||||
|
- **FR-4.6** JSON MUST always be the full record. The synopsis-vs-detail choice
|
||||||
|
(a one-line overview list vs an expanded section) is a text-only concern; the
|
||||||
|
same command emits complete JSON in either case.
|
||||||
|
|
||||||
**FR-5 `App` entry point**
|
**FR-5 `App` entry point**
|
||||||
|
|
||||||
- **FR-5.1** `App` MUST register `-color`, `-json`, `-version`, and `-server`
|
- **FR-5.1** `App` MUST register `-color` and `-version`; `-server` only when a
|
||||||
(only when a server is configured).
|
server is configured; and `-json` only when the app opts in (its commands use
|
||||||
|
`Emit`), so a CLI never advertises a flag it cannot honor.
|
||||||
- **FR-5.2** `App` MUST NOT dial anything; the client is built in a caller's
|
- **FR-5.2** `App` MUST NOT dial anything; the client is built in a caller's
|
||||||
`Connect` callback.
|
`Connect` callback.
|
||||||
- **FR-5.3** `-version` MUST print and exit without connecting.
|
- **FR-5.3** `-version` MUST print and exit without connecting.
|
||||||
|
|||||||
+22
-1
@@ -177,6 +177,25 @@ func runWatch(ctx context.Context, _ inventory, _ []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runInspect demonstrates the default renderer: it builds one value (the model)
|
||||||
|
// and hands it to cli.Render, which prints it as JSON under -json or as painted
|
||||||
|
// text (blue keys, bright-white values, nested objects indented) otherwise — no
|
||||||
|
// per-command text code.
|
||||||
|
func runInspect(_ context.Context, inv inventory, _ []string) error {
|
||||||
|
type host struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Services []string `json:"services"`
|
||||||
|
}
|
||||||
|
f := struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Hosts []host `json:"hosts"`
|
||||||
|
}{Count: len(inv.servers)}
|
||||||
|
for _, n := range inv.names() {
|
||||||
|
f.Hosts = append(f.Hosts, host{Name: n, Services: inv.servers[n]})
|
||||||
|
}
|
||||||
|
return cli.Render(f)
|
||||||
|
}
|
||||||
|
|
||||||
func runQuit(context.Context, inventory, []string) error { return cli.ErrQuit }
|
func runQuit(context.Context, inventory, []string) error { return cli.ErrQuit }
|
||||||
|
|
||||||
// buildTree is the single source of truth for the command set, built with the
|
// buildTree is the single source of truth for the command set, built with the
|
||||||
@@ -192,6 +211,7 @@ func buildTree() *cli.Node[inventory] {
|
|||||||
b.Dir("ping", "ping a server",
|
b.Dir("ping", "ping a server",
|
||||||
b.Slot("<name>", "ping this server", dynServers, runPing)),
|
b.Slot("<name>", "ping this server", dynServers, runPing)),
|
||||||
b.Cmd("watch", "stream a few events (any key stops it)", runWatch),
|
b.Cmd("watch", "stream a few events (any key stops it)", runWatch),
|
||||||
|
b.Cmd("inspect", "render the fleet via the default renderer (try -json / -color)", runInspect),
|
||||||
b.Cmd("colors", "show the ANSI color palette", runColors),
|
b.Cmd("colors", "show the ANSI color palette", runColors),
|
||||||
b.Cmd("quit", "exit the shell", runQuit),
|
b.Cmd("quit", "exit the shell", runQuit),
|
||||||
b.Cmd("exit", "exit the shell", runQuit),
|
b.Cmd("exit", "exit the shell", runQuit),
|
||||||
@@ -201,9 +221,10 @@ func buildTree() *cli.Node[inventory] {
|
|||||||
func main() {
|
func main() {
|
||||||
(&cli.App[inventory]{
|
(&cli.App[inventory]{
|
||||||
Name: "example",
|
Name: "example",
|
||||||
Version: "1.1.0",
|
Version: "1.3.0",
|
||||||
Prompt: "inv> ",
|
Prompt: "inv> ",
|
||||||
Root: buildTree(),
|
Root: buildTree(),
|
||||||
|
JSON: true, // commands use cli.Emit, so advertise -json
|
||||||
Greeting: "golang-cli example — try: show server, ping db1, colors, '?' for help, TAB to complete",
|
Greeting: "golang-cli example — try: show server, ping db1, colors, '?' for help, TAB to complete",
|
||||||
// Local CLI: no -server flag. Connect just hands over the in-memory data,
|
// Local CLI: no -server flag. Connect just hands over the in-memory data,
|
||||||
// proving App is transport-agnostic (it never dials anything itself).
|
// proving App is transport-agnostic (it never dials anything itself).
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Render emits v in the current output format, treating JSON as the model. In
|
||||||
|
// JSON mode it prints v as indented JSON. Otherwise it paints v as text: each
|
||||||
|
// object's scalar fields render on one line as blue "key=value" pairs (keys
|
||||||
|
// blue, values bright white, so the data stands out from the labels), nested
|
||||||
|
// objects indent under a blue "key:" header, and arrays render as one block per
|
||||||
|
// element.
|
||||||
|
//
|
||||||
|
// Pass a json.RawMessage (e.g. from protojson) to control the exact JSON and
|
||||||
|
// preserve field order; pass a struct or map and encoding/json handles it
|
||||||
|
// (structs keep field order, maps sort keys).
|
||||||
|
//
|
||||||
|
// Render is the default presentation, equivalent to "no printer". A command that
|
||||||
|
// wants bespoke text — semantic color, tables, custom layout — should instead
|
||||||
|
// branch on IsJSON(), calling EmitJSON in the JSON arm and its own painter
|
||||||
|
// otherwise. The library never applies semantic (red/green) color; that is the
|
||||||
|
// command's choice, because only it knows what a value means.
|
||||||
|
func Render(v any) error {
|
||||||
|
raw, err := toRawJSON(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if IsJSON() {
|
||||||
|
return printIndentedJSON(raw)
|
||||||
|
}
|
||||||
|
s, err := renderText(raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprint(os.Stdout, s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderText paints raw JSON as text (blue keys, white values). Split out so it
|
||||||
|
// can be tested without capturing stdout.
|
||||||
|
func renderText(raw json.RawMessage) (string, error) {
|
||||||
|
val, err := parseOrdered(raw)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
paintValue(&b, val, "")
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmitJSON prints v as indented JSON to stdout, regardless of the output format.
|
||||||
|
// Use it in the JSON arm of a command that paints its own text.
|
||||||
|
func EmitJSON(v any) error {
|
||||||
|
raw, err := toRawJSON(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return printIndentedJSON(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toRawJSON(v any) (json.RawMessage, error) {
|
||||||
|
if rm, ok := v.(json.RawMessage); ok {
|
||||||
|
return rm, nil
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal json: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func printIndentedJSON(raw json.RawMessage) error {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := json.Indent(&buf, raw, "", " "); err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stdout, string(raw)) // not indentable JSON — print as-is
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintln(os.Stdout, buf.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// orderedField is one key/value of a JSON object, preserving source order so the
|
||||||
|
// painted text follows the order of the (proto) JSON rather than Go map order.
|
||||||
|
type orderedField struct {
|
||||||
|
key string
|
||||||
|
val any // string, json.Number, bool, nil, []any, or []orderedField
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOrdered(raw json.RawMessage) (any, error) {
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
dec.UseNumber()
|
||||||
|
v, err := parseOrderedValue(dec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse json: %w", err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOrderedValue(dec *json.Decoder) (any, error) {
|
||||||
|
t, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if d, ok := t.(json.Delim); ok {
|
||||||
|
switch d {
|
||||||
|
case '{':
|
||||||
|
return parseOrderedObject(dec)
|
||||||
|
case '[':
|
||||||
|
return parseOrderedArray(dec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t, nil // string, json.Number, bool, or nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOrderedObject(dec *json.Decoder) ([]orderedField, error) {
|
||||||
|
var fields []orderedField
|
||||||
|
for dec.More() {
|
||||||
|
keyTok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
val, err := parseOrderedValue(dec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
fields = append(fields, orderedField{key: keyTok.(string), val: val})
|
||||||
|
}
|
||||||
|
_, err := dec.Token() // closing '}'
|
||||||
|
return fields, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOrderedArray(dec *json.Decoder) ([]any, error) {
|
||||||
|
var arr []any
|
||||||
|
for dec.More() {
|
||||||
|
v, err := parseOrderedValue(dec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
arr = append(arr, v)
|
||||||
|
}
|
||||||
|
_, err := dec.Token() // closing ']'
|
||||||
|
return arr, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func paintValue(b *strings.Builder, v any, indent string) {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case []orderedField:
|
||||||
|
paintObject(b, t, indent)
|
||||||
|
case []any:
|
||||||
|
paintArray(b, t, indent)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(b, "%s%s\n", indent, Paint(scalarString(v), White))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func paintObject(b *strings.Builder, fields []orderedField, indent string) {
|
||||||
|
var scalars []string
|
||||||
|
var nested []orderedField
|
||||||
|
for _, f := range fields {
|
||||||
|
switch f.val.(type) {
|
||||||
|
case []orderedField, []any:
|
||||||
|
nested = append(nested, f)
|
||||||
|
default:
|
||||||
|
scalars = append(scalars, Label(f.key)+"="+Paint(scalarString(f.val), White))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(scalars) > 0 {
|
||||||
|
fmt.Fprintf(b, "%s%s\n", indent, strings.Join(scalars, " "))
|
||||||
|
}
|
||||||
|
for _, f := range nested {
|
||||||
|
fmt.Fprintf(b, "%s%s:\n", indent, Label(f.key))
|
||||||
|
paintValue(b, f.val, indent+" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func paintArray(b *strings.Builder, arr []any, indent string) {
|
||||||
|
for _, el := range arr {
|
||||||
|
switch el.(type) {
|
||||||
|
case []orderedField, []any:
|
||||||
|
paintValue(b, el, indent)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(b, "%s%s\n", indent, Paint(scalarString(el), White))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scalarString(v any) string {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case nil:
|
||||||
|
return "null"
|
||||||
|
case bool:
|
||||||
|
if x {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
case json.Number:
|
||||||
|
return x.String()
|
||||||
|
case string:
|
||||||
|
return x
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", x)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// SPDX-FileCopyrightText: (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRenderTextNoColor checks the plain text shape: object scalars on one line
|
||||||
|
// as key=value, nested objects indented under "key:", arrays as one block per
|
||||||
|
// element, and source field order preserved (not sorted).
|
||||||
|
func TestRenderTextNoColor(t *testing.T) {
|
||||||
|
SetColor(false)
|
||||||
|
raw := json.RawMessage(`{
|
||||||
|
"instanceId": "host1",
|
||||||
|
"connected": true,
|
||||||
|
"version": "1.2.3",
|
||||||
|
"labels": {"site": "ams", "rack": "b3"},
|
||||||
|
"bvis": [
|
||||||
|
{"evpnId": "blue", "installed": true},
|
||||||
|
{"evpnId": "red", "installed": false}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
got, err := renderText(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderText: %v", err)
|
||||||
|
}
|
||||||
|
want := "instanceId=host1 connected=true version=1.2.3\n" +
|
||||||
|
"labels:\n" +
|
||||||
|
" site=ams rack=b3\n" +
|
||||||
|
"bvis:\n" +
|
||||||
|
" evpnId=blue installed=true\n" +
|
||||||
|
" evpnId=red installed=false\n"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("renderText mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderTextColor checks keys are blue and values bright white when color is
|
||||||
|
// on.
|
||||||
|
func TestRenderTextColor(t *testing.T) {
|
||||||
|
SetColor(true)
|
||||||
|
defer SetColor(false)
|
||||||
|
got, err := renderText(json.RawMessage(`{"a": "x"}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderText: %v", err)
|
||||||
|
}
|
||||||
|
want := Blue + "a" + Reset + "=" + White + "x" + Reset + "\n"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("colored render = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderPreservesNumberAndNull checks json.Number passes through verbatim
|
||||||
|
// (no float reformatting) and null renders as "null".
|
||||||
|
func TestRenderPreservesNumberAndNull(t *testing.T) {
|
||||||
|
SetColor(false)
|
||||||
|
got, err := renderText(json.RawMessage(`{"vni": 10000000, "primary": null}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderText: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "vni=10000000") {
|
||||||
|
t.Errorf("number not verbatim: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "primary=null") {
|
||||||
|
t.Errorf("null not rendered: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderTextFromStruct checks a Go struct (not RawMessage) is accepted and
|
||||||
|
// keeps struct field order.
|
||||||
|
func TestRenderTextFromStruct(t *testing.T) {
|
||||||
|
SetColor(false)
|
||||||
|
v := struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}{Name: "web1", Count: 3}
|
||||||
|
raw, err := toRawJSON(v)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("toRawJSON: %v", err)
|
||||||
|
}
|
||||||
|
got, err := renderText(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderText: %v", err)
|
||||||
|
}
|
||||||
|
if got != "name=web1 count=3\n" {
|
||||||
|
t.Errorf("struct render = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user