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