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 ins, err := c.CreateInstance(ctx, proto.Instance{
155 Path: cwd,
156 DataDir: dataDir,
157 Debug: debug,
158 YOLO: yolo,
159 })
160 if err != nil {
161 return nil, fmt.Errorf("failed to create or connect to instance: %v", err)
162 }
163
164 c.SetID(ins.ID)
165
166 return c, nil
167}
168
169func MaybePrependStdin(prompt string) (string, error) {
170 if term.IsTerminal(os.Stdin.Fd()) {
171 return prompt, nil
172 }
173 fi, err := os.Stdin.Stat()
174 if err != nil {
175 return prompt, err
176 }
177 if fi.Mode()&os.ModeNamedPipe == 0 {
178 return prompt, nil
179 }
180 bts, err := io.ReadAll(os.Stdin)
181 if err != nil {
182 return prompt, err
183 }
184 return string(bts) + "\n\n" + prompt, nil
185}
186
187func ResolveCwd(cmd *cobra.Command) (string, error) {
188 cwd, _ := cmd.Flags().GetString("cwd")
189 if cwd != "" {
190 err := os.Chdir(cwd)
191 if err != nil {
192 return "", fmt.Errorf("failed to change directory: %v", err)
193 }
194 return cwd, nil
195 }
196 cwd, err := os.Getwd()
197 if err != nil {
198 return "", fmt.Errorf("failed to get current working directory: %v", err)
199 }
200 return cwd, nil
201}
202
203func createDotCrushDir(dir string) error {
204 if err := os.MkdirAll(dir, 0o700); err != nil {
205 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
206 }
207
208 gitIgnorePath := filepath.Join(dir, ".gitignore")
209 if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
210 if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
211 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
212 }
213 }
214
215 return nil
216}