feat(completion): complete flag values and stop offering file names

Every value completed to file names, because no command registered a
completion. Values without a completer now complete nothing, and only
path flags (--*-file, --input, --attachment, --asset, --file, --ssh-key,
--dir, --cwd) and arguments named file or directory complete paths.

--json completes the fields of the command, also after a comma. --host,
--user, --remote and -R complete from hosts.yml, Git remotes and the
instance. enumFlag and enumValues give a flag one list of values that
validation (case-insensitive) and completion share, and instance
completers for issues, pull requests, labels, milestones, users,
branches, tags, releases, organizations, teams, workflows, runs, secrets
and variables are ready for commands to attach. They give up after two
seconds, never prompt, retry or refresh an OAuth token, and drop values
with control characters.

tea completion bash now says to use eval "$(tea completion bash)",
because bash 3.2 on macOS cannot source <(...), and names the
bash-completion package; the zsh help says to run compinit first. The
README matches.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 22:55:27 +02:00
1 parent 48c1e5b781
commit f03d0fdf54
5 files changed
+1034 -8

No files matched your search

+320
View File
@@ -4,9 +4,22 @@
package app
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"time"
"gitea.dev/tea/internal/auth"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/stretchr/testify/require"
)
@@ -24,9 +37,316 @@ func TestAliasCompletionUsesBuiltInCommandsAndFlags(t *testing.T) {
{[]string{"__complete", "listing", "--auth"}, "--author"},
{[]string{"__completeNoDesc", "--no-input", "listing", "--ba"}, "--base"},
{[]string{"__complete", "inspect", "7", "--j"}, "--json"},
{[]string{"__complete", "listing", "--json", "nu"}, "number"},
} {
out.Reset()
require.NoError(t, Execute(context.Background(), deps, tc.args))
require.Contains(t, out.String(), tc.want)
}
}
// complete runs `tea __complete args...` and returns the candidates and the directive.
func complete(t *testing.T, deps Dependencies, out *bytes.Buffer, args ...string) ([]string, cobra.ShellCompDirective) {
t.Helper()
out.Reset()
require.NoError(t, Execute(context.Background(), deps, append([]string{cobra.ShellCompRequestCmd}, args...)))
lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n")
directive, err := strconv.Atoi(strings.TrimPrefix(lines[len(lines)-1], ":"))
require.NoError(t, err, out.String())
return lines[:len(lines)-1], cobra.ShellCompDirective(directive)
}
func TestEnumFlagsShareValidationAndCompletion(t *testing.T) {
deps, out, _ := testDeps(t)
var state string
var events []string
run := func(args ...string) error {
root := NewRoot(deps)
probe := &cobra.Command{Use: "probe", RunE: func(*cobra.Command, []string) error { return nil }}
enumFlag(probe, &state, "state", "s", "open", "Filter by state", "open", "closed", "all")
probe.Flags().StringSliceVar(&events, "events", nil, "Events to deliver")
enumValues(probe, "events", "push", "pull_request", "release")
root.AddCommand(probe)
root.SetArgs(args)
out.Reset()
return root.ExecuteContext(context.Background())
}
require.NoError(t, run("probe", "-s", "CLOSED", "--events", "Push,release"))
require.Equal(t, "closed", state)
require.Equal(t, []string{"push", "release"}, events)
err := run("probe", "--state", "merged")
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
require.EqualError(t, err, "--state must be open, closed, or all")
require.EqualError(t, run("probe", "--events", "push,nope"), "--events must be push, pull_request, or release")
require.NoError(t, run("__complete", "probe", "--state", "c"))
require.Equal(t, "closed\n:4\n", out.String())
require.NoError(t, run("__complete", "probe", "--events", "push,"))
require.Equal(t, "push,pull_request\npush,release\n:6\n", out.String())
probe := &cobra.Command{Use: "probe"}
enumFlag(probe, &state, "state", "s", "open", "Filter by state", "open", "closed", "all")
require.Equal(t, "Filter by state {open|closed|all}", probe.Flag("state").Usage)
require.Equal(t, "s", probe.Flag("state").Shorthand)
}
func TestJSONFieldCompletion(t *testing.T) {
deps, out, _ := testDeps(t)
values, directive := complete(t, deps, out, "issue", "list", "--json", "")
require.Equal(t, jsonFields[services.Issue](), values)
require.Equal(t, cobra.ShellCompDirectiveNoSpace|cobra.ShellCompDirectiveNoFileComp, directive)
for args, want := range map[string][]string{
"issue list --json number,ti": {"number,title"},
"issue list --json=num": {"number"},
"issue list --json=state,ti": {"state,title"},
"pr view --json head_r": {"head_remote_deleted", "head_repo"},
"version --json ": {},
} {
values, _ := complete(t, deps, out, strings.Split(args, " ")...)
require.Equal(t, want, values, args)
}
values, _ = complete(t, deps, out, "issue", "list", "--json", "number,")
require.Contains(t, values, "number,title")
require.NotContains(t, values, "number,number")
}
func TestCompletionOffersPathsOnlyWherePathsAreExpected(t *testing.T) {
deps, out, _ := testDeps(t)
for args, want := range map[string]cobra.ShellCompDirective{
"issue view ": cobra.ShellCompDirectiveNoFileComp,
"issue view 5 ": cobra.ShellCompDirectiveNoFileComp,
"issue list --state ": cobra.ShellCompDirectiveNoFileComp,
"issue create --body-file ": cobra.ShellCompDirectiveDefault,
"issue create --attachment ": cobra.ShellCompDirectiveDefault,
"api --input ": cobra.ShellCompDirectiveDefault,
"auth login --ssh-key ": cobra.ShellCompDirectiveDefault,
"ssh-key create ": cobra.ShellCompDirectiveDefault,
"release asset upload ": cobra.ShellCompDirectiveNoFileComp,
"release asset upload v1 ": cobra.ShellCompDirectiveDefault,
"release asset upload v1 a.txt ": cobra.ShellCompDirectiveDefault,
"repo clone alice/project ": cobra.ShellCompDirectiveFilterDirs,
"repo clone alice/project dir ": cobra.ShellCompDirectiveNoFileComp,
"--cwd ": cobra.ShellCompDirectiveFilterDirs,
"--jq ": cobra.ShellCompDirectiveNoFileComp,
} {
_, directive := complete(t, deps, out, strings.Split(args, " ")...)
require.Equal(t, want, directive, args)
}
// No other argument or flag value falls back to file names.
walkCommands(NewRoot(deps), func(cmd *cobra.Command) {
if !cmd.HasParent() || !cmd.IsAvailableCommand() {
return
}
path := strings.Fields(cmd.CommandPath())[1:]
slots := argSlots(cmd.Use)
_, directive := complete(t, deps, out, append(path, "")...)
require.Equal(t, len(slots) > 0 && slots[0].kind == "file", directive == cobra.ShellCompDirectiveDefault, "%s <TAB>: %d", cmd.CommandPath(), directive)
for _, flags := range []*pflag.FlagSet{cmd.LocalNonPersistentFlags(), cmd.PersistentFlags()} {
flags.VisitAll(func(flag *pflag.Flag) {
if flag.NoOptDefVal != "" || flag.Hidden {
return
}
_, directive := complete(t, deps, out, append(path, "--"+flag.Name, "")...)
path := strings.HasSuffix(flag.Name, "-file") || slices.Contains(pathFlags, flag.Name)
require.Equal(t, path, directive == cobra.ShellCompDirectiveDefault, "%s --%s <TAB>: %d", cmd.CommandPath(), flag.Name, directive)
})
}
})
}
func TestArgSlots(t *testing.T) {
for use, want := range map[string][]argSlot{
"list": {},
"view <number>": {{}},
"upload <tag> <file>...": {{}, {kind: "file", variadic: true}},
"clone <repository> [directory]": {{}, {kind: "directory"}},
"create <public-key-file>": {{kind: "file"}},
"review [<number> | <url> | <branch>]": {{}},
"man [command...]": {{variadic: true}},
"set <key> <value>": {{}, {}},
} {
require.Equal(t, want, append([]argSlot{}, argSlots(use)...), use)
}
}
func TestOfflineCompletions(t *testing.T) {
deps, out, _ := testDeps(t)
deps.Git = &fakeGit{results: map[string]string{
`config --get-regexp ^remote\..*\.url$`: "remote.origin.url https://gitea.example.com/alice/project.git\nremote.my.fork.url git@gitea.example.com:bob/project.git\n",
"for-each-ref --format=%(refname:short) refs/heads": "main\nfeature/login\n",
}}
configureAccounts(t, deps, "https://gitea.example.com", "alice", "alice", "bob")
require.NoError(t, Execute(context.Background(), deps, []string{"alias", "set", "co", "pr checkout"}))
for args, want := range map[string][]string{
"--host ": {"https://gitea.example.com\talice, bob"},
"--user ": {"alice\thttps://gitea.example.com (active)", "bob\thttps://gitea.example.com"},
"--remote ": {"origin\thttps://gitea.example.com/alice/project.git", "my.fork\tgit@gitea.example.com:bob/project.git"},
} {
values, directive := complete(t, deps, out, strings.Split(args, " ")...)
require.Equal(t, want, values, args)
require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive, args)
}
a := &application{deps: deps}
cmd := &cobra.Command{}
cmd.SetContext(context.Background())
for _, tc := range []struct {
complete cobra.CompletionFunc
args []string
prefix string
want []string
}{
{a.completeAliases, nil, "", []string{"co\tpr checkout"}},
{a.completeConfig, nil, "git", []string{"git_protocol"}},
{a.completeConfig, []string{"color"}, "a", []string{"auto", "always"}},
{a.completeConfig, []string{"default_host"}, "", []string{"https://gitea.example.com\talice, bob"}},
{a.completeConfig, []string{"color", "auto"}, "", []string{}},
{a.completeLocalBranches, nil, "f", []string{"feature/login\tlocal branch"}},
} {
values, _ := tc.complete(cmd, tc.args, tc.prefix)
require.Equal(t, tc.want, append([]string{}, values...), "%v %q", tc.args, tc.prefix)
}
}
func TestRepositoryCompletionUsesRemotesAndInstance(t *testing.T) {
var tokens []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokens = append(tokens, r.Header.Get("Authorization"))
switch r.URL.Path {
case "/api/v1/user/repos", "/api/v1/users/alice/repos":
_, _ = w.Write([]byte(`[{"full_name":"alice/project","description":"Demo"},{"full_name":"alice/other"},{"full_name":"alice/x\u001b[2J"}]`))
case "/api/v1/users/alice":
_, _ = w.Write([]byte(`{"id":1,"login":"alice"}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
deps, out, _ := testDeps(t)
deps.Git = &fakeGit{results: map[string]string{
`config --get-regexp ^remote\..*\.url$`: "remote.origin.url " + server.URL + "/alice/project.git\nremote.github.url https://github.com/cli/cli.git\n",
}}
configureAccounts(t, deps, server.URL, "alice", "alice")
values, directive := complete(t, deps, out, "-R", "")
require.Equal(t, []string{"alice/project\tremote origin", "alice/other"}, values)
require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive)
require.Equal(t, []string{"token alice-token"}, tokens)
// An owner before the slash lists that owner's repositories.
values, _ = complete(t, deps, out, "issue", "list", "-R", "alice/o")
require.Equal(t, []string{"alice/other"}, values)
require.Contains(t, tokens, "token alice-token")
}
func TestAPICompletionNeverRefreshesOrFails(t *testing.T) {
requests := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests++
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
deps, out, _ := testDeps(t)
now := time.Now()
deps.Now = func() time.Time { return now }
require.NoError(t, testManager(t, deps).Register(server.URL, "alice", settings.StoreFile, settings.AuthOAuth, auth.HostOptions{}, auth.Credential{AccessToken: "expired", RefreshToken: "refresh", ExpiresAt: now.Add(-time.Minute)}))
values, directive := complete(t, deps, out, "-R", "")
require.Empty(t, values)
require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive)
require.Zero(t, requests, "an expired OAuth token must not be refreshed while completing")
credential, err := deps.Credentials.Load(server.URL, "alice", settings.StoreFile)
require.NoError(t, err)
require.Equal(t, "refresh", credential.RefreshToken)
configureAccounts(t, deps, server.URL, "alice", "alice")
values, directive = complete(t, deps, out, "-R", "")
require.Empty(t, values)
require.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive)
require.Equal(t, 1, requests, "completion requests are not retried")
}
func TestInstanceCompletions(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/alice/project/pulls":
require.Equal(t, "open", r.URL.Query().Get("state"))
_, _ = w.Write([]byte(`[{"number":13,"title":"Add login support"},{"number":2,"title":"Fix\u001b]0;pwned\u0007 docs"},{"number":17,"title":"Improve greeting"}]`))
case "/api/v1/repos/alice/project/labels":
_, _ = w.Write([]byte(`[{"id":1,"name":"bug","description":"Something is broken"},{"id":2,"name":"docs"},{"id":3,"name":"bad\u001b[31m"}]`))
case "/api/v1/orgs/alice/labels":
w.WriteHeader(http.StatusNotFound)
case "/api/v1/repos/alice/project/assignees":
_, _ = w.Write([]byte(`[{"login":"alice","full_name":"Alice"},{"login":"bob"}]`))
case "/api/v1/user/orgs":
_, _ = w.Write([]byte(`[{"id":4,"username":"acme","full_name":"Acme Inc"}]`))
case "/api/v1/orgs/acme/teams":
_, _ = w.Write([]byte(`[{"id":2,"name":"developers","description":"Developers"}]`))
case "/api/v1/repos/alice/project/issues":
require.Equal(t, "issues", r.URL.Query().Get("type"))
_, _ = w.Write([]byte(`[{"number":4,"title":"Crash when config is missing"}]`))
case "/api/v1/repos/alice/project/milestones":
_, _ = w.Write([]byte(`[{"id":1,"title":"v1.0","description":"First release"}]`))
case "/api/v1/repos/alice/project/branches":
_, _ = w.Write([]byte(`[{"name":"main","protected":true},{"name":"docs/guide"}]`))
case "/api/v1/repos/alice/project/tags":
_, _ = w.Write([]byte(`[{"name":"v0.1.0"}]`))
case "/api/v1/repos/alice/project/releases":
_, _ = w.Write([]byte(`[{"id":1,"tag_name":"v0.1.0","name":"First release"}]`))
case "/api/v1/repos/alice/project/actions/workflows":
_, _ = w.Write([]byte(`{"total_count":1,"workflows":[{"id":"ci.yml","name":"CI"}]}`))
case "/api/v1/repos/alice/project/actions/runs":
_, _ = w.Write([]byte(`{"workflow_runs":[{"id":47,"status":"completed","conclusion":"failure","display_title":"Add workflows"}]}`))
case "/api/v1/repos/alice/project/actions/secrets":
_, _ = w.Write([]byte(`[{"name":"DEPLOY_TOKEN","description":"Deploy key"}]`))
case "/api/v1/orgs/acme/actions/variables":
_, _ = w.Write([]byte(`[{"name":"REGION","data":"eu"}]`))
default:
t.Errorf("unexpected request %s", r.URL.Path)
w.WriteHeader(http.StatusTeapot)
}
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
a := &application{deps: deps}
a.options.repo = "alice/project"
cmd := &cobra.Command{}
cmd.SetContext(context.Background())
for _, tc := range []struct {
name string
complete cobra.CompletionFunc
args []string
prefix string
want []string
directive cobra.ShellCompDirective
}{
{"pulls by prefix", a.completePulls("open"), nil, "1", []string{"13\tAdd login support", "17\tImprove greeting"}, cobra.ShellCompDirectiveNoFileComp},
{"pulls skip given arguments and sanitize titles", a.completePulls("open"), []string{"13", "17"}, "", []string{"2\tFix]0;pwned docs"}, cobra.ShellCompDirectiveNoFileComp},
{"labels are comma-separated", a.completeLabels(), nil, "bug,", []string{"bug,docs"}, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp},
{"labels with descriptions", a.completeLabels(), nil, "b", []string{"bug\tSomething is broken"}, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp},
{"users", a.completeUsers(), nil, "", []string{"alice\tAlice", "bob"}, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp},
{"teams start with organizations", a.completeTeams(), nil, "", []string{"acme/\tAcme Inc"}, cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp},
{"teams of an organization", a.completeTeams(), nil, "acme/d", []string{"acme/developers\tDevelopers (ID 2)"}, cobra.ShellCompDirectiveNoFileComp},
{"issues", a.completeIssues("open"), nil, "", []string{"4\tCrash when config is missing"}, cobra.ShellCompDirectiveNoFileComp},
{"milestones", a.completeMilestones(), nil, "", []string{"v1.0\tFirst release"}, cobra.ShellCompDirectiveNoFileComp},
{"branches", a.completeBranches(), nil, "", []string{"main\tprotected", "docs/guide"}, cobra.ShellCompDirectiveNoFileComp},
{"tags", a.completeTags(), nil, "v", []string{"v0.1.0"}, cobra.ShellCompDirectiveNoFileComp},
{"releases", a.completeReleases(), nil, "", []string{"v0.1.0\tFirst release"}, cobra.ShellCompDirectiveNoFileComp},
{"workflows", a.completeWorkflows(), nil, "", []string{"ci.yml\tCI"}, cobra.ShellCompDirectiveNoFileComp},
{"runs", a.completeRuns(), nil, "4", []string{"47\tcompleted · failure · Add workflows"}, cobra.ShellCompDirectiveNoFileComp},
{"repository secrets", a.completeSecrets(func() services.ActionScope { return services.ActionScope{} }), nil, "", []string{"DEPLOY_TOKEN\tDeploy key"}, cobra.ShellCompDirectiveNoFileComp},
{"organization variables", a.completeVariables(func() services.ActionScope { return services.ActionScope{Organization: "acme"} }), nil, "", []string{"REGION"}, cobra.ShellCompDirectiveNoFileComp},
} {
values, directive := tc.complete(cmd, tc.args, tc.prefix)
require.Equal(t, tc.want, values, tc.name)
require.Equal(t, tc.directive, directive, tc.name)
}
}
func TestCompletionInstallHelp(t *testing.T) {
deps, out, _ := testDeps(t)
for shell, want := range map[string]string{
"bash": "$ eval \"$(tea completion bash)\"\n",
"zsh": "$ autoload -U compinit && compinit\n",
"fish": "$ tea completion fish > ~/.config/fish/completions/tea.fish\n",
"powershell": "$ tea completion powershell | Out-String | Invoke-Expression\n",
} {
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"completion", shell, "--help"}))
require.Contains(t, out.String(), want, shell)
require.NotContains(t, out.String(), "source <(tea completion bash)\n", shell)
}
}
+15
View File
@@ -118,10 +118,19 @@ func NewRoot(deps Dependencies) *cobra.Command {
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"))
@@ -151,8 +160,10 @@ func NewRoot(deps Dependencies) *cobra.Command {
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"},
@@ -166,6 +177,7 @@ func NewRoot(deps Dependencies) *cobra.Command {
}
rejectUnknownSubcommands(root)
addVerbAliases(root)
completeDefaults(root)
return root
}
@@ -303,6 +315,9 @@ func Execute(ctx context.Context, deps Dependencies, args []string) error {
}
args = joinJSONFields(root, slices.Concat(args[:commandAt], expanded))
}
if completion {
args = completeJSONValue(args)
}
}
if loadErr == nil {
for name, expansion := range snapshot.Preferences.Aliases {
+5 -4
View File
@@ -208,6 +208,7 @@ func TestRootHelpGroupsCommandsAndTopics(t *testing.T) {
"\n repo: ",
"\nACTIONS COMMANDS\n run: ",
"\nADDITIONAL COMMANDS\n admin: ",
"\n completion: Generate shell completion scripts\n",
"\nALIAS COMMANDS\n co: Alias for \"pr checkout\"\n",
"\nHELP TOPICS\n environment: ",
"\nFLAGS\n --help Show help for command\n --version Show tea version\n",
@@ -302,6 +303,10 @@ func TestHelpTopicsAndUnknownTopics(t *testing.T) {
require.EqualError(t, err, want, args)
require.Empty(t, out.String(), args)
}
values, _ := complete(t, deps, out, "help", "e")
require.Equal(t, []string{"environment\tEnvironment variables and global flags", "exit-codes\tExit codes used by tea"}, values)
values, _ = complete(t, deps, out, "help", "issue", "l")
require.Equal(t, []string{"list\tList repository issues"}, values)
}
func TestVerbAndFlagAliases(t *testing.T) {
@@ -423,10 +428,6 @@ const (
branch rename
branch unprotect
branch view
completion bash
completion fish
completion powershell
completion zsh
config get
config list
config path
+687
View File
@@ -0,0 +1,687 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"encoding/json"
"errors"
"maps"
"net/http"
"slices"
"strconv"
"strings"
"time"
"gitea.dev/tea/internal/auth"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"gitea.dev/tea/internal/terminal"
"gitea.dev/tea/internal/transport"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const (
// completionLimit caps how many values an instance completer fetches and offers.
completionLimit = 50
// completionTimeout bounds the Git and API calls behind one completion.
completionTimeout = 2 * time.Second
// enumAnnotation holds the values of an enum flag, which validation and completion share.
enumAnnotation = "tea.enum"
)
// enumFlag registers a string flag that accepts one of values, case-insensitively, completes them,
// and lists them in its usage, as in "Filter by state {open|closed|all}".
func enumFlag(cmd *cobra.Command, p *string, name, shorthand, value, usage string, values ...string) {
cmd.Flags().StringVarP(p, name, shorthand, value, usage+" {"+strings.Join(values, "|")+"}")
enumValues(cmd, name, values...)
}
// enumValues restricts the values of the existing flag name, so that validation and completion share
// one list. A slice flag accepts and completes comma-separated values.
func enumValues(cmd *cobra.Command, name string, values ...string) {
_ = cmd.Flags().SetAnnotation(name, enumAnnotation, values)
complete := completeValues(values...)
if _, ok := cmd.Flags().Lookup(name).Value.(pflag.SliceValue); ok {
complete = commaSeparated(complete)
}
_ = cmd.RegisterFlagCompletionFunc(name, complete)
}
// validateEnums rejects enum flag values outside their lists and stores the listed spelling of each
// value, so that values match case-insensitively.
func validateEnums(cmd *cobra.Command) error {
var err error
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
allowed := flag.Annotations[enumAnnotation]
if err != nil || allowed == nil || !flag.Changed {
return
}
slice, isSlice := flag.Value.(pflag.SliceValue)
given := []string{flag.Value.String()}
if isSlice {
given = slice.GetSlice()
}
for i, value := range given {
at := slices.IndexFunc(allowed, func(v string) bool { return strings.EqualFold(v, strings.TrimSpace(value)) })
if at < 0 {
err = oneOf(flag.Name, value, allowed...)
return
}
given[i] = allowed[at]
}
if isSlice {
err = slice.Replace(given)
} else {
err = flag.Value.Set(given[0])
}
})
return err
}
// completeValues completes the given values.
func completeValues(values ...string) cobra.CompletionFunc {
return func(_ *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
return matching(values, toComplete), cobra.ShellCompDirectiveNoFileComp
}
}
// matching keeps the candidates whose value starts with prefix, at most completionLimit of them.
func matching(candidates []cobra.Completion, prefix string) []cobra.Completion {
result := []cobra.Completion{}
for _, candidate := range candidates {
if value, _, _ := strings.Cut(candidate, "\t"); strings.HasPrefix(value, prefix) {
result = append(result, candidate)
if len(result) == completionLimit {
break
}
}
}
return result
}
// commaSeparated completes the last entry of a comma-separated value with complete, without offering
// entries that are already given, and keeps the cursor after it so that another comma can follow.
func commaSeparated(complete cobra.CompletionFunc) cobra.CompletionFunc {
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
done := toComplete[:strings.LastIndexByte(toComplete, ',')+1]
given := strings.Split(done, ",")
values, directive := complete(cmd, args, toComplete[len(done):])
result := make([]cobra.Completion, 0, len(values))
for _, value := range values {
if name, _, _ := strings.Cut(value, "\t"); !slices.Contains(given, name) {
result = append(result, done+value)
}
}
return result, directive | cobra.ShellCompDirectiveNoSpace
}
}
// mergeCompletions offers the candidates of every completer in order, each value once.
func mergeCompletions(completers ...cobra.CompletionFunc) cobra.CompletionFunc {
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
result, seen := []cobra.Completion{}, map[string]bool{}
for _, complete := range completers {
values, _ := complete(cmd, args, toComplete)
for _, value := range values {
if name, _, _ := strings.Cut(value, "\t"); !seen[name] {
seen[name] = true
result = append(result, value)
}
}
}
return matching(result, toComplete), cobra.ShellCompDirectiveNoFileComp
}
}
// completeJSONFields completes the comma-separated fields that --json can select for the command.
var completeJSONFields = commaSeparated(func(cmd *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if cmd.Annotations["tea.text"] != "" || cmd.Annotations["tea.group"] != "" {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return matching(commandOutputFields(cmd), toComplete), cobra.ShellCompDirectiveNoFileComp
})
// completeJSONValue completes the word after a bare --json as its fields. Without this, cobra completes
// that word as a positional argument, because the value of --json is optional.
func completeJSONValue(args []string) []string {
if n := len(args); n >= 2 && args[n-2] == "--json" && !strings.HasPrefix(args[n-1], "-") {
return slices.Concat(args[:n-2], []string{"--json=" + args[n-1]})
}
return args
}
// describe turns items into completion candidates, each a value with a one-line description. Server
// text cannot drive the terminal: values with control characters are skipped, descriptions sanitized.
func describe[T any](items []T, candidate func(T) (value, description string)) []cobra.Completion {
result := make([]cobra.Completion, 0, len(items))
for _, item := range items {
value, description := candidate(item)
if value == "" || terminal.SafeLine(value) != value {
continue
}
if description = strings.Join(strings.Fields(terminal.SafeLine(description)), " "); description != "" {
value = cobra.CompletionWithDesc(value, description)
}
result = append(result, value)
}
return result
}
// completionContext bounds the work behind one completion.
func completionContext(cmd *cobra.Command) (context.Context, context.CancelFunc) {
return context.WithTimeout(cmd.Context(), completionTimeout)
}
// completeHosts completes the configured instances with their accounts.
func (a *application) completeHosts(_ *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
snapshot, err := a.deps.Settings.Load()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
hosts := slices.Sorted(maps.Keys(snapshot.Hosts.Hosts))
return matching(describe(hosts, func(host string) (string, string) {
return host, strings.Join(slices.Sorted(maps.Keys(snapshot.Hosts.Hosts[host].Users)), ", ")
}), toComplete), cobra.ShellCompDirectiveNoFileComp
}
// completeAccounts completes the accounts of the selected instance, or of every instance when
// none is selected.
func (a *application) completeAccounts(cmd *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
ctx, cancel := completionContext(cmd)
defer cancel()
snapshot, err := a.deps.Settings.Load()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
hosts := snapshot.Hosts.Hosts
if t, err := a.resolveTarget(ctx, false, false); err == nil {
hosts = map[string]settings.Host{t.Host: hosts[t.Host]}
}
accounts := []cobra.Completion{}
for _, host := range slices.Sorted(maps.Keys(hosts)) {
accounts = append(accounts, describe(slices.Sorted(maps.Keys(hosts[host].Users)), func(user string) (string, string) {
if hosts[host].User == user {
return user, host + " (active)"
}
return user, host
})...)
}
return matching(accounts, toComplete), cobra.ShellCompDirectiveNoFileComp
}
// gitRemotes lists the remotes of the working directory with their URLs, in configuration order.
func (a *application) gitRemotes(ctx context.Context) [][2]string {
out, err := a.gitProbe().Output(ctx, a.options.cwd, false, "config", "--get-regexp", `^remote\..*\.url$`)
if err != nil {
return nil
}
var remotes [][2]string
for line := range strings.Lines(out) {
if key, url, ok := strings.Cut(strings.TrimSpace(line), " "); ok {
remotes = append(remotes, [2]string{strings.TrimSuffix(strings.TrimPrefix(key, "remote."), ".url"), url})
}
}
return remotes
}
// completeRemotes completes the Git remotes of the working directory.
func (a *application) completeRemotes(cmd *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
ctx, cancel := completionContext(cmd)
defer cancel()
return matching(describe(a.gitRemotes(ctx), func(remote [2]string) (string, string) { return remote[0], remote[1] }), toComplete), cobra.ShellCompDirectiveNoFileComp
}
// completeLocalBranches completes the branches of the local checkout.
func (a *application) completeLocalBranches(cmd *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
ctx, cancel := completionContext(cmd)
defer cancel()
out, err := a.gitProbe().Output(ctx, a.options.cwd, false, "for-each-ref", "--format=%(refname:short)", "refs/heads")
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return matching(describe(strings.Fields(out), func(branch string) (string, string) { return branch, "local branch" }), toComplete), cobra.ShellCompDirectiveNoFileComp
}
// completeAliases completes the configured alias names with their expansions.
func (a *application) completeAliases(_ *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
snapshot, err := a.deps.Settings.Load()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
aliases := snapshot.Preferences.Aliases
return matching(describe(slices.Sorted(maps.Keys(aliases)), func(name string) (string, string) { return name, aliases[name] }), toComplete), cobra.ShellCompDirectiveNoFileComp
}
// 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"},
"insecure": {"true", "false"},
"ssh_agent": {"true", "false"},
}
// completeConfig completes a setting name, then its value: the fixed values, the configured instances
// for default_host, or the Git remotes for remote.
func (a *application) completeConfig(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
switch {
case len(args) == 0:
return matching(configKeys, toComplete), cobra.ShellCompDirectiveNoFileComp
case len(args) > 1:
return nil, cobra.ShellCompDirectiveNoFileComp
case args[0] == "default_host":
return a.completeHosts(cmd, args, toComplete)
case args[0] == "remote":
return a.completeRemotes(cmd, args, toComplete)
}
return matching(configValues[args[0]], toComplete), cobra.ShellCompDirectiveNoFileComp
}
// apiCompletion completes the values that list fetches from the selected instance; requireRepo
// selects whether list needs a repository. It never prompts, refreshes an OAuth token or retries,
// and gives up after completionTimeout: failures complete nothing rather than file names. Values
// already given as arguments are not offered again.
func (a *application) apiCompletion(requireRepo bool, list func(ctx context.Context, t target, toComplete string) ([]cobra.Completion, error)) cobra.CompletionFunc {
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
ctx, cancel := completionContext(cmd)
defer cancel()
t, err := a.completionTarget(ctx, requireRepo)
var values []cobra.Completion
if err == nil {
values, err = list(ctx, t, toComplete)
}
if err != nil {
cobra.CompDebugln("tea: "+err.Error(), false)
return nil, cobra.ShellCompDirectiveNoFileComp
}
values = slices.DeleteFunc(values, func(value cobra.Completion) bool {
name, _, _ := strings.Cut(value, "\t")
return slices.Contains(args, name)
})
return matching(values, toComplete), cobra.ShellCompDirectiveNoFileComp
}
}
// completionTarget resolves an API completion's target like resolve, but never prompts or rotates
// credentials: an expired OAuth token or an encrypted SSH key completes nothing.
func (a *application) completionTarget(ctx context.Context, requireRepo bool) (target, error) {
t, err := a.resolveTarget(ctx, requireRepo, false)
if err != nil {
return t, err
}
var signer *auth.HTTPSign
token := a.deps.Getenv("GITEA_TOKEN")
if token != "" {
if instance, err := settings.NormalizeHost(a.tokenInstance()); err != nil || instance != t.Host {
return t, errors.New("GITEA_TOKEN belongs to another instance")
}
} else {
account, exists := t.host.Users[t.User]
switch {
case !exists:
return t, errors.New("no account selected")
case account.Tokenless():
if signer, err = auth.SignerForAccount(account, nil); err != nil {
return t, err
}
default:
credential, err := a.deps.Credentials.Load(t.Host, t.User, account.CredentialStore)
if err != nil {
return t, err
}
if !credential.ExpiresAt.IsZero() && !a.deps.Now().Before(credential.ExpiresAt) {
return t, errors.New("access token expired")
}
token = credential.AccessToken
}
}
t.client = a.deps.Client(t.Host, token, t.host.Insecure)
if signer != nil {
t.client.Signer = signer
}
t.client.Retries, t.client.Sudo = 0, a.options.sudo
return t, nil
}
// fetchJSON decodes the response to a GET of endpoint, for values that no service lists.
func fetchJSON(ctx context.Context, client *transport.Client, endpoint string, out any) error {
return services.API{Client: client}.Visit(ctx, services.APIOptions{Method: http.MethodGet, Endpoint: endpoint}, func(response *http.Response) error {
if response.StatusCode != http.StatusOK {
return nil // Visit reports the status.
}
return json.NewDecoder(response.Body).Decode(out)
})
}
// completeIssues completes issue numbers in state (open, closed or all) with their titles.
func (a *application) completeIssues(state string) cobra.CompletionFunc {
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
items, _, err := services.Issues{Client: t.client, Repository: t.Repo}.List(ctx, services.ListOptions{State: state, Kind: "issues", Limit: completionLimit, Page: 1})
return describe(items, func(item services.Issue) (string, string) { return strconv.FormatInt(item.Number, 10), item.Title }), err
})
}
// completePulls completes pull request numbers in state (open, closed or all) with their titles.
func (a *application) completePulls(state string) cobra.CompletionFunc {
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
items, _, err := services.Pulls{Client: t.client, Repository: t.Repo}.List(ctx, services.PullListOptions{State: state, Limit: completionLimit, Page: 1})
return describe(items, func(item services.Pull) (string, string) { return strconv.FormatInt(item.Number, 10), item.Title }), err
})
}
// completeLabels completes comma-separated label names of the repository and, when its owner is an
// organization, of the organization.
func (a *application) completeLabels() cobra.CompletionFunc {
return commaSeparated(a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
labels, err := services.Labels{Client: t.client, Repository: t.Repo}.List(ctx, completionLimit, 1)
if err != nil {
return nil, err
}
owner, _, _ := strings.Cut(t.Repo, "/")
if orgLabels, err := (services.Labels{Client: t.client, Organization: owner}).List(ctx, completionLimit, 1); err == nil {
labels = append(labels, orgLabels...)
}
return describe(labels, func(label services.Label) (string, string) { return label.Name, label.Description }), nil
}))
}
// completeMilestones completes the titles of open milestones.
func (a *application) completeMilestones() cobra.CompletionFunc {
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
items, err := services.Milestones{Client: t.client, Repository: t.Repo}.List(ctx, "open", completionLimit, 1)
return describe(items, func(item services.Milestone) (string, string) { return item.Title, item.Description }), err
})
}
// completeUsers completes comma-separated usernames that can be assigned in the repository.
func (a *application) completeUsers() cobra.CompletionFunc {
return commaSeparated(a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
type user struct {
Login string `json:"login"`
FullName string `json:"full_name"`
}
var users []user
err := fetchJSON(ctx, t.client, "repos/"+t.Repo+"/assignees", &users)
return describe(users, func(item user) (string, string) { return item.Login, item.FullName }), err
}))
}
// completeBranches completes the local branches, then the repository's branches.
func (a *application) completeBranches() cobra.CompletionFunc {
return mergeCompletions(a.completeLocalBranches, a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
items, err := services.Branches{Client: t.client}.List(ctx, t.Repo, completionLimit, 1)
return describe(items, func(item services.Branch) (string, string) {
if item.Protected {
return item.Name, "protected"
}
return item.Name, ""
}), err
}))
}
// completeTags completes the repository's tags.
func (a *application) completeTags() cobra.CompletionFunc {
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
type tag struct {
Name string `json:"name"`
}
var tags []tag
err := fetchJSON(ctx, t.client, "repos/"+t.Repo+"/tags?limit="+strconv.Itoa(completionLimit), &tags)
return describe(tags, func(item tag) (string, string) { return item.Name, "" }), err
})
}
// completeReleases completes release tags with their titles.
func (a *application) completeReleases() cobra.CompletionFunc {
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
items, err := services.Releases{Client: t.client, Repository: t.Repo}.List(ctx, completionLimit, 1)
return describe(items, func(item services.Release) (string, string) { return item.Tag, item.Title }), err
})
}
// completeOrgs completes the organizations of the account.
func (a *application) completeOrgs() cobra.CompletionFunc {
return a.apiCompletion(false, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
items, _, err := services.Organizations{Client: t.client}.List(ctx, true, "", completionLimit, 1)
return describe(items, func(item services.Organization) (string, string) { return item.Name, item.FullName }), err
})
}
// completeTeams completes teams as ORG/TEAM: first the account's organizations, then the teams of the
// organization before the slash.
func (a *application) completeTeams() cobra.CompletionFunc {
orgs := a.completeOrgs()
teams := a.apiCompletion(false, func(ctx context.Context, t target, toComplete string) ([]cobra.Completion, error) {
org, _, _ := strings.Cut(toComplete, "/")
items, err := services.Teams{Client: t.client}.List(ctx, org, completionLimit, 1)
return describe(items, func(item services.Team) (string, string) {
return org + "/" + item.Name, strings.TrimSpace(item.Description + " (ID " + strconv.FormatInt(item.ID, 10) + ")")
}), err
})
return func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
if strings.Contains(toComplete, "/") {
return teams(cmd, args, toComplete)
}
values, directive := orgs(cmd, args, toComplete)
for i, value := range values {
name, description, _ := strings.Cut(value, "\t")
values[i] = cobra.CompletionWithDesc(name+"/", description)
}
return values, directive | cobra.ShellCompDirectiveNoSpace
}
}
// completeRepos completes repositories as OWNER/NAME: those of the Git remotes on configured
// instances, then those of the owner before the slash, or else the account's repositories.
func (a *application) completeRepos() cobra.CompletionFunc {
remotes := func(cmd *cobra.Command, _ []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
ctx, cancel := completionContext(cmd)
defer cancel()
snapshot, err := a.deps.Settings.Load()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
values := []cobra.Completion{}
for _, remote := range a.gitRemotes(ctx) {
if host, repo, err := settings.RepositoryURL(remote[1], snapshot.Hosts.Hosts); err == nil && snapshot.Hosts.Hosts[host].Users != nil {
values = append(values, cobra.CompletionWithDesc(repo, "remote "+remote[0]))
}
}
return matching(values, toComplete), cobra.ShellCompDirectiveNoFileComp
}
return mergeCompletions(remotes, a.apiCompletion(false, func(ctx context.Context, t target, toComplete string) ([]cobra.Completion, error) {
owner, _, found := strings.Cut(toComplete, "/")
if !found {
owner = ""
}
items, _, err := services.Repositories{Client: t.client}.List(ctx, services.RepositoryListOptions{Owner: owner, Limit: completionLimit, Page: 1})
return describe(items, func(item services.Repository) (string, string) { return item.FullName, item.Description }), err
}))
}
// completeWorkflows completes workflow files with their names.
func (a *application) completeWorkflows() cobra.CompletionFunc {
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
items, err := services.Actions{Client: t.client, Repository: t.Repo}.Workflows(ctx)
return describe(items, func(item services.Workflow) (string, string) { return item.ID, item.Name }), err
})
}
// completeRuns completes the IDs of recent workflow runs with their status and title.
func (a *application) completeRuns() cobra.CompletionFunc {
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
items, err := services.Actions{Client: t.client, Repository: t.Repo}.Runs(ctx, services.RunListOptions{Limit: completionLimit, Page: 1})
return describe(items, func(item services.Run) (string, string) {
return strconv.FormatInt(item.ID, 10), strings.Join(slices.DeleteFunc([]string{item.Status, item.Conclusion, item.Title}, func(s string) bool { return s == "" }), " · ")
}), err
})
}
// completeSecrets completes Actions secret names in the scope that scope selects; a scope without an
// organization or account selects the repository.
func (a *application) completeSecrets(scope func() services.ActionScope) cobra.CompletionFunc {
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
items, err := services.ActionSecrets{Client: t.client, Scope: actionScope(scope, t)}.List(ctx, completionLimit, 1)
return describe(items, func(item services.ActionSecret) (string, string) { return item.Name, item.Description }), err
})
}
// completeVariables completes Actions variable names in the scope that scope selects; a scope without
// an organization or account selects the repository.
func (a *application) completeVariables(scope func() services.ActionScope) cobra.CompletionFunc {
return a.apiCompletion(true, func(ctx context.Context, t target, _ string) ([]cobra.Completion, error) {
items, err := services.ActionVariables{Client: t.client, Scope: actionScope(scope, t)}.List(ctx, completionLimit, 1)
return describe(items, func(item services.ActionVariable) (string, string) { return item.Name, item.Description }), err
})
}
// actionScope applies the target's repository to a scope without organization or account.
func actionScope(scope func() services.ActionScope, t target) services.ActionScope {
selected := scope()
if selected.Organization == "" && !selected.Account {
selected.Repository = t.Repo
}
return selected
}
// pathFlags take local file paths, like every flag whose name ends in -file.
var pathFlags = []string{"asset", "attachment", "file", "input", "ssh-key"}
// completeDefaults completes paths only where a command takes them: path flags, flags marked with
// MarkFlagFilename, and positional arguments named file or directory in Use. Everything else without a
// completer completes nothing, by the root's default directive, rather than file names.
func completeDefaults(cmd *cobra.Command) {
for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.PersistentFlags()} {
flags.VisitAll(func(flag *pflag.Flag) {
if _, registered := cmd.GetFlagCompletionFunc(flag.Name); registered {
return
}
switch {
case flag.Name == "dir":
_ = cmd.RegisterFlagCompletionFunc(flag.Name, completeDirectories)
case strings.HasSuffix(flag.Name, "-file") || slices.Contains(pathFlags, flag.Name) || flag.Annotations[cobra.BashCompFilenameExt] != nil:
_ = cmd.RegisterFlagCompletionFunc(flag.Name, completeFiles)
}
})
}
if slots := argSlots(cmd.Use); len(slots) > 0 {
complete := cmd.ValidArgsFunction
cmd.ValidArgsFunction = func(c *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
slot := slots[min(len(args), len(slots)-1)]
switch {
case len(args) >= len(slots) && !slot.variadic:
return nil, cobra.ShellCompDirectiveNoFileComp
case slot.kind == "file":
return completeFiles(c, args, toComplete)
case slot.kind == "directory":
return completeDirectories(c, args, toComplete)
case complete != nil:
return complete(c, args, toComplete)
}
return nil, cobra.ShellCompDirectiveNoFileComp
}
}
for _, child := range cmd.Commands() {
completeDefaults(child)
}
}
// completeFiles leaves the completion of file paths to the shell.
func completeFiles(*cobra.Command, []string, string) ([]cobra.Completion, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveDefault
}
// completeDirectories leaves the completion of directory paths to the shell.
func completeDirectories(*cobra.Command, []string, string) ([]cobra.Completion, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveFilterDirs
}
// argSlot is one positional argument that a Use line declares, such as <number>, [directory] or
// <file>...; kind is "file" or "directory" for path arguments.
type argSlot struct {
kind string
variadic bool
}
// argSlots parses the positional arguments of a Use line.
func argSlots(use string) []argSlot {
_, rest, _ := strings.Cut(use, " ")
var slots []argSlot
depth, start := 0, 0
for i, r := range rest {
switch r {
case '<', '[':
if depth == 0 {
start = i
}
depth++
case '>', ']':
if depth--; depth != 0 {
continue
}
slot := argSlot{variadic: strings.HasPrefix(rest[i+1:], "...") || strings.HasSuffix(rest[start:i], "...")}
for name := range strings.FieldsFuncSeq(rest[start:i+1], func(r rune) bool { return strings.ContainsRune("<>[]|. ", r) }) {
switch {
case name == "file" || strings.HasSuffix(name, "-file"):
slot.kind = "file"
case name == "directory" || name == "dir":
slot.kind = "directory"
}
}
slots = append(slots, slot)
}
}
return slots
}
// completionInstallHelp explains how to load the completion scripts, including with macOS's bash 3.2
// and in zsh without compinit.
func completionInstallHelp(root *cobra.Command) {
completion, _, err := root.Find([]string{"completion"})
if err != nil || completion == root {
return
}
completion.Short = "Generate shell completion scripts"
completion.Long = `Generate shell completion scripts for tea.
Completion offers commands, flags, flag values such as --state or --json fields, and values
from the selected instance, such as repositories, issue numbers and labels. See
tea completion <shell> --help for how to load the script in your shell.`
help := map[string][2]string{
"bash": {`Generate the completion script for bash.
The script needs the bash-completion package. On macOS, install bash-completion with
Homebrew for the system bash 3.2, or bash-completion@2 for a Homebrew bash, and load it
as its install notes say.
To load completions in every new shell, add the eval line below to ~/.bashrc (on macOS,
~/.bash_profile). Use eval: bash 3.2, the default on macOS, cannot source <(tea completion bash).`, `# Load completions in this shell, or in every shell from ~/.bashrc
$ eval "$(tea completion bash)"
# Or install the script for every user (Linux, then macOS)
$ tea completion bash > /etc/bash_completion.d/tea
$ tea completion bash > "$(brew --prefix)/etc/bash_completion.d/tea"`},
"zsh": {`Generate the completion script for zsh.
Completion must be initialized before the script loads: add these lines to ~/.zshrc,
keeping an existing compinit call instead of the first line. Without compinit, zsh
reports "command not found: compdef".`, `$ autoload -U compinit && compinit
$ source <(tea completion zsh)
# Or install the script into a directory of your fpath once
$ tea completion zsh > "${fpath[1]}/_tea"`},
"fish": {`Generate the completion script for fish.
To load completions in every new shell, install the script once.`, `$ tea completion fish > ~/.config/fish/completions/tea.fish`},
"powershell": {`Generate the completion script for PowerShell.
To load completions in every new shell, add this line to your PowerShell profile.`, `$ tea completion powershell | Out-String | Invoke-Expression`},
}
for _, shell := range completion.Commands() {
shell.Long, shell.Example = help[shell.Name()][0], help[shell.Name()][1]
if flag := shell.Flags().Lookup("no-descriptions"); flag != nil {
flag.Usage = "Disable completion descriptions"
}
}
}