- Global options -v/-vv/-vvv, -q, --ansi/--no-ansi, -n and --env on every command; PeekGlobals reads them before config loads, OnGlobals hooks re-apply them after parsing (diary.ApplyGlobals in the generated main) - Bare binary, --help and list print the Artisan list with the version header from BuildVersion and namespace blocks - Execute runs the root through fang (no version, man page or completions) and returns 0, 1 or 2; Fail renders errors before the root exists - NewRoot rejects duplicate command names (ErrDuplicateCommand) and flags that reuse a global option (ErrReservedFlag) - Generated main and summer use PeekGlobals, compass.Open with --env, diary.FromGlobals and bonfire.Execute; docs checker knows the globals
150 lines
4.1 KiB
Go
150 lines
4.1 KiB
Go
package bonfire
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/colorprofile"
|
|
)
|
|
|
|
// builtinCommands describes the root's own commands, help and list. They are
|
|
// registered by NewRoot, so plugins cannot use their names.
|
|
func builtinCommands() []Command {
|
|
return []Command{
|
|
{
|
|
Name: "help",
|
|
Description: "Display help for a command",
|
|
Args: []Arg{{Name: "command_name", Description: "The command name"}},
|
|
},
|
|
{
|
|
Name: "list",
|
|
Description: "List commands",
|
|
Flags: []Flag{
|
|
{Name: "all", Description: "Show hidden commands too", Bare: true},
|
|
{Name: "format", Description: "The output format (txt or json)", Default: "txt"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func builtinCommand(name string) Command {
|
|
for _, c := range builtinCommands() {
|
|
if c.Name == name {
|
|
return c
|
|
}
|
|
}
|
|
return Command{Name: name}
|
|
}
|
|
|
|
// usageName is how a binary is invoked in usage lines and examples: summer
|
|
// for the tool, bin/<name> for an application binary.
|
|
func usageName(name string) string {
|
|
if name == "summer" {
|
|
return name
|
|
}
|
|
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 {
|
|
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")
|
|
default:
|
|
return Usage(fmt.Errorf("list: unknown format %q (use txt or json)", format))
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// all returns the root's commands and its builtins.
|
|
func (l *lister) all() []Command {
|
|
return append(append([]Command(nil), l.commands...), builtinCommands()...)
|
|
}
|
|
|
|
// render 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 {
|
|
p := DefaultPalette()
|
|
var top []Command
|
|
groups := map[string][]Command{}
|
|
width := 0
|
|
for _, c := range l.all() {
|
|
ns, _, namespaced := strings.Cut(c.Name, ":")
|
|
if namespaced {
|
|
groups[ns] = append(groups[ns], c)
|
|
} else {
|
|
top = append(top, c)
|
|
}
|
|
width = max(width, len(c.Name))
|
|
}
|
|
byName := func(a, b Command) int { return strings.Compare(a.Name, b.Name) }
|
|
slices.SortFunc(top, byName)
|
|
namespaces := make([]string, 0, len(groups))
|
|
for ns := range groups {
|
|
namespaces = append(namespaces, ns)
|
|
}
|
|
slices.Sort(namespaces)
|
|
|
|
var b strings.Builder
|
|
b.WriteString(p.Header.Render(l.version.Header(l.name)) + "\n\n")
|
|
b.WriteString(p.Header.Render("Usage:") + "\n")
|
|
b.WriteString(" " + usageName(l.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")
|
|
line := func(c Command) {
|
|
pad := strings.Repeat(" ", width-len(c.Name)+2)
|
|
b.WriteString(" " + p.Success.Render(c.Name) + pad + c.Description + "\n")
|
|
}
|
|
for _, c := range top {
|
|
line(c)
|
|
}
|
|
for _, ns := range namespaces {
|
|
b.WriteString(" " + p.Header.Render(ns) + "\n")
|
|
cmds := groups[ns]
|
|
slices.SortFunc(cmds, byName)
|
|
for _, c := range cmds {
|
|
line(c)
|
|
}
|
|
}
|
|
return writeStyled(l.out, g, b.String())
|
|
}
|
|
|
|
// writeGlobalOptions writes the global option lines, aligned.
|
|
func writeGlobalOptions(b *strings.Builder, p Palette) {
|
|
width := 0
|
|
for _, o := range globalOptions {
|
|
width = max(width, len(o.flags))
|
|
}
|
|
for _, o := range globalOptions {
|
|
pad := strings.Repeat(" ", width-len(o.flags)+2)
|
|
b.WriteString(" " + p.Success.Render(o.flags) + pad + o.description + "\n")
|
|
}
|
|
}
|
|
|
|
// writeStyled writes s to w through a colorprofile writer, so palette
|
|
// styles follow --ansi, --no-ansi, NO_COLOR and the terminal.
|
|
func writeStyled(w io.Writer, g Globals, s string) error {
|
|
cw := &colorprofile.Writer{Forward: w, Profile: DetectProfile(w, g.Color, nil)}
|
|
_, err := io.WriteString(cw, s)
|
|
return err
|
|
}
|