Files
gitea-tea/internal/app/help_topics.go
T

348 lines
14 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"cmp"
"fmt"
"io"
"slices"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// Help pages use gh-style sections: USAGE, the command lists, FLAGS, INHERITED FLAGS, ARGUMENTS,
// JSON FIELDS, EXAMPLES and LEARN MORE. Commands document themselves through these fields:
//
// - Short: one line, a capitalized verb phrase without a final period ("List issues").
// - Long: optional paragraphs shown instead of Short on the command's own page.
// - Example: unindented lines; "$ tea …" for commands and "# …" for comments above them.
// - Annotations["tea.arguments"]: the ARGUMENTS section, describing accepted argument formats.
// - Annotations["tea.confirm"]: "true" when the command asks for confirmation, which shows --yes.
const (
// topicAnnotation marks a hidden command that only holds a help topic.
topicAnnotation = "tea.topic"
// globalFlagsLine names the global flags that every help page leaves out of its flag lists.
globalFlagsLine = "Global flags: --host, --user, --sudo, --no-input, --interactive, --debug (see tea help environment)"
)
// noRepoCommands are command paths, with their subcommands, that never select a repository, so their
// help omits --repo, --remote and --cwd.
var noRepoCommands = []string{
"tea admin", "tea alias", "tea auth", "tea completion", "tea config", "tea help", "tea man", "tea org",
"tea repo create", "tea repo list", "tea repo migrate", "tea search repo", "tea ssh-key", "tea team", "tea version",
}
// writeHelp prints the help page of cmd.
func (a *application) writeHelp(cmd *cobra.Command, _ []string) {
if cmd.Annotations[topicAnnotation] != "" {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), strings.TrimSpace(cmd.Long))
return
}
color := a.deps.Streams.OutTTY && a.colorEnabled()
var page strings.Builder
section := func(title, body string) {
if body = strings.Trim(body, "\n"); body != "" {
fmt.Fprintf(&page, "%s\n%s\n\n", terminal.Emphasis(title, color), indent(body))
}
}
if description := cmp.Or(cmd.Long, cmd.Short); description != "" {
page.WriteString(description + "\n\n")
}
section("USAGE", usageLines(cmd))
section("ALIASES", aliasLines(cmd))
commands := availableCommands(cmd)
width := 0
for _, children := range commands {
for _, child := range children.commands {
width = max(width, len(child.Name())+3)
}
}
for _, children := range commands {
var lines strings.Builder
for _, child := range children.commands {
fmt.Fprintf(&lines, "%-*s%s\n", width, child.Name()+":", child.Short)
}
section(children.title, lines.String())
}
if !cmd.HasParent() {
section("FLAGS", "--help Show help for command\n--version Show tea version")
} else {
local, inherited := helpFlags(cmd)
section("FLAGS", local)
section("INHERITED FLAGS", inherited+"\n\n"+globalFlagsLine)
}
section("ARGUMENTS", cmd.Annotations["tea.arguments"])
if outputsJSON(cmd) {
section("JSON FIELDS", wrap(strings.Join(commandOutputFields(cmd), ", "), 78))
}
if !cmd.HasParent() {
section("EXAMPLES", "$ tea issue create\n$ tea issue list --label bug\n$ tea pr checkout 13\n$ tea repo clone owner/name")
} else {
section("EXAMPLES", cmd.Example)
}
section("LEARN MORE", "Use `tea <command> <subcommand> --help` for more information about a command.\n"+
"Read `tea help environment` for global flags and environment variables.\n"+
"Read `tea help formatting` for --json and --jq, and `tea help exit-codes` for exit codes.")
_, _ = io.WriteString(cmd.OutOrStdout(), strings.TrimRight(page.String(), "\n")+"\n")
}
// indent indents every non-empty line of text by two spaces.
func indent(text string) string {
lines := strings.Split(text, "\n")
for i, line := range lines {
if line != "" {
lines[i] = " " + line
}
}
return strings.Join(lines, "\n")
}
// usageLines shows how to run cmd; command groups take a subcommand.
func usageLines(cmd *cobra.Command) string {
switch {
case !cmd.HasParent():
return "tea <command> <subcommand> [flags]"
case cmd.Annotations["tea.group"] != "":
return cmd.CommandPath() + " <command> [flags]"
case cmd.HasAvailableSubCommands():
return cmd.UseLine() + "\n" + cmd.CommandPath() + " <command> [flags]"
}
return cmd.UseLine()
}
// aliasLines shows the other names of cmd.
func aliasLines(cmd *cobra.Command) string {
lines := make([]string, 0, len(cmd.Aliases))
for _, alias := range cmd.Aliases {
lines = append(lines, cmd.Parent().CommandPath()+" "+alias)
}
return strings.Join(lines, "\n")
}
// commandList is one titled list of commands on a help page.
type commandList struct {
title string
commands []*cobra.Command
}
// availableCommands lists the subcommands of cmd by group, the ungrouped ones, then help topics.
func availableCommands(cmd *cobra.Command) []commandList {
var lists []commandList
for _, group := range cmd.Groups() {
lists = append(lists, commandList{title: strings.ToUpper(group.Title)})
}
other, topics := commandList{title: "AVAILABLE COMMANDS"}, commandList{title: "HELP TOPICS"}
for _, child := range cmd.Commands() {
switch at := slices.IndexFunc(cmd.Groups(), func(g *cobra.Group) bool { return g.ID == child.GroupID }); {
case child.Annotations[topicAnnotation] != "":
topics.commands = append(topics.commands, child)
case !child.IsAvailableCommand():
case at >= 0:
lists[at].commands = append(lists[at].commands, child)
default:
other.commands = append(other.commands, child)
}
}
return append(lists, other, topics)
}
// helpFlags renders the flags of cmd, counting parents' persistent flags such as --org as its own, and
// the global flags that apply to it: --repo, --remote and --cwd, --json and --jq, and --yes.
func helpFlags(cmd *cobra.Command) (local, inherited string) {
globals := cmd.Root().PersistentFlags()
var own []*pflag.Flag
for _, flags := range []*pflag.FlagSet{cmd.LocalFlags(), cmd.InheritedFlags()} {
flags.VisitAll(func(flag *pflag.Flag) {
if flag.Name != "help" && globals.Lookup(flag.Name) != flag {
own = append(own, flag)
}
})
}
slices.SortFunc(own, func(x, y *pflag.Flag) int { return strings.Compare(x.Name, y.Name) })
path := cmd.CommandPath()
repo := !slices.ContainsFunc(noRepoCommands, func(prefix string) bool { return path == prefix || strings.HasPrefix(path, prefix+" ") })
shown := map[string]bool{"repo": repo, "remote": repo, "cwd": repo, "json": outputsJSON(cmd), "jq": outputsJSON(cmd) || cmd.Annotations["tea.rawjson"] != "", "yes": cmd.Annotations["tea.confirm"] != ""}
var shared []*pflag.Flag
for _, name := range []string{"repo", "remote", "cwd", "json", "jq", "yes"} {
if shown[name] {
shared = append(shared, globals.Lookup(name))
}
}
if help := cmd.Flags().Lookup("help"); help != nil {
shared = append(shared, help)
}
return flagUsages(own), flagUsages(shared)
}
// outputsJSON reports whether the --json flag applies to cmd.
func outputsJSON(cmd *cobra.Command) bool {
return cmd.Annotations["tea.text"] == "" && cmd.Annotations["tea.group"] == "" && commandOutputFields(cmd) != nil
}
// flagUsages renders flags in order as aligned usage lines without their common indentation. The
// optional --json value and the help flag read as in gh: "--json fields", "Show help for command".
func flagUsages(flags []*pflag.Flag) string {
set := pflag.NewFlagSet("help", pflag.ContinueOnError)
set.SortFlags = false
for _, flag := range flags {
shown := *flag
switch flag.Name {
case "json":
shown.NoOptDefVal = ""
case "help":
shown.Usage = "Show help for command"
}
set.AddFlag(&shown)
}
lines := strings.Split(strings.TrimRight(set.FlagUsages(), "\n"), "\n")
common := -1
for _, line := range lines {
if strings.TrimSpace(line) != "" {
spaces := len(line) - len(strings.TrimLeft(line, " "))
if common < 0 || spaces < common {
common = spaces
}
}
}
for i, line := range lines {
lines[i] = line[min(max(common, 0), len(line)):]
}
return strings.Join(lines, "\n")
}
// wrap breaks text at spaces into lines of at most width characters.
func wrap(text string, width int) string {
var lines []string
line := ""
for _, word := range strings.Fields(text) {
if line != "" && len(line)+1+len(word) > width {
lines, line = append(lines, line), ""
}
line = strings.TrimPrefix(line+" "+word, " ")
}
return strings.Join(append(lines, line), "\n")
}
// helpCommand prints the help of a command or topic, and rejects unknown ones.
func (a *application) helpCommand() *cobra.Command {
return &cobra.Command{
Use: "help [command...]", Short: "Show help for a command or topic",
Annotations: textOutput("help prints text"),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
target, _, err := cmd.Root().Find(args)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var names []cobra.Completion
for _, child := range target.Commands() {
if (child.IsAvailableCommand() || child.Annotations[topicAnnotation] != "") && strings.HasPrefix(child.Name(), toComplete) {
names = append(names, cobra.CompletionWithDesc(child.Name(), child.Short))
}
}
return names, cobra.ShellCompDirectiveNoFileComp
},
RunE: func(cmd *cobra.Command, args []string) error {
target, rest, err := cmd.Root().Find(args)
if err == nil && (len(rest) == 0 || !target.HasSubCommands()) {
target.InitDefaultHelpFlag()
return target.Help()
}
if len(rest) == 0 {
rest = args[len(args)-1:]
}
return unknownHelpTopic(target, args, rest[0])
},
}
}
// unknownHelpTopic reports a help request for a command or topic that does not exist; name is the
// first word that parent does not know.
func unknownHelpTopic(parent *cobra.Command, args []string, name string) error {
err := fmt.Errorf("unknown help topic %q", strings.Join(args, " "))
suggestions := parent.SuggestionsFor(name)
for _, child := range parent.Commands() {
if child.Annotations[topicAnnotation] != "" && strings.HasPrefix(child.Name(), name) {
suggestions = append(suggestions, child.Name())
}
}
if len(suggestions) > 0 {
err = fmt.Errorf("%w; did you mean %s?", err, strings.Join(suggestions, " or "))
} else {
err = fmt.Errorf("%w; run tea help to list commands and topics", err)
}
return clierr.Wrap(clierr.Usage, err)
}
// helpTopics returns the help topic commands; globals are the flags that every command accepts.
func helpTopics(globals *pflag.FlagSet) []*cobra.Command {
var flags []*pflag.Flag
globals.VisitAll(func(flag *pflag.Flag) { flags = append(flags, flag) })
topics := []*cobra.Command{
{Use: "environment", Short: "Environment variables and global flags", Long: `Global flags work with every command, before or after the command name:
` + indent(flagUsages(flags)) + `
tea selects the repository from --repo, TEA_REPO, git config tea.repository or a
Git remote (--remote, else the current branch's remote, upstream or origin, skipping
remotes on instances without an account, or not on --host or the GITEA_TOKEN instance),
and the instance from the repository URL, --host, TEA_HOST or GITEA_INSTANCE_URL,
default_host, or the only configured instance.
Environment variables:
TEA_CONFIG_DIR configuration directory; tea config path prints the files
TEA_HOST, GITEA_INSTANCE_URL instance URL, overriding default_host
GITEA_TOKEN access token for that instance instead of stored credentials
GITEA_INSTANCE_INSECURE with GITEA_TOKEN, skip TLS verification when true
GITEA_INSTANCE_SSH_HOST with GITEA_TOKEN, SSH host that identifies the instance's remotes
TEA_USER account on the selected instance, like --user
TEA_REPO repository as OWNER/REPO or URL, like --repo
TEA_EDITOR editor, overriding config.yml
TEA_PAGER pager, overriding config.yml
TEA_COLOR color preference: auto, always or never
NO_COLOR disables automatic color when set
VISUAL, EDITOR, PAGER, BROWSER editor (VISUAL first), pager and browser when config.yml sets none
Preferences in config.yml are described in tea config --help.`},
{Use: "exit-codes", Short: "Exit codes used by tea", Long: `tea exits with one of these codes:
0 success, including an empty list
1 the command failed
2 usage error: an unknown command or flag, or a missing or invalid argument
3 canceled: a declined confirmation, an aborted prompt or a timeout
4 authentication is missing or was rejected
8 tea pr checks: some checks are still pending
130 interrupted with Ctrl-C
A command may document further codes in its help.`},
{Use: "formatting", Short: "JSON output with --json and --jq", Long: `tea prints text for people by default. Scripts should use JSON: the columns and
wording of text output may change between releases.
--json print every field
--json FIELDS print only the comma-separated FIELDS, such as --json number,title
-q, --jq EXPR filter the JSON with a jq expression; implies --json
The JSON FIELDS section of a command's help lists its fields, and shell completion
offers them after --json. An unknown field reports the available ones.
--jq runs inside tea, so jq does not need to be installed. String results print
without quotes:
$ tea issue list --json number,title --jq '.[] | "\(.number)\t\(.title)"'
On a terminal, JSON is indented and colored; otherwise each value prints on one line.
Commands that return no resource, such as deletes, reject --json.`},
}
for _, topic := range topics {
topic.Hidden, topic.Annotations = true, map[string]string{topicAnnotation: "true"}
}
return topics
}