Skip to content

Commit df4f927

Browse files
authored
Merge pull request #519 from bborn/task/2041-add-tab-completion-for-cli-commands
Add tab completion for CLI commands
2 parents 8e2a78a + 3181b6e commit df4f927

File tree

3 files changed

+429
-47
lines changed

3 files changed

+429
-47
lines changed

cmd/task/completion.go

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/spf13/cobra"
8+
9+
"github.com/bborn/workflow/internal/db"
10+
)
11+
12+
// newCompletionCmd creates the completion command with subcommands for each shell.
13+
func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command {
14+
completionCmd := &cobra.Command{
15+
Use: "completion [bash|zsh|fish|powershell]",
16+
Short: "Generate shell completion scripts",
17+
Long: `Generate shell completion scripts for ty.
18+
19+
To load completions:
20+
21+
Bash:
22+
$ source <(ty completion bash)
23+
24+
# To load completions for each session, execute once:
25+
# Linux:
26+
$ ty completion bash > /etc/bash_completion.d/ty
27+
# macOS:
28+
$ ty completion bash > $(brew --prefix)/etc/bash_completion.d/ty
29+
30+
Zsh:
31+
# If shell completion is not already enabled in your environment,
32+
# you will need to enable it. You can execute the following once:
33+
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
34+
35+
# To load completions for each session, execute once:
36+
$ ty completion zsh > "${fpath[1]}/_ty"
37+
38+
# You will need to start a new shell for this setup to take effect.
39+
40+
Fish:
41+
$ ty completion fish | source
42+
43+
# To load completions for each session, execute once:
44+
$ ty completion fish > ~/.config/fish/completions/ty.fish
45+
46+
PowerShell:
47+
PS> ty completion powershell | Out-String | Invoke-Expression
48+
49+
# To load completions for every new session, run:
50+
PS> ty completion powershell > ty.ps1
51+
# and source this file from your PowerShell profile.
52+
`,
53+
DisableFlagsInUseLine: true,
54+
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
55+
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
56+
Run: func(cmd *cobra.Command, args []string) {
57+
switch args[0] {
58+
case "bash":
59+
rootCmd.GenBashCompletion(os.Stdout)
60+
case "zsh":
61+
rootCmd.GenZshCompletion(os.Stdout)
62+
case "fish":
63+
rootCmd.GenFishCompletion(os.Stdout, true)
64+
case "powershell":
65+
rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
66+
}
67+
},
68+
}
69+
70+
return completionCmd
71+
}
72+
73+
// completeTaskIDs returns a completion function that suggests task IDs with their titles.
74+
func completeTaskIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
75+
if len(args) >= 1 {
76+
return nil, cobra.ShellCompDirectiveNoFileComp
77+
}
78+
return fetchTaskCompletions(toComplete)
79+
}
80+
81+
// completeTaskIDsThenStatus completes task ID for first arg, status for second.
82+
func completeTaskIDsThenStatus(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
83+
if len(args) == 0 {
84+
return fetchTaskCompletions(toComplete)
85+
}
86+
if len(args) == 1 {
87+
return validStatuses(), cobra.ShellCompDirectiveNoFileComp
88+
}
89+
return nil, cobra.ShellCompDirectiveNoFileComp
90+
}
91+
92+
// completeTaskIDsThenProject completes task ID for first arg, project name for second.
93+
func completeTaskIDsThenProject(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
94+
if len(args) == 0 {
95+
return fetchTaskCompletions(toComplete)
96+
}
97+
if len(args) == 1 {
98+
return fetchProjectCompletions()
99+
}
100+
return nil, cobra.ShellCompDirectiveNoFileComp
101+
}
102+
103+
// completeProjectNames returns a completion function for project names.
104+
func completeProjectNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
105+
if len(args) >= 1 {
106+
return nil, cobra.ShellCompDirectiveNoFileComp
107+
}
108+
return fetchProjectCompletions()
109+
}
110+
111+
// completeSettingKeys returns a completion function for settings set first arg.
112+
func completeSettingKeys(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
113+
if len(args) == 0 {
114+
return []string{
115+
"anthropic_api_key\tAPI key for ghost text autocomplete",
116+
"autocomplete_enabled\tEnable/disable ghost text (true/false)",
117+
"idle_suspend_timeout\tIdle timeout before suspending (e.g. 6h)",
118+
}, cobra.ShellCompDirectiveNoFileComp
119+
}
120+
return nil, cobra.ShellCompDirectiveNoFileComp
121+
}
122+
123+
// completeTypeNames returns a completion function for task type names.
124+
func completeTypeNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
125+
if len(args) >= 1 {
126+
return nil, cobra.ShellCompDirectiveNoFileComp
127+
}
128+
return fetchTypeCompletions()
129+
}
130+
131+
// completeFlagProjects provides completions for --project flag values.
132+
func completeFlagProjects(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
133+
return fetchProjectCompletions()
134+
}
135+
136+
// completeFlagExecutors provides completions for --executor flag values.
137+
func completeFlagExecutors(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
138+
return []string{
139+
"claude\tAnthropic Claude (default)",
140+
"codex\tOpenAI Codex",
141+
"gemini\tGoogle Gemini",
142+
"pi\tInflection Pi",
143+
"opencode\tOpenCode",
144+
"openclaw\tOpenClaw",
145+
}, cobra.ShellCompDirectiveNoFileComp
146+
}
147+
148+
// completeFlagTypes provides completions for --type flag values.
149+
func completeFlagTypes(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
150+
comps, directive := fetchTypeCompletions()
151+
if len(comps) > 0 {
152+
return comps, directive
153+
}
154+
// Fallback to built-in types if DB unavailable
155+
return []string{"code", "writing", "thinking"}, cobra.ShellCompDirectiveNoFileComp
156+
}
157+
158+
// completeFlagStatuses provides completions for --status flag values.
159+
func completeFlagStatuses(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
160+
return validStatuses(), cobra.ShellCompDirectiveNoFileComp
161+
}
162+
163+
// fetchTaskCompletions opens the DB and returns task ID completions.
164+
func fetchTaskCompletions(toComplete string) ([]string, cobra.ShellCompDirective) {
165+
database, err := db.Open(db.DefaultPath())
166+
if err != nil {
167+
return nil, cobra.ShellCompDirectiveNoFileComp
168+
}
169+
defer database.Close()
170+
171+
tasks, err := database.ListTasks(db.ListTasksOptions{IncludeClosed: true, Limit: 50})
172+
if err != nil {
173+
return nil, cobra.ShellCompDirectiveNoFileComp
174+
}
175+
176+
var completions []string
177+
for _, t := range tasks {
178+
desc := t.Title
179+
if len(desc) > 40 {
180+
desc = desc[:37] + "..."
181+
}
182+
completions = append(completions, fmt.Sprintf("%d\t[%s] %s", t.ID, t.Status, desc))
183+
}
184+
return completions, cobra.ShellCompDirectiveNoFileComp
185+
}
186+
187+
// fetchProjectCompletions opens the DB and returns project name completions.
188+
func fetchProjectCompletions() ([]string, cobra.ShellCompDirective) {
189+
database, err := db.Open(db.DefaultPath())
190+
if err != nil {
191+
return nil, cobra.ShellCompDirectiveNoFileComp
192+
}
193+
defer database.Close()
194+
195+
projects, err := database.ListProjects()
196+
if err != nil {
197+
return nil, cobra.ShellCompDirectiveNoFileComp
198+
}
199+
200+
var completions []string
201+
for _, p := range projects {
202+
desc := p.Name
203+
if p.Path != "" {
204+
desc = p.Path
205+
}
206+
completions = append(completions, fmt.Sprintf("%s\t%s", p.Name, desc))
207+
}
208+
return completions, cobra.ShellCompDirectiveNoFileComp
209+
}
210+
211+
// fetchTypeCompletions opens the DB and returns task type completions.
212+
func fetchTypeCompletions() ([]string, cobra.ShellCompDirective) {
213+
database, err := db.Open(db.DefaultPath())
214+
if err != nil {
215+
return nil, cobra.ShellCompDirectiveNoFileComp
216+
}
217+
defer database.Close()
218+
219+
types, err := database.ListTaskTypes()
220+
if err != nil {
221+
return nil, cobra.ShellCompDirectiveNoFileComp
222+
}
223+
224+
var completions []string
225+
for _, t := range types {
226+
label := t.Label
227+
if label == "" {
228+
label = t.Name
229+
}
230+
completions = append(completions, fmt.Sprintf("%s\t%s", t.Name, label))
231+
}
232+
return completions, cobra.ShellCompDirectiveNoFileComp
233+
}

cmd/task/completion_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/spf13/cobra"
8+
)
9+
10+
func TestNewCompletionCmd(t *testing.T) {
11+
rootCmd := &cobra.Command{Use: "ty"}
12+
completionCmd := newCompletionCmd(rootCmd)
13+
14+
if completionCmd.Use != "completion [bash|zsh|fish|powershell]" {
15+
t.Errorf("unexpected Use: %s", completionCmd.Use)
16+
}
17+
18+
if len(completionCmd.ValidArgs) != 4 {
19+
t.Errorf("expected 4 valid args, got %d", len(completionCmd.ValidArgs))
20+
}
21+
22+
expected := map[string]bool{"bash": true, "zsh": true, "fish": true, "powershell": true}
23+
for _, arg := range completionCmd.ValidArgs {
24+
if !expected[arg] {
25+
t.Errorf("unexpected valid arg: %s", arg)
26+
}
27+
}
28+
}
29+
30+
func TestCompletionCmdOutput(t *testing.T) {
31+
shells := []string{"bash", "zsh", "fish", "powershell"}
32+
33+
for _, shell := range shells {
34+
t.Run(shell, func(t *testing.T) {
35+
rootCmd := &cobra.Command{Use: "ty"}
36+
completionCmd := newCompletionCmd(rootCmd)
37+
rootCmd.AddCommand(completionCmd)
38+
39+
// Capture output
40+
old := os.Stdout
41+
r, w, _ := os.Pipe()
42+
os.Stdout = w
43+
44+
rootCmd.SetArgs([]string{"completion", shell})
45+
err := rootCmd.Execute()
46+
47+
w.Close()
48+
os.Stdout = old
49+
50+
if err != nil {
51+
t.Fatalf("completion %s failed: %v", shell, err)
52+
}
53+
54+
buf := make([]byte, 1024)
55+
n, _ := r.Read(buf)
56+
if n == 0 {
57+
t.Errorf("completion %s produced no output", shell)
58+
}
59+
})
60+
}
61+
}
62+
63+
func TestCompleteTaskIDsThenStatus(t *testing.T) {
64+
// When 1 arg already provided (task ID), should return statuses
65+
completions, directive := completeTaskIDsThenStatus(nil, []string{"42"}, "")
66+
if directive != cobra.ShellCompDirectiveNoFileComp {
67+
t.Errorf("expected NoFileComp directive")
68+
}
69+
70+
statuses := validStatuses()
71+
if len(completions) != len(statuses) {
72+
t.Errorf("expected %d statuses, got %d", len(statuses), len(completions))
73+
}
74+
75+
// When 2 args already provided, no more completions
76+
completions, _ = completeTaskIDsThenStatus(nil, []string{"42", "done"}, "")
77+
if len(completions) != 0 {
78+
t.Errorf("expected no completions for 2+ args, got %d", len(completions))
79+
}
80+
}
81+
82+
func TestCompleteSettingKeys(t *testing.T) {
83+
completions, directive := completeSettingKeys(nil, []string{}, "")
84+
if directive != cobra.ShellCompDirectiveNoFileComp {
85+
t.Errorf("expected NoFileComp directive")
86+
}
87+
if len(completions) != 3 {
88+
t.Errorf("expected 3 setting keys, got %d", len(completions))
89+
}
90+
91+
// After first arg, no more completions
92+
completions, _ = completeSettingKeys(nil, []string{"anthropic_api_key"}, "")
93+
if len(completions) != 0 {
94+
t.Errorf("expected no completions after key, got %d", len(completions))
95+
}
96+
}
97+
98+
func TestCompleteFlagExecutors(t *testing.T) {
99+
completions, directive := completeFlagExecutors(nil, nil, "")
100+
if directive != cobra.ShellCompDirectiveNoFileComp {
101+
t.Errorf("expected NoFileComp directive")
102+
}
103+
if len(completions) != 6 {
104+
t.Errorf("expected 6 executors, got %d", len(completions))
105+
}
106+
}
107+
108+
func TestCompleteFlagStatuses(t *testing.T) {
109+
completions, directive := completeFlagStatuses(nil, nil, "")
110+
if directive != cobra.ShellCompDirectiveNoFileComp {
111+
t.Errorf("expected NoFileComp directive")
112+
}
113+
if len(completions) == 0 {
114+
t.Error("expected some status completions")
115+
}
116+
}

0 commit comments

Comments
 (0)