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	c := exec.CommandContext(cmd.Context(), exe, "server")
258	stdoutPath := filepath.Join(chDir, "stdout.log")
259	stderrPath := filepath.Join(chDir, "stderr.log")
260	detachProcess(c)
261
262	stdout, err := os.Create(stdoutPath)
263	if err != nil {
264		return fmt.Errorf("failed to create stdout log file: %v", err)
265	}
266	defer stdout.Close()
267	c.Stdout = stdout
268
269	stderr, err := os.Create(stderrPath)
270	if err != nil {
271		return fmt.Errorf("failed to create stderr log file: %v", err)
272	}
273	defer stderr.Close()
274	c.Stderr = stderr
275
276	if err := c.Start(); err != nil {
277		return fmt.Errorf("failed to start crush server: %v", err)
278	}
279
280	if err := c.Process.Release(); err != nil {
281		return fmt.Errorf("failed to detach crush server process: %v", err)
282	}
283
284	return nil
285}
286
287func shouldEnableMetrics(cfg *config.Config) bool {
288	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
289		return false
290	}
291	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
292		return false
293	}
294	if cfg.Options.DisableMetrics {
295		return false
296	}
297	return true
298}
299
300func MaybePrependStdin(prompt string) (string, error) {
301	if term.IsTerminal(os.Stdin.Fd()) {
302		return prompt, nil
303	}
304	fi, err := os.Stdin.Stat()
305	if err != nil {
306		return prompt, err
307	}
308	if fi.Mode()&os.ModeNamedPipe == 0 {
309		return prompt, nil
310	}
311	bts, err := io.ReadAll(os.Stdin)
312	if err != nil {
313		return prompt, err
314	}
315	return string(bts) + "\n\n" + prompt, nil
316}
317
318func ResolveCwd(cmd *cobra.Command) (string, error) {
319	cwd, _ := cmd.Flags().GetString("cwd")
320	if cwd != "" {
321		err := os.Chdir(cwd)
322		if err != nil {
323			return "", fmt.Errorf("failed to change directory: %v", err)
324		}
325		return cwd, nil
326	}
327	cwd, err := os.Getwd()
328	if err != nil {
329		return "", fmt.Errorf("failed to get current working directory: %v", err)
330	}
331	return cwd, nil
332}
333
334func createDotCrushDir(dir string) error {
335	if err := os.MkdirAll(dir, 0o700); err != nil {
336		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
337	}
338
339	gitIgnorePath := filepath.Join(dir, ".gitignore")
340	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
341		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
342			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
343		}
344	}
345
346	return nil
347}