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