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 "github.com/charmbracelet/bubbletea/v2"
 16	"github.com/charmbracelet/colorprofile"
 17	"github.com/charmbracelet/crush/internal/app"
 18	"github.com/charmbracelet/crush/internal/config"
 19	"github.com/charmbracelet/crush/internal/db"
 20	"github.com/charmbracelet/crush/internal/event"
 21	"github.com/charmbracelet/crush/internal/tui"
 22	"github.com/charmbracelet/crush/internal/ui/common"
 23	ui "github.com/charmbracelet/crush/internal/ui/model"
 24	"github.com/charmbracelet/crush/internal/version"
 25	"github.com/charmbracelet/fang"
 26	"github.com/charmbracelet/lipgloss/v2"
 27	uv "github.com/charmbracelet/ultraviolet"
 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
 38	rootCmd.Flags().BoolP("help", "h", false, "Help")
 39	rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
 40
 41	rootCmd.AddCommand(
 42		runCmd,
 43		dirsCmd,
 44		updateProvidersCmd,
 45		logsCmd,
 46		schemaCmd,
 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 := setupApp(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		com := common.DefaultCommon(app.Config())
 90		ui := ui.New(com, app)
 91		ui.QueryVersion = shouldQueryTerminalVersion(env)
 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
 98		go app.Subscribe(program)
 99
100		if _, err := program.Run(); err != nil {
101			event.Error(err)
102			slog.Error("TUI run error", "error", err)
103			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
104		}
105		return nil
106	},
107	PostRun: func(cmd *cobra.Command, args []string) {
108		event.AppExited()
109	},
110}
111
112var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
113    ▄▄▄▄▄▄▄▄    ▄▄▄▄▄▄▄▄
114  ███████████  ███████████
115████████████████████████████
116████████████████████████████
117██████████▀██████▀██████████
118██████████ ██████ ██████████
119▀▀██████▄████▄▄████▄██████▀▀
120  ████████████████████████
121    ████████████████████
122       ▀▀██████████▀▀
123           ▀▀▀▀▀▀
124`)
125
126// copied from cobra:
127const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
128`
129
130func Execute() {
131	// NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
132	// it forward to a bytes.Buffer, write the colored heartbit to it, and then
133	// finally prepend it in the version template.
134	// Unfortunately cobra doesn't give us a way to set a function to handle
135	// printing the version, and PreRunE runs after the version is already
136	// handled, so that doesn't work either.
137	// This is the only way I could find that works relatively well.
138	if term.IsTerminal(os.Stdout.Fd()) {
139		var b bytes.Buffer
140		w := colorprofile.NewWriter(os.Stdout, os.Environ())
141		w.Forward = &b
142		_, _ = w.WriteString(heartbit.String())
143		rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
144	}
145	if err := fang.Execute(
146		context.Background(),
147		rootCmd,
148		fang.WithVersion(version.Version),
149		fang.WithNotifySignal(os.Interrupt),
150	); err != nil {
151		os.Exit(1)
152	}
153}
154
155// setupApp handles the common setup logic for both interactive and non-interactive modes.
156// It returns the app instance, config, cleanup function, and any error.
157func setupApp(cmd *cobra.Command) (*app.App, error) {
158	debug, _ := cmd.Flags().GetBool("debug")
159	yolo, _ := cmd.Flags().GetBool("yolo")
160	dataDir, _ := cmd.Flags().GetString("data-dir")
161	ctx := cmd.Context()
162
163	cwd, err := ResolveCwd(cmd)
164	if err != nil {
165		return nil, err
166	}
167
168	cfg, err := config.Init(cwd, dataDir, debug)
169	if err != nil {
170		return nil, err
171	}
172
173	if cfg.Permissions == nil {
174		cfg.Permissions = &config.Permissions{}
175	}
176	cfg.Permissions.SkipRequests = yolo
177
178	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
179		return nil, err
180	}
181
182	// Connect to DB; this will also run migrations.
183	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
184	if err != nil {
185		return nil, err
186	}
187
188	appInstance, err := app.New(ctx, conn, cfg)
189	if err != nil {
190		slog.Error("Failed to create app instance", "error", err)
191		return nil, err
192	}
193
194	if shouldEnableMetrics() {
195		event.Init()
196	}
197
198	return appInstance, nil
199}
200
201func shouldEnableMetrics() bool {
202	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
203		return false
204	}
205	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
206		return false
207	}
208	if config.Get().Options.DisableMetrics {
209		return false
210	}
211	return true
212}
213
214func MaybePrependStdin(prompt string) (string, error) {
215	if term.IsTerminal(os.Stdin.Fd()) {
216		return prompt, nil
217	}
218	fi, err := os.Stdin.Stat()
219	if err != nil {
220		return prompt, err
221	}
222	if fi.Mode()&os.ModeNamedPipe == 0 {
223		return prompt, nil
224	}
225	bts, err := io.ReadAll(os.Stdin)
226	if err != nil {
227		return prompt, err
228	}
229	return string(bts) + "\n\n" + prompt, nil
230}
231
232func ResolveCwd(cmd *cobra.Command) (string, error) {
233	cwd, _ := cmd.Flags().GetString("cwd")
234	if cwd != "" {
235		err := os.Chdir(cwd)
236		if err != nil {
237			return "", fmt.Errorf("failed to change directory: %v", err)
238		}
239		return cwd, nil
240	}
241	cwd, err := os.Getwd()
242	if err != nil {
243		return "", fmt.Errorf("failed to get current working directory: %v", err)
244	}
245	return cwd, nil
246}
247
248func createDotCrushDir(dir string) error {
249	if err := os.MkdirAll(dir, 0o700); err != nil {
250		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
251	}
252
253	gitIgnorePath := filepath.Join(dir, ".gitignore")
254	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
255		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
256			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
257		}
258	}
259
260	return nil
261}
262
263func shouldQueryTerminalVersion(env uv.Environ) bool {
264	termType := env.Getenv("TERM")
265	termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
266	_, okSSHTTY := env.LookupEnv("SSH_TTY")
267	return (!okTermProg && !okSSHTTY) ||
268		(!strings.Contains(termProg, "Apple") && !okSSHTTY) ||
269		// Terminals that do support XTVERSION.
270		strings.Contains(termType, "ghostty") ||
271		strings.Contains(termType, "wezterm") ||
272		strings.Contains(termType, "alacritty") ||
273		strings.Contains(termType, "kitty") ||
274		strings.Contains(termType, "rio")
275}