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