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	)
 50}
 51
 52var rootCmd = &cobra.Command{
 53	Use:   "crush",
 54	Short: "An AI assistant for software development",
 55	Long:  "An AI assistant for software development and similar tasks with direct access to 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	// Register this project in the centralized projects list.
203	if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
204		slog.Warn("Failed to register project", "error", err)
205		// Non-fatal: continue even if registration fails
206	}
207
208	// Connect to DB; this will also run migrations.
209	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
210	if err != nil {
211		return nil, err
212	}
213
214	appInstance, err := app.New(ctx, conn, cfg)
215	if err != nil {
216		slog.Error("Failed to create app instance", "error", err)
217		return nil, err
218	}
219
220	if shouldEnableMetrics() {
221		event.Init()
222	}
223
224	return appInstance, nil
225}
226
227func shouldEnableMetrics() bool {
228	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
229		return false
230	}
231	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
232		return false
233	}
234	if config.Get().Options.DisableMetrics {
235		return false
236	}
237	return true
238}
239
240func MaybePrependStdin(prompt string) (string, error) {
241	if term.IsTerminal(os.Stdin.Fd()) {
242		return prompt, nil
243	}
244	fi, err := os.Stdin.Stat()
245	if err != nil {
246		return prompt, err
247	}
248	// Check if stdin is a named pipe ( | ) or regular file ( < ).
249	if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
250		return prompt, nil
251	}
252	bts, err := io.ReadAll(os.Stdin)
253	if err != nil {
254		return prompt, err
255	}
256	return string(bts) + "\n\n" + prompt, nil
257}
258
259func ResolveCwd(cmd *cobra.Command) (string, error) {
260	cwd, _ := cmd.Flags().GetString("cwd")
261	if cwd != "" {
262		err := os.Chdir(cwd)
263		if err != nil {
264			return "", fmt.Errorf("failed to change directory: %v", err)
265		}
266		return cwd, nil
267	}
268	cwd, err := os.Getwd()
269	if err != nil {
270		return "", fmt.Errorf("failed to get current working directory: %v", err)
271	}
272	return cwd, nil
273}
274
275func createDotCrushDir(dir string) error {
276	if err := os.MkdirAll(dir, 0o700); err != nil {
277		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
278	}
279
280	gitIgnorePath := filepath.Join(dir, ".gitignore")
281	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
282		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
283			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
284		}
285	}
286
287	return nil
288}
289
290func shouldQueryTerminalVersion(env uv.Environ) bool {
291	termType := env.Getenv("TERM")
292	termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
293	_, okSSHTTY := env.LookupEnv("SSH_TTY")
294	return (!okTermProg && !okSSHTTY) ||
295		(!strings.Contains(termProg, "Apple") && !okSSHTTY) ||
296		// Terminals that do support XTVERSION.
297		stringext.ContainsAny(termType, "alacritty", "ghostty", "kitty", "rio", "wezterm")
298}