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