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