// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT // Package app builds the tea command tree and its injected dependencies. package app import ( "cmp" "context" "errors" "fmt" "os" "reflect" "regexp" "slices" "strings" "sync" "time" "unicode" "gitea.dev/tea/internal/aliases" "gitea.dev/tea/internal/auth" "gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/git" "gitea.dev/tea/internal/services" "gitea.dev/tea/internal/settings" "gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/transport" "gitea.dev/tea/internal/version" "github.com/spf13/cobra" "github.com/spf13/pflag" ) // Dependencies supplies external boundaries for a fresh CLI invocation. type Dependencies struct { Browser terminal.Browser Editor terminal.Editor Pager terminal.Pager Executable func() (string, error) Git git.Runner Now func() time.Time Settings *settings.Store Credentials *auth.Store Streams terminal.Streams Prompter terminal.Prompter IssueForm terminal.IssueForm IssueEditForm terminal.IssueEditForm PullForm terminal.PullForm ReviewForm terminal.ReviewForm MergeForm terminal.MergeForm MilestoneForm terminal.MilestoneForm Getenv func(string) string Client func(string, string, bool) *transport.Client } // System connects the CLI to the current environment and terminal. func System() Dependencies { streams := terminal.System() store := settings.New(os.Getenv("TEA_CONFIG_DIR")) prompter := &terminal.FormPrompter{Streams: streams} return Dependencies{Browser: terminal.SystemBrowser{ErrOut: streams.ErrOut}, Editor: terminal.SystemEditor{Streams: streams}, Pager: terminal.SystemPager{}, Executable: os.Executable, Git: git.System{In: streams.In, ErrOut: streams.ErrOut}, Now: time.Now, Settings: store, Credentials: &auth.Store{Dir: store.Dir, Keyring: auth.SystemKeyring{}}, Streams: streams, Prompter: prompter, IssueForm: prompter, IssueEditForm: prompter, PullForm: prompter, ReviewForm: prompter, MergeForm: prompter, MilestoneForm: prompter, Getenv: os.Getenv, Client: transport.New} } type options struct { host, user, repo, cwd, remote string sudo string json, jq string noInput, interactive, yes bool debug bool } type application struct { deps Dependencies options options // snapshot caches the settings for one run; generation is the store's generation it was loaded at. mu sync.Mutex snapshot *settings.Snapshot generation uint64 } // settings returns the settings, loaded once per run and again after tea changed them. func (a *application) settings() (settings.Snapshot, error) { a.mu.Lock() defer a.mu.Unlock() generation := a.deps.Settings.Generation() if a.snapshot != nil && a.generation == generation { return *a.snapshot, nil } snapshot, err := a.deps.Settings.Load() if err != nil { return snapshot, err } a.snapshot, a.generation = &snapshot, generation return snapshot, nil } // NewRoot builds fresh commands. Construction performs no disk, network, or Git IO. func NewRoot(deps Dependencies) *cobra.Command { if deps.Executable == nil { deps.Executable = os.Executable } if deps.Now == nil { deps.Now = time.Now } if deps.Git == nil { deps.Git = git.System{In: deps.Streams.In, ErrOut: deps.Streams.ErrOut} } if deps.Browser == nil { deps.Browser = terminal.SystemBrowser{ErrOut: deps.Streams.ErrOut} } if deps.Editor == nil { deps.Editor = terminal.SystemEditor{Streams: deps.Streams} } a := &application{deps: deps} if client := deps.Client; client != nil { a.deps.Client = func(instance, token string, insecure bool) *transport.Client { if a.options.debug { return client(instance, token, insecure).Debug(deps.Streams.ErrOut) } return client(instance, token, insecure) } } root := &cobra.Command{Use: "tea", Short: "Work with Gitea from your terminal", Long: "Work seamlessly with Gitea from the command line.", Version: version.Format(), SilenceErrors: true, SilenceUsage: true} root.SetIn(deps.Streams.In) root.SetOut(deps.Streams.Out) root.SetErr(deps.Streams.ErrOut) root.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { return clierr.Wrap(clierr.Usage, suggestFlags(cmd, err)) }) flags := root.PersistentFlags() flags.StringVar(&a.options.host, "host", "", "Gitea instance `URL`, including any base path") flags.StringVar(&a.options.user, "user", "", "Account `name` on the selected instance") flags.StringVar(&a.options.sudo, "sudo", "", "Perform API requests as this `user` (requires an administrator account)") flags.StringVarP(&a.options.repo, "repo", "R", "", "Select a repository as `OWNER/REPO` or URL") flags.StringVar(&a.options.cwd, "cwd", "", "Working `directory` for repository discovery") flags.StringVar(&a.options.remote, "remote", "", "Git `remote` used for repository discovery") flags.StringVar(&a.options.json, "json", "", "Output JSON with the specified comma-separated `fields`, or all fields") flags.Lookup("json").NoOptDefVal = "*" flags.StringVarP(&a.options.jq, "jq", "q", "", "Filter JSON output using a jq `expression`; implies --json") flags.BoolVar(&a.options.noInput, "no-input", false, "Disable prompts and implicit editor/browser interactions") flags.BoolVar(&a.options.interactive, "interactive", false, "Allow prompts with machine output") flags.BoolVarP(&a.options.yes, "yes", "y", false, "Accept confirmations without prompting") flags.BoolVar(&a.options.debug, "debug", false, "Log the method, URL, status, and duration of each HTTP request to stderr") _ = root.RegisterFlagCompletionFunc("host", a.completeHosts) _ = root.RegisterFlagCompletionFunc("user", a.completeAccounts) _ = root.RegisterFlagCompletionFunc("repo", a.completeRepos()) _ = root.RegisterFlagCompletionFunc("remote", a.completeRemotes) _ = root.RegisterFlagCompletionFunc("cwd", completeDirectories) _ = root.RegisterFlagCompletionFunc("json", completeJSONFields) root.PersistentPreRunE = func(cmd *cobra.Command, _ []string) error { if err := a.checkOutputFlags(cmd); err != nil { return err } if err := validateEnums(cmd); err != nil { return err } if cmd.Flags().Changed("sudo") { if a.options.sudo == "" || strings.IndexFunc(a.options.sudo, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 { return clierr.Wrap(clierr.Usage, errors.New("--sudo requires a username")) } for c := cmd; c.HasParent(); c = c.Parent() { if c.Name() == "auth" && !c.Parent().HasParent() { return clierr.Wrap(clierr.Usage, errors.New("--sudo cannot be used with auth commands")) } } } if offline(cmd) { return nil } snapshot, err := a.settings() if err != nil { return err } return a.policy(snapshot).Validate(a.deps.Streams) } root.AddCommand(a.configCommand(), a.aliasCommand(), a.authCommand(), a.apiCommand(), a.repoCommand(), a.issueCommand(), a.prCommand(), a.doctorCommand(), a.workflowCommand(), a.runCommand(), a.labelCommand(), a.milestoneCommand(), a.sshKeyCommand(), a.timeCommand(), a.wikiCommand(), a.actionValueCommand(true), a.actionValueCommand(false), a.releaseCommand(), a.organizationCommand(), a.notificationCommand(), a.teamCommand(), a.branchCommand(), a.webhookCommand(), a.adminCommand(), a.searchCommand(), a.statusCommand(false)) root.AddCommand(a.manCommand()) root.AddCommand(&cobra.Command{Use: "version", Short: "Print tea's version", Example: "$ tea version", Annotations: textOutput("version prints text"), RunE: func(cmd *cobra.Command, _ []string) error { _, err := fmt.Fprintln(cmd.OutOrStdout(), version.Format()) return err }}) root.AddCommand(helpTopics(flags)...) root.SetHelpCommand(a.helpCommand()) root.InitDefaultHelpCmd() root.InitDefaultCompletionCmd() completionInstallHelp(root) root.SetVersionTemplate("{{.Version}}\n") root.SetHelpFunc(a.writeHelp) root.CompletionOptions.SetDefaultShellCompDirective(cobra.ShellCompDirectiveNoFileComp) root.AddGroup( &cobra.Group{ID: "core", Title: "Core commands"}, &cobra.Group{ID: "actions", Title: "Actions commands"}, &cobra.Group{ID: "additional", Title: "Additional commands"}, &cobra.Group{ID: "alias", Title: "Alias commands"}, ) for _, cmd := range root.Commands() { if cmd.Annotations[topicAnnotation] == "" { cmd.GroupID = cmp.Or(rootGroups[cmd.Name()], "additional") } } rejectUnknownSubcommands(root) addVerbAliases(root) completeDefaults(root) return root } // rootGroups places the core and Actions commands in their root help groups (D26); every other // command is additional. var rootGroups = map[string]string{ "auth": "core", "issue": "core", "org": "core", "pr": "core", "release": "core", "repo": "core", "run": "actions", "secret": "actions", "variable": "actions", "workflow": "actions", } // verbAliases are the gh-style short names that every command with a common verb accepts (D6). // Commands add other gh verbs, such as new, co, add and get, to their own Aliases. var verbAliases = map[string]string{"list": "ls", "delete": "rm"} // addVerbAliases gives every list and delete command its short name. func addVerbAliases(cmd *cobra.Command) { if alias, ok := verbAliases[cmd.Name()]; ok && cmd.HasParent() && !slices.Contains(cmd.Aliases, alias) { cmd.Aliases = append(cmd.Aliases, alias) } for _, child := range cmd.Commands() { addVerbAliases(child) } } // flagAliases makes the flags of cmd accept each key of aliases as another name of the flag it maps // to, without showing it in help. Renamed flags keep their old names working this way, such as the // plural --add-labels after the singular gh name --add-label became primary (D8). func flagAliases(cmd *cobra.Command, aliases map[string]string) { normalize := cmd.Flags().GetNormalizeFunc() cmd.Flags().SetNormalizeFunc(func(flags *pflag.FlagSet, name string) pflag.NormalizedName { if to, ok := aliases[name]; ok { name = to } return normalize(flags, name) }) } // suggestFlags adds the flags of cmd with a similar name to an unknown flag error, as in // "unknown flag: --lable; did you mean --label?". func suggestFlags(cmd *cobra.Command, err error) error { name, unknown := strings.CutPrefix(err.Error(), "unknown flag: --") if !unknown || len(name) < 3 { return err } var suggestions []string cmd.Flags().VisitAll(func(flag *pflag.Flag) { if !flag.Hidden && (strings.HasPrefix(flag.Name, name) || editDistance(name, flag.Name) <= 2) { suggestions = append(suggestions, "--"+flag.Name) } }) if len(suggestions) == 0 { return err } return fmt.Errorf("%w; did you mean %s?", err, strings.Join(suggestions, " or ")) } // editDistance counts the single-character insertions, deletions and substitutions that turn a into b. func editDistance(a, b string) int { row := make([]int, len(b)+1) for j := range row { row[j] = j } for i := 1; i <= len(a); i++ { diagonal := row[0] row[0] = i for j := 1; j <= len(b); j++ { substitution := diagonal if a[i-1] != b[j-1] { substitution++ } diagonal = row[j] row[j] = min(row[j]+1, row[j-1]+1, substitution) } } return row[len(b)] } // checkOutputFlags validates --json and --jq before a command runs. --jq alone selects every field, // except on commands that filter their raw JSON themselves (annotation tea.rawjson). func (a *application) checkOutputFlags(cmd *cobra.Command) error { selected := "" switch { case cmd.Flags().Changed("json"): if a.options.json == "" { return clierr.Wrap(clierr.Usage, errors.New("--json= needs field names; use --json alone for every field")) } selected = "--json" case a.options.jq != "" && cmd.Annotations["tea.rawjson"] == "": selected, a.options.json = "--jq", "*" } if reason := cmd.Annotations["tea.text"]; reason != "" && selected != "" { return clierr.Wrap(clierr.Usage, fmt.Errorf("%s; %s is not supported", reason, selected)) } if flag := cmd.Flags().Lookup("web"); flag != nil && flag.Value.String() == "true" && (a.options.json != "" || a.options.jq != "") { return clierr.Wrap(clierr.Usage, errors.New("--web cannot be combined with --json or --jq")) } return clierr.Wrap(clierr.Usage, terminal.ValidateOutput(a.options.json, a.options.jq, commandOutputFields(cmd))) } // offline reports commands that must work without valid settings. func offline(cmd *cobra.Command) bool { if cmd.Annotations["tea.group"] == "true" || cmd.Annotations["tea.offline"] == "true" { return true } switch cmd.Name() { case "help", "version", "man", cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd: return true } return cmd.HasParent() && cmd.Parent().Name() == "completion" } // rejectUnknownSubcommands makes `tea bogus` and `tea issue bogus` usage errors instead of printing help. func rejectUnknownSubcommands(cmd *cobra.Command) { if cmd.HasSubCommands() && !cmd.Runnable() { if cmd.Annotations == nil { cmd.Annotations = map[string]string{} } cmd.Annotations["tea.group"] = "true" cmd.Args, cmd.SuggestionsMinimumDistance = cobra.ArbitraryArgs, 2 cmd.RunE = func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return cmd.Help() } err := fmt.Errorf("unknown command %q for %q", args[0], cmd.CommandPath()) if suggestions := cmd.SuggestionsFor(args[0]); len(suggestions) > 0 { err = fmt.Errorf("%w; did you mean %s?", err, strings.Join(suggestions, " or ")) } return clierr.Wrap(clierr.Usage, err) } } for _, child := range cmd.Commands() { rejectUnknownSubcommands(child) } } func builtins(root *cobra.Command) map[string]bool { names := map[string]bool{"help": true, "completion": true} for _, cmd := range root.Commands() { if cmd.Annotations["tea.alias"] != "true" { names[cmd.Name()] = true } } return names } // Execute expands aliases and executes the fresh root without printing errors. func Execute(ctx context.Context, deps Dependencies, args []string) error { root := NewRoot(deps) if at := commandIndex(root, args); at == len(args) || (args[at] != cobra.ShellCompRequestCmd && args[at] != cobra.ShellCompNoDescRequestCmd) { args = joinJSONFields(root, args) } // Broken settings or aliases must not prevent help or recovery commands. snapshot, loadErr := deps.Settings.Load() names := builtins(root) if commandAt := commandIndex(root, args); commandAt < len(args) { name := args[commandAt] completion := name == cobra.ShellCompRequestCmd || name == cobra.ShellCompNoDescRequestCmd switch { case loadErr != nil: // An alias cannot be expanded without settings; report why instead of "unknown command". if !names[name] && !completion { return loadErr } case completion: completionArgs := args[commandAt+1:] if aliasAt := commandIndex(root, completionArgs); aliasAt < len(completionArgs) { if expanded, err := aliases.ExpandCompletion(completionArgs[aliasAt:], snapshot.Preferences.Aliases, names); err == nil { args = slices.Concat(args[:commandAt+1], completionArgs[:aliasAt], expanded) } } default: expanded, err := aliases.Expand(args[commandAt:], snapshot.Preferences.Aliases, names) if err != nil { return clierr.Wrap(clierr.Usage, err) } args = joinJSONFields(root, slices.Concat(args[:commandAt], expanded)) } if completion { args = completeJSONValue(args) } } if loadErr == nil { for name, expansion := range snapshot.Preferences.Aliases { if aliases.Check(name, snapshot.Preferences.Aliases, names) == nil { root.AddCommand(&cobra.Command{Use: name, Short: fmt.Sprintf("Alias for %q", expansion), GroupID: "alias", Annotations: map[string]string{"tea.alias": "true"}, RunE: func(*cobra.Command, []string) error { return clierr.Wrap(clierr.Usage, errors.New("alias must be expanded before execution")) }}) } } } root.SetArgs(args) err := root.ExecuteContext(ctx) if apiErr, ok := errors.AsType[*transport.APIError](err); ok && apiErr.Status == 401 { return clierr.Wrap(clierr.Authentication, err) } return err } var jsonFieldList = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]*(,[A-Za-z][A-Za-z0-9_]*)*$`) // joinJSONFields accepts `--json number,title` like `--json=number,title`. A field-shaped word after a // bare --json stays a positional argument only when the selected command does not output those fields // and accepts the word as an argument, so a misspelled field reports the available fields rather than // an argument count. A bare --json still selects every field. Unknown commands, such as aliases before // expansion, are joined. func joinJSONFields(root *cobra.Command, args []string) []string { for i := 0; i < len(args); i++ { if args[i] == "--" { break } if args[i] != "--json" || i+1 >= len(args) || !jsonFieldList.MatchString(args[i+1]) { continue } cmd, _, err := root.Find(slices.Concat(args[:i+1], args[i+2:])) if err == nil && terminal.ValidateOutput(args[i+1], "", commandOutputFields(cmd)) != nil && acceptsArgs(args) { continue } args = slices.Concat(args[:i], []string{"--json=" + args[i+1]}, args[i+2:]) } return args } // acceptsArgs reports whether the command that args select accepts their positional arguments. It // parses a separate command tree, because parsing sets the flags of the one that runs. func acceptsArgs(args []string) bool { cmd, rest, err := NewRoot(Dependencies{}).Find(args) return err == nil && cmd.ParseFlags(rest) == nil && cmd.ValidateArgs(cmd.Flags().Args()) == nil } func (a *application) policy(snapshot settings.Snapshot) terminal.Policy { return terminal.Policy{Enabled: snapshot.Preferences.Prompt == "enabled", NoInput: a.options.noInput, Machine: a.options.json != "", Interactive: a.options.interactive} } // titleCase capitalizes an ASCII command word for help and status text. func titleCase(s string) string { if s == "" { return s } return strings.ToUpper(s[:1]) + s[1:] } // textOutput marks a command whose output is not a JSON resource, so --json is rejected up front. func textOutput(reason string) map[string]string { return map[string]string{"tea.text": reason} } // commandIndex skips persistent flags without parsing or mutating command state. func commandIndex(root *cobra.Command, args []string) int { for i := 0; i < len(args); i++ { arg := args[i] if arg == "--" { return i + 1 } if !strings.HasPrefix(arg, "-") || arg == "-" { return i } name := strings.TrimLeft(arg, "-") if strings.Contains(name, "=") { continue } flag := root.PersistentFlags().Lookup(name) if flag == nil && len(name) > 0 && !strings.HasPrefix(arg, "--") { flag = root.PersistentFlags().ShorthandLookup(name[:1]) if len(name) > 1 { continue } } if flag != nil && flag.NoOptDefVal == "" { i++ } } return len(args) } func commandOutputFields(cmd *cobra.Command) []string { if cmd.Name() == "status" && cmd.Parent() != nil { if cmd.Parent().Name() == "pr" { return jsonFields[services.PullStatus]() } if cmd.Parent().Parent() == nil { return jsonFields[services.AccountStatus]() } } if cmd.Parent() == nil { return nil } if cmd.Parent().Name() == "search" { if cmd.Name() == "repo" { return jsonFields[services.Repository]() } return jsonFields[services.SearchItem]() } switch cmd.Parent().Name() { case "auth": if cmd.Name() == "status" { return jsonFields[authStatusRow]() } case "config": if cmd.Name() == "get" || cmd.Name() == "list" { return configKeys } case "tea": if cmd.Name() == "doctor" { return jsonFields[doctorReport]() } case "attachment": return jsonFields[services.IssueAttachment]() case "artifact": return jsonFields[services.Artifact]() case "user": if cmd.Parent().Parent() != nil && cmd.Parent().Parent().Name() == "admin" { return jsonFields[services.AdminUser]() } case "webhook": return jsonFields[services.Webhook]() case "branch": return jsonFields[services.Branch]() case "team": return jsonFields[services.Team]() case "notification": return jsonFields[services.Notification]() case "org": return jsonFields[services.Organization]() case "member": return jsonFields[services.OrganizationMember]() case "asset": return jsonFields[services.ReleaseAsset]() case "release": if cmd.Name() == "upload" { return jsonFields[services.ReleaseAsset]() } return jsonFields[services.Release]() case "secret": return jsonFields[services.ActionSecret]() case "variable": return jsonFields[services.ActionVariable]() case "wiki": if cmd.Name() == "revisions" { return jsonFields[services.WikiRevision]() } return jsonFields[services.WikiPage]() case "time": return jsonFields[services.TrackedTime]() case "ssh-key": return jsonFields[services.SSHKey]() case "comment": return jsonFields[services.Comment]() case "issue": if cmd.Name() == "comment" { return jsonFields[services.Comment]() } return jsonFields[services.Issue]() case "review": if cmd.Name() == "request" || cmd.Name() == "cancel-request" { return jsonFields[services.Pull]() } if cmd.Name() == "comments" || cmd.Name() == "reply" { return jsonFields[services.ReviewComment]() } return jsonFields[services.Review]() case "pr": switch cmd.Name() { case "checkout": return jsonFields[git.CheckoutResult]() case "checks": return jsonFields[services.Check]() case "review": return jsonFields[services.Review]() case "clean": return jsonFields[git.CleanResult]() case "comment": return jsonFields[services.Comment]() } return jsonFields[services.Pull]() case "repo": if cmd.Name() == "sync" { return jsonFields[git.SyncResult]() } return jsonFields[services.Repository]() case "label": return jsonFields[services.Label]() case "milestone": return jsonFields[services.Milestone]() case "workflow": if cmd.Name() == "run" { return jsonFields[services.DispatchResult]() } return jsonFields[services.Workflow]() case "run": if cmd.Name() == "jobs" { return jsonFields[services.Job]() } return jsonFields[services.Run]() } return nil } // jsonFields lists the JSON names of T's fields, which --json can select, in alphabetical order. func jsonFields[T any]() []string { t := reflect.TypeFor[T]() fields := make([]string, 0, t.NumField()) for field := range t.Fields() { if name, _, _ := strings.Cut(field.Tag.Get("json"), ","); name != "" && name != "-" { fields = append(fields, name) } } slices.Sort(fields) return fields }