root.go

  1package cmd
  2
  3import (
  4	"bytes"
  5	"context"
  6	"errors"
  7	"fmt"
  8	"io"
  9	"io/fs"
 10	"log/slog"
 11	"net/url"
 12	"os"
 13	"os/exec"
 14	"path/filepath"
 15	"regexp"
 16	"strconv"
 17	"strings"
 18	"time"
 19
 20	tea "charm.land/bubbletea/v2"
 21	"charm.land/lipgloss/v2"
 22	"github.com/charmbracelet/colorprofile"
 23	"github.com/charmbracelet/crush/internal/app"
 24	"github.com/charmbracelet/crush/internal/client"
 25	"github.com/charmbracelet/crush/internal/config"
 26	"github.com/charmbracelet/crush/internal/db"
 27	"github.com/charmbracelet/crush/internal/event"
 28	"github.com/charmbracelet/crush/internal/log"
 29	"github.com/charmbracelet/crush/internal/projects"
 30	"github.com/charmbracelet/crush/internal/proto"
 31	"github.com/charmbracelet/crush/internal/server"
 32	"github.com/charmbracelet/crush/internal/ui/common"
 33	ui "github.com/charmbracelet/crush/internal/ui/model"
 34	"github.com/charmbracelet/crush/internal/version"
 35	"github.com/charmbracelet/fang"
 36	uv "github.com/charmbracelet/ultraviolet"
 37	"github.com/charmbracelet/x/ansi"
 38	"github.com/charmbracelet/x/exp/charmtone"
 39	"github.com/charmbracelet/x/term"
 40	"github.com/spf13/cobra"
 41)
 42
 43var clientHost string
 44
 45func init() {
 46	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
 47	rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
 48	rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
 49	rootCmd.Flags().BoolP("help", "h", false, "Help")
 50	rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
 51
 52	rootCmd.Flags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
 53
 54	rootCmd.AddCommand(
 55		runCmd,
 56		dirsCmd,
 57		projectsCmd,
 58		updateProvidersCmd,
 59		logsCmd,
 60		schemaCmd,
 61		loginCmd,
 62		statsCmd,
 63	)
 64}
 65
 66var rootCmd = &cobra.Command{
 67	Use:   "crush",
 68	Short: "A terminal-first AI assistant for software development",
 69	Long:  "A glamorous, terminal-first AI assistant for software development and adjacent tasks",
 70	Example: `
 71# Run in interactive mode
 72crush
 73
 74# Run non-interactively
 75crush run "Guess my 5 favorite PokΓ©mon"
 76
 77# Run a non-interactively with pipes and redirection
 78cat README.md | crush run "make this more glamorous" > GLAMOROUS_README.md
 79
 80# Run with debug logging in a specific directory
 81crush --debug --cwd /path/to/project
 82
 83# Run in yolo mode (auto-accept all permissions; use with care)
 84crush --yolo
 85
 86# Run with custom data directory
 87crush --data-dir /path/to/custom/.crush
 88  `,
 89	RunE: func(cmd *cobra.Command, args []string) error {
 90		hostURL, err := server.ParseHostURL(clientHost)
 91		if err != nil {
 92			return fmt.Errorf("invalid host URL: %v", err)
 93		}
 94
 95		if err := ensureServer(cmd, hostURL); err != nil {
 96			return err
 97		}
 98
 99		appInstance, err := setupAppWithProgressBar(cmd)
100		if err != nil {
101			return err
102		}
103		defer appInstance.Shutdown()
104
105		// Register the workspace with the server so it tracks active
106		// clients and auto-shuts down when the last one exits.
107		cwd, _ := ResolveCwd(cmd)
108		dataDir, _ := cmd.Flags().GetString("data-dir")
109		debug, _ := cmd.Flags().GetBool("debug")
110		yolo, _ := cmd.Flags().GetBool("yolo")
111
112		c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
113		if err != nil {
114			return fmt.Errorf("failed to create client: %v", err)
115		}
116
117		ws, err := c.CreateWorkspace(cmd.Context(), proto.Workspace{
118			Path:    cwd,
119			DataDir: dataDir,
120			Debug:   debug,
121			YOLO:    yolo,
122			Version: version.Version,
123		})
124		if err != nil {
125			return fmt.Errorf("failed to register workspace: %v", err)
126		}
127		defer func() { _ = c.DeleteWorkspace(cmd.Context(), ws.ID) }()
128
129		event.AppInitialized()
130
131		// Set up the TUI.
132		var env uv.Environ = os.Environ()
133
134		com := common.DefaultCommon(appInstance)
135		model := ui.New(com)
136
137		program := tea.NewProgram(
138			model,
139			tea.WithEnvironment(env),
140			tea.WithContext(cmd.Context()),
141			tea.WithFilter(ui.MouseEventFilter), // Filter mouse events based on focus state
142		)
143		go appInstance.Subscribe(program)
144
145		if _, err := program.Run(); err != nil {
146			event.Error(err)
147			slog.Error("TUI run error", "error", err)
148			return errors.New("Crush crashed. If metrics are enabled, we were notified about it. If you'd like to report it, please copy the stacktrace above and open an issue at https://github.com/charmbracelet/crush/issues/new?template=bug.yml") //nolint:staticcheck
149		}
150		return nil
151	},
152}
153
154var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
155    β–„β–„β–„β–„β–„β–„β–„β–„    β–„β–„β–„β–„β–„β–„β–„β–„
156  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
157β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
158β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
159β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
160β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
161β–€β–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–„β–ˆβ–ˆβ–ˆβ–ˆβ–„β–„β–ˆβ–ˆβ–ˆβ–ˆβ–„β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–€
162  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
163    β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
164       β–€β–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–€
165           β–€β–€β–€β–€β–€β–€
166`)
167
168// copied from cobra:
169const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
170`
171
172func Execute() {
173	// NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
174	// it forward to a bytes.Buffer, write the colored heartbit to it, and then
175	// finally prepend it in the version template.
176	// Unfortunately cobra doesn't give us a way to set a function to handle
177	// printing the version, and PreRunE runs after the version is already
178	// handled, so that doesn't work either.
179	// This is the only way I could find that works relatively well.
180	if term.IsTerminal(os.Stdout.Fd()) {
181		var b bytes.Buffer
182		w := colorprofile.NewWriter(os.Stdout, os.Environ())
183		w.Forward = &b
184		_, _ = w.WriteString(heartbit.String())
185		rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
186	}
187	if err := fang.Execute(
188		context.Background(),
189		rootCmd,
190		fang.WithVersion(version.Version),
191		fang.WithNotifySignal(os.Interrupt),
192	); err != nil {
193		os.Exit(1)
194	}
195}
196
197// supportsProgressBar tries to determine whether the current terminal supports
198// progress bars by looking into environment variables.
199func supportsProgressBar() bool {
200	if !term.IsTerminal(os.Stderr.Fd()) {
201		return false
202	}
203	termProg := os.Getenv("TERM_PROGRAM")
204	_, isWindowsTerminal := os.LookupEnv("WT_SESSION")
205
206	return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
207}
208
209func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
210	app, err := setupApp(cmd)
211	if err != nil {
212		return nil, err
213	}
214
215	// Check if progress bar is enabled in config (defaults to true if nil)
216	progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress
217	if progressEnabled && supportsProgressBar() {
218		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
219		defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
220	}
221
222	return app, nil
223}
224
225// setupApp handles the common setup logic for both interactive and non-interactive modes.
226// It returns the app instance, config, cleanup function, and any error.
227func setupApp(cmd *cobra.Command) (*app.App, error) {
228	debug, _ := cmd.Flags().GetBool("debug")
229	yolo, _ := cmd.Flags().GetBool("yolo")
230	dataDir, _ := cmd.Flags().GetString("data-dir")
231	ctx := cmd.Context()
232
233	cwd, err := ResolveCwd(cmd)
234	if err != nil {
235		return nil, err
236	}
237
238	store, err := config.Init(cwd, dataDir, debug)
239	if err != nil {
240		return nil, err
241	}
242
243	cfg := store.Config()
244	if cfg.Permissions == nil {
245		cfg.Permissions = &config.Permissions{}
246	}
247	cfg.Permissions.SkipRequests = yolo
248
249	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
250		return nil, err
251	}
252
253	// Register this project in the centralized projects list.
254	if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
255		slog.Warn("Failed to register project", "error", err)
256		// Non-fatal: continue even if registration fails
257	}
258
259	// Connect to DB; this will also run migrations.
260	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
261	if err != nil {
262		return nil, err
263	}
264
265	appInstance, err := app.New(ctx, conn, store)
266	if err != nil {
267		slog.Error("Failed to create app instance", "error", err)
268		return nil, err
269	}
270
271	if shouldEnableMetrics(cfg) {
272		event.Init()
273	}
274
275	return appInstance, nil
276}
277
278// setupClientApp sets up a client-based workspace via the server. It
279// auto-starts a detached server process if the socket does not exist.
280func setupClientApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, *proto.Workspace, error) {
281	debug, _ := cmd.Flags().GetBool("debug")
282	yolo, _ := cmd.Flags().GetBool("yolo")
283	dataDir, _ := cmd.Flags().GetString("data-dir")
284	ctx := cmd.Context()
285
286	cwd, err := ResolveCwd(cmd)
287	if err != nil {
288		return nil, nil, err
289	}
290
291	c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
292	if err != nil {
293		return nil, nil, err
294	}
295
296	ws, err := c.CreateWorkspace(ctx, proto.Workspace{
297		Path:    cwd,
298		DataDir: dataDir,
299		Debug:   debug,
300		YOLO:    yolo,
301		Env:     os.Environ(),
302	})
303	if err != nil {
304		return nil, nil, fmt.Errorf("failed to create workspace: %v", err)
305	}
306
307	cfg, err := c.GetGlobalConfig(cmd.Context())
308	if err != nil {
309		return nil, nil, fmt.Errorf("failed to get global config: %v", err)
310	}
311
312	if shouldEnableMetrics(cfg) {
313		event.Init()
314	}
315
316	return c, ws, nil
317}
318
319// ensureServer auto-starts a detached server if the socket file does not
320// exist. When connecting to an existing server, it waits for the health
321// endpoint to respond.
322func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
323	switch hostURL.Scheme {
324	case "unix", "npipe":
325		_, err := os.Stat(hostURL.Host)
326		if err != nil && errors.Is(err, fs.ErrNotExist) {
327			if err := startDetachedServer(cmd); err != nil {
328				return err
329			}
330		}
331
332		for range 10 {
333			_, err = os.Stat(hostURL.Host)
334			if err == nil {
335				break
336			}
337			select {
338			case <-cmd.Context().Done():
339				return cmd.Context().Err()
340			case <-time.After(100 * time.Millisecond):
341			}
342		}
343		if err != nil {
344			return fmt.Errorf("failed to initialize crush server: %v", err)
345		}
346	}
347
348	return nil
349}
350
351// waitForHealth polls the server's health endpoint until it responds.
352func waitForHealth(ctx context.Context, c *client.Client) error {
353	var err error
354	for range 10 {
355		err = c.Health(ctx)
356		if err == nil {
357			return nil
358		}
359		select {
360		case <-ctx.Done():
361			return ctx.Err()
362		case <-time.After(100 * time.Millisecond):
363		}
364	}
365	return fmt.Errorf("failed to connect to crush server: %v", err)
366}
367
368// streamEvents forwards SSE events from the client to the TUI program.
369func streamEvents(ctx context.Context, evc <-chan any, p *tea.Program) {
370	defer log.RecoverPanic("app.Subscribe", func() {
371		slog.Info("TUI subscription panic: attempting graceful shutdown")
372		p.Quit()
373	})
374
375	for {
376		select {
377		case <-ctx.Done():
378			slog.Debug("TUI message handler shutting down")
379			return
380		case ev, ok := <-evc:
381			if !ok {
382				slog.Debug("TUI message channel closed")
383				return
384			}
385			p.Send(ev)
386		}
387	}
388}
389
390var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
391
392func startDetachedServer(cmd *cobra.Command) error {
393	exe, err := os.Executable()
394	if err != nil {
395		return fmt.Errorf("failed to get executable path: %v", err)
396	}
397
398	safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
399	chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
400	if err := os.MkdirAll(chDir, 0o700); err != nil {
401		return fmt.Errorf("failed to create server working directory: %v", err)
402	}
403
404	cmdArgs := []string{"server"}
405	if clientHost != server.DefaultHost() {
406		cmdArgs = append(cmdArgs, "--host", clientHost)
407	}
408
409	c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
410	stdoutPath := filepath.Join(chDir, "stdout.log")
411	stderrPath := filepath.Join(chDir, "stderr.log")
412	detachProcess(c)
413
414	stdout, err := os.Create(stdoutPath)
415	if err != nil {
416		return fmt.Errorf("failed to create stdout log file: %v", err)
417	}
418	defer stdout.Close()
419	c.Stdout = stdout
420
421	stderr, err := os.Create(stderrPath)
422	if err != nil {
423		return fmt.Errorf("failed to create stderr log file: %v", err)
424	}
425	defer stderr.Close()
426	c.Stderr = stderr
427
428	if err := c.Start(); err != nil {
429		return fmt.Errorf("failed to start crush server: %v", err)
430	}
431
432	if err := c.Process.Release(); err != nil {
433		return fmt.Errorf("failed to detach crush server process: %v", err)
434	}
435
436	return nil
437}
438
439func shouldEnableMetrics(cfg *config.Config) bool {
440	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
441		return false
442	}
443	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
444		return false
445	}
446	if cfg.Options.DisableMetrics {
447		return false
448	}
449	return true
450}
451
452func MaybePrependStdin(prompt string) (string, error) {
453	if term.IsTerminal(os.Stdin.Fd()) {
454		return prompt, nil
455	}
456	fi, err := os.Stdin.Stat()
457	if err != nil {
458		return prompt, err
459	}
460	// Check if stdin is a named pipe ( | ) or regular file ( < ).
461	if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
462		return prompt, nil
463	}
464	bts, err := io.ReadAll(os.Stdin)
465	if err != nil {
466		return prompt, err
467	}
468	return string(bts) + "\n\n" + prompt, nil
469}
470
471func ResolveCwd(cmd *cobra.Command) (string, error) {
472	cwd, _ := cmd.Flags().GetString("cwd")
473	if cwd != "" {
474		err := os.Chdir(cwd)
475		if err != nil {
476			return "", fmt.Errorf("failed to change directory: %v", err)
477		}
478		return cwd, nil
479	}
480	cwd, err := os.Getwd()
481	if err != nil {
482		return "", fmt.Errorf("failed to get current working directory: %v", err)
483	}
484	return cwd, nil
485}
486
487func createDotCrushDir(dir string) error {
488	if err := os.MkdirAll(dir, 0o700); err != nil {
489		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
490	}
491
492	gitIgnorePath := filepath.Join(dir, ".gitignore")
493	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
494		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
495			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
496		}
497	}
498
499	return nil
500}