diff --git a/internal/cmd/root.go b/internal/cmd/root.go index c6c24d5963c57981b1e91911146c1893728ffe37..8c3b0324f9929ac5040b9d5c574f30ae5525653d 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -6,6 +6,7 @@ import ( "io" "log/slog" "os" + "strings" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/app" @@ -18,6 +19,17 @@ import ( "github.com/spf13/cobra" ) +func init() { + rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory") + rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug") + + rootCmd.Flags().BoolP("help", "h", false, "Help") + rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)") + + runCmd.Flags().BoolP("quiet", "q", false, "Hide spinner") + rootCmd.AddCommand(runCmd) +} + var rootCmd = &cobra.Command{ Use: "crush", Short: "Terminal-based AI assistant for software development", @@ -25,91 +37,36 @@ var rootCmd = &cobra.Command{ It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration to assist developers in writing, debugging, and understanding code directly from the terminal.`, Example: ` - # Run in interactive mode - crush +# Run in interactive mode +crush - # Run with debug logging - crush -d +# Run with debug logging +crush -d - # Run with debug slog.in a specific directory - crush -d -c /path/to/project +# Run with debug logging in a specific directory +crush -d -c /path/to/project - # Print version - crush -v +# Print version +crush -v - # Run a single non-interactive prompt - crush -p "Explain the use of context in Go" +# Run a single non-interactive prompt +crush run "Explain the use of context in Go" - # Run a single non-interactive prompt with JSON output format - crush -p "Explain the use of context in Go" -f json - - # Run in dangerous mode (auto-accept all permissions) - crush -y +# Run in dangerous mode (auto-accept all permissions) +crush -y `, RunE: func(cmd *cobra.Command, args []string) error { - // Load the config - // XXX: Handle errors. - debug, _ := cmd.Flags().GetBool("debug") - cwd, _ := cmd.Flags().GetString("cwd") - prompt, _ := cmd.Flags().GetString("prompt") - quiet, _ := cmd.Flags().GetBool("quiet") - yolo, _ := cmd.Flags().GetBool("yolo") - - if cwd != "" { - err := os.Chdir(cwd) - if err != nil { - return fmt.Errorf("failed to change directory: %v", err) - } - } - if cwd == "" { - c, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current working directory: %v", err) - } - cwd = c - } - - cfg, err := config.Init(cwd, debug) - if err != nil { - return err - } - if cfg.Permissions == nil { - cfg.Permissions = &config.Permissions{} - } - cfg.Permissions.SkipRequests = yolo - - ctx := cmd.Context() - - // Connect to DB; this will also run migrations. - conn, err := db.Connect(ctx, cfg.Options.DataDirectory) + app, err := setupApp(cmd) if err != nil { return err } - - app, err := app.New(ctx, conn, cfg) - if err != nil { - slog.Error("Failed to create app instance", "error", err) - return err - } defer app.Shutdown() - prompt, err = maybePrependStdin(prompt) - if err != nil { - slog.Error("Failed to read from stdin", "error", err) - return err - } - - // Non-interactive mode. - if prompt != "" { - // Run non-interactive flow using the App method - return app.RunNonInteractive(ctx, prompt, quiet) - } - // Set up the TUI. program := tea.NewProgram( tui.New(app), tea.WithAltScreen(), - tea.WithContext(ctx), + tea.WithContext(cmd.Context()), tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state ) @@ -124,6 +81,47 @@ to assist developers in writing, debugging, and understanding code directly from }, } +var runCmd = &cobra.Command{ + Use: "run [prompt...]", + Short: "Run a single non-interactive prompt", + Long: `Run a single prompt in non-interactive mode and exit. +The prompt can be provided as arguments or piped from stdin.`, + Example: ` +# Run a simple prompt +crush run Explain the use of context in Go + +# Pipe input from stdin +echo "What is this code doing?" | crush run + +# Run with quiet mode (no spinner) +crush run -q "Generate a README for this project" + `, + RunE: func(cmd *cobra.Command, args []string) error { + quiet, _ := cmd.Flags().GetBool("quiet") + + app, err := setupApp(cmd) + if err != nil { + return err + } + defer app.Shutdown() + + prompt := strings.Join(args, " ") + + prompt, err = maybePrependStdin(prompt) + if err != nil { + slog.Error("Failed to read from stdin", "error", err) + return err + } + + if prompt == "" { + return fmt.Errorf("no prompt provided") + } + + // Run non-interactive flow using the App method + return app.RunNonInteractive(cmd.Context(), prompt, quiet) + }, +} + func Execute() { if err := fang.Execute( context.Background(), @@ -135,16 +133,41 @@ func Execute() { } } -func init() { - rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory") +// setupApp handles the common setup logic for both interactive and non-interactive modes. +// It returns the app instance, config, cleanup function, and any error. +func setupApp(cmd *cobra.Command) (*app.App, error) { + debug, _ := cmd.Flags().GetBool("debug") + yolo, _ := cmd.Flags().GetBool("yolo") + ctx := cmd.Context() - rootCmd.Flags().BoolP("help", "h", false, "Help") - rootCmd.Flags().BoolP("debug", "d", false, "Debug") - rootCmd.Flags().StringP("prompt", "p", "", "Prompt to run in non-interactive mode") - rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)") + cwd, err := resolveCwd(cmd) + if err != nil { + return nil, err + } + + cfg, err := config.Init(cwd, debug) + if err != nil { + return nil, err + } + + if cfg.Permissions == nil { + cfg.Permissions = &config.Permissions{} + } + cfg.Permissions.SkipRequests = yolo + + // Connect to DB; this will also run migrations. + conn, err := db.Connect(ctx, cfg.Options.DataDirectory) + if err != nil { + return nil, err + } - // Add quiet flag to hide spinner in non-interactive mode - rootCmd.Flags().BoolP("quiet", "q", false, "Hide spinner in non-interactive mode") + appInstance, err := app.New(ctx, conn, cfg) + if err != nil { + slog.Error("Failed to create app instance", "error", err) + return nil, err + } + + return appInstance, nil } func maybePrependStdin(prompt string) (string, error) { @@ -164,3 +187,19 @@ func maybePrependStdin(prompt string) (string, error) { } return string(bts) + "\n\n" + prompt, nil } + +func resolveCwd(cmd *cobra.Command) (string, error) { + cwd, _ := cmd.Flags().GetString("cwd") + if cwd != "" { + err := os.Chdir(cwd) + if err != nil { + return "", fmt.Errorf("failed to change directory: %v", err) + } + return cwd, nil + } + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get current working directory: %v", err) + } + return cwd, nil +}