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