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, ins, err := setupApp(cmd, hostURL)
112 if err != nil {
113 return err
114 }
115
116 for range 10 {
117 err = c.Health(cmd.Context())
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, ins)
132 if err != nil {
133 return fmt.Errorf("failed to create TUI model: %v", err)
134 }
135
136 defer func() { c.DeleteInstance(cmd.Context(), ins.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(), ins.ID)
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, *proto.Instance, 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, nil, err
211 }
212
213 c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
214 if err != nil {
215 return nil, 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, nil, fmt.Errorf("failed to create or connect to instance: %v", err)
226 }
227
228 cfg, err := c.GetGlobalConfig(cmd.Context())
229 if err != nil {
230 return nil, nil, fmt.Errorf("failed to get global config: %v", err)
231 }
232
233 if shouldEnableMetrics(cfg) {
234 event.Init()
235 }
236
237 return c, ins, nil
238}
239
240var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
241
242func startDetachedServer(cmd *cobra.Command) error {
243 // Start the server as a detached process if the socket does not exist.
244 exe, err := os.Executable()
245 if err != nil {
246 return fmt.Errorf("failed to get executable path: %v", err)
247 }
248
249 safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
250 chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
251 if err := os.MkdirAll(chDir, 0o700); err != nil {
252 return fmt.Errorf("failed to create server working directory: %v", err)
253 }
254
255 args := []string{"server"}
256 if clientHost != server.DefaultHost() {
257 args = append(args, "--host", clientHost)
258 }
259
260 c := exec.CommandContext(cmd.Context(), exe, args...)
261 stdoutPath := filepath.Join(chDir, "stdout.log")
262 stderrPath := filepath.Join(chDir, "stderr.log")
263 detachProcess(c)
264
265 stdout, err := os.Create(stdoutPath)
266 if err != nil {
267 return fmt.Errorf("failed to create stdout log file: %v", err)
268 }
269 defer stdout.Close()
270 c.Stdout = stdout
271
272 stderr, err := os.Create(stderrPath)
273 if err != nil {
274 return fmt.Errorf("failed to create stderr log file: %v", err)
275 }
276 defer stderr.Close()
277 c.Stderr = stderr
278
279 if err := c.Start(); err != nil {
280 return fmt.Errorf("failed to start crush server: %v", err)
281 }
282
283 if err := c.Process.Release(); err != nil {
284 return fmt.Errorf("failed to detach crush server process: %v", err)
285 }
286
287 return nil
288}
289
290func shouldEnableMetrics(cfg *config.Config) bool {
291 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
292 return false
293 }
294 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
295 return false
296 }
297 if cfg.Options.DisableMetrics {
298 return false
299 }
300 return true
301}
302
303func MaybePrependStdin(prompt string) (string, error) {
304 if term.IsTerminal(os.Stdin.Fd()) {
305 return prompt, nil
306 }
307 fi, err := os.Stdin.Stat()
308 if err != nil {
309 return prompt, err
310 }
311 if fi.Mode()&os.ModeNamedPipe == 0 {
312 return prompt, nil
313 }
314 bts, err := io.ReadAll(os.Stdin)
315 if err != nil {
316 return prompt, err
317 }
318 return string(bts) + "\n\n" + prompt, nil
319}
320
321func ResolveCwd(cmd *cobra.Command) (string, error) {
322 cwd, _ := cmd.Flags().GetString("cwd")
323 if cwd != "" {
324 err := os.Chdir(cwd)
325 if err != nil {
326 return "", fmt.Errorf("failed to change directory: %v", err)
327 }
328 return cwd, nil
329 }
330 cwd, err := os.Getwd()
331 if err != nil {
332 return "", fmt.Errorf("failed to get current working directory: %v", err)
333 }
334 return cwd, nil
335}
336
337func createDotCrushDir(dir string) error {
338 if err := os.MkdirAll(dir, 0o700); err != nil {
339 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
340 }
341
342 gitIgnorePath := filepath.Join(dir, ".gitignore")
343 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
344 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
345 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
346 }
347 }
348
349 return nil
350}