1package cmd
  2
  3import (
  4	"context"
  5	"fmt"
  6	"io"
  7	"log/slog"
  8	"os"
  9	"path/filepath"
 10
 11	tea "github.com/charmbracelet/bubbletea/v2"
 12	"github.com/charmbracelet/crush/internal/app"
 13	"github.com/charmbracelet/crush/internal/config"
 14	"github.com/charmbracelet/crush/internal/db"
 15	"github.com/charmbracelet/crush/internal/tui"
 16	"github.com/charmbracelet/crush/internal/version"
 17	"github.com/charmbracelet/fang"
 18	"github.com/charmbracelet/x/term"
 19	"github.com/spf13/cobra"
 20)
 21
 22func init() {
 23	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
 24	rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
 25	rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
 26
 27	rootCmd.Flags().BoolP("help", "h", false, "Help")
 28	rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
 29
 30	rootCmd.AddCommand(runCmd)
 31	rootCmd.AddCommand(updateProvidersCmd)
 32}
 33
 34var rootCmd = &cobra.Command{
 35	Use:   "crush",
 36	Short: "Terminal-based AI assistant for software development",
 37	Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
 38It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
 39to assist developers in writing, debugging, and understanding code directly from the terminal.`,
 40	Example: `
 41# Run in interactive mode
 42crush
 43
 44# Run with debug logging
 45crush -d
 46
 47# Run with debug logging in a specific directory
 48crush -d -c /path/to/project
 49
 50# Run with custom data directory
 51crush -D /path/to/custom/.crush
 52
 53# Print version
 54crush -v
 55
 56# Run a single non-interactive prompt
 57crush run "Explain the use of context in Go"
 58
 59# Run in dangerous mode (auto-accept all permissions)
 60crush -y
 61  `,
 62	RunE: func(cmd *cobra.Command, args []string) error {
 63		app, err := setupApp(cmd)
 64		if err != nil {
 65			return err
 66		}
 67		defer app.Shutdown()
 68
 69		// Set up the TUI.
 70		program := tea.NewProgram(
 71			tui.New(app),
 72			tea.WithAltScreen(),
 73			tea.WithContext(cmd.Context()),
 74			tea.WithMouseCellMotion(),            // Use cell motion instead of all motion to reduce event flooding
 75			tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
 76		)
 77
 78		go app.Subscribe(program)
 79
 80		if _, err := program.Run(); err != nil {
 81			slog.Error("TUI run error", "error", err)
 82			return fmt.Errorf("TUI error: %v", err)
 83		}
 84		return nil
 85	},
 86}
 87
 88func Execute() {
 89	if err := fang.Execute(
 90		context.Background(),
 91		rootCmd,
 92		fang.WithVersion(version.Version),
 93		fang.WithNotifySignal(os.Interrupt),
 94	); err != nil {
 95		os.Exit(1)
 96	}
 97}
 98
 99// setupApp handles the common setup logic for both interactive and non-interactive modes.
100// It returns the app instance, config, cleanup function, and any error.
101func setupApp(cmd *cobra.Command) (*app.App, error) {
102	debug, _ := cmd.Flags().GetBool("debug")
103	yolo, _ := cmd.Flags().GetBool("yolo")
104	dataDir, _ := cmd.Flags().GetString("data-dir")
105	ctx := cmd.Context()
106
107	cwd, err := ResolveCwd(cmd)
108	if err != nil {
109		return nil, err
110	}
111
112	cfg, err := config.Init(cwd, dataDir, debug)
113	if err != nil {
114		return nil, err
115	}
116
117	if cfg.Permissions == nil {
118		cfg.Permissions = &config.Permissions{}
119	}
120	cfg.Permissions.SkipRequests = yolo
121
122	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
123		return nil, err
124	}
125
126	// Connect to DB; this will also run migrations.
127	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
128	if err != nil {
129		return nil, err
130	}
131
132	appInstance, err := app.New(ctx, conn, cfg)
133	if err != nil {
134		slog.Error("Failed to create app instance", "error", err)
135		return nil, err
136	}
137
138	return appInstance, nil
139}
140
141func MaybePrependStdin(prompt string) (string, error) {
142	if term.IsTerminal(os.Stdin.Fd()) {
143		return prompt, nil
144	}
145	fi, err := os.Stdin.Stat()
146	if err != nil {
147		return prompt, err
148	}
149	if fi.Mode()&os.ModeNamedPipe == 0 {
150		return prompt, nil
151	}
152	bts, err := io.ReadAll(os.Stdin)
153	if err != nil {
154		return prompt, err
155	}
156	return string(bts) + "\n\n" + prompt, nil
157}
158
159func ResolveCwd(cmd *cobra.Command) (string, error) {
160	cwd, _ := cmd.Flags().GetString("cwd")
161	if cwd != "" {
162		err := os.Chdir(cwd)
163		if err != nil {
164			return "", fmt.Errorf("failed to change directory: %v", err)
165		}
166		return cwd, nil
167	}
168	cwd, err := os.Getwd()
169	if err != nil {
170		return "", fmt.Errorf("failed to get current working directory: %v", err)
171	}
172	return cwd, nil
173}
174
175func createDotCrushDir(dir string) error {
176	if err := os.MkdirAll(dir, 0o700); err != nil {
177		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
178	}
179
180	gitIgnorePath := filepath.Join(dir, ".gitignore")
181	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
182		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
183			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
184		}
185	}
186
187	return nil
188}