package bonfire import ( "context" "encoding/json" "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", 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"}, }, }, } } 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/ for an application binary. func usageName(name string) string { if name == "summer" { return name } return "bin/" + name } // 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 st.renderList(st.globals(), all == "true") case "json": return st.renderJSON() default: return WithHint(Usage(fmt.Errorf("list: unknown format %q", format)), "list prints txt (the default) or json.", "{bin} list --format=json") } } return c } // 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 } // 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). 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 st.all() { if c.Hidden && !all { continue } 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(st.version.Header(st.name)) + "\n\n") b.WriteString(p.Header.Render("Usage:") + "\n") b.WriteString(" " + usageName(st.name) + " [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(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. 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 } // 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 }