diff --git a/cmd/task/completion.go b/cmd/task/completion.go new file mode 100644 index 00000000..934045e8 --- /dev/null +++ b/cmd/task/completion.go @@ -0,0 +1,233 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/bborn/workflow/internal/db" +) + +// newCompletionCmd creates the completion command with subcommands for each shell. +func newCompletionCmd(rootCmd *cobra.Command) *cobra.Command { + completionCmd := &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Long: `Generate shell completion scripts for ty. + +To load completions: + +Bash: + $ source <(ty completion bash) + + # To load completions for each session, execute once: + # Linux: + $ ty completion bash > /etc/bash_completion.d/ty + # macOS: + $ ty completion bash > $(brew --prefix)/etc/bash_completion.d/ty + +Zsh: + # If shell completion is not already enabled in your environment, + # you will need to enable it. You can execute the following once: + $ echo "autoload -U compinit; compinit" >> ~/.zshrc + + # To load completions for each session, execute once: + $ ty completion zsh > "${fpath[1]}/_ty" + + # You will need to start a new shell for this setup to take effect. + +Fish: + $ ty completion fish | source + + # To load completions for each session, execute once: + $ ty completion fish > ~/.config/fish/completions/ty.fish + +PowerShell: + PS> ty completion powershell | Out-String | Invoke-Expression + + # To load completions for every new session, run: + PS> ty completion powershell > ty.ps1 + # and source this file from your PowerShell profile. +`, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + Run: func(cmd *cobra.Command, args []string) { + switch args[0] { + case "bash": + rootCmd.GenBashCompletion(os.Stdout) + case "zsh": + rootCmd.GenZshCompletion(os.Stdout) + case "fish": + rootCmd.GenFishCompletion(os.Stdout, true) + case "powershell": + rootCmd.GenPowerShellCompletionWithDesc(os.Stdout) + } + }, + } + + return completionCmd +} + +// completeTaskIDs returns a completion function that suggests task IDs with their titles. +func completeTaskIDs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return fetchTaskCompletions(toComplete) +} + +// completeTaskIDsThenStatus completes task ID for first arg, status for second. +func completeTaskIDsThenStatus(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return fetchTaskCompletions(toComplete) + } + if len(args) == 1 { + return validStatuses(), cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp +} + +// completeTaskIDsThenProject completes task ID for first arg, project name for second. +func completeTaskIDsThenProject(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return fetchTaskCompletions(toComplete) + } + if len(args) == 1 { + return fetchProjectCompletions() + } + return nil, cobra.ShellCompDirectiveNoFileComp +} + +// completeProjectNames returns a completion function for project names. +func completeProjectNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return fetchProjectCompletions() +} + +// completeSettingKeys returns a completion function for settings set first arg. +func completeSettingKeys(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return []string{ + "anthropic_api_key\tAPI key for ghost text autocomplete", + "autocomplete_enabled\tEnable/disable ghost text (true/false)", + "idle_suspend_timeout\tIdle timeout before suspending (e.g. 6h)", + }, cobra.ShellCompDirectiveNoFileComp + } + return nil, cobra.ShellCompDirectiveNoFileComp +} + +// completeTypeNames returns a completion function for task type names. +func completeTypeNames(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) >= 1 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return fetchTypeCompletions() +} + +// completeFlagProjects provides completions for --project flag values. +func completeFlagProjects(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return fetchProjectCompletions() +} + +// completeFlagExecutors provides completions for --executor flag values. +func completeFlagExecutors(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return []string{ + "claude\tAnthropic Claude (default)", + "codex\tOpenAI Codex", + "gemini\tGoogle Gemini", + "pi\tInflection Pi", + "opencode\tOpenCode", + "openclaw\tOpenClaw", + }, cobra.ShellCompDirectiveNoFileComp +} + +// completeFlagTypes provides completions for --type flag values. +func completeFlagTypes(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + comps, directive := fetchTypeCompletions() + if len(comps) > 0 { + return comps, directive + } + // Fallback to built-in types if DB unavailable + return []string{"code", "writing", "thinking"}, cobra.ShellCompDirectiveNoFileComp +} + +// completeFlagStatuses provides completions for --status flag values. +func completeFlagStatuses(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return validStatuses(), cobra.ShellCompDirectiveNoFileComp +} + +// fetchTaskCompletions opens the DB and returns task ID completions. +func fetchTaskCompletions(toComplete string) ([]string, cobra.ShellCompDirective) { + database, err := db.Open(db.DefaultPath()) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer database.Close() + + tasks, err := database.ListTasks(db.ListTasksOptions{IncludeClosed: true, Limit: 50}) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var completions []string + for _, t := range tasks { + desc := t.Title + if len(desc) > 40 { + desc = desc[:37] + "..." + } + completions = append(completions, fmt.Sprintf("%d\t[%s] %s", t.ID, t.Status, desc)) + } + return completions, cobra.ShellCompDirectiveNoFileComp +} + +// fetchProjectCompletions opens the DB and returns project name completions. +func fetchProjectCompletions() ([]string, cobra.ShellCompDirective) { + database, err := db.Open(db.DefaultPath()) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer database.Close() + + projects, err := database.ListProjects() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var completions []string + for _, p := range projects { + desc := p.Name + if p.Path != "" { + desc = p.Path + } + completions = append(completions, fmt.Sprintf("%s\t%s", p.Name, desc)) + } + return completions, cobra.ShellCompDirectiveNoFileComp +} + +// fetchTypeCompletions opens the DB and returns task type completions. +func fetchTypeCompletions() ([]string, cobra.ShellCompDirective) { + database, err := db.Open(db.DefaultPath()) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer database.Close() + + types, err := database.ListTaskTypes() + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var completions []string + for _, t := range types { + label := t.Label + if label == "" { + label = t.Name + } + completions = append(completions, fmt.Sprintf("%s\t%s", t.Name, label)) + } + return completions, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/task/completion_test.go b/cmd/task/completion_test.go new file mode 100644 index 00000000..532dd9ef --- /dev/null +++ b/cmd/task/completion_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "os" + "testing" + + "github.com/spf13/cobra" +) + +func TestNewCompletionCmd(t *testing.T) { + rootCmd := &cobra.Command{Use: "ty"} + completionCmd := newCompletionCmd(rootCmd) + + if completionCmd.Use != "completion [bash|zsh|fish|powershell]" { + t.Errorf("unexpected Use: %s", completionCmd.Use) + } + + if len(completionCmd.ValidArgs) != 4 { + t.Errorf("expected 4 valid args, got %d", len(completionCmd.ValidArgs)) + } + + expected := map[string]bool{"bash": true, "zsh": true, "fish": true, "powershell": true} + for _, arg := range completionCmd.ValidArgs { + if !expected[arg] { + t.Errorf("unexpected valid arg: %s", arg) + } + } +} + +func TestCompletionCmdOutput(t *testing.T) { + shells := []string{"bash", "zsh", "fish", "powershell"} + + for _, shell := range shells { + t.Run(shell, func(t *testing.T) { + rootCmd := &cobra.Command{Use: "ty"} + completionCmd := newCompletionCmd(rootCmd) + rootCmd.AddCommand(completionCmd) + + // Capture output + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + rootCmd.SetArgs([]string{"completion", shell}) + err := rootCmd.Execute() + + w.Close() + os.Stdout = old + + if err != nil { + t.Fatalf("completion %s failed: %v", shell, err) + } + + buf := make([]byte, 1024) + n, _ := r.Read(buf) + if n == 0 { + t.Errorf("completion %s produced no output", shell) + } + }) + } +} + +func TestCompleteTaskIDsThenStatus(t *testing.T) { + // When 1 arg already provided (task ID), should return statuses + completions, directive := completeTaskIDsThenStatus(nil, []string{"42"}, "") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("expected NoFileComp directive") + } + + statuses := validStatuses() + if len(completions) != len(statuses) { + t.Errorf("expected %d statuses, got %d", len(statuses), len(completions)) + } + + // When 2 args already provided, no more completions + completions, _ = completeTaskIDsThenStatus(nil, []string{"42", "done"}, "") + if len(completions) != 0 { + t.Errorf("expected no completions for 2+ args, got %d", len(completions)) + } +} + +func TestCompleteSettingKeys(t *testing.T) { + completions, directive := completeSettingKeys(nil, []string{}, "") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("expected NoFileComp directive") + } + if len(completions) != 3 { + t.Errorf("expected 3 setting keys, got %d", len(completions)) + } + + // After first arg, no more completions + completions, _ = completeSettingKeys(nil, []string{"anthropic_api_key"}, "") + if len(completions) != 0 { + t.Errorf("expected no completions after key, got %d", len(completions)) + } +} + +func TestCompleteFlagExecutors(t *testing.T) { + completions, directive := completeFlagExecutors(nil, nil, "") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("expected NoFileComp directive") + } + if len(completions) != 6 { + t.Errorf("expected 6 executors, got %d", len(completions)) + } +} + +func TestCompleteFlagStatuses(t *testing.T) { + completions, directive := completeFlagStatuses(nil, nil, "") + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("expected NoFileComp directive") + } + if len(completions) == 0 { + t.Error("expected some status completions") + } +} diff --git a/cmd/task/main.go b/cmd/task/main.go index 1ca5296d..e57ae64f 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -402,9 +402,10 @@ Tasks will automatically reconnect to their agent sessions when viewed.`, // Delete subcommand - delete a task, kill its agent session, and remove worktree deleteCmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a task, kill its agent session, and remove its worktree", - Args: cobra.ExactArgs(1), + Use: "delete ", + Short: "Delete a task, kill its agent session, and remove its worktree", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeTaskIDs, Run: func(cmd *cobra.Command, args []string) { var taskID int64 if _, err := fmt.Sscanf(args[0], "%d", &taskID); err != nil { @@ -633,6 +634,9 @@ Examples: createCmd.Flags().Bool("pinned", false, "Pin the task to the top of its column") createCmd.Flags().StringP("branch", "b", "", "Existing branch to checkout for worktree (e.g., fix/ui-overflow)") createCmd.Flags().Bool("json", false, "Output in JSON format") + createCmd.RegisterFlagCompletionFunc("project", completeFlagProjects) + createCmd.RegisterFlagCompletionFunc("type", completeFlagTypes) + createCmd.RegisterFlagCompletionFunc("executor", completeFlagExecutors) rootCmd.AddCommand(createCmd) // List subcommand - list tasks @@ -811,6 +815,9 @@ Examples: listCmd.Flags().IntP("limit", "n", 50, "Maximum number of tasks to return") listCmd.Flags().Bool("json", false, "Output in JSON format") listCmd.Flags().Bool("pr", false, "Show PR/CI status (requires network)") + listCmd.RegisterFlagCompletionFunc("status", completeFlagStatuses) + listCmd.RegisterFlagCompletionFunc("project", completeFlagProjects) + listCmd.RegisterFlagCompletionFunc("type", completeFlagTypes) rootCmd.AddCommand(listCmd) boardCmd := &cobra.Command{ @@ -923,8 +930,9 @@ Press Ctrl+C to stop.`, // Show subcommand - show task details showCmd := &cobra.Command{ - Use: "show ", - Short: "Show task details", + Use: "show ", + Short: "Show task details", + ValidArgsFunction: completeTaskIDs, Long: `Show detailed information about a task. Examples: @@ -1152,8 +1160,9 @@ Examples: // Update subcommand - update task fields updateCmd := &cobra.Command{ - Use: "update ", - Short: "Update a task", + Use: "update ", + Short: "Update a task", + ValidArgsFunction: completeTaskIDs, Long: `Update task fields. Examples: @@ -1273,12 +1282,16 @@ Examples: updateCmd.Flags().StringP("executor", "e", "", "Update task executor: claude, codex, gemini, pi, opencode, openclaw") updateCmd.Flags().String("tags", "", "Update task tags (comma-separated)") updateCmd.Flags().Bool("pinned", false, "Pin or unpin the task") + updateCmd.RegisterFlagCompletionFunc("project", completeFlagProjects) + updateCmd.RegisterFlagCompletionFunc("type", completeFlagTypes) + updateCmd.RegisterFlagCompletionFunc("executor", completeFlagExecutors) rootCmd.AddCommand(updateCmd) // Move subcommand - move a task to a different project moveCmd := &cobra.Command{ - Use: "move ", - Short: "Move a task to a different project", + Use: "move ", + Short: "Move a task to a different project", + ValidArgsFunction: completeTaskIDsThenProject, Long: `Move a task to a different project. This properly cleans up the task's worktree and agent sessions from the old project, @@ -1383,9 +1396,10 @@ Examples: // Execute subcommand - queue a task for execution executeCmd := &cobra.Command{ - Use: "execute ", - Aliases: []string{"queue", "run"}, - Short: "Queue a task for execution", + Use: "execute ", + Aliases: []string{"queue", "run"}, + Short: "Queue a task for execution", + ValidArgsFunction: completeTaskIDs, Long: `Queue a task to be executed by the daemon. Examples: @@ -1440,8 +1454,9 @@ Examples: rootCmd.AddCommand(executeCmd) statusCmd := &cobra.Command{ - Use: "status ", - Short: "Set a task's status", + Use: "status ", + Short: "Set a task's status", + ValidArgsFunction: completeTaskIDsThenStatus, Long: `Manually update a task's status. Useful for automation/orchestration when you need to move cards between columns without opening the TUI. @@ -1489,9 +1504,10 @@ Valid statuses: backlog, queued, processing, blocked, done, archived.`, rootCmd.AddCommand(statusCmd) pinCmd := &cobra.Command{ - Use: "pin ", - Short: "Pin, unpin, or toggle a task", - Args: cobra.ExactArgs(1), + Use: "pin ", + Short: "Pin, unpin, or toggle a task", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeTaskIDs, Run: func(cmd *cobra.Command, args []string) { var taskID int64 if _, err := fmt.Sscanf(args[0], "%d", &taskID); err != nil { @@ -1547,9 +1563,10 @@ Valid statuses: backlog, queued, processing, blocked, done, archived.`, // Close subcommand - mark a task as done closeCmd := &cobra.Command{ - Use: "close ", - Aliases: []string{"done", "complete"}, - Short: "Mark a task as done", + Use: "close ", + ValidArgsFunction: completeTaskIDs, + Aliases: []string{"done", "complete"}, + Short: "Mark a task as done", Long: `Mark a task as completed. Examples: @@ -1604,8 +1621,9 @@ Examples: // Retry subcommand - retry a blocked/failed task retryCmd := &cobra.Command{ - Use: "retry ", - Short: "Retry a blocked or failed task", + Use: "retry ", + Short: "Retry a blocked or failed task", + ValidArgsFunction: completeTaskIDs, Long: `Retry a task that is blocked or failed, optionally with feedback. Examples: @@ -1654,8 +1672,9 @@ Examples: // Input subcommand - send input directly to a running task's executor inputCmd := &cobra.Command{ - Use: "input [message]", - Short: "Send input to a task's executor", + Use: "input [message]", + Short: "Send input to a task's executor", + ValidArgsFunction: completeTaskIDs, Long: `Send input directly to a running task's executor via tmux. This allows you to interact with a blocked or running task without going through @@ -1765,8 +1784,9 @@ Examples: // Output subcommand - capture recent output from executor pane outputCmd := &cobra.Command{ - Use: "output ", - Short: "Capture recent output from a task's executor", + Use: "output ", + Short: "Capture recent output from a task's executor", + ValidArgsFunction: completeTaskIDs, Long: `Capture recent output from a running task's executor pane. This allows you to see what the executor has outputted without attaching to the tmux pane. @@ -2039,8 +2059,9 @@ Examples: } settingsSetCmd := &cobra.Command{ - Use: "set ", - Short: "Set a setting value", + Use: "set ", + Short: "Set a setting value", + ValidArgsFunction: completeSettingKeys, Long: `Set a configuration setting. Available settings: @@ -2237,8 +2258,9 @@ Examples: // Projects show subcommand projectsShowCmd := &cobra.Command{ - Use: "show ", - Short: "Show project details", + Use: "show ", + Short: "Show project details", + ValidArgsFunction: completeProjectNames, Long: `Show detailed information about a project including its instructions. Examples: @@ -2288,8 +2310,9 @@ Examples: // Projects update subcommand projectsUpdateCmd := &cobra.Command{ - Use: "update ", - Short: "Update project settings", + Use: "update ", + Short: "Update project settings", + ValidArgsFunction: completeProjectNames, Long: `Update settings for an existing project. Examples: @@ -2343,8 +2366,9 @@ Examples: // Projects delete subcommand projectsDeleteCmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a project", + Use: "delete ", + Short: "Delete a project", + ValidArgsFunction: completeProjectNames, Long: `Delete a project. The 'personal' project cannot be deleted. Note: This only removes the project from the task system. It does not @@ -2366,8 +2390,9 @@ Examples: // Block command - create a dependency between two tasks blockCmd := &cobra.Command{ - Use: "block --by ", - Short: "Block a task until another task completes", + Use: "block --by ", + ValidArgsFunction: completeTaskIDs, + Short: "Block a task until another task completes", Long: `Create a dependency where a task is blocked until another task completes. Example: ty block 5 --by 3 @@ -2430,8 +2455,9 @@ Use --auto-queue to automatically move the blocked task to 'queued' when unblock // Unblock command - remove a dependency unblockCmd := &cobra.Command{ - Use: "unblock --from ", - Short: "Remove a blocking dependency", + Use: "unblock --from ", + ValidArgsFunction: completeTaskIDs, + Short: "Remove a blocking dependency", Long: `Remove a dependency so a task is no longer blocked by another. Example: ty unblock 5 --from 3 @@ -2472,8 +2498,9 @@ This removes the dependency where task #5 was blocked by task #3.`, // Deps command - show dependencies for a task depsCmd := &cobra.Command{ - Use: "deps ", - Short: "Show dependencies for a task", + Use: "deps ", + ValidArgsFunction: completeTaskIDs, + Short: "Show dependencies for a task", Long: `Display all dependencies for a task, showing: - Tasks that block this task (must complete before this task) - Tasks that this task blocks (waiting on this task)`, @@ -2634,9 +2661,10 @@ Examples: // Types show subcommand typesShowCmd := &cobra.Command{ - Use: "show ", - Short: "Show details of a task type", - Args: cobra.ExactArgs(1), + Use: "show ", + ValidArgsFunction: completeTypeNames, + Short: "Show details of a task type", + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { name := args[0] outputJSON, _ := cmd.Flags().GetBool("json") @@ -2799,8 +2827,9 @@ Examples: // Types edit subcommand typesEditCmd := &cobra.Command{ - Use: "edit ", - Short: "Edit an existing task type", + Use: "edit ", + ValidArgsFunction: completeTypeNames, + Short: "Edit an existing task type", Long: `Edit an existing task type. Built-in types can be edited but not deleted. All flags are optional - only specified values will be updated. @@ -2900,8 +2929,9 @@ Examples: // Types delete subcommand typesDeleteCmd := &cobra.Command{ - Use: "delete ", - Short: "Delete a custom task type", + Use: "delete ", + ValidArgsFunction: completeTypeNames, + Short: "Delete a custom task type", Long: `Delete a custom task type. Built-in types (code, writing, thinking) cannot be deleted. Examples: @@ -3007,6 +3037,9 @@ The server shares the same SQLite database the daemon writes to (WAL mode).`, serveCmd.Flags().Int("port", 8080, "Port to listen on") rootCmd.AddCommand(serveCmd) + // Completion command for shell tab completion + rootCmd.AddCommand(newCompletionCmd(rootCmd)) + if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error())) os.Exit(1)