package cmd import ( "context" "fmt" "os" "strings" "git.jnss.me/joakim/opal/internal/engine" "github.com/spf13/cobra" ) // ParsedArgs represents preprocessed command arguments type ParsedArgs struct { Command string Filters []string Modifiers []string CmdArgIndex int // position of command in os.Args[1:], -1 if not found } // Context key for parsed args type contextKey string const parsedArgsKey contextKey = "parsedArgs" // Global flags var ( configDirFlag string dataDirFlag string dryRunFlag bool ) // Command classification var commandNames = []string{ "add", "done", "modify", "delete", "start", "stop", "count", "projects", "tags", "info", "edit", "server", "sync", "reports", "setup", "version", "annotate", "denotate", "undo", "uncomplete", "log", "completion", } // Report names (dynamically populated) var reportNames = []string{ "active", "all", "completed", "list", "minimal", "newest", "next", "oldest", "overdue", "ready", "recurring", "template", "waiting", } var commandsWithModifiers = map[string]bool{ "add": true, "modify": true, "annotate": true, } var rootCmd = &cobra.Command{ Use: "opal [filter] [command|report] [modifiers]", Short: "Opal task manager - taskwarrior-inspired CLI task management", Long: `Opal is a powerful command-line task manager. It supports filtering, tags, priorities, projects, and recurring tasks.`, Args: cobra.ArbitraryArgs, Run: func(cmd *cobra.Command, args []string) { // Default behavior: run configured default report (defaults to "list") parsed := getParsedArgs(cmd) // Get default report from config cfg, err := engine.GetConfig() if err != nil { fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) os.Exit(1) } defaultReport := cfg.DefaultReport if defaultReport == "" { defaultReport = "list" } if err := runReport(defaultReport, parsed.Filters); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } }, } func Execute() error { // Check for help flags/command BEFORE preprocessing // This allows Cobra to handle help naturally for the root command if len(os.Args) > 1 { firstArg := os.Args[1] if firstArg == "-h" || firstArg == "--help" || firstArg == "help" { return rootCmd.Execute() } // Let Cobra's built-in completion machinery handle shell completions // directly, bypassing preprocessing that would create tasks. if firstArg == "__complete" || firstArg == "__completeNoDesc" { return rootCmd.Execute() } } // Preprocess arguments (read-only scan — os.Args is never mutated) if len(os.Args) > 1 { parsed := preprocessArgs(os.Args[1:]) ctx := context.WithValue(context.Background(), parsedArgsKey, parsed) rootCmd.SetContext(ctx) // Build clean args for Cobra via SetArgs (os.Args stays untouched). if parsed.CmdArgIndex >= 0 { i := parsed.CmdArgIndex + 1 // offset for binary name in os.Args cmdAndAfter := os.Args[i:] // command + subcommands + their flags preCmdFlags := collectFlags(os.Args[1:i]) // persistent flags before command cobraArgs := make([]string, 0, len(cmdAndAfter)+len(preCmdFlags)) cobraArgs = append(cobraArgs, cmdAndAfter...) cobraArgs = append(cobraArgs, preCmdFlags...) rootCmd.SetArgs(cobraArgs) } // CmdArgIndex == -1: no command found, don't call SetArgs. // Cobra processes os.Args naturally → root command → default report. } return rootCmd.Execute() } // getParsedArgs retrieves preprocessed args from context func getParsedArgs(cmd *cobra.Command) *ParsedArgs { if v := cmd.Context().Value(parsedArgsKey); v != nil { if parsed, ok := v.(*ParsedArgs); ok { return parsed } } return &ParsedArgs{} } // preprocessArgs parses command-line arguments before Cobra routing // Returns: command name, filters, modifiers // Flags (--foo) are stripped from filters/modifiers; Cobra handles them from os.Args. func preprocessArgs(args []string) *ParsedArgs { if len(args) == 0 { return &ParsedArgs{ Command: "list", // Default command Filters: []string{}, Modifiers: []string{}, CmdArgIndex: -1, } } // Find command position, skipping flag-like args cmdIdx := -1 cmdName := "" for i, arg := range args { if strings.HasPrefix(arg, "-") { continue // Skip flags — Cobra handles them } // Check regular commands for _, name := range commandNames { if arg == name { cmdIdx = i cmdName = name break } } // Check report names if cmdIdx < 0 { for _, name := range reportNames { if arg == name { cmdIdx = i cmdName = name break } } } if cmdIdx >= 0 { break } } // If no command found, treat as filters for default list command if cmdIdx == -1 { return &ParsedArgs{ Command: "list", Filters: stripFlags(args), Modifiers: []string{}, CmdArgIndex: -1, } } // Split arguments around command leftArgs := stripFlags(args[:cmdIdx]) rightArgs := []string{} if cmdIdx+1 < len(args) { rightArgs = stripFlags(args[cmdIdx+1:]) } // Determine how to interpret right args if commandsWithModifiers[cmdName] { return &ParsedArgs{ Command: cmdName, Filters: leftArgs, Modifiers: rightArgs, CmdArgIndex: cmdIdx, } } else { allFilters := append(leftArgs, rightArgs...) return &ParsedArgs{ Command: cmdName, Filters: allFilters, Modifiers: []string{}, CmdArgIndex: cmdIdx, } } } // stripFlags removes flag-like args (starting with -) from a slice func stripFlags(args []string) []string { var result []string for _, arg := range args { if !strings.HasPrefix(arg, "-") { result = append(result, arg) } } return result } // collectFlags extracts flag arguments (with their values) from a slice. // Uses Cobra's persistent flag registry to determine if a flag takes a value. func collectFlags(args []string) []string { var flags []string for i := 0; i < len(args); i++ { if !strings.HasPrefix(args[i], "-") { continue } flags = append(flags, args[i]) // If flag uses = syntax, value is already included if strings.Contains(args[i], "=") { continue } // Check if this flag takes a value argument if i+1 < len(args) { name := strings.TrimLeft(args[i], "-") f := rootCmd.PersistentFlags().Lookup(name) if f == nil && len(name) == 1 { f = rootCmd.PersistentFlags().ShorthandLookup(name) } if f != nil && f.Value.Type() != "bool" { i++ flags = append(flags, args[i]) } } } return flags } func init() { // Add persistent flags for directory overrides rootCmd.PersistentFlags().StringVar(&configDirFlag, "config-dir", "", "Config directory (default: $XDG_CONFIG_HOME/opal or ~/.config/opal)") rootCmd.PersistentFlags().StringVar(&dataDirFlag, "data-dir", "", "Data directory (default: $XDG_DATA_HOME/opal or ~/.local/share/opal)") rootCmd.PersistentFlags().BoolVar(&dryRunFlag, "dry-run", false, "Show matched tasks without performing the action") // Use PersistentPreRun for initialization (runs for all subcommands unless overridden) rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { initializeApp() } // 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" uncompleteCmd.GroupID = "task" logCmd.GroupID = "task" rootCmd.AddCommand(addCmd) rootCmd.AddCommand(doneCmd) rootCmd.AddCommand(modifyCmd) rootCmd.AddCommand(deleteCmd) rootCmd.AddCommand(startCmd) rootCmd.AddCommand(stopCmd) rootCmd.AddCommand(infoCmd) rootCmd.AddCommand(editCmd) rootCmd.AddCommand(annotateCmd) rootCmd.AddCommand(denotateCmd) rootCmd.AddCommand(undoCmd) rootCmd.AddCommand(uncompleteCmd) 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 rootCmd.Version = Version // Add report commands dynamically reportCommands := CreateReportCommands() for _, cmd := range reportCommands { cmd.GroupID = "report" rootCmd.AddCommand(cmd) } } func initializeApp() { // Set directory overrides from flags if provided if configDirFlag != "" { engine.SetConfigDirOverride(configDirFlag) } if dataDirFlag != "" { engine.SetDataDirOverride(dataDirFlag) } // Check for first run (before initializing DB/config) isFirstRun := engine.IsFirstRun() // Initialize database if err := engine.InitDB(); err != nil { fmt.Fprintf(os.Stderr, "Error initializing database: %v\n", err) os.Exit(1) } // On first run, create the config file with defaults if isFirstRun { if err := engine.InitConfig(); err != nil { fmt.Fprintf(os.Stderr, "Error creating config: %v\n", err) os.Exit(1) } showFirstRunMessage() } // Load config (reads file if present, otherwise uses defaults) if _, err := engine.LoadConfig(); err != nil { fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) os.Exit(1) } } func showFirstRunMessage() { fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "👋 Welcome to Opal Task Manager!") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Default configuration created. To customize your setup,") fmt.Fprintln(os.Stderr, "run: opal setup") fmt.Fprintln(os.Stderr, "") }