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	"github.com/charmbracelet/crush/internal/stringext"
 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
154// supportsProgressBar tries to determine whether the current terminal supports
155// progress bars by looking into environment variables.
156func supportsProgressBar() bool {
157	if !term.IsTerminal(os.Stderr.Fd()) {
158		return false
159	}
160	termProg := os.Getenv("TERM_PROGRAM")
161	_, isWindowsTerminal := os.LookupEnv("WT_SESSION")
162
163	return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
164}
165
166func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
167	if supportsProgressBar() {
168		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
169		defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
170	}
171
172	return setupApp(cmd)
173}
174
175// setupApp handles the common setup logic for both interactive and non-interactive modes.
176// It returns the app instance, config, cleanup function, and any error.
177func setupApp(cmd *cobra.Command) (*app.App, error) {
178	debug, _ := cmd.Flags().GetBool("debug")
179	yolo, _ := cmd.Flags().GetBool("yolo")
180	dataDir, _ := cmd.Flags().GetString("data-dir")
181	ctx := cmd.Context()
182
183	cwd, err := ResolveCwd(cmd)
184	if err != nil {
185		return nil, err
186	}
187
188	cfg, err := config.Init(cwd, dataDir, debug)
189	if err != nil {
190		return nil, err
191	}
192
193	if cfg.Permissions == nil {
194		cfg.Permissions = &config.Permissions{}
195	}
196	cfg.Permissions.SkipRequests = yolo
197
198	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
199		return nil, err
200	}
201
202	// Connect to DB; this will also run migrations.
203	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
204	if err != nil {
205		return nil, err
206	}
207
208	appInstance, err := app.New(ctx, conn, cfg)
209	if err != nil {
210		slog.Error("Failed to create app instance", "error", err)
211		return nil, err
212	}
213
214	if shouldEnableMetrics() {
215		event.Init()
216	}
217
218	return appInstance, nil
219}
220
221func shouldEnableMetrics() bool {
222	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
223		return false
224	}
225	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
226		return false
227	}
228	if config.Get().Options.DisableMetrics {
229		return false
230	}
231	return true
232}
233
234func MaybePrependStdin(prompt string) (string, error) {
235	if term.IsTerminal(os.Stdin.Fd()) {
236		return prompt, nil
237	}
238	fi, err := os.Stdin.Stat()
239	if err != nil {
240		return prompt, err
241	}
242	// Check if stdin is a named pipe ( | ) or regular file ( < ).
243	if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
244		return prompt, nil
245	}
246	bts, err := io.ReadAll(os.Stdin)
247	if err != nil {
248		return prompt, err
249	}
250	return string(bts) + "\n\n" + prompt, nil
251}
252
253func ResolveCwd(cmd *cobra.Command) (string, error) {
254	cwd, _ := cmd.Flags().GetString("cwd")
255	if cwd != "" {
256		err := os.Chdir(cwd)
257		if err != nil {
258			return "", fmt.Errorf("failed to change directory: %v", err)
259		}
260		return cwd, nil
261	}
262	cwd, err := os.Getwd()
263	if err != nil {
264		return "", fmt.Errorf("failed to get current working directory: %v", err)
265	}
266	return cwd, nil
267}
268
269func createDotCrushDir(dir string) error {
270	if err := os.MkdirAll(dir, 0o700); err != nil {
271		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
272	}
273
274	gitIgnorePath := filepath.Join(dir, ".gitignore")
275	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
276		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
277			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
278		}
279	}
280
281	return nil
282}
283
284func shouldQueryTerminalVersion(env uv.Environ) bool {
285	termType := env.Getenv("TERM")
286	termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
287	_, okSSHTTY := env.LookupEnv("SSH_TTY")
288	return (!okTermProg && !okSSHTTY) ||
289		(!strings.Contains(termProg, "Apple") && !okSSHTTY) ||
290		// Terminals that do support XTVERSION.
291		stringext.ContainsAny(termType, "alacritty", "ghostty", "kitty", "rio", "wezterm")
292}