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 c := exec.CommandContext(cmd.Context(), exe, "server")
258 stdoutPath := filepath.Join(chDir, "stdout.log")
259 stderrPath := filepath.Join(chDir, "stderr.log")
260 detachProcess(c)
261
262 stdout, err := os.Create(stdoutPath)
263 if err != nil {
264 return fmt.Errorf("failed to create stdout log file: %v", err)
265 }
266 defer stdout.Close()
267 c.Stdout = stdout
268
269 stderr, err := os.Create(stderrPath)
270 if err != nil {
271 return fmt.Errorf("failed to create stderr log file: %v", err)
272 }
273 defer stderr.Close()
274 c.Stderr = stderr
275
276 if err := c.Start(); err != nil {
277 return fmt.Errorf("failed to start crush server: %v", err)
278 }
279
280 if err := c.Process.Release(); err != nil {
281 return fmt.Errorf("failed to detach crush server process: %v", err)
282 }
283
284 return nil
285}
286
287func shouldEnableMetrics(cfg *config.Config) bool {
288 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
289 return false
290 }
291 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
292 return false
293 }
294 if cfg.Options.DisableMetrics {
295 return false
296 }
297 return true
298}
299
300func MaybePrependStdin(prompt string) (string, error) {
301 if term.IsTerminal(os.Stdin.Fd()) {
302 return prompt, nil
303 }
304 fi, err := os.Stdin.Stat()
305 if err != nil {
306 return prompt, err
307 }
308 if fi.Mode()&os.ModeNamedPipe == 0 {
309 return prompt, nil
310 }
311 bts, err := io.ReadAll(os.Stdin)
312 if err != nil {
313 return prompt, err
314 }
315 return string(bts) + "\n\n" + prompt, nil
316}
317
318func ResolveCwd(cmd *cobra.Command) (string, error) {
319 cwd, _ := cmd.Flags().GetString("cwd")
320 if cwd != "" {
321 err := os.Chdir(cwd)
322 if err != nil {
323 return "", fmt.Errorf("failed to change directory: %v", err)
324 }
325 return cwd, nil
326 }
327 cwd, err := os.Getwd()
328 if err != nil {
329 return "", fmt.Errorf("failed to get current working directory: %v", err)
330 }
331 return cwd, nil
332}
333
334func createDotCrushDir(dir string) error {
335 if err := os.MkdirAll(dir, 0o700); err != nil {
336 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
337 }
338
339 gitIgnorePath := filepath.Join(dir, ".gitignore")
340 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
341 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
342 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
343 }
344 }
345
346 return nil
347}