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