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