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