root.go

  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, err := setupApp(cmd, hostURL)
112		if err != nil {
113			return err
114		}
115
116		for range 10 {
117			err = c.Health()
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)
132		if err != nil {
133			return fmt.Errorf("failed to create TUI model: %v", err)
134		}
135
136		defer func() { c.DeleteInstance(cmd.Context(), c.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())
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, 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, err
211	}
212
213	c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
214	if err != nil {
215		return 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, fmt.Errorf("failed to create or connect to instance: %v", err)
226	}
227
228	c.SetID(ins.ID)
229
230	cfg, err := c.GetGlobalConfig()
231	if err != nil {
232		return nil, fmt.Errorf("failed to get global config: %v", err)
233	}
234
235	if shouldEnableMetrics(cfg) {
236		event.Init()
237	}
238
239	return c, nil
240}
241
242var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
243
244func startDetachedServer(cmd *cobra.Command) error {
245	// Start the server as a detached process if the socket does not exist.
246	exe, err := os.Executable()
247	if err != nil {
248		return fmt.Errorf("failed to get executable path: %v", err)
249	}
250
251	safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
252	chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
253	if err := os.MkdirAll(chDir, 0o700); err != nil {
254		return fmt.Errorf("failed to create server working directory: %v", err)
255	}
256
257	args := []string{"server"}
258	if clientHost != server.DefaultHost() {
259		args = append(args, "--host", clientHost)
260	}
261
262	c := exec.CommandContext(cmd.Context(), exe, args...)
263	stdoutPath := filepath.Join(chDir, "stdout.log")
264	stderrPath := filepath.Join(chDir, "stderr.log")
265	detachProcess(c)
266
267	stdout, err := os.Create(stdoutPath)
268	if err != nil {
269		return fmt.Errorf("failed to create stdout log file: %v", err)
270	}
271	defer stdout.Close()
272	c.Stdout = stdout
273
274	stderr, err := os.Create(stderrPath)
275	if err != nil {
276		return fmt.Errorf("failed to create stderr log file: %v", err)
277	}
278	defer stderr.Close()
279	c.Stderr = stderr
280
281	if err := c.Start(); err != nil {
282		return fmt.Errorf("failed to start crush server: %v", err)
283	}
284
285	if err := c.Process.Release(); err != nil {
286		return fmt.Errorf("failed to detach crush server process: %v", err)
287	}
288
289	return nil
290}
291
292func shouldEnableMetrics(cfg *config.Config) bool {
293	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
294		return false
295	}
296	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
297		return false
298	}
299	if cfg.Options.DisableMetrics {
300		return false
301	}
302	return true
303}
304
305func MaybePrependStdin(prompt string) (string, error) {
306	if term.IsTerminal(os.Stdin.Fd()) {
307		return prompt, nil
308	}
309	fi, err := os.Stdin.Stat()
310	if err != nil {
311		return prompt, err
312	}
313	if fi.Mode()&os.ModeNamedPipe == 0 {
314		return prompt, nil
315	}
316	bts, err := io.ReadAll(os.Stdin)
317	if err != nil {
318		return prompt, err
319	}
320	return string(bts) + "\n\n" + prompt, nil
321}
322
323func ResolveCwd(cmd *cobra.Command) (string, error) {
324	cwd, _ := cmd.Flags().GetString("cwd")
325	if cwd != "" {
326		err := os.Chdir(cwd)
327		if err != nil {
328			return "", fmt.Errorf("failed to change directory: %v", err)
329		}
330		return cwd, nil
331	}
332	cwd, err := os.Getwd()
333	if err != nil {
334		return "", fmt.Errorf("failed to get current working directory: %v", err)
335	}
336	return cwd, nil
337}
338
339func createDotCrushDir(dir string) error {
340	if err := os.MkdirAll(dir, 0o700); err != nil {
341		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
342	}
343
344	gitIgnorePath := filepath.Join(dir, ".gitignore")
345	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
346		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
347			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
348		}
349	}
350
351	return nil
352}