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
 15	tea "charm.land/bubbletea/v2"
 16	"charm.land/lipgloss/v2"
 17	"github.com/charmbracelet/colorprofile"
 18	"github.com/charmbracelet/crush/internal/app"
 19	"github.com/charmbracelet/crush/internal/config"
 20	"github.com/charmbracelet/crush/internal/db"
 21	"github.com/charmbracelet/crush/internal/event"
 22	termutil "github.com/charmbracelet/crush/internal/term"
 23	"github.com/charmbracelet/crush/internal/tui"
 24	"github.com/charmbracelet/crush/internal/version"
 25	"github.com/charmbracelet/fang"
 26	uv "github.com/charmbracelet/ultraviolet"
 27	"github.com/charmbracelet/x/ansi"
 28	"github.com/charmbracelet/x/exp/charmtone"
 29	"github.com/charmbracelet/x/term"
 30	"github.com/spf13/cobra"
 31)
 32
 33func init() {
 34	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
 35	rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
 36	rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
 37	rootCmd.Flags().BoolP("help", "h", false, "Help")
 38	rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
 39
 40	rootCmd.AddCommand(
 41		runCmd,
 42		dirsCmd,
 43		updateProvidersCmd,
 44		logsCmd,
 45		schemaCmd,
 46		loginCmd,
 47	)
 48}
 49
 50var rootCmd = &cobra.Command{
 51	Use:   "crush",
 52	Short: "Terminal-based AI assistant for software development",
 53	Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
 54It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
 55to assist developers in writing, debugging, and understanding code directly from the terminal.`,
 56	Example: `
 57# Run in interactive mode
 58crush
 59
 60# Run with debug logging
 61crush -d
 62
 63# Run with debug logging in a specific directory
 64crush -d -c /path/to/project
 65
 66# Run with custom data directory
 67crush -D /path/to/custom/.crush
 68
 69# Print version
 70crush -v
 71
 72# Run a single non-interactive prompt
 73crush run "Explain the use of context in Go"
 74
 75# Run in dangerous mode (auto-accept all permissions)
 76crush -y
 77  `,
 78	RunE: func(cmd *cobra.Command, args []string) error {
 79		app, err := setupAppWithProgressBar(cmd)
 80		if err != nil {
 81			return err
 82		}
 83		defer app.Shutdown()
 84
 85		event.AppInitialized()
 86
 87		// Set up the TUI.
 88		var env uv.Environ = os.Environ()
 89		ui := tui.New(app)
 90		ui.QueryVersion = shouldQueryTerminalVersion(env)
 91
 92		program := tea.NewProgram(
 93			ui,
 94			tea.WithEnvironment(env),
 95			tea.WithContext(cmd.Context()),
 96			tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
 97		go app.Subscribe(program)
 98
 99		if _, err := program.Run(); err != nil {
100			event.Error(err)
101			slog.Error("TUI run error", "error", err)
102			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
103		}
104		return nil
105	},
106	PostRun: func(cmd *cobra.Command, args []string) {
107		event.AppExited()
108	},
109}
110
111var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
112    ▄▄▄▄▄▄▄▄    ▄▄▄▄▄▄▄▄
113  ███████████  ███████████
114████████████████████████████
115████████████████████████████
116██████████▀██████▀██████████
117██████████ ██████ ██████████
118▀▀██████▄████▄▄████▄██████▀▀
119  ████████████████████████
120    ████████████████████
121       ▀▀██████████▀▀
122           ▀▀▀▀▀▀
123`)
124
125// copied from cobra:
126const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
127`
128
129func Execute() {
130	// NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
131	// it forward to a bytes.Buffer, write the colored heartbit to it, and then
132	// finally prepend it in the version template.
133	// Unfortunately cobra doesn't give us a way to set a function to handle
134	// printing the version, and PreRunE runs after the version is already
135	// handled, so that doesn't work either.
136	// This is the only way I could find that works relatively well.
137	if term.IsTerminal(os.Stdout.Fd()) {
138		var b bytes.Buffer
139		w := colorprofile.NewWriter(os.Stdout, os.Environ())
140		w.Forward = &b
141		_, _ = w.WriteString(heartbit.String())
142		rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
143	}
144	if err := fang.Execute(
145		context.Background(),
146		rootCmd,
147		fang.WithVersion(version.Version),
148		fang.WithNotifySignal(os.Interrupt),
149	); err != nil {
150		os.Exit(1)
151	}
152}
153
154func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
155	if termutil.SupportsProgressBar() {
156		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
157		defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
158	}
159
160	return setupApp(cmd)
161}
162
163// setupApp handles the common setup logic for both interactive and non-interactive modes.
164// It returns the app instance, config, cleanup function, and any error.
165func setupApp(cmd *cobra.Command) (*app.App, error) {
166	debug, _ := cmd.Flags().GetBool("debug")
167	yolo, _ := cmd.Flags().GetBool("yolo")
168	dataDir, _ := cmd.Flags().GetString("data-dir")
169	ctx := cmd.Context()
170
171	cwd, err := ResolveCwd(cmd)
172	if err != nil {
173		return nil, err
174	}
175
176	cfg, err := config.Init(cwd, dataDir, debug)
177	if err != nil {
178		return nil, err
179	}
180
181	if cfg.Permissions == nil {
182		cfg.Permissions = &config.Permissions{}
183	}
184	cfg.Permissions.SkipRequests = yolo
185
186	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
187		return nil, err
188	}
189
190	// Connect to DB; this will also run migrations.
191	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
192	if err != nil {
193		return nil, err
194	}
195
196	appInstance, err := app.New(ctx, conn, cfg)
197	if err != nil {
198		slog.Error("Failed to create app instance", "error", err)
199		return nil, err
200	}
201
202	if shouldEnableMetrics() {
203		event.Init()
204	}
205
206	return appInstance, nil
207}
208
209func shouldEnableMetrics() bool {
210	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
211		return false
212	}
213	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
214		return false
215	}
216	if config.Get().Options.DisableMetrics {
217		return false
218	}
219	return true
220}
221
222func MaybePrependStdin(prompt string) (string, error) {
223	if term.IsTerminal(os.Stdin.Fd()) {
224		return prompt, nil
225	}
226	fi, err := os.Stdin.Stat()
227	if err != nil {
228		return prompt, err
229	}
230	if fi.Mode()&os.ModeNamedPipe == 0 {
231		return prompt, nil
232	}
233	bts, err := io.ReadAll(os.Stdin)
234	if err != nil {
235		return prompt, err
236	}
237	return string(bts) + "\n\n" + prompt, nil
238}
239
240func ResolveCwd(cmd *cobra.Command) (string, error) {
241	cwd, _ := cmd.Flags().GetString("cwd")
242	if cwd != "" {
243		err := os.Chdir(cwd)
244		if err != nil {
245			return "", fmt.Errorf("failed to change directory: %v", err)
246		}
247		return cwd, nil
248	}
249	cwd, err := os.Getwd()
250	if err != nil {
251		return "", fmt.Errorf("failed to get current working directory: %v", err)
252	}
253	return cwd, nil
254}
255
256func createDotCrushDir(dir string) error {
257	if err := os.MkdirAll(dir, 0o700); err != nil {
258		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
259	}
260
261	gitIgnorePath := filepath.Join(dir, ".gitignore")
262	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
263		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
264			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
265		}
266	}
267
268	return nil
269}
270
271func shouldQueryTerminalVersion(env uv.Environ) bool {
272	termType := env.Getenv("TERM")
273	termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
274	_, okSSHTTY := env.LookupEnv("SSH_TTY")
275	return (!okTermProg && !okSSHTTY) ||
276		(!strings.Contains(termProg, "Apple") && !okSSHTTY) ||
277		// Terminals that do support XTVERSION.
278		strings.Contains(termType, "ghostty") ||
279		strings.Contains(termType, "wezterm") ||
280		strings.Contains(termType, "alacritty") ||
281		strings.Contains(termType, "kitty") ||
282		strings.Contains(termType, "rio")
283}