1package cmd
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "log/slog"
8 "os"
9 "path/filepath"
10
11 tea "github.com/charmbracelet/bubbletea/v2"
12 "github.com/charmbracelet/crush/internal/app"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/db"
15 "github.com/charmbracelet/crush/internal/tui"
16 "github.com/charmbracelet/crush/internal/version"
17 "github.com/charmbracelet/fang"
18 "github.com/charmbracelet/x/term"
19 "github.com/spf13/cobra"
20)
21
22func init() {
23 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
24 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
25 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
26
27 rootCmd.Flags().BoolP("help", "h", false, "Help")
28 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
29
30 rootCmd.AddCommand(runCmd)
31 rootCmd.AddCommand(updateProvidersCmd)
32}
33
34var rootCmd = &cobra.Command{
35 Use: "crush",
36 Short: "Terminal-based AI assistant for software development",
37 Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
38It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
39to assist developers in writing, debugging, and understanding code directly from the terminal.`,
40 Example: `
41# Run in interactive mode
42crush
43
44# Run with debug logging
45crush -d
46
47# Run with debug logging in a specific directory
48crush -d -c /path/to/project
49
50# Run with custom data directory
51crush -D /path/to/custom/.crush
52
53# Print version
54crush -v
55
56# Run a single non-interactive prompt
57crush run "Explain the use of context in Go"
58
59# Run in dangerous mode (auto-accept all permissions)
60crush -y
61 `,
62 RunE: func(cmd *cobra.Command, args []string) error {
63 app, err := setupApp(cmd)
64 if err != nil {
65 return err
66 }
67 defer app.Shutdown()
68
69 // Set up the TUI.
70 program := tea.NewProgram(
71 tui.New(app),
72 tea.WithAltScreen(),
73 tea.WithContext(cmd.Context()),
74 tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding
75 tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
76 )
77
78 go app.Subscribe(program)
79
80 if _, err := program.Run(); err != nil {
81 slog.Error("TUI run error", "error", err)
82 return fmt.Errorf("TUI error: %v", err)
83 }
84 return nil
85 },
86}
87
88func Execute() {
89 if err := fang.Execute(
90 context.Background(),
91 rootCmd,
92 fang.WithVersion(version.Version),
93 fang.WithNotifySignal(os.Interrupt),
94 ); err != nil {
95 os.Exit(1)
96 }
97}
98
99// setupApp handles the common setup logic for both interactive and non-interactive modes.
100// It returns the app instance, config, cleanup function, and any error.
101func setupApp(cmd *cobra.Command) (*app.App, error) {
102 debug, _ := cmd.Flags().GetBool("debug")
103 yolo, _ := cmd.Flags().GetBool("yolo")
104 dataDir, _ := cmd.Flags().GetString("data-dir")
105 ctx := cmd.Context()
106
107 cwd, err := ResolveCwd(cmd)
108 if err != nil {
109 return nil, err
110 }
111
112 cfg, err := config.Init(cwd, dataDir, debug)
113 if err != nil {
114 return nil, err
115 }
116
117 if cfg.Permissions == nil {
118 cfg.Permissions = &config.Permissions{}
119 }
120 cfg.Permissions.SkipRequests = yolo
121
122 if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
123 return nil, err
124 }
125
126 // Connect to DB; this will also run migrations.
127 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
128 if err != nil {
129 return nil, err
130 }
131
132 appInstance, err := app.New(ctx, conn, cfg)
133 if err != nil {
134 slog.Error("Failed to create app instance", "error", err)
135 return nil, err
136 }
137
138 return appInstance, nil
139}
140
141func MaybePrependStdin(prompt string) (string, error) {
142 if term.IsTerminal(os.Stdin.Fd()) {
143 return prompt, nil
144 }
145 fi, err := os.Stdin.Stat()
146 if err != nil {
147 return prompt, err
148 }
149 if fi.Mode()&os.ModeNamedPipe == 0 {
150 return prompt, nil
151 }
152 bts, err := io.ReadAll(os.Stdin)
153 if err != nil {
154 return prompt, err
155 }
156 return string(bts) + "\n\n" + prompt, nil
157}
158
159func ResolveCwd(cmd *cobra.Command) (string, error) {
160 cwd, _ := cmd.Flags().GetString("cwd")
161 if cwd != "" {
162 err := os.Chdir(cwd)
163 if err != nil {
164 return "", fmt.Errorf("failed to change directory: %v", err)
165 }
166 return cwd, nil
167 }
168 cwd, err := os.Getwd()
169 if err != nil {
170 return "", fmt.Errorf("failed to get current working directory: %v", err)
171 }
172 return cwd, nil
173}
174
175func createDotCrushDir(dir string) error {
176 if err := os.MkdirAll(dir, 0o700); err != nil {
177 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
178 }
179
180 gitIgnorePath := filepath.Join(dir, ".gitignore")
181 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
182 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
183 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
184 }
185 }
186
187 return nil
188}