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/ui/common"
 24	ui "github.com/charmbracelet/crush/internal/ui/model"
 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		statsCmd,
 50	)
 51}
 52
 53var rootCmd = &cobra.Command{
 54	Use:   "crush",
 55	Short: "A terminal-first AI assistant for software development",
 56	Long:  "A glamorous, terminal-first AI assistant for software development and adjacent tasks",
 57	Example: `
 58# Run in interactive mode
 59crush
 60
 61# Run non-interactively
 62crush run "Guess my 5 favorite PokΓ©mon"
 63
 64# Run a non-interactively with pipes and redirection
 65cat README.md | crush run "make this more glamorous" > GLAMOROUS_README.md
 66
 67# Run with debug logging in a specific directory
 68crush --debug --cwd /path/to/project
 69
 70# Run in yolo mode (auto-accept all permissions; use with care)
 71crush --yolo
 72
 73# Run with custom data directory
 74crush --data-dir /path/to/custom/.crush
 75  `,
 76	RunE: func(cmd *cobra.Command, args []string) error {
 77		app, err := setupAppWithProgressBar(cmd)
 78		if err != nil {
 79			return err
 80		}
 81		defer app.Shutdown()
 82
 83		event.AppInitialized()
 84
 85		// Set up the TUI.
 86		var env uv.Environ = os.Environ()
 87
 88		com := common.DefaultCommon(app)
 89		model := ui.New(com)
 90
 91		program := tea.NewProgram(
 92			model,
 93			tea.WithEnvironment(env),
 94			tea.WithContext(cmd.Context()),
 95			tea.WithFilter(ui.MouseEventFilter), // Filter mouse events based on focus state
 96		)
 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}
107
108var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
109    β–„β–„β–„β–„β–„β–„β–„β–„    β–„β–„β–„β–„β–„β–„β–„β–„
110  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
111β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
112β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
113β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
114β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
115β–€β–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–„β–ˆβ–ˆβ–ˆβ–ˆβ–„β–„β–ˆβ–ˆβ–ˆβ–ˆβ–„β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–€
116  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
117    β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
118       β–€β–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–€
119           β–€β–€β–€β–€β–€β–€
120`)
121
122// copied from cobra:
123const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
124`
125
126func Execute() {
127	// NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
128	// it forward to a bytes.Buffer, write the colored heartbit to it, and then
129	// finally prepend it in the version template.
130	// Unfortunately cobra doesn't give us a way to set a function to handle
131	// printing the version, and PreRunE runs after the version is already
132	// handled, so that doesn't work either.
133	// This is the only way I could find that works relatively well.
134	if term.IsTerminal(os.Stdout.Fd()) {
135		var b bytes.Buffer
136		w := colorprofile.NewWriter(os.Stdout, os.Environ())
137		w.Forward = &b
138		_, _ = w.WriteString(heartbit.String())
139		rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
140	}
141	if err := fang.Execute(
142		context.Background(),
143		rootCmd,
144		fang.WithVersion(version.Version),
145		fang.WithNotifySignal(os.Interrupt),
146	); err != nil {
147		os.Exit(1)
148	}
149}
150
151// supportsProgressBar tries to determine whether the current terminal supports
152// progress bars by looking into environment variables.
153func supportsProgressBar() bool {
154	if !term.IsTerminal(os.Stderr.Fd()) {
155		return false
156	}
157	termProg := os.Getenv("TERM_PROGRAM")
158	_, isWindowsTerminal := os.LookupEnv("WT_SESSION")
159
160	return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
161}
162
163func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
164	app, err := setupApp(cmd)
165	if err != nil {
166		return nil, err
167	}
168
169	// Check if progress bar is enabled in config (defaults to true if nil)
170	progressEnabled := app.Config().Options.Progress == nil || *app.Config().Options.Progress
171	if progressEnabled && supportsProgressBar() {
172		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
173		defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
174	}
175
176	return app, nil
177}
178
179// setupApp handles the common setup logic for both interactive and non-interactive modes.
180// It returns the app instance, config, cleanup function, and any error.
181func setupApp(cmd *cobra.Command) (*app.App, error) {
182	debug, _ := cmd.Flags().GetBool("debug")
183	yolo, _ := cmd.Flags().GetBool("yolo")
184	dataDir, _ := cmd.Flags().GetString("data-dir")
185	ctx := cmd.Context()
186
187	cwd, err := ResolveCwd(cmd)
188	if err != nil {
189		return nil, err
190	}
191
192	store, err := config.Init(cwd, dataDir, debug)
193	if err != nil {
194		return nil, err
195	}
196
197	cfg := store.Config()
198	if cfg.Permissions == nil {
199		cfg.Permissions = &config.Permissions{}
200	}
201	cfg.Permissions.SkipRequests = yolo
202
203	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
204		return nil, err
205	}
206
207	// Register this project in the centralized projects list.
208	if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
209		slog.Warn("Failed to register project", "error", err)
210		// Non-fatal: continue even if registration fails
211	}
212
213	// Connect to DB; this will also run migrations.
214	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
215	if err != nil {
216		return nil, err
217	}
218
219	appInstance, err := app.New(ctx, conn, store)
220	if err != nil {
221		slog.Error("Failed to create app instance", "error", err)
222		return nil, err
223	}
224
225	if shouldEnableMetrics(cfg) {
226		event.Init()
227	}
228
229	return appInstance, nil
230}
231
232func shouldEnableMetrics(cfg *config.Config) bool {
233	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
234		return false
235	}
236	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
237		return false
238	}
239	if cfg.Options.DisableMetrics {
240		return false
241	}
242	return true
243}
244
245func MaybePrependStdin(prompt string) (string, error) {
246	if term.IsTerminal(os.Stdin.Fd()) {
247		return prompt, nil
248	}
249	fi, err := os.Stdin.Stat()
250	if err != nil {
251		return prompt, err
252	}
253	// Check if stdin is a named pipe ( | ) or regular file ( < ).
254	if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
255		return prompt, nil
256	}
257	bts, err := io.ReadAll(os.Stdin)
258	if err != nil {
259		return prompt, err
260	}
261	return string(bts) + "\n\n" + prompt, nil
262}
263
264func ResolveCwd(cmd *cobra.Command) (string, error) {
265	cwd, _ := cmd.Flags().GetString("cwd")
266	if cwd != "" {
267		err := os.Chdir(cwd)
268		if err != nil {
269			return "", fmt.Errorf("failed to change directory: %v", err)
270		}
271		return cwd, nil
272	}
273	cwd, err := os.Getwd()
274	if err != nil {
275		return "", fmt.Errorf("failed to get current working directory: %v", err)
276	}
277	return cwd, nil
278}
279
280func createDotCrushDir(dir string) error {
281	if err := os.MkdirAll(dir, 0o700); err != nil {
282		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
283	}
284
285	gitIgnorePath := filepath.Join(dir, ".gitignore")
286	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
287		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
288			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
289		}
290	}
291
292	return nil
293}