1package cmd
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "log/slog"
10 "os"
11 "path/filepath"
12 "strconv"
13 "strings"
14
15 tea "charm.land/bubbletea/v2"
16 "charm.land/lipgloss/v2"
17 "git.secluded.site/crush/internal/app"
18 "git.secluded.site/crush/internal/config"
19 "git.secluded.site/crush/internal/db"
20 "git.secluded.site/crush/internal/event"
21 "git.secluded.site/crush/internal/projects"
22 "git.secluded.site/crush/internal/tui"
23 "git.secluded.site/crush/internal/tui/styles"
24 "git.secluded.site/crush/internal/ui/common"
25 ui "git.secluded.site/crush/internal/ui/model"
26 "git.secluded.site/crush/internal/version"
27 "github.com/charmbracelet/colorprofile"
28 "github.com/charmbracelet/fang"
29 uv "github.com/charmbracelet/ultraviolet"
30 "github.com/charmbracelet/x/ansi"
31 "github.com/charmbracelet/x/exp/charmtone"
32 xstrings "github.com/charmbracelet/x/exp/strings"
33 "github.com/charmbracelet/x/term"
34 "github.com/spf13/cobra"
35)
36
37// kittyTerminals defines terminals supporting querying capabilities.
38var kittyTerminals = []string{"alacritty", "ghostty", "kitty", "rio", "wezterm"}
39
40func init() {
41 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
42 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
43 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
44 rootCmd.PersistentFlags().BoolP("light", "l", false, "Use light theme")
45 rootCmd.Flags().BoolP("help", "h", false, "Help")
46 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
47
48 rootCmd.AddCommand(
49 runCmd,
50 dirsCmd,
51 projectsCmd,
52 updateProvidersCmd,
53 logsCmd,
54 schemaCmd,
55 loginCmd,
56 statsCmd,
57 )
58}
59
60var rootCmd = &cobra.Command{
61 Use: "crush",
62 Short: "An AI assistant for software development",
63 Long: "An AI assistant for software development and similar tasks with direct access to the terminal",
64 Example: `
65# Run in interactive mode
66crush
67
68# Run with debug logging
69crush -d
70
71# Run with debug logging in a specific directory
72crush -d -c /path/to/project
73
74# Run with custom data directory
75crush -D /path/to/custom/.crush
76
77# Print version
78crush -v
79
80# Run a single non-interactive prompt
81crush run "Explain the use of context in Go"
82
83# Run in dangerous mode (auto-accept all permissions)
84crush -y
85 `,
86 RunE: func(cmd *cobra.Command, args []string) error {
87 app, err := setupAppWithProgressBar(cmd)
88 if err != nil {
89 return err
90 }
91 defer app.Shutdown()
92
93 event.AppInitialized()
94
95 // Set up the TUI.
96 var env uv.Environ = os.Environ()
97
98 var model tea.Model
99 if v, _ := strconv.ParseBool(env.Getenv("CRUSH_NEW_UI")); v {
100 slog.Info("New UI in control!")
101 com := common.DefaultCommon(app)
102 ui := ui.New(com)
103 model = ui
104 } else {
105 ui := tui.New(app)
106 ui.QueryVersion = shouldQueryCapabilities(env)
107 model = ui
108 }
109 program := tea.NewProgram(
110 model,
111 tea.WithEnvironment(env),
112 tea.WithContext(cmd.Context()),
113 tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
114 go app.Subscribe(program)
115
116 if _, err := program.Run(); err != nil {
117 event.Error(err)
118 slog.Error("TUI run error", "error", err)
119 return errors.New("Crush crashed. If metrics are enabled, we were notified about it. If you'd like to report it, please copy the stacktrace above and open an issue at https://git.secluded.site/crush/issues/new?template=bug.yml") //nolint:staticcheck
120 }
121 return nil
122 },
123 PostRun: func(cmd *cobra.Command, args []string) {
124 event.AppExited()
125 },
126}
127
128var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
129 ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄
130 ███████████ ███████████
131████████████████████████████
132████████████████████████████
133██████████▀██████▀██████████
134██████████ ██████ ██████████
135▀▀██████▄████▄▄████▄██████▀▀
136 ████████████████████████
137 ████████████████████
138 ▀▀██████████▀▀
139 ▀▀▀▀▀▀
140`)
141
142// copied from cobra:
143const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
144`
145
146func Execute() {
147 // NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
148 // it forward to a bytes.Buffer, write the colored heartbit to it, and then
149 // finally prepend it in the version template.
150 // Unfortunately cobra doesn't give us a way to set a function to handle
151 // printing the version, and PreRunE runs after the version is already
152 // handled, so that doesn't work either.
153 // This is the only way I could find that works relatively well.
154 if term.IsTerminal(os.Stdout.Fd()) {
155 var b bytes.Buffer
156 w := colorprofile.NewWriter(os.Stdout, os.Environ())
157 w.Forward = &b
158 _, _ = w.WriteString(heartbit.String())
159 rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
160 }
161 if err := fang.Execute(
162 context.Background(),
163 rootCmd,
164 fang.WithVersion(version.Version),
165 fang.WithNotifySignal(os.Interrupt),
166 ); err != nil {
167 os.Exit(1)
168 }
169}
170
171// supportsProgressBar tries to determine whether the current terminal supports
172// progress bars by looking into environment variables.
173func supportsProgressBar() bool {
174 if !term.IsTerminal(os.Stderr.Fd()) {
175 return false
176 }
177 termProg := os.Getenv("TERM_PROGRAM")
178 _, isWindowsTerminal := os.LookupEnv("WT_SESSION")
179
180 return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
181}
182
183func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
184 app, err := setupApp(cmd)
185 if err != nil {
186 return nil, err
187 }
188
189 // Check if progress bar is enabled in config (defaults to true if nil)
190 progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress
191 if progressEnabled && supportsProgressBar() {
192 _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
193 defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
194 }
195
196 return app, nil
197}
198
199// setupApp handles the common setup logic for both interactive and non-interactive modes.
200// It returns the app instance, config, cleanup function, and any error.
201func setupApp(cmd *cobra.Command) (*app.App, error) {
202 debug, _ := cmd.Flags().GetBool("debug")
203 yolo, _ := cmd.Flags().GetBool("yolo")
204 light, _ := cmd.Flags().GetBool("light")
205 dataDir, _ := cmd.Flags().GetString("data-dir")
206 ctx := cmd.Context()
207
208 // Set light theme if requested.
209 if light {
210 if err := styles.DefaultManager().SetTheme("light"); err != nil {
211 slog.Warn("Failed to set light theme", "error", err)
212 }
213 }
214
215 cwd, err := ResolveCwd(cmd)
216 if err != nil {
217 return nil, err
218 }
219
220 cfg, err := config.Init(cwd, dataDir, debug)
221 if err != nil {
222 return nil, err
223 }
224
225 if cfg.Permissions == nil {
226 cfg.Permissions = &config.Permissions{}
227 }
228 cfg.Permissions.SkipRequests = yolo
229
230 if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
231 return nil, err
232 }
233
234 // Register this project in the centralized projects list.
235 if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
236 slog.Warn("Failed to register project", "error", err)
237 // Non-fatal: continue even if registration fails
238 }
239
240 // Connect to DB; this will also run migrations.
241 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
242 if err != nil {
243 return nil, err
244 }
245
246 appInstance, err := app.New(ctx, conn, cfg)
247 if err != nil {
248 slog.Error("Failed to create app instance", "error", err)
249 return nil, err
250 }
251
252 if shouldEnableMetrics() {
253 event.Init()
254 }
255
256 return appInstance, nil
257}
258
259func shouldEnableMetrics() bool {
260 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
261 return false
262 }
263 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
264 return false
265 }
266 if config.Get().Options.DisableMetrics {
267 return false
268 }
269 return false
270}
271
272func MaybePrependStdin(prompt string) (string, error) {
273 if term.IsTerminal(os.Stdin.Fd()) {
274 return prompt, nil
275 }
276 fi, err := os.Stdin.Stat()
277 if err != nil {
278 return prompt, err
279 }
280 // Check if stdin is a named pipe ( | ) or regular file ( < ).
281 if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
282 return prompt, nil
283 }
284 bts, err := io.ReadAll(os.Stdin)
285 if err != nil {
286 return prompt, err
287 }
288 return string(bts) + "\n\n" + prompt, nil
289}
290
291func ResolveCwd(cmd *cobra.Command) (string, error) {
292 cwd, _ := cmd.Flags().GetString("cwd")
293 if cwd != "" {
294 err := os.Chdir(cwd)
295 if err != nil {
296 return "", fmt.Errorf("failed to change directory: %v", err)
297 }
298 return cwd, nil
299 }
300 cwd, err := os.Getwd()
301 if err != nil {
302 return "", fmt.Errorf("failed to get current working directory: %v", err)
303 }
304 return cwd, nil
305}
306
307func createDotCrushDir(dir string) error {
308 if err := os.MkdirAll(dir, 0o700); err != nil {
309 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
310 }
311
312 gitIgnorePath := filepath.Join(dir, ".gitignore")
313 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
314 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
315 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
316 }
317 }
318
319 return nil
320}
321
322// TODO: Remove me after dropping the old TUI.
323func shouldQueryCapabilities(env uv.Environ) bool {
324 const osVendorTypeApple = "Apple"
325 termType := env.Getenv("TERM")
326 termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
327 _, okSSHTTY := env.LookupEnv("SSH_TTY")
328 if okTermProg && strings.Contains(termProg, osVendorTypeApple) {
329 return false
330 }
331 return (!okTermProg && !okSSHTTY) ||
332 (!strings.Contains(termProg, osVendorTypeApple) && !okSSHTTY) ||
333 // Terminals that do support XTVERSION.
334 xstrings.ContainsAnyOf(termType, kittyTerminals...)
335}