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