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