1package cmd
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "log/slog"
8 "os"
9 "path/filepath"
10
11 tea "github.com/charmbracelet/bubbletea/v2"
12 "github.com/charmbracelet/crush/internal/client"
13 "github.com/charmbracelet/crush/internal/log"
14 "github.com/charmbracelet/crush/internal/proto"
15 "github.com/charmbracelet/crush/internal/tui"
16 "github.com/charmbracelet/crush/internal/version"
17 "github.com/charmbracelet/fang"
18 "github.com/charmbracelet/x/term"
19 "github.com/spf13/cobra"
20)
21
22func init() {
23 rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
24 rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
25 rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
26
27 rootCmd.Flags().BoolP("help", "h", false, "Help")
28 rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
29
30 rootCmd.AddCommand(runCmd)
31 rootCmd.AddCommand(updateProvidersCmd)
32}
33
34var rootCmd = &cobra.Command{
35 Use: "crush",
36 Short: "Terminal-based AI assistant for software development",
37 Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
38It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
39to assist developers in writing, debugging, and understanding code directly from the terminal.`,
40 Example: `
41# Run in interactive mode
42crush
43
44# Run with debug logging
45crush -d
46
47# Run with debug logging in a specific directory
48crush -d -c /path/to/project
49
50# Run with custom data directory
51crush -D /path/to/custom/.crush
52
53# Print version
54crush -v
55
56# Run a single non-interactive prompt
57crush run "Explain the use of context in Go"
58
59# Run in dangerous mode (auto-accept all permissions)
60crush -y
61 `,
62 RunE: func(cmd *cobra.Command, args []string) error {
63 c, err := setupApp(cmd)
64 if err != nil {
65 return err
66 }
67
68 m := tui.New(c)
69
70 defer func() { c.DeleteInstance(cmd.Context(), c.ID()) }()
71
72 // Set up the TUI.
73 program := tea.NewProgram(
74 m,
75 tea.WithAltScreen(),
76 tea.WithContext(cmd.Context()),
77 tea.WithMouseCellMotion(), // Use cell motion instead of all motion to reduce event flooding
78 tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
79 )
80
81 evc, err := c.SubscribeEvents(cmd.Context())
82 if err != nil {
83 return fmt.Errorf("failed to subscribe to events: %v", err)
84 }
85
86 go streamEvents(cmd.Context(), evc, program)
87
88 if _, err := program.Run(); err != nil {
89 slog.Error("TUI run error", "error", err)
90 return fmt.Errorf("TUI error: %v", err)
91 }
92 return nil
93 },
94}
95
96func Execute() {
97 if err := fang.Execute(
98 context.Background(),
99 rootCmd,
100 fang.WithVersion(version.Version),
101 fang.WithNotifySignal(os.Interrupt),
102 ); err != nil {
103 os.Exit(1)
104 }
105}
106
107func streamEvents(ctx context.Context, evc <-chan any, p *tea.Program) {
108 defer log.RecoverPanic("app.Subscribe", func() {
109 slog.Info("TUI subscription panic: attempting graceful shutdown")
110 p.Quit()
111 })
112
113 for {
114 select {
115 case <-ctx.Done():
116 slog.Debug("TUI message handler shutting down")
117 return
118 case ev, ok := <-evc:
119 if !ok {
120 slog.Debug("TUI message channel closed")
121 return
122 }
123 p.Send(ev)
124 }
125 }
126}
127
128// setupApp handles the common setup logic for both interactive and non-interactive modes.
129// It returns the app instance, config, cleanup function, and any error.
130func setupApp(cmd *cobra.Command) (*client.Client, error) {
131 debug, _ := cmd.Flags().GetBool("debug")
132 yolo, _ := cmd.Flags().GetBool("yolo")
133 dataDir, _ := cmd.Flags().GetString("data-dir")
134 ctx := cmd.Context()
135
136 cwd, err := ResolveCwd(cmd)
137 if err != nil {
138 return nil, err
139 }
140
141 c, err := client.DefaultClient(cwd)
142 if err != nil {
143 return nil, err
144 }
145
146 if _, err := c.CreateInstance(ctx, proto.Instance{
147 DataDir: dataDir,
148 Debug: debug,
149 YOLO: yolo,
150 }); err != nil {
151 return nil, fmt.Errorf("failed to create or connect to instance: %v", err)
152 }
153
154 return c, nil
155}
156
157func MaybePrependStdin(prompt string) (string, error) {
158 if term.IsTerminal(os.Stdin.Fd()) {
159 return prompt, nil
160 }
161 fi, err := os.Stdin.Stat()
162 if err != nil {
163 return prompt, err
164 }
165 if fi.Mode()&os.ModeNamedPipe == 0 {
166 return prompt, nil
167 }
168 bts, err := io.ReadAll(os.Stdin)
169 if err != nil {
170 return prompt, err
171 }
172 return string(bts) + "\n\n" + prompt, nil
173}
174
175func ResolveCwd(cmd *cobra.Command) (string, error) {
176 cwd, _ := cmd.Flags().GetString("cwd")
177 if cwd != "" {
178 err := os.Chdir(cwd)
179 if err != nil {
180 return "", fmt.Errorf("failed to change directory: %v", err)
181 }
182 return cwd, nil
183 }
184 cwd, err := os.Getwd()
185 if err != nil {
186 return "", fmt.Errorf("failed to get current working directory: %v", err)
187 }
188 return cwd, nil
189}
190
191func createDotCrushDir(dir string) error {
192 if err := os.MkdirAll(dir, 0o700); err != nil {
193 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
194 }
195
196 gitIgnorePath := filepath.Join(dir, ".gitignore")
197 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
198 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
199 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
200 }
201 }
202
203 return nil
204}