1package cmd
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "io/fs"
9 "log/slog"
10 "net/url"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "regexp"
15 "strconv"
16 "time"
17
18 tea "github.com/charmbracelet/bubbletea/v2"
19 "github.com/charmbracelet/crush/internal/client"
20 "github.com/charmbracelet/crush/internal/config"
21 "github.com/charmbracelet/crush/internal/event"
22 "github.com/charmbracelet/crush/internal/log"
23 "github.com/charmbracelet/crush/internal/proto"
24 "github.com/charmbracelet/crush/internal/server"
25 "github.com/charmbracelet/crush/internal/tui"
26 "github.com/charmbracelet/crush/internal/version"
27 "github.com/charmbracelet/fang"
28 "github.com/charmbracelet/x/term"
29 "github.com/spf13/cobra"
30)
31
32var clientHost string
33
34func init() {
35 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
36 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
37 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
38
39 rootCmd.Flags().BoolP("help", "h", false, "Help")
40 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
41
42 rootCmd.Flags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
43
44 rootCmd.AddCommand(runCmd)
45 rootCmd.AddCommand(updateProvidersCmd)
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 hostURL, err := server.ParseHostURL(clientHost)
78 if err != nil {
79 return fmt.Errorf("invalid host URL: %v", err)
80 }
81
82 switch hostURL.Scheme {
83 case "unix", "npipe":
84 _, err := os.Stat(hostURL.Host)
85 if err != nil && errors.Is(err, fs.ErrNotExist) {
86 if err := startDetachedServer(cmd); err != nil {
87 return err
88 }
89 }
90
91 // Wait for the file to appear
92 for range 10 {
93 _, err = os.Stat(hostURL.Host)
94 if err == nil {
95 break
96 }
97 select {
98 case <-cmd.Context().Done():
99 return cmd.Context().Err()
100 case <-time.After(100 * time.Millisecond):
101 }
102 }
103 if err != nil {
104 return fmt.Errorf("failed to initialize crush server: %v", err)
105 }
106
107 default:
108 // TODO: implement TCP support
109 }
110
111 c, err := setupApp(cmd, hostURL)
112 if err != nil {
113 return err
114 }
115
116 for range 10 {
117 err = c.Health()
118 if err == nil {
119 break
120 }
121 select {
122 case <-cmd.Context().Done():
123 return cmd.Context().Err()
124 case <-time.After(100 * time.Millisecond):
125 }
126 }
127 if err != nil {
128 return fmt.Errorf("failed to connect to crush server: %v", err)
129 }
130
131 m, err := tui.New(c)
132 if err != nil {
133 return fmt.Errorf("failed to create TUI model: %v", err)
134 }
135
136 defer func() { c.DeleteInstance(cmd.Context(), c.ID()) }()
137
138 event.AppInitialized()
139
140 // Set up the TUI.
141 program := tea.NewProgram(
142 m,
143 tea.WithAltScreen(),
144 tea.WithContext(cmd.Context()),
145 tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding
146 tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
147 )
148
149 evc, err := c.SubscribeEvents(cmd.Context())
150 if err != nil {
151 return fmt.Errorf("failed to subscribe to events: %v", err)
152 }
153
154 go streamEvents(cmd.Context(), evc, program)
155
156 if _, err := program.Run(); err != nil {
157 event.Error(err)
158 slog.Error("TUI run error", "error", err)
159 return fmt.Errorf("TUI error: %v", err)
160 }
161 return nil
162 },
163 PostRun: func(cmd *cobra.Command, args []string) {
164 event.AppExited()
165 },
166}
167
168func Execute() {
169 if err := fang.Execute(
170 context.Background(),
171 rootCmd,
172 fang.WithVersion(version.Version),
173 fang.WithNotifySignal(os.Interrupt),
174 ); err != nil {
175 os.Exit(1)
176 }
177}
178
179func streamEvents(ctx context.Context, evc <-chan any, p *tea.Program) {
180 defer log.RecoverPanic("app.Subscribe", func() {
181 slog.Info("TUI subscription panic: attempting graceful shutdown")
182 p.Quit()
183 })
184
185 for {
186 select {
187 case <-ctx.Done():
188 slog.Debug("TUI message handler shutting down")
189 return
190 case ev, ok := <-evc:
191 if !ok {
192 slog.Debug("TUI message channel closed")
193 return
194 }
195 p.Send(ev)
196 }
197 }
198}
199
200// setupApp handles the common setup logic for both interactive and non-interactive modes.
201// It returns the app instance, config, cleanup function, and any error.
202func setupApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, error) {
203 debug, _ := cmd.Flags().GetBool("debug")
204 yolo, _ := cmd.Flags().GetBool("yolo")
205 dataDir, _ := cmd.Flags().GetString("data-dir")
206 ctx := cmd.Context()
207
208 cwd, err := ResolveCwd(cmd)
209 if err != nil {
210 return nil, err
211 }
212
213 c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
214 if err != nil {
215 return nil, err
216 }
217
218 ins, err := c.CreateInstance(ctx, proto.Instance{
219 Path: cwd,
220 DataDir: dataDir,
221 Debug: debug,
222 YOLO: yolo,
223 })
224 if err != nil {
225 return nil, fmt.Errorf("failed to create or connect to instance: %v", err)
226 }
227
228 c.SetID(ins.ID)
229
230 cfg, err := c.GetGlobalConfig()
231 if err != nil {
232 return nil, fmt.Errorf("failed to get global config: %v", err)
233 }
234
235 if shouldEnableMetrics(cfg) {
236 event.Init()
237 }
238
239 return c, nil
240}
241
242var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
243
244func startDetachedServer(cmd *cobra.Command) error {
245 // Start the server as a detached process if the socket does not exist.
246 exe, err := os.Executable()
247 if err != nil {
248 return fmt.Errorf("failed to get executable path: %v", err)
249 }
250
251 safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
252 chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
253 if err := os.MkdirAll(chDir, 0o700); err != nil {
254 return fmt.Errorf("failed to create server working directory: %v", err)
255 }
256
257 args := []string{"server"}
258 if clientHost != server.DefaultHost() {
259 args = append(args, "--host", clientHost)
260 }
261
262 c := exec.CommandContext(cmd.Context(), exe, args...)
263 stdoutPath := filepath.Join(chDir, "stdout.log")
264 stderrPath := filepath.Join(chDir, "stderr.log")
265 detachProcess(c)
266
267 stdout, err := os.Create(stdoutPath)
268 if err != nil {
269 return fmt.Errorf("failed to create stdout log file: %v", err)
270 }
271 defer stdout.Close()
272 c.Stdout = stdout
273
274 stderr, err := os.Create(stderrPath)
275 if err != nil {
276 return fmt.Errorf("failed to create stderr log file: %v", err)
277 }
278 defer stderr.Close()
279 c.Stderr = stderr
280
281 if err := c.Start(); err != nil {
282 return fmt.Errorf("failed to start crush server: %v", err)
283 }
284
285 if err := c.Process.Release(); err != nil {
286 return fmt.Errorf("failed to detach crush server process: %v", err)
287 }
288
289 return nil
290}
291
292func shouldEnableMetrics(cfg *config.Config) bool {
293 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
294 return false
295 }
296 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
297 return false
298 }
299 if cfg.Options.DisableMetrics {
300 return false
301 }
302 return true
303}
304
305func MaybePrependStdin(prompt string) (string, error) {
306 if term.IsTerminal(os.Stdin.Fd()) {
307 return prompt, nil
308 }
309 fi, err := os.Stdin.Stat()
310 if err != nil {
311 return prompt, err
312 }
313 if fi.Mode()&os.ModeNamedPipe == 0 {
314 return prompt, nil
315 }
316 bts, err := io.ReadAll(os.Stdin)
317 if err != nil {
318 return prompt, err
319 }
320 return string(bts) + "\n\n" + prompt, nil
321}
322
323func ResolveCwd(cmd *cobra.Command) (string, error) {
324 cwd, _ := cmd.Flags().GetString("cwd")
325 if cwd != "" {
326 err := os.Chdir(cwd)
327 if err != nil {
328 return "", fmt.Errorf("failed to change directory: %v", err)
329 }
330 return cwd, nil
331 }
332 cwd, err := os.Getwd()
333 if err != nil {
334 return "", fmt.Errorf("failed to get current working directory: %v", err)
335 }
336 return cwd, nil
337}
338
339func createDotCrushDir(dir string) error {
340 if err := os.MkdirAll(dir, 0o700); err != nil {
341 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
342 }
343
344 gitIgnorePath := filepath.Join(dir, ".gitignore")
345 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
346 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
347 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
348 }
349 }
350
351 return nil
352}