// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package app import ( "errors" "fmt" "maps" "path/filepath" "slices" "strconv" "strings" "gitea.dev/tea/internal/aliases" "gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/settings" "gitea.dev/tea/internal/terminal" "github.com/spf13/cobra" ) // Settings that tea config reads and writes, by where they are stored. var ( preferenceKeys = []string{"browser", "color", "default_host", "editor", "git_protocol", "hyperlinks", "markdown", "pager", "prompt", "remote"} instanceKeys = []string{"git_protocol", "insecure", "oauth_client_id", "ssh_host", "user"} accountKeys = []string{"ssh_agent", "ssh_certificate_principal", "ssh_key", "ssh_key_fingerprint"} // configKeys are every setting that config get reads, in alphabetical order. configKeys = slices.Compact(slices.Sorted(slices.Values(slices.Concat(preferenceKeys, instanceKeys, accountKeys, []string{"version"})))) ) // configValues lists the values of the settings that accept a fixed set. var configValues = map[string][]string{ "git_protocol": {"https", "ssh"}, "prompt": {"enabled", "disabled"}, "color": {"auto", "always", "never"}, "hyperlinks": {"auto", "always", "never"}, "markdown": {"auto", "raw"}, "insecure": {"true", "false"}, "ssh_agent": {"true", "false"}, } func (a *application) effectivePreferences(snapshot settings.Snapshot) (settings.Preferences, map[string]string, error) { preferences := snapshot.Preferences sources := map[string]string{} for key, configured := range snapshot.Configured { if configured { sources[key] = "config.yml" } } host, source := preferences.DefaultHost, sources["default_host"] if a.options.host != "" { host, source = a.options.host, "--host" } else { for _, key := range []string{"TEA_HOST", "GITEA_INSTANCE_URL"} { if value := a.deps.Getenv(key); value != "" { host, source = value, key break } } } if host == "" && len(snapshot.Hosts.Hosts) == 1 { for name := range snapshot.Hosts.Hosts { host, source = name, "sole configured instance" } } if host != "" { normalized, err := settings.ResolveHost(host, snapshot.Hosts.Hosts) if err != nil { return preferences, nil, clierr.Wrap(clierr.Usage, err) } preferences.DefaultHost = normalized sources["default_host"] = source if protocol := snapshot.Hosts.Hosts[normalized].GitProtocol; protocol != "" { preferences.GitProtocol = protocol sources["git_protocol"] = "hosts.yml " + normalized } } if a.options.remote != "" { preferences.Remote = a.options.remote sources["remote"] = "--remote" } if value := strings.TrimSpace(a.deps.Getenv("TEA_PAGER")); value != "" { preferences.Pager = value sources["pager"] = "TEA_PAGER" } else if value := strings.TrimSpace(a.deps.Getenv("PAGER")); value != "" && sources["pager"] == "" { preferences.Pager = value sources["pager"] = "PAGER" } if value := strings.TrimSpace(a.deps.Getenv("TEA_COLOR")); value != "" { preferences.Color = value sources["color"] = "TEA_COLOR" } if value := a.deps.Getenv("TEA_EDITOR"); value != "" { preferences.Editor = value sources["editor"] = "TEA_EDITOR" } else if value := a.deps.Getenv("VISUAL"); value != "" && sources["editor"] == "" { preferences.Editor = value sources["editor"] = "VISUAL" } else if value := a.deps.Getenv("EDITOR"); value != "" && sources["editor"] == "" { preferences.Editor = value sources["editor"] = "EDITOR" } if value := a.deps.Getenv("BROWSER"); value != "" && sources["browser"] == "" { preferences.Browser = value sources["browser"] = "BROWSER" } return preferences, sources, nil } // configSetting is one effective setting. type configSetting struct { Key string Value any } // settingValues returns the effective preferences, the settings of the selected instance when it is // configured, and with --user the settings of that account, each with its source. func (a *application) settingValues(snapshot settings.Snapshot) (map[string]any, map[string]string, error) { if a.options.user != "" && a.options.host == "" { return nil, nil, clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user")) } preferences, sources, err := a.effectivePreferences(snapshot) if err != nil { return nil, nil, err } values := map[string]any{ "version": preferences.Version, "default_host": preferences.DefaultHost, "git_protocol": preferences.GitProtocol, "editor": preferences.Editor, "prompt": preferences.Prompt, "pager": preferences.Pager, "browser": preferences.Browser, "color": preferences.Color, "hyperlinks": preferences.Hyperlinks, "markdown": preferences.Markdown, "remote": preferences.Remote, } host, configured := snapshot.Hosts.Hosts[preferences.DefaultHost] if !configured { if a.options.user != "" { return nil, nil, clierr.Wrap(clierr.Usage, fmt.Errorf("instance %s is not configured; run tea auth login --host %s", preferences.DefaultHost, preferences.DefaultHost)) } return values, sources, nil } for key, value := range map[string]any{"user": host.User, "ssh_host": host.SSHHost, "insecure": host.Insecure, "oauth_client_id": host.OAuthClientID} { values[key] = value sources[key] = "hosts.yml " + preferences.DefaultHost } if a.options.user != "" { user, exists := matchAccount(host.Users, a.options.user) if !exists { return nil, nil, clierr.Wrap(clierr.Usage, fmt.Errorf("account %q is not logged in on %s (accounts: %s)", a.options.user, preferences.DefaultHost, accountNames(host.Users))) } account := host.Users[user] for key, value := range map[string]any{"ssh_key": account.SSHKey, "ssh_agent": account.SSHAgent, "ssh_certificate_principal": account.SSHCertPrincipal, "ssh_key_fingerprint": account.SSHKeyFingerprint} { values[key] = value sources[key] = "hosts.yml " + preferences.DefaultHost + " user " + user } } return values, sources, nil } // unknownSetting explains why key has no value: it names an account or instance setting that needs // --host or --user, or no setting at all. func unknownSetting(key string) error { switch { case slices.Contains(accountKeys, key): return clierr.Wrap(clierr.Usage, fmt.Errorf("%s is an account setting; select the account with --host URL --user NAME", key)) case slices.Contains(instanceKeys, key): return clierr.Wrap(clierr.Usage, fmt.Errorf("%s is an instance setting; select a configured instance with --host URL", key)) } return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown setting %q; settings: %s", key, strings.Join(configKeys, ", "))) } func (a *application) configCommand() *cobra.Command { group := &cobra.Command{Use: "config", Short: "Manage settings", Long: `Manage tea's settings. Preferences live in config.yml and instance settings in hosts.yml, in the configuration directory ($TEA_CONFIG_DIR, or tea under the XDG config directory); tea config path prints both files. Environment variables that override settings are listed in tea help environment. Preferences (tea config set ): default_host instance used when no --host, environment variable or repository selects one git_protocol https or ssh, for clone and checkout remotes editor command that edits bodies prompt enabled or disabled pager command that pages long output browser command that opens web pages; never disables opening color auto, always or never hyperlinks auto, always or never: link the first column of lists to its web page markdown auto or raw: render markdown bodies on a terminal, or print them as written remote Git remote that selects the repository aliases command shortcuts; manage them with tea alias Instance settings (tea config set --host ): user active account; change it with tea auth switch ssh_host SSH host[:port] when it differs from the web host insecure true skips TLS certificate verification git_protocol https or ssh, overriding the preference for this instance oauth_client_id OAuth application used by tea auth login --oauth Account settings (tea config set --host --user ): ssh_key, ssh_agent, ssh_certificate_principal, ssh_key_fingerprint`} group.AddCommand(&cobra.Command{ Use: "path", Short: "Print the paths of the settings files", Args: argsExact(0), Annotations: map[string]string{"tea.text": "config path prints text", "tea.offline": "true"}, Example: "$ tea config path\n# Settings of another configuration directory\n$ TEA_CONFIG_DIR=~/work/tea tea config path", RunE: func(cmd *cobra.Command, _ []string) error { _, err := fmt.Fprintf(cmd.OutOrStdout(), "config\t%s\nhosts\t%s\n", filepath.Join(a.deps.Settings.Dir, "config.yml"), filepath.Join(a.deps.Settings.Dir, "hosts.yml")) return err }, }) var getSource bool get := &cobra.Command{ Use: "get ", Short: "Print the value of a setting", Args: argsExact(1), ValidArgsFunction: a.completeConfig, Example: `$ tea config get editor # Show where the value comes from $ tea config get default_host --show-source # Read an instance or account setting $ tea config get ssh_host --host https://gitea.com $ tea config get ssh_key --host https://gitea.com --user alice`, RunE: func(cmd *cobra.Command, args []string) error { snapshot, err := a.deps.Settings.Load() if err != nil { return err } values, sources, err := a.settingValues(snapshot) if err != nil { return err } value, ok := values[args[0]] if !ok { return unknownSetting(args[0]) } if a.options.json != "" { return a.writeSettingsJSON(cmd, map[string]any{args[0]: value}, sources, getSource) } line := fmt.Sprint(value) if getSource { line += "\t" + settingSource(sources, args[0]) } _, err = fmt.Fprintln(cmd.OutOrStdout(), line) return err }, } get.Flags().BoolVar(&getSource, "show-source", false, "Also print where the value comes from") group.AddCommand(get) list := &cobra.Command{ Use: "list", Short: "List settings and their values", Args: argsExact(0), Example: `$ tea config list # Include the settings of an instance $ tea config list --host https://gitea.com`, RunE: func(cmd *cobra.Command, _ []string) error { snapshot, err := a.deps.Settings.Load() if err != nil { return err } values, _, err := a.settingValues(snapshot) if err != nil { return err } if a.options.json != "" { return a.writeJSON(cmd, values) } items := make([]configSetting, 0, len(values)) for _, key := range slices.Sorted(maps.Keys(values)) { items = append(items, configSetting{key, values[key]}) } return writeTable(a, cmd, items, listSpec[configSetting]{ Columns: []terminal.Column{{Header: "Key"}, {Header: "Value", Flex: true}}, Row: func(item configSetting) []terminal.Cell { return []terminal.Cell{{Text: item.Key}, {Text: fmt.Sprint(item.Value)}} }, }, listPage{Number: 1}) }, } group.AddCommand(list) group.AddCommand(&cobra.Command{ Use: "set ", Short: "Change a setting", Args: argsExact(2), ValidArgsFunction: a.completeConfig, Annotations: textOutput("config set does not return a resource"), Example: `$ tea config set editor "code --wait" $ tea config set git_protocol ssh # Keep raw markdown and plain IDs on a terminal $ tea config set markdown raw $ tea config set hyperlinks never # Change an instance or account setting $ tea config set ssh_host git.example.com:2222 --host https://example.com $ tea config set ssh_key ~/.ssh/id_ed25519 --host https://example.com --user alice`, RunE: func(cmd *cobra.Command, args []string) error { key, value := args[0], args[1] if a.options.user != "" && a.options.host == "" { return clierr.Wrap(clierr.Usage, errors.New("account settings require both --host and --user")) } if a.options.host == "" { value, err := a.setPreference(key, value) if err != nil { return err } a.reportSet(cmd, key, value, "") if key == "default_host" && value != "" { a.warnWithoutAccount(cmd) } return nil } instance, err := a.setInstanceSetting(key, value) if err != nil { return err } scope := " for " + instance if a.options.user != "" { scope = " for " + a.options.user + " on " + instance } a.reportSet(cmd, key, value, scope) return nil }, }) return group } // settingSource names where the value of key comes from. func settingSource(sources map[string]string, key string) string { if source := sources[key]; source != "" { return source } return "default" } // writeSettingsJSON writes settings as a key-value object, or with source as objects of value and source. func (a *application) writeSettingsJSON(cmd *cobra.Command, values map[string]any, sources map[string]string, source bool) error { if !source { return a.writeJSON(cmd, values) } type sourced struct { Value any `json:"value"` Source string `json:"source"` } results := map[string]sourced{} for key, value := range values { results[key] = sourced{value, settingSource(sources, key)} } return a.writeJSON(cmd, results) } // reportSet confirms a changed setting; scope names the instance or account it belongs to. func (a *application) reportSet(cmd *cobra.Command, key, value, scope string) { if value == "" { a.done(cmd, "Cleared %s%s", key, scope) return } a.done(cmd, "Set %s%s to %s", key, scope, value) } // warnWithoutAccount points out a default_host that has no account to act as yet. func (a *application) warnWithoutAccount(cmd *cobra.Command) { snapshot, err := a.deps.Settings.Load() if host := snapshot.Preferences.DefaultHost; err == nil && len(snapshot.Hosts.Hosts[host].Users) == 0 { a.warn(cmd, "No account on %s yet; log in with: tea auth login --host %s", host, host) } } // setPreference changes a preference in config.yml and returns the stored value, such as a // normalized default_host. func (a *application) setPreference(key, value string) (string, error) { return value, a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error { p := &s.Preferences switch key { case "default_host": if value != "" { normalized, err := settings.ResolveHost(value, s.Hosts.Hosts) if err != nil { return clierr.Wrap(clierr.Usage, err) } value = normalized } p.DefaultHost = value case "git_protocol": p.GitProtocol = value case "editor": p.Editor = value case "prompt": p.Prompt = value case "pager": p.Pager = value case "browser": p.Browser = value case "color": p.Color = value case "hyperlinks": p.Hyperlinks = value case "markdown": p.Markdown = value case "remote": p.Remote = value case "user": return clierr.Wrap(clierr.Usage, errors.New("the active account changes with tea auth switch")) default: if slices.Contains(instanceKeys, key) || slices.Contains(accountKeys, key) { return unknownSetting(key) } return clierr.Wrap(clierr.Usage, fmt.Errorf("unknown setting %q; preferences: %s", key, strings.Join(preferenceKeys, ", "))) } return clierr.Wrap(clierr.Usage, s.Validate()) }) } // setInstanceSetting changes a setting of the --host instance in hosts.yml, or with --user one of its // account's settings, and returns the instance. func (a *application) setInstanceSetting(key, value string) (string, error) { var instance string return instance, a.deps.Settings.Update(settings.TargetHosts, func(s *settings.Snapshot) error { var err error if instance, err = settings.ResolveHost(a.options.host, s.Hosts.Hosts); err != nil { return clierr.Wrap(clierr.Usage, err) } host, exists := s.Hosts.Hosts[instance] if !exists { return clierr.Wrap(clierr.Usage, fmt.Errorf("instance %s is not configured; run tea auth login --host %s", instance, instance)) } if a.options.user != "" { user, exists := matchAccount(host.Users, a.options.user) if !exists { return clierr.Wrap(clierr.Usage, fmt.Errorf("account %q is not logged in on %s (accounts: %s)", a.options.user, instance, accountNames(host.Users))) } account := host.Users[user] switch key { case "ssh_key": account.SSHKey = value case "ssh_agent": if value != "true" && value != "false" { return clierr.Wrap(clierr.Usage, errors.New("ssh_agent must be true or false")) } account.SSHAgent = value == "true" case "ssh_certificate_principal": account.SSHCertPrincipal = value case "ssh_key_fingerprint": account.SSHKeyFingerprint = value default: return clierr.Wrap(clierr.Usage, fmt.Errorf("%q is not an account setting; account settings: %s", key, strings.Join(accountKeys, ", "))) } host.Users[user] = account s.Hosts.Hosts[instance] = host return clierr.Wrap(clierr.Usage, s.Validate()) } switch key { case "git_protocol": host.GitProtocol = value case "ssh_host": host.SSHHost = value case "insecure": if value != "true" && value != "false" { return clierr.Wrap(clierr.Usage, errors.New("insecure must be true or false")) } host.Insecure = value == "true" case "oauth_client_id": host.OAuthClientID = value case "user": return clierr.Wrap(clierr.Usage, errors.New("the active account changes with tea auth switch")) default: return clierr.Wrap(clierr.Usage, fmt.Errorf("%q is not an instance setting; instance settings: git_protocol, insecure, oauth_client_id, ssh_host", key)) } s.Hosts.Hosts[instance] = host return clierr.Wrap(clierr.Usage, s.Validate()) }) } func (a *application) aliasCommand() *cobra.Command { group := &cobra.Command{Use: "alias", Short: "Create shortcuts for tea commands", Long: `Create shortcuts for tea commands. An alias expands to a tea command with its arguments before tea runs it: after tea alias set il "issue list --label bug", tea il --state all runs tea issue list --label bug --state all. $1, $2 and so on in the expansion take the arguments in that position; other arguments follow the expansion. Aliases never run a shell, and built-in commands always win over an alias of the same name.`} var clobber bool set := &cobra.Command{ Use: "set ", Short: "Create a shortcut for a tea command", Annotations: textOutput("alias set does not return a resource"), Args: func(cmd *cobra.Command, args []string) error { if len(args) > 2 { return clierr.Wrap(clierr.Usage, fmt.Errorf("the expansion must be one argument; quote it: tea alias set %s %q", args[0], strings.Join(args[1:], " "))) } return argsExact(2)(cmd, args) }, Example: `# tea il runs tea issue list --label bug $ tea alias set il "issue list --label bug" # $1 takes the first argument: tea review 13 runs tea pr review 13 --approve $ tea alias set review 'pr review $1 --approve' # Replace an existing alias $ tea alias set il "issue list --assignee @me" --clobber`, RunE: func(cmd *cobra.Command, args []string) error { name, expansion := args[0], strings.TrimPrefix(strings.TrimSpace(args[1]), "tea ") var previous string err := a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error { previous = s.Preferences.Aliases[name] if previous != "" && !clobber { return fmt.Errorf("alias %s already expands to %q; replace it with --clobber", name, previous) } s.Preferences.Aliases[name] = expansion return clierr.Wrap(clierr.Usage, aliases.Check(name, s.Preferences.Aliases, builtins(cmd.Root()))) }) if err != nil { return err } if previous != "" { a.done(cmd, "Changed alias %s from %q to %q", name, previous, expansion) } else { a.done(cmd, "Added alias %s for %q", name, expansion) } return nil }, } set.Flags().BoolVar(&clobber, "clobber", false, "Replace an existing alias of the same name") set.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { return clierr.Wrap(clierr.Usage, fmt.Errorf("%w; quote an expansion with flags: tea alias set il \"issue list --state all\"", err)) }) group.AddCommand(set) var all bool remove := &cobra.Command{ Use: "delete [...]", Short: "Delete shortcuts", ValidArgsFunction: a.completeAliases, Annotations: textOutput("alias deletion does not return a resource"), Args: func(_ *cobra.Command, args []string) error { if len(args) == 0 && !all { return clierr.Wrap(clierr.Usage, errors.New("name the aliases to delete, or pass --all")) } if len(args) > 0 && all { return clierr.Wrap(clierr.Usage, errors.New("alias names and --all are mutually exclusive")) } return nil }, Example: "$ tea alias delete il\n$ tea alias delete il review\n$ tea alias delete --all", RunE: func(cmd *cobra.Command, args []string) error { deleted := map[string]string{} err := a.deps.Settings.Update(settings.TargetPreferences, func(s *settings.Snapshot) error { names := args if all { names = slices.Collect(maps.Keys(s.Preferences.Aliases)) } var missing []string for _, name := range names { expansion, ok := s.Preferences.Aliases[name] if !ok { missing = append(missing, strconv.Quote(name)) continue } deleted[name] = expansion delete(s.Preferences.Aliases, name) } if len(missing) > 0 { return clierr.Wrap(clierr.Usage, fmt.Errorf("no alias named %s; tea alias list shows them", strings.Join(missing, ", "))) } return nil }) if err != nil { return err } if len(deleted) == 0 { a.warn(cmd, "No aliases to delete") } for _, name := range slices.Sorted(maps.Keys(deleted)) { a.done(cmd, "Deleted alias %s (%s)", name, deleted[name]) } return nil }, } remove.Flags().BoolVar(&all, "all", false, "Delete every alias") group.AddCommand(remove) group.AddCommand(&cobra.Command{ Use: "list", Short: "List shortcuts", Args: argsExact(0), Example: "$ tea alias list\n# Print the expansion of one alias\n$ tea alias list --jq .il", RunE: func(cmd *cobra.Command, _ []string) error { s, err := a.deps.Settings.Load() if err != nil { return err } if a.options.json != "" { return a.writeJSON(cmd, s.Preferences.Aliases) } return writeTable(a, cmd, slices.Sorted(maps.Keys(s.Preferences.Aliases)), listSpec[string]{ Columns: []terminal.Column{{Header: "Name"}, {Header: "Expansion", Flex: true}}, Row: func(name string) []terminal.Cell { return []terminal.Cell{{Text: name, Bold: true}, {Text: s.Preferences.Aliases[name]}} }, Empty: "no aliases configured; create one with: tea alias set ", }, listPage{Number: 1}) }, }) return group }