root.go

  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}