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}