root.go

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