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