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