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 "time"
16
17 tea "github.com/charmbracelet/bubbletea/v2"
18 "github.com/charmbracelet/crush/internal/client"
19 "github.com/charmbracelet/crush/internal/config"
20 "github.com/charmbracelet/crush/internal/log"
21 "github.com/charmbracelet/crush/internal/proto"
22 "github.com/charmbracelet/crush/internal/server"
23 "github.com/charmbracelet/crush/internal/tui"
24 "github.com/charmbracelet/crush/internal/version"
25 "github.com/charmbracelet/fang"
26 "github.com/charmbracelet/x/term"
27 "github.com/spf13/cobra"
28)
29
30var clientHost string
31
32func init() {
33 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
34 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
35 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
36
37 rootCmd.Flags().BoolP("help", "h", false, "Help")
38 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
39
40 rootCmd.Flags().StringVar(&clientHost, "host", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
41
42 rootCmd.AddCommand(runCmd)
43 rootCmd.AddCommand(updateProvidersCmd)
44}
45
46var rootCmd = &cobra.Command{
47 Use: "crush",
48 Short: "Terminal-based AI assistant for software development",
49 Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
50It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
51to assist developers in writing, debugging, and understanding code directly from the terminal.`,
52 Example: `
53# Run in interactive mode
54crush
55
56# Run with debug logging
57crush -d
58
59# Run with debug logging in a specific directory
60crush -d -c /path/to/project
61
62# Run with custom data directory
63crush -D /path/to/custom/.crush
64
65# Print version
66crush -v
67
68# Run a single non-interactive prompt
69crush run "Explain the use of context in Go"
70
71# Run in dangerous mode (auto-accept all permissions)
72crush -y
73 `,
74 RunE: func(cmd *cobra.Command, args []string) error {
75 hostURL, err := server.ParseHostURL(clientHost)
76 if err != nil {
77 return fmt.Errorf("invalid host URL: %v", err)
78 }
79
80 switch hostURL.Scheme {
81 case "unix", "npipe":
82 _, err := os.Stat(hostURL.Host)
83 if err != nil && errors.Is(err, fs.ErrNotExist) {
84 slog.Info("Starting server...", "host", clientHost)
85 if err := startDetachedServer(cmd); err != nil {
86 return err
87 }
88 }
89
90 // Wait for the file to appear
91 for range 10 {
92 _, err = os.Stat(hostURL.Host)
93 if err == nil {
94 break
95 }
96 select {
97 case <-cmd.Context().Done():
98 return cmd.Context().Err()
99 case <-time.After(100 * time.Millisecond):
100 }
101 }
102 if err != nil {
103 return fmt.Errorf("failed to connect to crush server: %v", err)
104 }
105
106 default:
107 // TODO: implement TCP support
108 }
109
110 c, err := setupApp(cmd, hostURL)
111 if err != nil {
112 return err
113 }
114
115 tries := 5
116 for i := range tries {
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 if i == tries-1 {
127 return fmt.Errorf("failed to connect to crush server after %d attempts: %v", tries, err)
128 }
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 // Set up the TUI.
139 program := tea.NewProgram(
140 m,
141 tea.WithAltScreen(),
142 tea.WithContext(cmd.Context()),
143 tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding
144 tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
145 )
146
147 evc, err := c.SubscribeEvents(cmd.Context())
148 if err != nil {
149 return fmt.Errorf("failed to subscribe to events: %v", err)
150 }
151
152 go streamEvents(cmd.Context(), evc, program)
153
154 if _, err := program.Run(); err != nil {
155 slog.Error("TUI run error", "error", err)
156 return fmt.Errorf("TUI error: %v", err)
157 }
158 return nil
159 },
160}
161
162func Execute() {
163 if err := fang.Execute(
164 context.Background(),
165 rootCmd,
166 fang.WithVersion(version.Version),
167 fang.WithNotifySignal(os.Interrupt),
168 ); err != nil {
169 os.Exit(1)
170 }
171}
172
173func streamEvents(ctx context.Context, evc <-chan any, p *tea.Program) {
174 defer log.RecoverPanic("app.Subscribe", func() {
175 slog.Info("TUI subscription panic: attempting graceful shutdown")
176 p.Quit()
177 })
178
179 for {
180 select {
181 case <-ctx.Done():
182 slog.Debug("TUI message handler shutting down")
183 return
184 case ev, ok := <-evc:
185 if !ok {
186 slog.Debug("TUI message channel closed")
187 return
188 }
189 p.Send(ev)
190 }
191 }
192}
193
194// setupApp handles the common setup logic for both interactive and non-interactive modes.
195// It returns the app instance, config, cleanup function, and any error.
196func setupApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, error) {
197 debug, _ := cmd.Flags().GetBool("debug")
198 yolo, _ := cmd.Flags().GetBool("yolo")
199 dataDir, _ := cmd.Flags().GetString("data-dir")
200 ctx := cmd.Context()
201
202 cwd, err := ResolveCwd(cmd)
203 if err != nil {
204 return nil, err
205 }
206
207 c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
208 if err != nil {
209 return nil, err
210 }
211
212 ins, err := c.CreateInstance(ctx, proto.Instance{
213 Path: cwd,
214 DataDir: dataDir,
215 Debug: debug,
216 YOLO: yolo,
217 })
218 if err != nil {
219 return nil, fmt.Errorf("failed to create or connect to instance: %v", err)
220 }
221
222 c.SetID(ins.ID)
223
224 return c, nil
225}
226
227var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
228
229func startDetachedServer(cmd *cobra.Command) error {
230 // Start the server as a detached process if the socket does not exist.
231 exe, err := os.Executable()
232 if err != nil {
233 return fmt.Errorf("failed to get executable path: %v", err)
234 }
235
236 safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
237 chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
238 if err := os.MkdirAll(chDir, 0o700); err != nil {
239 return fmt.Errorf("failed to create server working directory: %v", err)
240 }
241
242 c := exec.CommandContext(cmd.Context(), exe, "server")
243 stdoutPath := filepath.Join(chDir, "stdout.log")
244 stderrPath := filepath.Join(chDir, "stderr.log")
245 detachProcess(c)
246
247 stdout, err := os.Create(stdoutPath)
248 if err != nil {
249 return fmt.Errorf("failed to create stdout log file: %v", err)
250 }
251 defer stdout.Close()
252 c.Stdout = stdout
253
254 stderr, err := os.Create(stderrPath)
255 if err != nil {
256 return fmt.Errorf("failed to create stderr log file: %v", err)
257 }
258 defer stderr.Close()
259 c.Stderr = stderr
260
261 if err := c.Start(); err != nil {
262 return fmt.Errorf("failed to start crush server: %v", err)
263 }
264
265 if err := c.Process.Release(); err != nil {
266 return fmt.Errorf("failed to detach crush server process: %v", err)
267 }
268
269 return nil
270}
271
272func MaybePrependStdin(prompt string) (string, error) {
273 if term.IsTerminal(os.Stdin.Fd()) {
274 return prompt, nil
275 }
276 fi, err := os.Stdin.Stat()
277 if err != nil {
278 return prompt, err
279 }
280 if fi.Mode()&os.ModeNamedPipe == 0 {
281 return prompt, nil
282 }
283 bts, err := io.ReadAll(os.Stdin)
284 if err != nil {
285 return prompt, err
286 }
287 return string(bts) + "\n\n" + prompt, nil
288}
289
290func ResolveCwd(cmd *cobra.Command) (string, error) {
291 cwd, _ := cmd.Flags().GetString("cwd")
292 if cwd != "" {
293 err := os.Chdir(cwd)
294 if err != nil {
295 return "", fmt.Errorf("failed to change directory: %v", err)
296 }
297 return cwd, nil
298 }
299 cwd, err := os.Getwd()
300 if err != nil {
301 return "", fmt.Errorf("failed to get current working directory: %v", err)
302 }
303 return cwd, nil
304}
305
306func createDotCrushDir(dir string) error {
307 if err := os.MkdirAll(dir, 0o700); err != nil {
308 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
309 }
310
311 gitIgnorePath := filepath.Join(dir, ".gitignore")
312 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
313 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
314 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
315 }
316 }
317
318 return nil
319}