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