feat: add shell completions, command grouping, and dynamic completions

Add completion command for bash/zsh/fish/powershell generation. Organize
help text using Cobra command groups (Task Commands, Reports, Other).
Register dynamic ValidArgsFunction on filter-accepting commands to
suggest +tag and project:name completions from the database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 13:59:21 +01:00
parent 32cc05a546
commit feb5406077
6 changed files with 139 additions and 7 deletions
+48
View File
@@ -0,0 +1,48 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completions",
Long: `Generate shell completion scripts for opal.
To load completions:
Bash:
$ source <(opal completion bash)
# To load on startup, add to ~/.bashrc:
$ echo 'source <(opal completion bash)' >> ~/.bashrc
Zsh:
$ source <(opal completion zsh)
# To load on startup, add to ~/.zshrc:
$ echo 'source <(opal completion zsh)' >> ~/.zshrc
Fish:
$ opal completion fish | source
# To load on startup:
$ opal completion fish > ~/.config/fish/completions/opal.fish`,
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)
default:
fmt.Fprintf(os.Stderr, "Unknown shell: %s\n", args[0])
os.Exit(1)
}
},
}
+50
View File
@@ -0,0 +1,50 @@
package cmd
import (
"fmt"
"git.jnss.me/joakim/opal/internal/engine"
"github.com/spf13/cobra"
)
// taskFilterCompletion provides dynamic completions for task filter arguments.
// Suggests +tag and project:name completions from the database.
func taskFilterCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
var completions []string
tags, err := engine.GetAllTags()
if err == nil {
for _, tag := range tags {
completions = append(completions, fmt.Sprintf("+%s", tag))
}
}
projects, err := engine.GetAllProjects()
if err == nil {
for _, proj := range projects {
completions = append(completions, fmt.Sprintf("project:%s", proj))
}
}
// Add known attribute keys
for key := range engine.ValidAttributeKeys {
completions = append(completions, fmt.Sprintf("%s:", key))
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
func init() {
// Register dynamic completions for commands that accept filters
addCmd.ValidArgsFunction = taskFilterCompletion
doneCmd.ValidArgsFunction = taskFilterCompletion
deleteCmd.ValidArgsFunction = taskFilterCompletion
modifyCmd.ValidArgsFunction = taskFilterCompletion
startCmd.ValidArgsFunction = taskFilterCompletion
stopCmd.ValidArgsFunction = taskFilterCompletion
editCmd.ValidArgsFunction = taskFilterCompletion
infoCmd.ValidArgsFunction = taskFilterCompletion
annotateCmd.ValidArgsFunction = taskFilterCompletion
denotateCmd.ValidArgsFunction = taskFilterCompletion
logCmd.ValidArgsFunction = taskFilterCompletion
}
+38 -7
View File
@@ -34,7 +34,7 @@ var commandNames = []string{
"add", "done", "modify", "delete", "add", "done", "modify", "delete",
"start", "stop", "count", "projects", "tags", "start", "stop", "count", "projects", "tags",
"info", "edit", "server", "sync", "reports", "setup", "info", "edit", "server", "sync", "reports", "setup",
"version", "annotate", "denotate", "undo", "log", "version", "annotate", "denotate", "undo", "log", "completion",
} }
// Report names (dynamically populated) // Report names (dynamically populated)
@@ -222,31 +222,62 @@ func init() {
initializeApp() initializeApp()
} }
// Add regular subcommands // Command groups for organized help output
rootCmd.AddGroup(
&cobra.Group{ID: "task", Title: "Task Commands:"},
&cobra.Group{ID: "report", Title: "Reports:"},
&cobra.Group{ID: "other", Title: "Other:"},
)
// Task commands
addCmd.GroupID = "task"
doneCmd.GroupID = "task"
modifyCmd.GroupID = "task"
deleteCmd.GroupID = "task"
startCmd.GroupID = "task"
stopCmd.GroupID = "task"
editCmd.GroupID = "task"
infoCmd.GroupID = "task"
annotateCmd.GroupID = "task"
denotateCmd.GroupID = "task"
undoCmd.GroupID = "task"
logCmd.GroupID = "task"
rootCmd.AddCommand(addCmd) rootCmd.AddCommand(addCmd)
rootCmd.AddCommand(doneCmd) rootCmd.AddCommand(doneCmd)
rootCmd.AddCommand(modifyCmd) rootCmd.AddCommand(modifyCmd)
rootCmd.AddCommand(deleteCmd) rootCmd.AddCommand(deleteCmd)
rootCmd.AddCommand(startCmd) rootCmd.AddCommand(startCmd)
rootCmd.AddCommand(stopCmd) rootCmd.AddCommand(stopCmd)
rootCmd.AddCommand(countCmd)
rootCmd.AddCommand(projectsCmd)
rootCmd.AddCommand(tagsCmd)
rootCmd.AddCommand(infoCmd) rootCmd.AddCommand(infoCmd)
rootCmd.AddCommand(editCmd) rootCmd.AddCommand(editCmd)
rootCmd.AddCommand(reportsCmd)
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(annotateCmd) rootCmd.AddCommand(annotateCmd)
rootCmd.AddCommand(denotateCmd) rootCmd.AddCommand(denotateCmd)
rootCmd.AddCommand(undoCmd) rootCmd.AddCommand(undoCmd)
rootCmd.AddCommand(logCmd) rootCmd.AddCommand(logCmd)
// Other commands
countCmd.GroupID = "other"
projectsCmd.GroupID = "other"
tagsCmd.GroupID = "other"
reportsCmd.GroupID = "other"
versionCmd.GroupID = "other"
completionCmd.GroupID = "other"
rootCmd.AddCommand(countCmd)
rootCmd.AddCommand(projectsCmd)
rootCmd.AddCommand(tagsCmd)
rootCmd.AddCommand(reportsCmd)
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(completionCmd)
// Enable --version flag on root command // Enable --version flag on root command
rootCmd.Version = Version rootCmd.Version = Version
// Add report commands dynamically // Add report commands dynamically
reportCommands := CreateReportCommands() reportCommands := CreateReportCommands()
for _, cmd := range reportCommands { for _, cmd := range reportCommands {
cmd.GroupID = "report"
rootCmd.AddCommand(cmd) rootCmd.AddCommand(cmd)
} }
} }
+1
View File
@@ -198,6 +198,7 @@ Examples:
} }
func init() { func init() {
serverCmd.GroupID = "other"
rootCmd.AddCommand(serverCmd) rootCmd.AddCommand(serverCmd)
serverCmd.AddCommand(serverStartCmd) serverCmd.AddCommand(serverStartCmd)
serverCmd.AddCommand(keygenCmd) serverCmd.AddCommand(keygenCmd)
+1
View File
@@ -49,6 +49,7 @@ Examples:
} }
func init() { func init() {
setupCmd.GroupID = "other"
rootCmd.AddCommand(setupCmd) rootCmd.AddCommand(setupCmd)
setupCmd.Flags().BoolVar(&showSystemdFlag, "show-systemd", false, "Show systemd service template") setupCmd.Flags().BoolVar(&showSystemdFlag, "show-systemd", false, "Show systemd service template")
+1
View File
@@ -391,6 +391,7 @@ Examples:
} }
func init() { func init() {
syncCmd.GroupID = "other"
rootCmd.AddCommand(syncCmd) rootCmd.AddCommand(syncCmd)
syncCmd.AddCommand(syncInitCmd) syncCmd.AddCommand(syncInitCmd)