// 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 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 --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 [flags]" case cmd.Annotations["tea.group"] != "": return cmd.CommandPath() + " [flags]" case cmd.HasAvailableSubCommands(): return cmd.UseLine() + "\n" + cmd.CommandPath() + " [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 and the global flags that apply to it: --repo, --remote and --cwd // unless it never selects a repository, --json and --jq when it outputs JSON, and --yes when it asks // for confirmation. Persistent flags of parent commands, such as --org, count as its own. 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), "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), 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 }