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