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().StringVar(&clientHost, "host", 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				slog.Info("Starting server...", "host", clientHost)
 87				if err := startDetachedServer(cmd); err != nil {
 88					return err
 89				}
 90			}
 91
 92			// Wait for the file to appear
 93			for range 10 {
 94				_, err = os.Stat(hostURL.Host)
 95				if err == nil {
 96					break
 97				}
 98				select {
 99				case <-cmd.Context().Done():
100					return cmd.Context().Err()
101				case <-time.After(100 * time.Millisecond):
102				}
103			}
104			if err != nil {
105				return fmt.Errorf("failed to initialize crush server: %v", err)
106			}
107
108		default:
109			// TODO: implement TCP support
110		}
111
112		c, err := setupApp(cmd, hostURL)
113		if err != nil {
114			return err
115		}
116
117		for range 10 {
118			err = c.Health()
119			if err == nil {
120				break
121			}
122			select {
123			case <-cmd.Context().Done():
124				return cmd.Context().Err()
125			case <-time.After(100 * time.Millisecond):
126			}
127		}
128		if err != nil {
129			return fmt.Errorf("failed to connect to crush server: %v", err)
130		}
131
132		m, err := tui.New(c)
133		if err != nil {
134			return fmt.Errorf("failed to create TUI model: %v", err)
135		}
136
137		defer func() { c.DeleteInstance(cmd.Context(), c.ID()) }()
138
139		event.AppInitialized()
140
141		// Set up the TUI.
142		program := tea.NewProgram(
143			m,
144			tea.WithAltScreen(),
145			tea.WithContext(cmd.Context()),
146			tea.WithMouseCellMotion(),            // Use cell motion instead of all motion to reduce event flooding
147			tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
148		)
149
150		evc, err := c.SubscribeEvents(cmd.Context())
151		if err != nil {
152			return fmt.Errorf("failed to subscribe to events: %v", err)
153		}
154
155		go streamEvents(cmd.Context(), evc, program)
156
157		if _, err := program.Run(); err != nil {
158			event.Error(err)
159			slog.Error("TUI run error", "error", err)
160			return fmt.Errorf("TUI error: %v", err)
161		}
162		return nil
163	},
164	PostRun: func(cmd *cobra.Command, args []string) {
165		event.AppExited()
166	},
167}
168
169func Execute() {
170	if err := fang.Execute(
171		context.Background(),
172		rootCmd,
173		fang.WithVersion(version.Version),
174		fang.WithNotifySignal(os.Interrupt),
175	); err != nil {
176		os.Exit(1)
177	}
178}
179
180func streamEvents(ctx context.Context, evc <-chan any, p *tea.Program) {
181	defer log.RecoverPanic("app.Subscribe", func() {
182		slog.Info("TUI subscription panic: attempting graceful shutdown")
183		p.Quit()
184	})
185
186	for {
187		select {
188		case <-ctx.Done():
189			slog.Debug("TUI message handler shutting down")
190			return
191		case ev, ok := <-evc:
192			if !ok {
193				slog.Debug("TUI message channel closed")
194				return
195			}
196			p.Send(ev)
197		}
198	}
199}
200
201// setupApp handles the common setup logic for both interactive and non-interactive modes.
202// It returns the app instance, config, cleanup function, and any error.
203func setupApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, error) {
204	debug, _ := cmd.Flags().GetBool("debug")
205	yolo, _ := cmd.Flags().GetBool("yolo")
206	dataDir, _ := cmd.Flags().GetString("data-dir")
207	ctx := cmd.Context()
208
209	cwd, err := ResolveCwd(cmd)
210	if err != nil {
211		return nil, err
212	}
213
214	c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
215	if err != nil {
216		return nil, err
217	}
218
219	ins, err := c.CreateInstance(ctx, proto.Instance{
220		Path:    cwd,
221		DataDir: dataDir,
222		Debug:   debug,
223		YOLO:    yolo,
224	})
225	if err != nil {
226		return nil, fmt.Errorf("failed to create or connect to instance: %v", err)
227	}
228
229	c.SetID(ins.ID)
230
231	cfg, err := c.GetGlobalConfig()
232	if err != nil {
233		return nil, fmt.Errorf("failed to get global config: %v", err)
234	}
235
236	if shouldEnableMetrics(cfg) {
237		event.Init()
238	}
239
240	return c, nil
241}
242
243var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
244
245func startDetachedServer(cmd *cobra.Command) error {
246	// Start the server as a detached process if the socket does not exist.
247	exe, err := os.Executable()
248	if err != nil {
249		return fmt.Errorf("failed to get executable path: %v", err)
250	}
251
252	safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
253	chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
254	if err := os.MkdirAll(chDir, 0o700); err != nil {
255		return fmt.Errorf("failed to create server working directory: %v", err)
256	}
257
258	c := exec.CommandContext(cmd.Context(), exe, "server")
259	stdoutPath := filepath.Join(chDir, "stdout.log")
260	stderrPath := filepath.Join(chDir, "stderr.log")
261	detachProcess(c)
262
263	stdout, err := os.Create(stdoutPath)
264	if err != nil {
265		return fmt.Errorf("failed to create stdout log file: %v", err)
266	}
267	defer stdout.Close()
268	c.Stdout = stdout
269
270	stderr, err := os.Create(stderrPath)
271	if err != nil {
272		return fmt.Errorf("failed to create stderr log file: %v", err)
273	}
274	defer stderr.Close()
275	c.Stderr = stderr
276
277	if err := c.Start(); err != nil {
278		return fmt.Errorf("failed to start crush server: %v", err)
279	}
280
281	if err := c.Process.Release(); err != nil {
282		return fmt.Errorf("failed to detach crush server process: %v", err)
283	}
284
285	return nil
286}
287
288func shouldEnableMetrics(cfg *config.Config) bool {
289	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
290		return false
291	}
292	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
293		return false
294	}
295	if cfg.Options.DisableMetrics {
296		return false
297	}
298	return true
299}
300
301func MaybePrependStdin(prompt string) (string, error) {
302	if term.IsTerminal(os.Stdin.Fd()) {
303		return prompt, nil
304	}
305	fi, err := os.Stdin.Stat()
306	if err != nil {
307		return prompt, err
308	}
309	if fi.Mode()&os.ModeNamedPipe == 0 {
310		return prompt, nil
311	}
312	bts, err := io.ReadAll(os.Stdin)
313	if err != nil {
314		return prompt, err
315	}
316	return string(bts) + "\n\n" + prompt, nil
317}
318
319func ResolveCwd(cmd *cobra.Command) (string, error) {
320	cwd, _ := cmd.Flags().GetString("cwd")
321	if cwd != "" {
322		err := os.Chdir(cwd)
323		if err != nil {
324			return "", fmt.Errorf("failed to change directory: %v", err)
325		}
326		return cwd, nil
327	}
328	cwd, err := os.Getwd()
329	if err != nil {
330		return "", fmt.Errorf("failed to get current working directory: %v", err)
331	}
332	return cwd, nil
333}
334
335func createDotCrushDir(dir string) error {
336	if err := os.MkdirAll(dir, 0o700); err != nil {
337		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
338	}
339
340	gitIgnorePath := filepath.Join(dir, ".gitignore")
341	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
342		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
343			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
344		}
345	}
346
347	return nil
348}