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/tui"
 24	"github.com/charmbracelet/crush/internal/ui/common"
 25	ui "github.com/charmbracelet/crush/internal/ui/model"
 26	"github.com/charmbracelet/crush/internal/version"
 27	"github.com/charmbracelet/fang"
 28	uv "github.com/charmbracelet/ultraviolet"
 29	"github.com/charmbracelet/x/ansi"
 30	"github.com/charmbracelet/x/exp/charmtone"
 31	xstrings "github.com/charmbracelet/x/exp/strings"
 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			model = ui
102		} else {
103			ui := tui.New(app)
104			ui.QueryVersion = shouldQueryCapabilities(env)
105			model = ui
106		}
107		program := tea.NewProgram(
108			model,
109			tea.WithEnvironment(env),
110			tea.WithContext(cmd.Context()),
111			tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
112		go app.Subscribe(program)
113
114		if _, err := program.Run(); err != nil {
115			event.Error(err)
116			slog.Error("TUI run error", "error", err)
117			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
118		}
119		return nil
120	},
121	PostRun: func(cmd *cobra.Command, args []string) {
122		event.AppExited()
123	},
124}
125
126var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
127    ▄▄▄▄▄▄▄▄    ▄▄▄▄▄▄▄▄
128  ███████████  ███████████
129████████████████████████████
130████████████████████████████
131██████████▀██████▀██████████
132██████████ ██████ ██████████
133▀▀██████▄████▄▄████▄██████▀▀
134  ████████████████████████
135    ████████████████████
136       ▀▀██████████▀▀
137           ▀▀▀▀▀▀
138`)
139
140// copied from cobra:
141const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
142`
143
144func Execute() {
145	// NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
146	// it forward to a bytes.Buffer, write the colored heartbit to it, and then
147	// finally prepend it in the version template.
148	// Unfortunately cobra doesn't give us a way to set a function to handle
149	// printing the version, and PreRunE runs after the version is already
150	// handled, so that doesn't work either.
151	// This is the only way I could find that works relatively well.
152	if term.IsTerminal(os.Stdout.Fd()) {
153		var b bytes.Buffer
154		w := colorprofile.NewWriter(os.Stdout, os.Environ())
155		w.Forward = &b
156		_, _ = w.WriteString(heartbit.String())
157		rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
158	}
159	if err := fang.Execute(
160		context.Background(),
161		rootCmd,
162		fang.WithVersion(version.Version),
163		fang.WithNotifySignal(os.Interrupt),
164	); err != nil {
165		os.Exit(1)
166	}
167}
168
169// supportsProgressBar tries to determine whether the current terminal supports
170// progress bars by looking into environment variables.
171func supportsProgressBar() bool {
172	if !term.IsTerminal(os.Stderr.Fd()) {
173		return false
174	}
175	termProg := os.Getenv("TERM_PROGRAM")
176	_, isWindowsTerminal := os.LookupEnv("WT_SESSION")
177
178	return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
179}
180
181func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
182	app, err := setupApp(cmd)
183	if err != nil {
184		return nil, err
185	}
186
187	// Check if progress bar is enabled in config (defaults to true if nil)
188	progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress
189	if progressEnabled && supportsProgressBar() {
190		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
191		defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
192	}
193
194	return app, nil
195}
196
197// setupApp handles the common setup logic for both interactive and non-interactive modes.
198// It returns the app instance, config, cleanup function, and any error.
199func setupApp(cmd *cobra.Command) (*app.App, error) {
200	debug, _ := cmd.Flags().GetBool("debug")
201	yolo, _ := cmd.Flags().GetBool("yolo")
202	dataDir, _ := cmd.Flags().GetString("data-dir")
203	ctx := cmd.Context()
204
205	cwd, err := ResolveCwd(cmd)
206	if err != nil {
207		return nil, err
208	}
209
210	cfg, err := config.Init(cwd, dataDir, debug)
211	if err != nil {
212		return nil, err
213	}
214
215	if cfg.Permissions == nil {
216		cfg.Permissions = &config.Permissions{}
217	}
218	cfg.Permissions.SkipRequests = yolo
219
220	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
221		return nil, err
222	}
223
224	// Register this project in the centralized projects list.
225	if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
226		slog.Warn("Failed to register project", "error", err)
227		// Non-fatal: continue even if registration fails
228	}
229
230	// Connect to DB; this will also run migrations.
231	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
232	if err != nil {
233		return nil, err
234	}
235
236	appInstance, err := app.New(ctx, conn, cfg)
237	if err != nil {
238		slog.Error("Failed to create app instance", "error", err)
239		return nil, err
240	}
241
242	if shouldEnableMetrics() {
243		event.Init()
244	}
245
246	return appInstance, nil
247}
248
249func shouldEnableMetrics() bool {
250	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
251		return false
252	}
253	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
254		return false
255	}
256	if config.Get().Options.DisableMetrics {
257		return false
258	}
259	return true
260}
261
262func MaybePrependStdin(prompt string) (string, error) {
263	if term.IsTerminal(os.Stdin.Fd()) {
264		return prompt, nil
265	}
266	fi, err := os.Stdin.Stat()
267	if err != nil {
268		return prompt, err
269	}
270	// Check if stdin is a named pipe ( | ) or regular file ( < ).
271	if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
272		return prompt, nil
273	}
274	bts, err := io.ReadAll(os.Stdin)
275	if err != nil {
276		return prompt, err
277	}
278	return string(bts) + "\n\n" + prompt, nil
279}
280
281func ResolveCwd(cmd *cobra.Command) (string, error) {
282	cwd, _ := cmd.Flags().GetString("cwd")
283	if cwd != "" {
284		err := os.Chdir(cwd)
285		if err != nil {
286			return "", fmt.Errorf("failed to change directory: %v", err)
287		}
288		return cwd, nil
289	}
290	cwd, err := os.Getwd()
291	if err != nil {
292		return "", fmt.Errorf("failed to get current working directory: %v", err)
293	}
294	return cwd, nil
295}
296
297func createDotCrushDir(dir string) error {
298	if err := os.MkdirAll(dir, 0o700); err != nil {
299		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
300	}
301
302	gitIgnorePath := filepath.Join(dir, ".gitignore")
303	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
304		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
305			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
306		}
307	}
308
309	return nil
310}
311
312// TODO: Remove me after dropping the old TUI.
313func shouldQueryCapabilities(env uv.Environ) bool {
314	const osVendorTypeApple = "Apple"
315	termType := env.Getenv("TERM")
316	termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
317	_, okSSHTTY := env.LookupEnv("SSH_TTY")
318	if okTermProg && strings.Contains(termProg, osVendorTypeApple) {
319		return false
320	}
321	return (!okTermProg && !okSSHTTY) ||
322		(!strings.Contains(termProg, osVendorTypeApple) && !okSSHTTY) ||
323		// Terminals that do support XTVERSION.
324		xstrings.ContainsAnyOf(termType, kittyTerminals...)
325}