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}