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}