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