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