root.go

  1package cmd
  2
  3import (
  4	"bytes"
  5	"context"
  6	"errors"
  7	"fmt"
  8	"io"
  9	"log/slog"
 10	"os"
 11	"path/filepath"
 12	"strconv"
 13	"strings"
 14	"time"
 15
 16	tea "charm.land/bubbletea/v2"
 17	"charm.land/lipgloss/v2"
 18	"github.com/charmbracelet/colorprofile"
 19	"github.com/charmbracelet/crush/internal/app"
 20	"github.com/charmbracelet/crush/internal/config"
 21	"github.com/charmbracelet/crush/internal/db"
 22	"github.com/charmbracelet/crush/internal/event"
 23	"github.com/charmbracelet/crush/internal/stringext"
 24	termutil "github.com/charmbracelet/crush/internal/term"
 25	"github.com/charmbracelet/crush/internal/tui"
 26	tuiutil "github.com/charmbracelet/crush/internal/tui/util"
 27	"github.com/charmbracelet/crush/internal/update"
 28	"github.com/charmbracelet/crush/internal/version"
 29	"github.com/charmbracelet/fang"
 30	uv "github.com/charmbracelet/ultraviolet"
 31	"github.com/charmbracelet/x/ansi"
 32	"github.com/charmbracelet/x/exp/charmtone"
 33	"github.com/charmbracelet/x/term"
 34	"github.com/spf13/cobra"
 35)
 36
 37func init() {
 38	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
 39	rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
 40	rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
 41	rootCmd.Flags().BoolP("help", "h", false, "Help")
 42	rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
 43
 44	rootCmd.AddCommand(
 45		runCmd,
 46		dirsCmd,
 47		updateCmd,
 48		updateProvidersCmd,
 49		logsCmd,
 50		schemaCmd,
 51		loginCmd,
 52	)
 53}
 54
 55var rootCmd = &cobra.Command{
 56	Use:   "crush",
 57	Short: "Terminal-based AI assistant for software development",
 58	Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
 59It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
 60to assist developers in writing, debugging, and understanding code directly from the terminal.`,
 61	Example: `
 62# Run in interactive mode
 63crush
 64
 65# Run with debug logging
 66crush -d
 67
 68# Run with debug logging in a specific directory
 69crush -d -c /path/to/project
 70
 71# Run with custom data directory
 72crush -D /path/to/custom/.crush
 73
 74# Print version
 75crush -v
 76
 77# Run a single non-interactive prompt
 78crush run "Explain the use of context in Go"
 79
 80# Run in dangerous mode (auto-accept all permissions)
 81crush -y
 82  `,
 83	RunE: func(cmd *cobra.Command, args []string) error {
 84		app, err := setupAppWithProgressBar(cmd)
 85		if err != nil {
 86			return err
 87		}
 88		defer app.Shutdown()
 89
 90		event.AppInitialized()
 91
 92		// Set up the TUI.
 93		var env uv.Environ = os.Environ()
 94		ui := tui.New(app)
 95		ui.QueryVersion = shouldQueryTerminalVersion(env)
 96
 97		program := tea.NewProgram(
 98			ui,
 99			tea.WithEnvironment(env),
100			tea.WithContext(cmd.Context()),
101			tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
102		go app.Subscribe(program)
103
104		// Create a cancellable context for the update check that gets cancelled
105		// when the TUI exits.
106		updateCtx, cancelUpdate := context.WithCancel(cmd.Context())
107		defer cancelUpdate()
108
109		// Start async update check unless disabled.
110		go checkForUpdateAsync(updateCtx, program)
111
112		if _, err := program.Run(); err != nil {
113			event.Error(err)
114			slog.Error("TUI run error", "error", err)
115			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
116		}
117		return nil
118	},
119	PostRun: func(cmd *cobra.Command, args []string) {
120		event.AppExited()
121	},
122}
123
124var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
125    ▄▄▄▄▄▄▄▄    ▄▄▄▄▄▄▄▄
126  ███████████  ███████████
127████████████████████████████
128████████████████████████████
129██████████▀██████▀██████████
130██████████ ██████ ██████████
131▀▀██████▄████▄▄████▄██████▀▀
132  ████████████████████████
133    ████████████████████
134       ▀▀██████████▀▀
135           ▀▀▀▀▀▀
136`)
137
138// copied from cobra:
139const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
140`
141
142func Execute() {
143	// NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
144	// it forward to a bytes.Buffer, write the colored heartbit to it, and then
145	// finally prepend it in the version template.
146	// Unfortunately cobra doesn't give us a way to set a function to handle
147	// printing the version, and PreRunE runs after the version is already
148	// handled, so that doesn't work either.
149	// This is the only way I could find that works relatively well.
150	versionTemplate := defaultVersionTemplate
151	if term.IsTerminal(os.Stdout.Fd()) {
152		var b bytes.Buffer
153		w := colorprofile.NewWriter(os.Stdout, os.Environ())
154		w.Forward = &b
155		_, _ = w.WriteString(heartbit.String())
156		versionTemplate = b.String() + "\n" + defaultVersionTemplate
157	}
158
159	// Check if version flag is present and add update notification if available.
160	if hasVersionFlag() {
161		if updateMsg := checkForUpdateSync(); updateMsg != "" {
162			versionTemplate += updateMsg
163		}
164	}
165
166	rootCmd.SetVersionTemplate(versionTemplate)
167
168	if err := fang.Execute(
169		context.Background(),
170		rootCmd,
171		fang.WithVersion(version.Version),
172		fang.WithNotifySignal(os.Interrupt),
173	); err != nil {
174		os.Exit(1)
175	}
176}
177
178// hasVersionFlag checks if the version flag is present in os.Args.
179func hasVersionFlag() bool {
180	for _, arg := range os.Args {
181		if arg == "-v" || arg == "--version" {
182			return true
183		}
184	}
185	return false
186}
187
188// isAutoUpdateDisabled checks if update checks are disabled via env var.
189// Config is not loaded at this point (called before Execute), so only env var is checked.
190func isAutoUpdateDisabled() bool {
191	if str, ok := os.LookupEnv("CRUSH_DISABLE_AUTO_UPDATE"); ok {
192		v, _ := strconv.ParseBool(str)
193		return v
194	}
195	return false
196}
197
198// checkForUpdateSync performs a synchronous update check with a short timeout.
199// Returns a formatted update message if an update is available, empty string otherwise.
200func checkForUpdateSync() string {
201	if isAutoUpdateDisabled() {
202		return ""
203	}
204
205	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
206	defer cancel()
207
208	info, err := update.Check(ctx, version.Version, update.Default)
209	if err != nil {
210		return ""
211	}
212
213	if info.IsDevelopment() {
214		return info.DevelopmentVersionBrief()
215	}
216
217	if !info.Available() {
218		return ""
219	}
220
221	return fmt.Sprintf("\nUpdate available: v%s → v%s\nRun 'crush update apply' to install.\n", info.Current, info.Latest)
222}
223
224// checkForUpdateAsync checks for updates in the background and applies them if possible.
225func checkForUpdateAsync(ctx context.Context, program *tea.Program) {
226	// Check config (if loaded) or env var.
227	if isAutoUpdateDisabled() {
228		return
229	}
230	if cfg := config.Get(); cfg != nil && cfg.Options.DisableAutoUpdate {
231		return
232	}
233
234	checkCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
235	defer cancel()
236
237	info, err := update.Check(checkCtx, version.Version, update.Default)
238	if err != nil {
239		slog.Debug("background update check failed", "error", err)
240		return
241	}
242	if info.IsDevelopment() {
243		slog.Debug("skipping background update for development version", "version", info.Current)
244		return
245	}
246	if !info.Available() {
247		return
248	}
249
250	// Check if context was cancelled while checking.
251	if ctx.Err() != nil {
252		return
253	}
254
255	// Guard against nil release (shouldn't happen, but defensive).
256	if info.Release == nil {
257		slog.Debug("background update check returned nil release")
258		return
259	}
260
261	// Check install method.
262	method := update.DetectInstallMethod()
263	if !method.CanSelfUpdate() {
264		// Package manager install - show instructions.
265		program.Send(tuiutil.InfoMsg{
266			Type: tuiutil.InfoTypeUpdate,
267			Msg:  fmt.Sprintf("Update available: v%s → v%s. Run: %s", info.Current, info.Latest, method.UpdateInstructions()),
268			TTL:  30 * time.Second,
269		})
270		return
271	}
272
273	// Attempt self-update.
274	asset, err := update.FindAsset(info.Release.Assets)
275	if err != nil {
276		slog.Debug("failed to find update asset for platform", "error", err)
277		program.Send(tuiutil.InfoMsg{
278			Type: tuiutil.InfoTypeWarn,
279			Msg:  "Update available but failed to find asset. Run 'crush update' for details.",
280			TTL:  15 * time.Second,
281		})
282		return
283	}
284
285	// Check if context was cancelled before download.
286	if ctx.Err() != nil {
287		return
288	}
289
290	binaryPath, err := update.Download(checkCtx, asset, info.Release)
291	if err != nil {
292		// Don't show error message if context was cancelled (user exited).
293		if ctx.Err() != nil {
294			return
295		}
296		slog.Debug("background update download failed", "error", err)
297		program.Send(tuiutil.InfoMsg{
298			Type: tuiutil.InfoTypeWarn,
299			Msg:  "Update download failed. Run 'crush update' for details.",
300			TTL:  15 * time.Second,
301		})
302		return
303	}
304	defer os.Remove(binaryPath)
305
306	// Check if context was cancelled before apply.
307	if ctx.Err() != nil {
308		return
309	}
310
311	if err := update.Apply(binaryPath); err != nil {
312		slog.Debug("background update apply failed", "error", err)
313		program.Send(tuiutil.InfoMsg{
314			Type: tuiutil.InfoTypeWarn,
315			Msg:  "Update failed to install. Run 'crush update' for details.",
316			TTL:  15 * time.Second,
317		})
318		return
319	}
320
321	// Success!
322	program.Send(tuiutil.InfoMsg{
323		Type: tuiutil.InfoTypeUpdate,
324		Msg:  fmt.Sprintf("Updated to v%s! Restart Crush to use the new version.", info.Latest),
325		TTL:  30 * time.Second,
326	})
327}
328
329func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
330	if termutil.SupportsProgressBar() {
331		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
332		defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
333	}
334
335	return setupApp(cmd)
336}
337
338// setupApp handles the common setup logic for both interactive and non-interactive modes.
339// It returns the app instance, config, cleanup function, and any error.
340func setupApp(cmd *cobra.Command) (*app.App, error) {
341	debug, _ := cmd.Flags().GetBool("debug")
342	yolo, _ := cmd.Flags().GetBool("yolo")
343	dataDir, _ := cmd.Flags().GetString("data-dir")
344	ctx := cmd.Context()
345
346	cwd, err := ResolveCwd(cmd)
347	if err != nil {
348		return nil, err
349	}
350
351	cfg, err := config.Init(cwd, dataDir, debug)
352	if err != nil {
353		return nil, err
354	}
355
356	if cfg.Permissions == nil {
357		cfg.Permissions = &config.Permissions{}
358	}
359	cfg.Permissions.SkipRequests = yolo
360
361	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
362		return nil, err
363	}
364
365	// Connect to DB; this will also run migrations.
366	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
367	if err != nil {
368		return nil, err
369	}
370
371	appInstance, err := app.New(ctx, conn, cfg)
372	if err != nil {
373		slog.Error("Failed to create app instance", "error", err)
374		return nil, err
375	}
376
377	if shouldEnableMetrics() {
378		event.Init()
379	}
380
381	return appInstance, nil
382}
383
384func shouldEnableMetrics() bool {
385	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
386		return false
387	}
388	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
389		return false
390	}
391	if config.Get().Options.DisableMetrics {
392		return false
393	}
394	return true
395}
396
397func MaybePrependStdin(prompt string) (string, error) {
398	if term.IsTerminal(os.Stdin.Fd()) {
399		return prompt, nil
400	}
401	fi, err := os.Stdin.Stat()
402	if err != nil {
403		return prompt, err
404	}
405	if fi.Mode()&os.ModeNamedPipe == 0 {
406		return prompt, nil
407	}
408	bts, err := io.ReadAll(os.Stdin)
409	if err != nil {
410		return prompt, err
411	}
412	return string(bts) + "\n\n" + prompt, nil
413}
414
415func ResolveCwd(cmd *cobra.Command) (string, error) {
416	cwd, _ := cmd.Flags().GetString("cwd")
417	if cwd != "" {
418		err := os.Chdir(cwd)
419		if err != nil {
420			return "", fmt.Errorf("failed to change directory: %v", err)
421		}
422		return cwd, nil
423	}
424	cwd, err := os.Getwd()
425	if err != nil {
426		return "", fmt.Errorf("failed to get current working directory: %v", err)
427	}
428	return cwd, nil
429}
430
431func createDotCrushDir(dir string) error {
432	if err := os.MkdirAll(dir, 0o700); err != nil {
433		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
434	}
435
436	gitIgnorePath := filepath.Join(dir, ".gitignore")
437	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
438		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
439			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
440		}
441	}
442
443	return nil
444}
445
446func shouldQueryTerminalVersion(env uv.Environ) bool {
447	termType := env.Getenv("TERM")
448	termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
449	_, okSSHTTY := env.LookupEnv("SSH_TTY")
450	return (!okTermProg && !okSSHTTY) ||
451		(!strings.Contains(termProg, "Apple") && !okSSHTTY) ||
452		// Terminals that do support XTVERSION.
453		stringext.ContainsAny(termType, "alacritty", "ghostty", "kitty", "rio", "wezterm")
454}