1package cmd
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "io"
8 "log/slog"
9 "os"
10 "path/filepath"
11 "strconv"
12
13 tea "github.com/charmbracelet/bubbletea/v2"
14 "github.com/charmbracelet/colorprofile"
15 "github.com/charmbracelet/crush/internal/app"
16 "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/db"
18 "github.com/charmbracelet/crush/internal/event"
19 "github.com/charmbracelet/crush/internal/tui"
20 "github.com/charmbracelet/crush/internal/version"
21 "github.com/charmbracelet/fang"
22 "github.com/charmbracelet/lipgloss/v2"
23 "github.com/charmbracelet/x/exp/charmtone"
24 "github.com/charmbracelet/x/term"
25 "github.com/spf13/cobra"
26)
27
28func init() {
29 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
30 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
31 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
32
33 rootCmd.Flags().BoolP("help", "h", false, "Help")
34 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
35
36 rootCmd.AddCommand(
37 runCmd,
38 dirsCmd,
39 updateProvidersCmd,
40 logsCmd,
41 schemaCmd,
42 )
43}
44
45var rootCmd = &cobra.Command{
46 Use: "crush",
47 Short: "Terminal-based AI assistant for software development",
48 Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
49It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
50to assist developers in writing, debugging, and understanding code directly from the terminal.`,
51 Example: `
52# Run in interactive mode
53crush
54
55# Run with debug logging
56crush -d
57
58# Run with debug logging in a specific directory
59crush -d -c /path/to/project
60
61# Run with custom data directory
62crush -D /path/to/custom/.crush
63
64# Print version
65crush -v
66
67# Run a single non-interactive prompt
68crush run "Explain the use of context in Go"
69
70# Run in dangerous mode (auto-accept all permissions)
71crush -y
72 `,
73 RunE: func(cmd *cobra.Command, args []string) error {
74 app, err := setupApp(cmd)
75 if err != nil {
76 return err
77 }
78 defer app.Shutdown()
79
80 event.AppInitialized()
81
82 // Set up the TUI.
83 program := tea.NewProgram(
84 tui.New(app),
85 tea.WithAltScreen(),
86 tea.WithContext(cmd.Context()),
87 tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding
88 tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
89 )
90
91 go app.Subscribe(program)
92
93 if _, err := program.Run(); err != nil {
94 event.Error(err)
95 slog.Error("TUI run error", "error", err)
96 return fmt.Errorf("TUI error: %v", err)
97 }
98 return nil
99 },
100 PostRun: func(cmd *cobra.Command, args []string) {
101 event.AppExited()
102 },
103}
104
105var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
106 ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄
107 ███████████ ███████████
108████████████████████████████
109████████████████████████████
110██████████▀██████▀██████████
111██████████ ██████ ██████████
112▀▀██████▄████▄▄████▄██████▀▀
113 ████████████████████████
114 ████████████████████
115 ▀▀██████████▀▀
116 ▀▀▀▀▀▀
117`)
118
119// copied from cobra:
120const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
121`
122
123func Execute() {
124 // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
125 // it forward to a bytes.Buffer, write the colored heartbit to it, and then
126 // finally prepend it in the version template.
127 // Unfortunately cobra doesn't give us a way to set a function to handle
128 // printing the version, and PreRunE runs after the version is already
129 // handled, so that doesn't work either.
130 // This is the only way I could find that works relatively well.
131 if term.IsTerminal(os.Stdout.Fd()) {
132 var b bytes.Buffer
133 w := colorprofile.NewWriter(os.Stdout, os.Environ())
134 w.Forward = &b
135 _, _ = w.WriteString(heartbit.String())
136 rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
137 }
138 if err := fang.Execute(
139 context.Background(),
140 rootCmd,
141 fang.WithVersion(version.Version),
142 fang.WithNotifySignal(os.Interrupt),
143 ); err != nil {
144 os.Exit(1)
145 }
146}
147
148// setupApp handles the common setup logic for both interactive and non-interactive modes.
149// It returns the app instance, config, cleanup function, and any error.
150func setupApp(cmd *cobra.Command) (*app.App, error) {
151 debug, _ := cmd.Flags().GetBool("debug")
152 yolo, _ := cmd.Flags().GetBool("yolo")
153 dataDir, _ := cmd.Flags().GetString("data-dir")
154 ctx := cmd.Context()
155
156 cwd, err := ResolveCwd(cmd)
157 if err != nil {
158 return nil, err
159 }
160
161 cfg, err := config.Init(cwd, dataDir, debug)
162 if err != nil {
163 return nil, err
164 }
165
166 if cfg.Permissions == nil {
167 cfg.Permissions = &config.Permissions{}
168 }
169 cfg.Permissions.SkipRequests = yolo
170
171 if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
172 return nil, err
173 }
174
175 // Connect to DB; this will also run migrations.
176 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
177 if err != nil {
178 return nil, err
179 }
180
181 appInstance, err := app.New(ctx, conn, cfg)
182 if err != nil {
183 slog.Error("Failed to create app instance", "error", err)
184 return nil, err
185 }
186
187 if shouldEnableMetrics() {
188 event.Init()
189 }
190
191 return appInstance, nil
192}
193
194func shouldEnableMetrics() bool {
195 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
196 return false
197 }
198 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
199 return false
200 }
201 if config.Get().Options.DisableMetrics {
202 return false
203 }
204 return true
205}
206
207func MaybePrependStdin(prompt string) (string, error) {
208 if term.IsTerminal(os.Stdin.Fd()) {
209 return prompt, nil
210 }
211 fi, err := os.Stdin.Stat()
212 if err != nil {
213 return prompt, err
214 }
215 if fi.Mode()&os.ModeNamedPipe == 0 {
216 return prompt, nil
217 }
218 bts, err := io.ReadAll(os.Stdin)
219 if err != nil {
220 return prompt, err
221 }
222 return string(bts) + "\n\n" + prompt, nil
223}
224
225func ResolveCwd(cmd *cobra.Command) (string, error) {
226 cwd, _ := cmd.Flags().GetString("cwd")
227 if cwd != "" {
228 err := os.Chdir(cwd)
229 if err != nil {
230 return "", fmt.Errorf("failed to change directory: %v", err)
231 }
232 return cwd, nil
233 }
234 cwd, err := os.Getwd()
235 if err != nil {
236 return "", fmt.Errorf("failed to get current working directory: %v", err)
237 }
238 return cwd, nil
239}
240
241func createDotCrushDir(dir string) error {
242 if err := os.MkdirAll(dir, 0o700); err != nil {
243 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
244 }
245
246 gitIgnorePath := filepath.Join(dir, ".gitignore")
247 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
248 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
249 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
250 }
251 }
252
253 return nil
254}