feat(bonfire): Artisan help, examples, JSON listing and error hints

- help <command> and <command> --help print Description, Usage,
  Arguments, Options with defaults, Global options, Help and Examples
- Command.Examples and Command.Hidden (appended, additive); list --all
  shows hidden commands, list --format=json prints a bonfire.Listing
- CheckDocs reports commands lacking description, long help, examples or
  argument and option descriptions
- Error block with the ERROR badge, WithHint explanation and Try lines
  ({bin} replaced), cobra suggestions as Try, -v error chain, messages
  scrubbed by sunscreen; Reported errors print nothing and exit 1
This commit is contained in:
Jakub Zych
2026-10-21 12:21:24 +02:00
parent 80fae5c279
commit fb32886775
12 changed files with 1130 additions and 52 deletions

View File

@@ -62,10 +62,16 @@ Both programs list their commands with no arguments, with `--help` and with `lis
```sh
summer
summer make:model --help
summer help migrate
./bin/acme --help
./bin/acme migrate:rollback --help
./bin/acme help migrate:rollback
./bin/acme list --all
./bin/acme list --format=json
```
A command's help shows its description, usage, arguments, options with their defaults, the global options, the long help and examples. `list --all` includes hidden commands, and `list --format=json` prints every command with its arguments, options and examples for scripts and agents.
A command that `summer` delegates to the application binary, such as `summer serve`, answers `--help` with the binary's own help, so run it inside an application directory. Asking the help command of `summer` about a delegated command instead prints a short description and its common flags without running the binary.
### Global options

View File

@@ -16,7 +16,7 @@ A `bonfire.Command` has a name, a description, its positional arguments and flag
- Each `bonfire.Arg` is a positional argument with a name, a description and a `Required` marker. The usage line shows required arguments as `<name>` and optional ones as `[name]`.
- Each `bonfire.Flag` is a string flag. Set `bonfire.Flag.Bare` for a switch such as `--dry-run` that stores `true` when given alone, and `bonfire.Flag.Repeatable` for a flag that can be given several times.
- `bonfire.Command.Run` receives the context, a `bonfire.Input` and a `bonfire.Output`.
- `bonfire.Command.Long` is optional extended help shown by the help command; without it the description is shown. Set `bonfire.Command.Passthrough` for a command that forwards to another program: flag parsing is turned off, so every argument after the command name, including `--help`, `-h` and `--`, reaches `bonfire.Input.Args` unchanged. Declared flags then appear in the usage text only, and declared arguments are not count-checked.
- `bonfire.Command.Long` is optional extended help, shown under "Help:" by `help <command>` and `<command> --help`. `bonfire.Command.Examples` and `bonfire.Command.Hidden` are described below. Set `bonfire.Command.Passthrough` for a command that forwards to another program: flag parsing is turned off, so every argument after the command name, including `--help`, `-h` and `--`, reaches `bonfire.Input.Args` unchanged. Declared flags then appear in the usage text only, and declared arguments are not count-checked.
Read arguments with `bonfire.Input.Argument`, scalar and bare flags with `bonfire.Input.Flag`, and repeatable flags with `bonfire.Input.Flags`, which returns the values in the order given:
@@ -60,7 +60,133 @@ A scaffolded plugin's `Commands` method returns the generated list of everything
- widgets: `bonfire.Output.Table`, `bonfire.Output.Spinner` around a function and `bonfire.Output.Progress` for a progress bar, which fall back to plain lines when the output is not a terminal;
- prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm`, `bonfire.Output.Choice` and `bonfire.Output.Secret`. Prompts return their defaults when input ends, so a command run from cron or a script never hangs.
Return an error from `Run` to fail the command. The binary prints it and exits with status 1.
Return an error from `Run` to fail the command. The binary prints it as an error block on stderr and exits with status 1; see [Errors and hints](#errors-and-hints).
## Examples and hidden commands
`bonfire.Command.Examples` lists usage examples. Each `bonfire.Example` holds the command line without the binary and a short description; help prints the description as a `#` comment and the line after `bin/<name>` (or `summer` for the tool's own commands), so one example serves both. `bonfire.Command.Hidden` keeps a command that still runs out of the list; `list --all` and `list --format=json` show it:
```go src=modules/bonfire/example_test.go#ExampleExample
export := bonfire.Command{
Name: "blog:export",
Description: "Export published posts as JSON",
Long: "Writes every published post, newest first, to a JSON file.",
Flags: []bonfire.Flag{
{Name: "path", Description: "Output file", Default: "posts.json"},
},
Examples: []bonfire.Example{
{Command: "blog:export --path=backup.json", Description: "Export to another file"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
return nil
},
}
// Hidden commands run, but list shows them only with --all.
cleanup := bonfire.Command{
Name: "blog:cleanup",
Description: "Remove orphaned uploads",
Hidden: true,
}
root, err := bonfire.NewRootIO("acme", []bonfire.Command{export, cleanup}, strings.NewReader(""), os.Stdout, os.Stdout)
if err != nil {
fmt.Println(err)
return
}
bonfire.Execute(context.Background(), root, []string{"help", "blog:export"})
// Output:
// Description:
// Export published posts as JSON
//
// Usage:
// blog:export [options]
//
// Options:
// --path=PATH Output file [default: "posts.json"]
//
// Global options:
// -h, --help Display help for the given command
// -q, --quiet Do not output any message
// -v|vv|vvv Increase verbosity
// --ansi|no-ansi Force (or disable) ANSI output
// -n, --no-interaction Do not ask any interactive question
// --env The environment the command runs under
//
// Help:
// Writes every published post, newest first, to a JSON file.
//
// Examples:
// # Export to another file
// bin/acme blog:export --path=backup.json
```
`list --format=json` prints every command with its arguments, options (with defaults) and examples as a `bonfire.Listing`, for documentation checks, scripts and agents. `bonfire.Describe` builds the same listing from a command slice.
## Errors and hints
When `Run` returns an error, the binary prints a block on stderr: the ERROR badge and the message, then "Run with -v for details.". With `-v` the block lists the wrapped errors under "Caused by:" instead. Messages pass through `sunscreen.Scrub`, so a token inside an error text is not printed.
Wrap an error with `bonfire.WithHint` to explain it and to say what to run next. `{bin}` in the Try text becomes `bin/<name>` or `summer`, so the hint names the right program:
```go src=modules/bonfire/example_test.go#ExampleWithHint
publish := bonfire.Command{
Name: "blog:publish",
Description: "Publish a draft post",
Args: []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
slug, _ := in.Argument("slug")
return bonfire.WithHint(fmt.Errorf("blog: no draft named %q", slug),
"Only drafts can be published; published posts keep their slug.",
"{bin} blog:drafts")
},
}
root, err := bonfire.NewRootIO("acme", []bonfire.Command{publish}, strings.NewReader(""), os.Stdout, os.Stdout)
if err != nil {
fmt.Println(err)
return
}
code := bonfire.Execute(context.Background(), root, []string{"blog:publish", "hello-world"})
fmt.Println("exit code:", code)
// Output:
// ERROR blog: no draft named "hello-world"
//
// Only drafts can be published; published posts keep their slug.
//
// Try:
// bin/acme blog:drafts
//
// Run with -v for details.
//
// exit code: 1
```
A command that has already printed its problems, such as a checker that lists every finding, returns `bonfire.Reported(err)`: the binary exits 1 and prints nothing more. A mistake in how the command was called, such as an unknown flag or a missing argument, exits 2 and the block suggests `help <command>`; wrap your own usage checks with `bonfire.Usage` to get the same exit code.
## Documentation coverage
`bonfire.CheckDocs` reports each command that lacks a description, long help, an example, or a description of one of its arguments or options. It only reports; the framework's own test fails on any problem, and a plugin repository can call it from a test and log the problems:
```go src=modules/bonfire/example_test.go#ExampleCheckDocs
commands := []bonfire.Command{
{
Name: "blog:publish",
Description: "Publish a draft post",
Long: "Publishes the draft with the given slug and notifies subscribers.",
Args: []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}},
Examples: []bonfire.Example{{Command: "blog:publish hello-world"}},
},
{
Name: "blog:reindex",
Description: "Rebuild the search index",
Flags: []bonfire.Flag{{Name: "batch", Default: "500"}},
},
}
for _, problem := range bonfire.CheckDocs(commands) {
fmt.Println(problem)
}
// Output: blog:reindex: missing long help, examples, option --batch description
```
## Calling commands in-process

View File

@@ -12,7 +12,7 @@ bonfire is the console layer of SummerCMS. Plugins and framework modules describ
## Role in the framework
A base layer for console commands with no framework dependencies. `pact` uses it so plugins can contribute commands, and `lagoon`, `conga`, `cabana`, `lighthouse`, `flare` and `surf` build their own commands (migrate, workers, serve, admin tools) on it. Keeping it separate means every module describes commands the same way without depending on the CLI binary.
A base layer for console commands; its only framework dependency is `sunscreen`, which scrubs credentials from error messages. `pact` uses it so plugins can contribute commands, and `lagoon`, `conga`, `cabana`, `lighthouse`, `flare` and `surf` build their own commands (migrate, workers, serve, admin tools) on it. Keeping it separate means every module describes commands the same way without depending on the CLI binary.
Use cases:
@@ -32,7 +32,11 @@ Use cases:
- Passthrough commands (`bonfire.Command.Passthrough`) skip flag parsing, so every argument, including `--help`, reaches `bonfire.Input.Args` unchanged; this suits commands that forward to another program. `bonfire.Command.Long` adds extended help text that the help command shows instead of the description.
- Artisan-style command list: a bare binary (or `--help`, or `list`) prints a version header (`bonfire.Version.Header`, read from the build information by `bonfire.BuildVersion`), the usage line, the global options and the commands, top-level names first and then one block per namespace, with aligned descriptions.
- Global options on every command: `-v`/`-vv`/`-vvv` (`--verbose`), `-q` (`--quiet`, implies `-n`), `--ansi`/`--no-ansi` (last wins), `-n` (`--no-interaction`) and `--env`. `bonfire.PeekGlobals` reads them from the raw arguments before the root exists, so the generated main can pick the configuration environment and the log verbosity before plugins boot; `bonfire.OnGlobals` hooks receive the parsed `bonfire.Globals` before every command runs (roots built by `bonfire.Call` run no hook).
- Exit codes: `bonfire.Execute` runs the root through fang and returns `bonfire.ExitOK`, `bonfire.ExitFailure`, or `bonfire.ExitUsage` for an unknown command or flag and a wrong number of arguments (`bonfire.UsageError`). Errors print as a block on the error stream (`bonfire.RenderError`); `bonfire.Fail` prints errors that happen before the root exists.
- Artisan-style help: `help <command>` and `<command> --help` print Description, Usage, Arguments, Options with `[default: ...]` and "(multiple values allowed)", the global options, Help (`bonfire.Command.Long`) and Examples (`bonfire.Command.Examples`, printed after `bin/<name>` or `summer`).
- Hidden commands (`bonfire.Command.Hidden`) run but stay out of the list unless `list --all`.
- JSON catalog: `list --format=json` prints every command, hidden ones flagged, with arguments, options, defaults and examples as a `bonfire.Listing` (`bonfire.Describe` builds it from a command slice).
- Documentation coverage: `bonfire.CheckDocs` reports commands lacking a description, long help, examples, or argument and option descriptions.
- Error block and exit codes: `bonfire.Execute` runs the root through fang and returns `bonfire.ExitOK`, `bonfire.ExitFailure`, or `bonfire.ExitUsage` for an unknown command or flag and a wrong number of arguments (`bonfire.UsageError`). Errors print on the error stream as a block (`bonfire.RenderError`): the ERROR badge and the message, the explanation and Try lines of `bonfire.WithHint` (`{bin}` becomes `bin/<name>` or `summer`), cobra's "did you mean" suggestions as Try lines, and "Run with -v for details." or, at `-v`, the wrapped error chain; every message passes through `sunscreen.Scrub`. Errors wrapped by `bonfire.Reported` print nothing. `bonfire.Fail` prints errors that happen before the root exists.
- Name checks at boot: two commands with the same name, or a command named `list` or `help`, make `bonfire.NewRoot` fail with `bonfire.ErrDuplicateCommand`; a command flag that reuses a global option's name or shorthand fails with `bonfire.ErrReservedFlag` instead of panicking.
- Injectable streams (`bonfire.NewRootIO`, `bonfire.NewOutput`) so commands can be tested against buffers.
- In-process calls: `bonfire.Call` runs a named command with arguments against any writer (Laravel `Artisan::call`), and `bonfire.Catalog` holds an application binary's final command list so code outside the Cobra root, such as the conga scheduler, can call any registered command.
@@ -98,7 +102,8 @@ if catalog.Has("blog:import") {
| Identifier | Description |
|------------|-------------|
| `bonfire.Command` | A console command: name, description, optional extended help (`bonfire.Command.Long`), flags, arguments, the `bonfire.Command.Passthrough` switch that hands every argument to Run unparsed, and the `bonfire.Command.Run` function. |
| `bonfire.Command` | A console command: name, description, optional extended help (`bonfire.Command.Long`), flags, arguments, the `bonfire.Command.Passthrough` switch that hands every argument to Run unparsed, the `bonfire.Command.Run` function, usage examples (`bonfire.Command.Examples`) and the `bonfire.Command.Hidden` switch. |
| `bonfire.Example` | One usage example: the command line without the binary (`Command`) and a short `Description`. |
| `bonfire.Flag` | A string flag; `bonfire.Flag.Bare` allows the flag without a value, `bonfire.Flag.Repeatable` makes it an ordered multi-value flag. |
| `bonfire.Arg` | A positional argument with a name, description and required marker. |
| `bonfire.Input` | Parsed view handed to `bonfire.Command.Run`: `bonfire.Input.Args`, `bonfire.Input.Argument`, `bonfire.Input.Flag` and `bonfire.Input.Flags`. |
@@ -121,6 +126,19 @@ if catalog.Has("blog:import") {
| `bonfire.RenderError` | Writes an error as the console error block. |
| `bonfire.UsageError` | Marks a usage mistake (exit code 2); `bonfire.Usage` wraps an error in it. |
| `bonfire.Usage` | Wraps an error as a `bonfire.UsageError`. |
| `bonfire.HintError` | An error with an `Explanation` and a `Try` text that the error block prints. |
| `bonfire.WithHint` | Wraps an error in a `bonfire.HintError`; `{bin}` in the Try text becomes `bin/<name>` or `summer`. |
| `bonfire.ErrReported` | Marks an error whose details the command already printed. |
| `bonfire.Reported` | Wraps an error so it matches `bonfire.ErrReported`: exit code 1, no error block. |
| `bonfire.Listing` | The JSON catalog of `list --format=json`: `Application` (`bonfire.ApplicationInfo`), `GlobalOptions` and `Commands`. |
| `bonfire.ApplicationInfo` | Name, version and framework version of a binary in a `bonfire.Listing`. |
| `bonfire.CommandInfo` | One command of a `bonfire.Listing`: name, description, long help, usage, hidden and passthrough switches, `Arguments`, `Options` and `Examples`. |
| `bonfire.ArgumentInfo` | One argument of a `bonfire.CommandInfo`. |
| `bonfire.OptionInfo` | One flag of a `bonfire.CommandInfo`, or a global option, with its default. |
| `bonfire.ExampleInfo` | One example of a `bonfire.CommandInfo`. |
| `bonfire.Describe` | Builds the `bonfire.Listing` of a binary from its commands and `bonfire.Version`. |
| `bonfire.CheckDocs` | Reports commands lacking a description, long help, examples or argument and option descriptions, as `bonfire.DocProblem` values. |
| `bonfire.DocProblem` | One command's documentation gaps; `bonfire.DocProblem.String` reads "<command>: missing description, examples". |
| `bonfire.ErrDuplicateCommand` | Returned by `bonfire.NewRoot` when two commands share a name or a command is named `list` or `help`. |
| `bonfire.ErrReservedFlag` | Returned by `bonfire.NewRoot` when a command flag reuses a global option's name or shorthand. |
| `bonfire.NewOutput` | Builds a `bonfire.Output` over the given streams, applying the terminal and color policy. |
@@ -148,9 +166,9 @@ Without these variables, color is enabled only when stdout is a terminal.
## Dependencies
- SummerCMS modules: none.
- SummerCMS modules: `sunscreen` (scrubs error messages).
- Third-party: `github.com/spf13/cobra`, `github.com/spf13/pflag`, `golang.org/x/term`, `charm.land/lipgloss/v2`, `charm.land/fang/v2` and `github.com/charmbracelet/colorprofile` (decided in `.planning/notes/2026-10-21-console-charm-stack.md`).
- Standard library: `bufio`, `context`, `errors`, `fmt`, `io`, `os`, `runtime/debug`, `slices`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`.
- Standard library: `bufio`, `context`, `encoding/json`, `errors`, `fmt`, `io`, `os`, `runtime/debug`, `slices`, `strconv`, `strings`, `sync`, `time`, `unicode/utf8`.
## Testing

View File

@@ -15,8 +15,8 @@ var ErrCommandName = errors.New("bonfire: plugin command name must be namespace:
type Command struct {
Name string
Description string
// Long is optional extended help that cobra shows for the help command
// (help <command>). When empty, cobra falls back to Description.
// Long is optional extended help, shown under "Help:" by help
// <command> and <command> --help.
Long string
// Passthrough turns off flag parsing for the command: every argument
// after the command name, including --help, -h and --, reaches Run
@@ -29,6 +29,22 @@ type Command struct {
Flags []Flag
Args []Arg
Run func(ctx context.Context, in Input, out Output) error
// Examples are shown under "Examples:" in the command's help and in
// the JSON listing, each as a "#" comment and the command line.
Examples []Example
// Hidden keeps a runnable command out of the list; list --all and the
// JSON listing still show it.
Hidden bool
}
// Example is one usage example of a command.
type Example struct {
// Command is the command line without the binary, for example
// "admin:token:create jane --json"; help prints it after summer or
// bin/<name>.
Command string
// Description is a short sentence shown as a "#" comment above it.
Description string
}
// Flag describes a string flag passed to a command.

55
modules/bonfire/docs.go Normal file
View File

@@ -0,0 +1,55 @@
package bonfire
import (
"slices"
"strings"
)
// DocProblem lists what one command's documentation lacks.
type DocProblem struct {
// Command is the command name.
Command string
// Missing names each gap: "description", "long help", "examples",
// "argument <name> description" or "option --<name> description".
Missing []string
}
// String formats the problem as "<command>: missing description, examples".
func (p DocProblem) String() string {
return p.Command + ": missing " + strings.Join(p.Missing, ", ")
}
// CheckDocs reports every command that lacks a description, long help, at
// least one example, or a description of one of its arguments or options,
// sorted by command name. It only reports: the framework's own test fails
// on a problem, plugin repositories log them.
func CheckDocs(commands []Command) []DocProblem {
var problems []DocProblem
for _, c := range commands {
var missing []string
if strings.TrimSpace(c.Description) == "" {
missing = append(missing, "description")
}
if strings.TrimSpace(c.Long) == "" {
missing = append(missing, "long help")
}
if len(c.Examples) == 0 {
missing = append(missing, "examples")
}
for _, a := range c.Args {
if strings.TrimSpace(a.Description) == "" {
missing = append(missing, "argument "+a.Name+" description")
}
}
for _, f := range c.Flags {
if strings.TrimSpace(f.Description) == "" {
missing = append(missing, "option --"+f.Name+" description")
}
}
if len(missing) > 0 {
problems = append(problems, DocProblem{Command: c.Name, Missing: missing})
}
}
slices.SortStableFunc(problems, func(a, b DocProblem) int { return strings.Compare(a.Command, b.Command) })
return problems
}

View File

@@ -2,9 +2,11 @@ package bonfire
import (
"errors"
"fmt"
"io"
"strings"
"git.golem15.com/golem15/summercms/modules/sunscreen"
"github.com/charmbracelet/colorprofile"
)
// UsageError marks a mistake in how a command was called: an unknown
@@ -12,6 +14,9 @@ import (
// for it.
type UsageError struct {
Err error
// command is the command that was called wrongly, when known; the
// error block suggests its help.
command string
}
func (e *UsageError) Error() string { return e.Err.Error() }
@@ -39,14 +44,181 @@ func isUsage(err error) bool {
return strings.HasPrefix(err.Error(), "unknown command")
}
// RenderError writes err to w as the console error block: a blank line,
// " ERROR <message>" and a blank line. name is the root name of the binary
// (summer, or the application binary's name).
// HintError adds an explanation and a command to try to an error. The error
// block prints both under the error message; {bin} in Try becomes summer or
// bin/<name>.
type HintError struct {
Err error
Explanation string
Try string
}
func (e *HintError) Error() string { return e.Err.Error() }
func (e *HintError) Unwrap() error { return e.Err }
// WithHint returns err with an explanation and a Try line (several lines are
// allowed); "{bin}" in try is replaced by how the binary is called. A nil err
// stays nil.
func WithHint(err error, explanation, try string) error {
if err == nil {
return nil
}
return &HintError{Err: err, Explanation: explanation, Try: try}
}
// ErrReported marks an error whose details the command already printed:
// the error block prints nothing and the exit code is 1.
var ErrReported = errors.New("bonfire: error already reported")
type reportedError struct {
err error
}
func (e *reportedError) Error() string { return e.err.Error() }
func (e *reportedError) Unwrap() error { return e.err }
func (e *reportedError) Is(target error) bool { return target == ErrReported }
// Reported returns an error that matches both ErrReported and err, for a
// command that has already printed its problems. A nil err gives
// ErrReported.
func Reported(err error) error {
if err == nil {
return ErrReported
}
return &reportedError{err: err}
}
// RenderError writes err to w as the console error block: the ERROR badge
// and the message, the explanation and the Try lines of a HintError ({bin}
// replaced by summer or bin/<name>, for the root named name), then "Run with
// -v for details." at verbosity 0 or the wrapped error chain under "Caused
// by:" at -v and above. cobra's unknown command error becomes `Command "x"
// is not defined.` with its suggestions as Try lines, and other usage errors
// suggest the command's help. Every message passes through sunscreen.Scrub.
// Errors wrapped by Reported print nothing.
func RenderError(w io.Writer, name string, err error, g Globals) {
if err == nil || w == nil {
if err == nil || w == nil || errors.Is(err, ErrReported) {
return
}
_ = name
_ = g
fmt.Fprintf(w, "\n ERROR %s\n\n", err.Error())
if cw, ok := w.(*colorprofile.Writer); ok {
w = cw.Forward
}
bin := usageName(name)
p := DefaultPalette()
message := err.Error()
explanation, try := "", ""
var hint *HintError
if errors.As(err, &hint) {
explanation, try = hint.Explanation, hint.Try
}
if strings.HasPrefix(message, "unknown command") {
cmdName, suggestions := parseUnknownCommand(message)
message = `Command "` + cmdName + `" is not defined.`
if try == "" {
var lines []string
for _, s := range suggestions {
lines = append(lines, "{bin} "+s)
}
if len(lines) == 0 {
lines = []string{"{bin} list"}
}
try = strings.Join(lines, "\n")
}
} else if try == "" {
var ue *UsageError
if errors.As(err, &ue) {
if ue.command != "" {
try = "{bin} help " + ue.command
} else {
try = "{bin} list"
}
}
}
var b strings.Builder
b.WriteString("\n")
for i, line := range strings.Split(sunscreen.Scrub(strings.TrimRight(message, "\n")), "\n") {
if i == 0 {
b.WriteString(" " + p.ErrorBold.Reverse(true).Render(" ERROR ") + " " + line + "\n")
continue
}
b.WriteString(" " + line + "\n")
}
if explanation != "" {
b.WriteString("\n")
for _, line := range strings.Split(sunscreen.Scrub(explanation), "\n") {
b.WriteString(" " + line + "\n")
}
}
if try != "" {
b.WriteString("\n " + p.Header.Render("Try:") + "\n")
for _, line := range strings.Split(strings.ReplaceAll(try, "{bin}", bin), "\n") {
b.WriteString(" " + p.Success.Render(sunscreen.Scrub(line)) + "\n")
}
}
if g.Verbosity > 0 {
if chain := causes(err, err.Error()); len(chain) > 0 {
b.WriteString("\n " + p.Header.Render("Caused by:") + "\n")
for _, c := range chain {
b.WriteString(" " + p.Muted.Render("- "+sunscreen.Scrub(c)) + "\n")
}
}
} else {
b.WriteString("\n " + p.Muted.Render("Run with -v for details.") + "\n")
}
b.WriteString("\n")
_ = writeStyled(w, g, b.String())
}
// parseUnknownCommand reads the command name and cobra's "Did you mean
// this?" suggestions from an unknown command error.
func parseUnknownCommand(msg string) (string, []string) {
name := ""
if i := strings.Index(msg, `"`); i >= 0 {
if j := strings.Index(msg[i+1:], `"`); j >= 0 {
name = msg[i+1 : i+1+j]
}
}
var suggestions []string
if _, rest, ok := strings.Cut(msg, "Did you mean this?"); ok {
for _, line := range strings.Split(rest, "\n") {
if s := strings.TrimSpace(line); s != "" {
suggestions = append(suggestions, s)
}
}
}
return name, suggestions
}
// causes walks the wrapped errors below err (errors.Join members expanded)
// and returns each distinct message other than top.
func causes(err error, top string) []string {
var out []string
seen := map[string]bool{top: true}
var walk func(error)
walk = func(e error) {
for e != nil {
if msg := e.Error(); !seen[msg] {
seen[msg] = true
out = append(out, msg)
}
if joined, ok := e.(interface{ Unwrap() []error }); ok {
for _, m := range joined.Unwrap() {
walk(m)
}
return
}
e = errors.Unwrap(e)
}
}
walk(errors.Unwrap(err))
if joined, ok := err.(interface{ Unwrap() []error }); ok {
for _, m := range joined.Unwrap() {
walk(m)
}
}
return out
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"strings"
"git.golem15.com/golem15/summercms/modules/bonfire"
)
@@ -51,3 +52,111 @@ func ExampleCatalog() {
}
// Output: publish hello-world, tags [news go], dry run true
}
func ExampleExample() {
export := bonfire.Command{
Name: "blog:export",
Description: "Export published posts as JSON",
Long: "Writes every published post, newest first, to a JSON file.",
Flags: []bonfire.Flag{
{Name: "path", Description: "Output file", Default: "posts.json"},
},
Examples: []bonfire.Example{
{Command: "blog:export --path=backup.json", Description: "Export to another file"},
},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
return nil
},
}
// Hidden commands run, but list shows them only with --all.
cleanup := bonfire.Command{
Name: "blog:cleanup",
Description: "Remove orphaned uploads",
Hidden: true,
}
root, err := bonfire.NewRootIO("acme", []bonfire.Command{export, cleanup}, strings.NewReader(""), os.Stdout, os.Stdout)
if err != nil {
fmt.Println(err)
return
}
bonfire.Execute(context.Background(), root, []string{"help", "blog:export"})
// Output:
// Description:
// Export published posts as JSON
//
// Usage:
// blog:export [options]
//
// Options:
// --path=PATH Output file [default: "posts.json"]
//
// Global options:
// -h, --help Display help for the given command
// -q, --quiet Do not output any message
// -v|vv|vvv Increase verbosity
// --ansi|no-ansi Force (or disable) ANSI output
// -n, --no-interaction Do not ask any interactive question
// --env The environment the command runs under
//
// Help:
// Writes every published post, newest first, to a JSON file.
//
// Examples:
// # Export to another file
// bin/acme blog:export --path=backup.json
}
func ExampleWithHint() {
publish := bonfire.Command{
Name: "blog:publish",
Description: "Publish a draft post",
Args: []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
slug, _ := in.Argument("slug")
return bonfire.WithHint(fmt.Errorf("blog: no draft named %q", slug),
"Only drafts can be published; published posts keep their slug.",
"{bin} blog:drafts")
},
}
root, err := bonfire.NewRootIO("acme", []bonfire.Command{publish}, strings.NewReader(""), os.Stdout, os.Stdout)
if err != nil {
fmt.Println(err)
return
}
code := bonfire.Execute(context.Background(), root, []string{"blog:publish", "hello-world"})
fmt.Println("exit code:", code)
// Output:
// ERROR blog: no draft named "hello-world"
//
// Only drafts can be published; published posts keep their slug.
//
// Try:
// bin/acme blog:drafts
//
// Run with -v for details.
//
// exit code: 1
}
func ExampleCheckDocs() {
commands := []bonfire.Command{
{
Name: "blog:publish",
Description: "Publish a draft post",
Long: "Publishes the draft with the given slug and notifies subscribers.",
Args: []bonfire.Arg{{Name: "slug", Description: "Post slug", Required: true}},
Examples: []bonfire.Example{{Command: "blog:publish hello-world"}},
},
{
Name: "blog:reindex",
Description: "Rebuild the search index",
Flags: []bonfire.Flag{{Name: "batch", Default: "500"}},
},
}
for _, problem := range bonfire.CheckDocs(commands) {
fmt.Println(problem)
}
// Output: blog:reindex: missing long help, examples, option --batch description
}

View File

@@ -7,7 +7,6 @@ import (
"strings"
"charm.land/fang/v2"
"github.com/charmbracelet/colorprofile"
"github.com/spf13/cobra"
)
@@ -50,10 +49,9 @@ func Execute(ctx context.Context, root *cobra.Command, args []string) int {
fang.WithoutManpage(),
fang.WithoutCompletions(),
fang.WithErrorHandler(func(w io.Writer, _ fang.Styles, err error) {
if cw, ok := w.(*colorprofile.Writer); ok {
// RenderError applies its own color profile.
w = cw.Forward
}
// fang hands over a colorprofile writer built from the
// environment only; RenderError unwraps it and applies
// --ansi and --no-ansi itself.
RenderError(w, name, err, g)
}),
)

125
modules/bonfire/help.go Normal file
View File

@@ -0,0 +1,125 @@
package bonfire
import (
"strconv"
"strings"
)
// helpUsage is the usage line of a command in help and the JSON listing:
// the name, its arguments and [options]; passthrough commands end with
// [--] <args...> because everything after the name reaches them unparsed.
func helpUsage(c Command) string {
u := commandUsage(c) + " [options]"
if c.Passthrough {
u += " [--] <args...>"
}
return u
}
// optionLabel is the left column of a flag: "-x, --name=NAME" for value
// flags, "--name" for bare flags, indented four spaces without shorthand.
func optionLabel(f Flag) string {
label := " "
if f.Shorthand != "" {
label = "-" + f.Shorthand + ", "
}
label += "--" + f.Name
if !f.Bare {
label += "=" + strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_"))
}
return label
}
// optionDetail is a flag's description followed by its default and the
// repeatable marker.
func optionDetail(f Flag) string {
parts := []string{}
if f.Description != "" {
parts = append(parts, f.Description)
}
if f.Default != "" && !f.Bare {
if _, err := strconv.Atoi(f.Default); err == nil {
parts = append(parts, "[default: "+f.Default+"]")
} else {
parts = append(parts, "[default: "+strconv.Quote(f.Default)+"]")
}
}
if f.Repeatable {
parts = append(parts, "(multiple values allowed)")
}
return strings.Join(parts, " ")
}
// renderHelp writes a command's help in the Artisan layout: Description,
// Usage, Arguments, Options with defaults, Global options, Help (the long
// text) and Examples, each section present only when it has content.
func (st *rootState) renderHelp(c Command, g Globals) error {
p := DefaultPalette()
var b strings.Builder
section := func(title string) {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(p.Header.Render(title+":") + "\n")
}
if c.Description != "" {
section("Description")
b.WriteString(" " + c.Description + "\n")
}
section("Usage")
b.WriteString(" " + helpUsage(c) + "\n")
width := 0
for _, a := range c.Args {
width = max(width, len(a.Name))
}
for _, f := range c.Flags {
width = max(width, len(optionLabel(f)))
}
row := func(label, detail string) {
line := " " + p.Success.Render(label)
if detail != "" {
line += strings.Repeat(" ", width-len(label)+2) + detail
}
b.WriteString(line + "\n")
}
if len(c.Args) > 0 {
section("Arguments")
for _, a := range c.Args {
row(a.Name, a.Description)
}
}
if len(c.Flags) > 0 {
section("Options")
for _, f := range c.Flags {
row(optionLabel(f), optionDetail(f))
}
}
section("Global options")
writeGlobalOptions(&b, p)
if long := strings.TrimSpace(c.Long); long != "" {
section("Help")
for _, line := range strings.Split(long, "\n") {
if strings.TrimSpace(line) == "" {
b.WriteString("\n")
continue
}
b.WriteString(" " + line + "\n")
}
}
if len(c.Examples) > 0 {
section("Examples")
bin := usageName(st.name)
for i, e := range c.Examples {
if i > 0 {
b.WriteString("\n")
}
if e.Description != "" {
b.WriteString(" " + p.Muted.Render("# "+e.Description) + "\n")
}
b.WriteString(" " + bin + " " + e.Command + "\n")
}
}
return writeStyled(st.out, g, b.String())
}

View File

@@ -0,0 +1,235 @@
package bonfire
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
)
func smokeTokenCommand() Command {
return Command{
Name: "admin:token:create",
Description: "Create a personal API token for a backend admin",
Long: "Creates a token for the admin with the given login.\nThe token is printed once.",
Args: []Arg{{Name: "login", Description: "The admin's login", Required: true}},
Flags: []Flag{
{Name: "scopes", Description: "Comma-separated scopes", Default: "*"},
{Name: "expires", Description: "Days until expiry", Default: "90"},
{Name: "json", Description: "Print the token as JSON", Bare: true},
{Name: "tag", Description: "Tag to add", Repeatable: true},
},
Examples: []Example{
{Command: "admin:token:create jane --scopes=journal.*", Description: "Token for the MCP server, journal only"},
{Command: "admin:token:create jane --json", Description: "Script-friendly output"},
},
Run: func(context.Context, Input, Output) error { return nil },
}
}
// smokeRoot returns a runner that executes args on a fresh root each time
// (pflag keeps a command's local flag values between executions).
func smokeRoot(t *testing.T, out *bytes.Buffer, commands ...Command) func(args ...string) int {
t.Helper()
return func(args ...string) int {
t.Helper()
root, err := NewRootIO("acme", commands, strings.NewReader(""), out, out,
WithVersion(Version{App: "v1.4.0", Framework: "v0.3.2"}))
if err != nil {
t.Fatal(err)
}
out.Reset()
return Execute(context.Background(), root, args)
}
}
// TestHelpSmoke: <command> --help renders the Artisan sections in order,
// with defaults, the repeatable marker and examples prefixed by bin/<name>,
// and help <command> prints the same.
func TestHelpSmoke(t *testing.T) {
var out bytes.Buffer
run := smokeRoot(t, &out, smokeTokenCommand())
if code := run("admin:token:create", "--help"); code != ExitOK {
t.Fatalf("--help exit = %d\n%s", code, out.String())
}
got := out.String()
order := []string{
"Description:\n Create a personal API token for a backend admin\n",
"Usage:\n admin:token:create <login> [options]\n",
"Arguments:\n login ",
"Options:\n",
` --scopes=SCOPES Comma-separated scopes [default: "*"]`,
" --expires=EXPIRES Days until expiry [default: 90]",
" --json ",
"Tag to add (multiple values allowed)",
"Global options:\n -h, --help",
"Help:\n Creates a token for the admin with the given login.\n The token is printed once.\n",
"Examples:\n # Token for the MCP server, journal only\n bin/acme admin:token:create jane --scopes=journal.*\n\n # Script-friendly output\n bin/acme admin:token:create jane --json\n",
}
last := -1
for _, want := range order {
if want == ` --scopes=SCOPES Comma-separated scopes [default: "*"]` || want == " --expires=EXPIRES Days until expiry [default: 90]" {
// The detail column is aligned to the longest label.
label, detail, _ := strings.Cut(strings.TrimSpace(want), " ")
i := strings.Index(got, label)
if i < 0 || !strings.Contains(got[i:], detail) {
t.Fatalf("help missing %q / %q:\n%s", label, detail, got)
}
continue
}
i := strings.Index(got, want)
if i < 0 {
t.Fatalf("help missing %q:\n%s", want, got)
}
if i < last {
t.Fatalf("%q out of order:\n%s", want, got)
}
last = i
}
if strings.Contains(got, "--json=") || strings.Contains(got, "[default: \"true\"]") {
t.Fatalf("bare flag rendered with a value:\n%s", got)
}
viaFlag := got
if code := run("help", "admin:token:create"); code != ExitOK {
t.Fatalf("help exit = %d", code)
}
if out.String() != viaFlag {
t.Fatalf("help <command> differs from <command> --help:\n%s\n---\n%s", out.String(), viaFlag)
}
if code := run("help"); code != ExitOK || !strings.Contains(out.String(), "Available commands:") {
t.Fatalf("bare help exit = %d\n%s", code, out.String())
}
}
// TestListJSONSmoke: list --format=json decodes into Listing with every
// command, a Hidden one flagged; the text list hides it unless --all.
func TestListJSONSmoke(t *testing.T) {
hidden := Command{Name: "acme:secret", Description: "A hidden maintenance command", Hidden: true,
Run: func(context.Context, Input, Output) error { return nil }}
var out bytes.Buffer
run := smokeRoot(t, &out, smokeTokenCommand(), hidden)
if code := run("list", "--format=json"); code != ExitOK {
t.Fatalf("json exit = %d\n%s", code, out.String())
}
var l Listing
if err := json.Unmarshal(out.Bytes(), &l); err != nil {
t.Fatalf("decode: %v\n%s", err, out.String())
}
if l.Application.Name != "acme" || l.Application.Version != "v1.4.0" || l.Application.Framework != "v0.3.2" {
t.Fatalf("application = %+v", l.Application)
}
if len(l.GlobalOptions) == 0 {
t.Fatal("no global options")
}
byName := map[string]CommandInfo{}
for _, c := range l.Commands {
byName[c.Name] = c
}
for _, name := range []string{"acme:secret", "admin:token:create", "list", "help"} {
if _, ok := byName[name]; !ok {
t.Fatalf("listing misses %s: %+v", name, l.Commands)
}
}
if !byName["acme:secret"].Hidden {
t.Fatal("hidden command not flagged")
}
tok := byName["admin:token:create"]
if len(tok.Arguments) != 1 || len(tok.Options) != 4 || len(tok.Examples) != 2 || tok.Options[1].Default != "90" {
t.Fatalf("token command = %+v", tok)
}
if !strings.Contains(out.String(), `"global_options"`) {
t.Fatal("json keys are not snake case")
}
if code := run("list"); code != ExitOK || strings.Contains(out.String(), "acme:secret") {
t.Fatalf("list shows the hidden command (exit %d):\n%s", code, out.String())
}
if code := run("list", "--all"); code != ExitOK || !strings.Contains(out.String(), "acme:secret") {
t.Fatalf("list --all hides the command (exit %d):\n%s", code, out.String())
}
if code := run("acme:secret"); code != ExitOK {
t.Fatalf("hidden command does not run: %d", code)
}
if code := run("list", "--format=xml"); code != ExitUsage {
t.Fatalf("unknown format exit = %d", code)
}
problems := CheckDocs([]Command{smokeTokenCommand(), hidden, {Name: "acme:bare", Args: []Arg{{Name: "x"}}, Flags: []Flag{{Name: "y"}}}})
if len(problems) != 2 || problems[0].Command != "acme:bare" || problems[1].Command != "acme:secret" {
t.Fatalf("CheckDocs = %v", problems)
}
if got := problems[0].String(); got != "acme:bare: missing description, long help, examples, argument x description, option --y description" {
t.Fatalf("problem = %q", got)
}
}
// TestErrorRenderSmoke: the error block shows hints with {bin} replaced,
// the -v hint or the wrapped chain, nothing for Reported errors, the exit
// codes, the unknown command suggestion and scrubbed secrets.
func TestErrorRenderSmoke(t *testing.T) {
inner := errors.New("open summer.yaml: no such file")
hinted := WithHint(fmt.Errorf("no application found in /tmp/x: %w", inner),
"summer make:command needs an application root (summer.yaml).",
"cd ..; {bin} make:command acme.blog:publish")
var buf bytes.Buffer
RenderError(&buf, "acme", hinted, Globals{})
got := buf.String()
for _, want := range []string{
" ERROR no application found in /tmp/x: open summer.yaml: no such file\n",
"\n summer make:command needs an application root (summer.yaml).\n",
"\n Try:\n cd ..; bin/acme make:command acme.blog:publish\n",
"\n Run with -v for details.\n",
} {
if !strings.Contains(got, want) {
t.Fatalf("block missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "Caused by:") {
t.Fatalf("chain shown at verbosity 0:\n%s", got)
}
buf.Reset()
RenderError(&buf, "summer", hinted, Globals{Verbosity: 1})
got = buf.String()
if !strings.Contains(got, "Caused by:\n - open summer.yaml: no such file\n") || strings.Contains(got, "Run with -v") || !strings.Contains(got, "cd ..; summer make:command") {
t.Fatalf("verbose block:\n%s", got)
}
buf.Reset()
RenderError(&buf, "acme", errors.New("upstream said Bearer abcdefghijklmnop0123456789"), Globals{Verbosity: 1})
if strings.Contains(buf.String(), "abcdefghijklmnop0123456789") {
t.Fatalf("bearer token not scrubbed:\n%s", buf.String())
}
buf.Reset()
RenderError(&buf, "acme", Reported(errors.New("3 problems")), Globals{})
if buf.Len() != 0 {
t.Fatalf("reported error printed: %q", buf.String())
}
var out bytes.Buffer
run := smokeRoot(t, &out,
Command{Name: "migrate", Description: "Run the migrations", Run: func(context.Context, Input, Output) error { return nil }},
Command{Name: "docs:build", Description: "Build docs", Run: func(_ context.Context, _ Input, o Output) error {
o.Println("problem 1")
return Reported(errors.New("docs:build failed"))
}},
)
if code := run("docs:build"); code != ExitFailure || strings.Contains(out.String(), "ERROR") {
t.Fatalf("reported: exit %d\n%s", code, out.String())
}
if code := run("migrat"); code != ExitUsage {
t.Fatalf("unknown command exit = %d", code)
}
got = out.String()
if !strings.Contains(got, `ERROR Command "migrat" is not defined.`) || !strings.Contains(got, "Try:\n bin/acme migrate\n") {
t.Fatalf("unknown command block:\n%s", got)
}
if code := run("migrate", "extra", "--bogus"); code != ExitUsage || !strings.Contains(out.String(), "bin/acme help migrate") {
t.Fatalf("unknown flag: exit %d\n%s", code, out.String())
}
if code := run("help", "migrat"); code != ExitUsage || !strings.Contains(out.String(), "bin/acme help migrate") {
t.Fatalf("help unknown: exit %d\n%s", code, out.String())
}
}

View File

@@ -2,6 +2,7 @@ package bonfire
import (
"context"
"encoding/json"
"fmt"
"io"
"slices"
@@ -17,15 +18,24 @@ func builtinCommands() []Command {
{
Name: "help",
Description: "Display help for a command",
Long: "Prints the description, usage, arguments, options, long help and examples of a command. Without a command name it prints the command list.",
Args: []Arg{{Name: "command_name", Description: "The command name"}},
Examples: []Example{
{Command: "help migrate", Description: "Help for the migrate command"},
},
},
{
Name: "list",
Description: "List commands",
Long: "Prints the commands grouped by namespace. --all includes hidden commands; --format=json prints every command with its arguments, options and examples.",
Flags: []Flag{
{Name: "all", Description: "Show hidden commands too", Bare: true},
{Name: "format", Description: "The output format (txt or json)", Default: "txt"},
},
Examples: []Example{
{Command: "list --all", Description: "Every command, hidden ones included"},
{Command: "list --format=json", Description: "The catalog for scripts and agents"},
},
},
}
}
@@ -48,45 +58,71 @@ func usageName(name string) string {
return "bin/" + name
}
// lister renders the command list of one root.
type lister struct {
name string
commands []Command
version Version
out io.Writer
console *console
}
// command is the list command with its run function.
func (l *lister) command() Command {
// listCommand is the list command with its run function.
func (st *rootState) listCommand() Command {
c := builtinCommand("list")
c.Run = func(_ context.Context, in Input, _ Output) error {
all, _ := in.Flag("all")
format, _ := in.Flag("format")
switch format {
case "", "txt":
return l.render(l.console.globals, all == "true")
return st.renderList(st.globals(), all == "true")
case "json":
return st.renderJSON()
default:
return Usage(fmt.Errorf("list: unknown format %q (use txt or json)", format))
return WithHint(Usage(fmt.Errorf("list: unknown format %q", format)),
"list prints txt (the default) or json.", "{bin} list --format=json")
}
}
return c
}
// all returns the root's commands and its builtins.
func (l *lister) all() []Command {
return append(append([]Command(nil), l.commands...), builtinCommands()...)
// helpCommand is the help command: the list without an argument, a
// command's help with one.
func (st *rootState) helpCommand() Command {
c := builtinCommand("help")
c.Run = func(_ context.Context, in Input, _ Output) error {
args := in.Args()
if len(args) == 0 {
return st.renderList(st.globals(), false)
}
name := args[0]
for _, cmd := range st.all() {
if cmd.Name == name {
return st.renderHelp(cmd, st.globals())
}
}
err := Usage(fmt.Errorf("Command %q is not defined.", name))
var try []string
for _, s := range st.root.SuggestionsFor(name) {
try = append(try, "{bin} help "+s)
}
if len(try) == 0 {
try = []string{"{bin} list"}
}
return WithHint(err, "", strings.Join(try, "\n"))
}
return c
}
// render writes the Artisan list: the version header, usage, the global
// all returns the root's commands and its builtins.
func (st *rootState) all() []Command {
return append(append([]Command(nil), st.commands...), builtinCommands()...)
}
// renderList writes the Artisan list: the version header, usage, the global
// options and the commands, top-level names first and then one block per
// namespace (the text before the first colon).
func (l *lister) render(g Globals, all bool) error {
// namespace (the text before the first colon). Hidden commands are left out
// unless all is true.
func (st *rootState) renderList(g Globals, all bool) error {
p := DefaultPalette()
var top []Command
groups := map[string][]Command{}
width := 0
for _, c := range l.all() {
for _, c := range st.all() {
if c.Hidden && !all {
continue
}
ns, _, namespaced := strings.Cut(c.Name, ":")
if namespaced {
groups[ns] = append(groups[ns], c)
@@ -104,9 +140,9 @@ func (l *lister) render(g Globals, all bool) error {
slices.Sort(namespaces)
var b strings.Builder
b.WriteString(p.Header.Render(l.version.Header(l.name)) + "\n\n")
b.WriteString(p.Header.Render(st.version.Header(st.name)) + "\n\n")
b.WriteString(p.Header.Render("Usage:") + "\n")
b.WriteString(" " + usageName(l.name) + " <command> [options] [arguments]\n\n")
b.WriteString(" " + usageName(st.name) + " <command> [options] [arguments]\n\n")
b.WriteString(p.Header.Render("Options:") + "\n")
writeGlobalOptions(&b, p)
b.WriteString("\n" + p.Header.Render("Available commands:") + "\n")
@@ -125,7 +161,17 @@ func (l *lister) render(g Globals, all bool) error {
line(c)
}
}
return writeStyled(l.out, g, b.String())
return writeStyled(st.out, g, b.String())
}
// renderJSON writes Describe of the root as indented JSON.
func (st *rootState) renderJSON() error {
data, err := json.MarshalIndent(Describe(st.name, st.all(), st.version), "", " ")
if err != nil {
return err
}
_, err = st.out.Write(append(data, '\n'))
return err
}
// writeGlobalOptions writes the global option lines, aligned.
@@ -147,3 +193,112 @@ func writeStyled(w io.Writer, g Globals, s string) error {
_, err := io.WriteString(cw, s)
return err
}
// Listing is the JSON catalog of a binary's commands (list --format=json).
type Listing struct {
Application ApplicationInfo `json:"application"`
GlobalOptions []OptionInfo `json:"global_options"`
Commands []CommandInfo `json:"commands"`
}
// ApplicationInfo names the binary and its versions in a Listing.
type ApplicationInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Framework string `json:"framework"`
}
// CommandInfo is one command of a Listing.
type CommandInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Long string `json:"long"`
Usage string `json:"usage"`
Hidden bool `json:"hidden"`
Passthrough bool `json:"passthrough"`
Arguments []ArgumentInfo `json:"arguments"`
Options []OptionInfo `json:"options"`
Examples []ExampleInfo `json:"examples"`
}
// ArgumentInfo is one positional argument of a CommandInfo.
type ArgumentInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Required bool `json:"required"`
}
// OptionInfo is one flag of a CommandInfo or a global option.
type OptionInfo struct {
Name string `json:"name"`
Shorthand string `json:"shorthand"`
Description string `json:"description"`
Default string `json:"default"`
Bare bool `json:"bare"`
Repeatable bool `json:"repeatable"`
}
// ExampleInfo is one example of a CommandInfo, the command line without the
// binary.
type ExampleInfo struct {
Command string `json:"command"`
Description string `json:"description"`
}
// globalOptionInfos are the global options as Listing entries.
var globalOptionInfos = []OptionInfo{
{Name: flagHelp, Shorthand: "h", Description: "Display help for the given command", Bare: true},
{Name: flagQuiet, Shorthand: "q", Description: "Do not output any message", Bare: true},
{Name: flagVerbose, Shorthand: "v", Description: "Increase verbosity (-v, -vv or -vvv)", Bare: true, Repeatable: true},
{Name: flagANSI, Description: "Force ANSI output", Bare: true},
{Name: flagNoANSI, Description: "Disable ANSI output", Bare: true},
{Name: flagNoInteraction, Shorthand: "n", Description: "Do not ask any interactive question", Bare: true},
{Name: flagEnv, Description: "The environment the command runs under"},
}
// Describe returns the Listing of a binary named name with the given
// commands, sorted by name: every command, hidden ones included, with its
// arguments, options and examples. NewRoot's list --format=json adds the
// list and help commands.
func Describe(name string, commands []Command, v Version) Listing {
l := Listing{
Application: ApplicationInfo{
Name: name,
Version: cleanVersion(v.App),
Framework: cleanVersion(v.Framework),
},
GlobalOptions: append([]OptionInfo(nil), globalOptionInfos...),
Commands: make([]CommandInfo, 0, len(commands)),
}
if v.Framework == "" {
l.Application.Framework = l.Application.Version
}
for _, c := range commands {
info := CommandInfo{
Name: c.Name,
Description: c.Description,
Long: c.Long,
Usage: helpUsage(c),
Hidden: c.Hidden,
Passthrough: c.Passthrough,
Arguments: []ArgumentInfo{},
Options: []OptionInfo{},
Examples: []ExampleInfo{},
}
for _, a := range c.Args {
info.Arguments = append(info.Arguments, ArgumentInfo{Name: a.Name, Description: a.Description, Required: a.Required})
}
for _, f := range c.Flags {
info.Options = append(info.Options, OptionInfo{
Name: f.Name, Shorthand: f.Shorthand, Description: f.Description,
Default: f.Default, Bare: f.Bare, Repeatable: f.Repeatable,
})
}
for _, e := range c.Examples {
info.Examples = append(info.Examples, ExampleInfo{Command: e.Command, Description: e.Description})
}
l.Commands = append(l.Commands, info)
}
slices.SortFunc(l.Commands, func(a, b CommandInfo) int { return strings.Compare(a.Name, b.Name) })
return l
}

View File

@@ -97,10 +97,19 @@ func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Write
root.SetErr(errW)
flags := &globalFlags{}
flags.register(root.PersistentFlags())
root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
return Usage(err)
root.SetFlagErrorFunc(func(c *cobra.Command, err error) error {
return usageFor(c, root, err)
})
st := &rootState{
root: root,
name: name,
commands: commands,
version: cfg.version,
out: out,
console: output,
flags: flags,
}
seen := map[string]bool{}
for _, b := range builtinCommands() {
seen[b.Name] = true
@@ -110,21 +119,28 @@ func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Write
return nil, fmt.Errorf("%w: %q", ErrDuplicateCommand, command.Name)
}
seen[command.Name] = true
cmd, err := wrap(command, output)
cmd, err := st.wrap(command, true)
if err != nil {
return nil, err
}
root.AddCommand(cmd)
}
lister := &lister{name: name, commands: commands, version: cfg.version, out: out, console: output}
listCmd, err := wrapCommand(lister.command(), output, false)
listCmd, err := st.wrap(st.listCommand(), false)
if err != nil {
return nil, err
}
root.AddCommand(listCmd)
helpCmd, err := st.wrap(st.helpCommand(), false)
if err != nil {
return nil, err
}
root.SetHelpCommand(helpCmd)
root.SetHelpFunc(func(*cobra.Command, []string) {
_ = st.renderList(st.globals(), false)
})
root.RunE = func(cmd *cobra.Command, _ []string) error {
return lister.render(output.globals, false)
return st.renderList(st.globals(), false)
}
root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
var g Globals
@@ -144,6 +160,36 @@ func NewRootIO(name string, commands []Command, in io.Reader, out, errW io.Write
return root, nil
}
// rootState is what the list, help and error rendering of one root share.
type rootState struct {
root *cobra.Command
name string
commands []Command
version Version
out io.Writer
console *console
flags *globalFlags
}
// globals returns the parsed global options of the current run.
func (st *rootState) globals() Globals {
return st.flags.globals()
}
// wrap turns a command value into a cobra command. validate checks the
// name form (the builtins list and help skip it).
func (st *rootState) wrap(c Command, validate bool) (*cobra.Command, error) {
cmd, err := wrapCommand(c, st.console, validate)
if err != nil {
return nil, err
}
cmd.Hidden = c.Hidden
cmd.SetHelpFunc(func(*cobra.Command, []string) {
_ = st.renderHelp(c, st.globals())
})
return cmd, nil
}
func wrap(c Command, out Output) (*cobra.Command, error) {
return wrapCommand(c, out, true)
}
@@ -211,6 +257,23 @@ func wrapCommand(c Command, out Output, validate bool) (*cobra.Command, error) {
// usageArgs marks argument-count failures as usage errors (exit code 2).
func usageArgs(fn cobra.PositionalArgs) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
return Usage(fn(cmd, args))
return usageFor(cmd, cmd.Root(), fn(cmd, args))
}
}
// usageFor wraps err as a UsageError that remembers the command, so the
// error block can suggest its help.
func usageFor(cmd, root *cobra.Command, err error) error {
if err == nil {
return nil
}
var ue *UsageError
if errors.As(err, &ue) {
return err
}
ue = &UsageError{Err: err}
if cmd != nil && cmd != root {
ue.command = cmd.Name()
}
return ue
}