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/projects"
 23	"github.com/charmbracelet/crush/internal/stringext"
 24	"github.com/charmbracelet/crush/internal/tui"
 25	"github.com/charmbracelet/crush/internal/version"
 26	"github.com/charmbracelet/fang"
 27	uv "github.com/charmbracelet/ultraviolet"
 28	"github.com/charmbracelet/x/ansi"
 29	"github.com/charmbracelet/x/exp/charmtone"
 30	"github.com/charmbracelet/x/term"
 31	"github.com/spf13/cobra"
 32)
 33
 34func init() {
 35	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
 36	rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
 37	rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
 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		projectsCmd,
 45		updateProvidersCmd,
 46		logsCmd,
 47		schemaCmd,
 48		loginCmd,
 49		trajectoryCmd,
 50	)
 51}
 52
 53var rootCmd = &cobra.Command{
 54	Use:   "crush",
 55	Short: "An AI assistant for software development",
 56	Long:  "An AI assistant for software development and similar tasks with direct access to the terminal",
 57	Example: `
 58# Run in interactive mode
 59crush
 60
 61# Run with debug logging
 62crush -d
 63
 64# Run with debug logging in a specific directory
 65crush -d -c /path/to/project
 66
 67# Run with custom data directory
 68crush -D /path/to/custom/.crush
 69
 70# Print version
 71crush -v
 72
 73# Run a single non-interactive prompt
 74crush run "Explain the use of context in Go"
 75
 76# Run in dangerous mode (auto-accept all permissions)
 77crush -y
 78  `,
 79	RunE: func(cmd *cobra.Command, args []string) error {
 80		app, err := setupAppWithProgressBar(cmd)
 81		if err != nil {
 82			return err
 83		}
 84		defer app.Shutdown()
 85
 86		event.AppInitialized()
 87
 88		// Set up the TUI.
 89		var env uv.Environ = os.Environ()
 90		ui := tui.New(app)
 91		ui.QueryVersion = shouldQueryTerminalVersion(env)
 92
 93		program := tea.NewProgram(
 94			ui,
 95			tea.WithEnvironment(env),
 96			tea.WithContext(cmd.Context()),
 97			tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
 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// supportsProgressBar tries to determine whether the current terminal supports
156// progress bars by looking into environment variables.
157func supportsProgressBar() bool {
158	if !term.IsTerminal(os.Stderr.Fd()) {
159		return false
160	}
161	termProg := os.Getenv("TERM_PROGRAM")
162	_, isWindowsTerminal := os.LookupEnv("WT_SESSION")
163
164	return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
165}
166
167func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
168	if supportsProgressBar() {
169		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
170		defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
171	}
172
173	return setupApp(cmd)
174}
175
176// setupApp handles the common setup logic for both interactive and non-interactive modes.
177// It returns the app instance, config, cleanup function, and any error.
178func setupApp(cmd *cobra.Command) (*app.App, error) {
179	debug, _ := cmd.Flags().GetBool("debug")
180	yolo, _ := cmd.Flags().GetBool("yolo")
181	dataDir, _ := cmd.Flags().GetString("data-dir")
182	ctx := cmd.Context()
183
184	cwd, err := ResolveCwd(cmd)
185	if err != nil {
186		return nil, err
187	}
188
189	cfg, err := config.Init(cwd, dataDir, debug)
190	if err != nil {
191		return nil, err
192	}
193
194	if cfg.Permissions == nil {
195		cfg.Permissions = &config.Permissions{}
196	}
197	cfg.Permissions.SkipRequests = yolo
198
199	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
200		return nil, err
201	}
202
203	// Register this project in the centralized projects list.
204	if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
205		slog.Warn("Failed to register project", "error", err)
206		// Non-fatal: continue even if registration fails
207	}
208
209	// Connect to DB; this will also run migrations.
210	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
211	if err != nil {
212		return nil, err
213	}
214
215	appInstance, err := app.New(ctx, conn, cfg)
216	if err != nil {
217		slog.Error("Failed to create app instance", "error", err)
218		return nil, err
219	}
220
221	if shouldEnableMetrics() {
222		event.Init()
223	}
224
225	return appInstance, nil
226}
227
228func shouldEnableMetrics() bool {
229	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
230		return false
231	}
232	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
233		return false
234	}
235	if config.Get().Options.DisableMetrics {
236		return false
237	}
238	return true
239}
240
241func MaybePrependStdin(prompt string) (string, error) {
242	if term.IsTerminal(os.Stdin.Fd()) {
243		return prompt, nil
244	}
245	fi, err := os.Stdin.Stat()
246	if err != nil {
247		return prompt, err
248	}
249	// Check if stdin is a named pipe ( | ) or regular file ( < ).
250	if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
251		return prompt, nil
252	}
253	bts, err := io.ReadAll(os.Stdin)
254	if err != nil {
255		return prompt, err
256	}
257	return string(bts) + "\n\n" + prompt, nil
258}
259
260func ResolveCwd(cmd *cobra.Command) (string, error) {
261	cwd, _ := cmd.Flags().GetString("cwd")
262	if cwd != "" {
263		err := os.Chdir(cwd)
264		if err != nil {
265			return "", fmt.Errorf("failed to change directory: %v", err)
266		}
267		return cwd, nil
268	}
269	cwd, err := os.Getwd()
270	if err != nil {
271		return "", fmt.Errorf("failed to get current working directory: %v", err)
272	}
273	return cwd, nil
274}
275
276func createDotCrushDir(dir string) error {
277	if err := os.MkdirAll(dir, 0o700); err != nil {
278		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
279	}
280
281	gitIgnorePath := filepath.Join(dir, ".gitignore")
282	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
283		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
284			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
285		}
286	}
287
288	return nil
289}
290
291func shouldQueryTerminalVersion(env uv.Environ) bool {
292	termType := env.Getenv("TERM")
293	termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
294	_, okSSHTTY := env.LookupEnv("SSH_TTY")
295	return (!okTermProg && !okSSHTTY) ||
296		(!strings.Contains(termProg, "Apple") && !okSSHTTY) ||
297		// Terminals that do support XTVERSION.
298		stringext.ContainsAny(termType, "alacritty", "ghostty", "kitty", "rio", "wezterm")
299}