mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
353 lines
14 KiB
Go
353 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+" ") })
|
|
// A command's own --remote, as in repo fork, hides the global flag of that name.
|
|
// repo clone takes its repository as an argument only, so --repo and --remote do nothing there.
|
|
selects := repo && path != "tea repo clone"
|
|
shown := map[string]bool{"repo": selects, "remote": selects && cmd.LocalFlags().Lookup("remote") == nil, "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 (tea repo
|
|
set-default) or a Git remote (--remote, else the base remote pr checkout recorded, the
|
|
current branch's remote, upstream or origin, skipping remotes on instances without an
|
|
account, or not on --host or the GITEA_TOKEN instance). Like gh, a clone with upstream
|
|
and origin targets upstream: a branch tracking origin does not select the fork,
|
|
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
|
|
}
|