root.go

  1package cmd
  2
  3import (
  4	"bytes"
  5	"context"
  6	"fmt"
  7	"io"
  8	"log/slog"
  9	"os"
 10	"path/filepath"
 11	"strconv"
 12
 13	tea "github.com/charmbracelet/bubbletea/v2"
 14	"github.com/charmbracelet/colorprofile"
 15	"github.com/charmbracelet/crush/internal/app"
 16	"github.com/charmbracelet/crush/internal/config"
 17	"github.com/charmbracelet/crush/internal/db"
 18	"github.com/charmbracelet/crush/internal/event"
 19	"github.com/charmbracelet/crush/internal/tui"
 20	"github.com/charmbracelet/crush/internal/version"
 21	"github.com/charmbracelet/fang"
 22	"github.com/charmbracelet/lipgloss/v2"
 23	"github.com/charmbracelet/x/exp/charmtone"
 24	"github.com/charmbracelet/x/term"
 25	"github.com/spf13/cobra"
 26)
 27
 28func init() {
 29	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
 30	rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
 31	rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
 32
 33	rootCmd.Flags().BoolP("help", "h", false, "Help")
 34	rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
 35
 36	rootCmd.AddCommand(runCmd)
 37	rootCmd.AddCommand(updateProvidersCmd)
 38}
 39
 40var rootCmd = &cobra.Command{
 41	Use:   "crush",
 42	Short: "Terminal-based AI assistant for software development",
 43	Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
 44It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
 45to assist developers in writing, debugging, and understanding code directly from the terminal.`,
 46	Example: `
 47# Run in interactive mode
 48crush
 49
 50# Run with debug logging
 51crush -d
 52
 53# Run with debug logging in a specific directory
 54crush -d -c /path/to/project
 55
 56# Run with custom data directory
 57crush -D /path/to/custom/.crush
 58
 59# Print version
 60crush -v
 61
 62# Run a single non-interactive prompt
 63crush run "Explain the use of context in Go"
 64
 65# Run in dangerous mode (auto-accept all permissions)
 66crush -y
 67  `,
 68	RunE: func(cmd *cobra.Command, args []string) error {
 69		app, err := setupApp(cmd)
 70		if err != nil {
 71			return err
 72		}
 73		defer app.Shutdown()
 74
 75		event.AppInitialized()
 76
 77		// Set up the TUI.
 78		program := tea.NewProgram(
 79			tui.New(app),
 80			tea.WithAltScreen(),
 81			tea.WithContext(cmd.Context()),
 82			tea.WithMouseCellMotion(),            // Use cell motion instead of all motion to reduce event flooding
 83			tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
 84		)
 85
 86		go app.Subscribe(program)
 87
 88		if _, err := program.Run(); err != nil {
 89			event.Error(err)
 90			slog.Error("TUI run error", "error", err)
 91			return fmt.Errorf("TUI error: %v", err)
 92		}
 93		return nil
 94	},
 95	PostRun: func(cmd *cobra.Command, args []string) {
 96		event.AppExited()
 97	},
 98}
 99
100var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
101    ▄▄▄▄▄▄▄▄    ▄▄▄▄▄▄▄▄
102  ███████████  ███████████
103████████████████████████████
104████████████████████████████
105██████████▀██████▀██████████
106██████████ ██████ ██████████
107▀▀██████▄████▄▄████▄██████▀▀
108  ████████████████████████
109    ████████████████████
110       ▀▀██████████▀▀
111           ▀▀▀▀▀▀
112`)
113
114// copied from cobra:
115const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
116`
117
118func Execute() {
119	// NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
120	// it forward to a bytes.Buffer, write the colored heartbit to it, and then
121	// finally prepend it in the version template.
122	// Unfortunately cobra doesn't give us a way to set a function to handle
123	// printing the version, and PreRunE runs after the version is already
124	// handled, so that doesn't work either.
125	// This is the only way I could find that works relatively well.
126	if term.IsTerminal(os.Stdout.Fd()) {
127		var b bytes.Buffer
128		w := colorprofile.NewWriter(os.Stdout, os.Environ())
129		w.Forward = &b
130		_, _ = w.WriteString(heartbit.String())
131		rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
132	}
133	if err := fang.Execute(
134		context.Background(),
135		rootCmd,
136		fang.WithVersion(version.Version),
137		fang.WithNotifySignal(os.Interrupt),
138	); err != nil {
139		os.Exit(1)
140	}
141}
142
143// setupApp handles the common setup logic for both interactive and non-interactive modes.
144// It returns the app instance, config, cleanup function, and any error.
145func setupApp(cmd *cobra.Command) (*app.App, error) {
146	debug, _ := cmd.Flags().GetBool("debug")
147	yolo, _ := cmd.Flags().GetBool("yolo")
148	dataDir, _ := cmd.Flags().GetString("data-dir")
149	ctx := cmd.Context()
150
151	cwd, err := ResolveCwd(cmd)
152	if err != nil {
153		return nil, err
154	}
155
156	cfg, err := config.Init(cwd, dataDir, debug)
157	if err != nil {
158		return nil, err
159	}
160
161	if cfg.Permissions == nil {
162		cfg.Permissions = &config.Permissions{}
163	}
164	cfg.Permissions.SkipRequests = yolo
165
166	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
167		return nil, err
168	}
169
170	// Connect to DB; this will also run migrations.
171	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
172	if err != nil {
173		return nil, err
174	}
175
176	appInstance, err := app.New(ctx, conn, cfg)
177	if err != nil {
178		slog.Error("Failed to create app instance", "error", err)
179		return nil, err
180	}
181
182	if shouldEnableMetrics() {
183		event.Init()
184	}
185
186	return appInstance, nil
187}
188
189func shouldEnableMetrics() bool {
190	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
191		return false
192	}
193	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
194		return false
195	}
196	if config.Get().Options.DisableMetrics {
197		return false
198	}
199	return true
200}
201
202func MaybePrependStdin(prompt string) (string, error) {
203	if term.IsTerminal(os.Stdin.Fd()) {
204		return prompt, nil
205	}
206	fi, err := os.Stdin.Stat()
207	if err != nil {
208		return prompt, err
209	}
210	if fi.Mode()&os.ModeNamedPipe == 0 {
211		return prompt, nil
212	}
213	bts, err := io.ReadAll(os.Stdin)
214	if err != nil {
215		return prompt, err
216	}
217	return string(bts) + "\n\n" + prompt, nil
218}
219
220func ResolveCwd(cmd *cobra.Command) (string, error) {
221	cwd, _ := cmd.Flags().GetString("cwd")
222	if cwd != "" {
223		err := os.Chdir(cwd)
224		if err != nil {
225			return "", fmt.Errorf("failed to change directory: %v", err)
226		}
227		return cwd, nil
228	}
229	cwd, err := os.Getwd()
230	if err != nil {
231		return "", fmt.Errorf("failed to get current working directory: %v", err)
232	}
233	return cwd, nil
234}
235
236func createDotCrushDir(dir string) error {
237	if err := os.MkdirAll(dir, 0o700); err != nil {
238		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
239	}
240
241	gitIgnorePath := filepath.Join(dir, ".gitignore")
242	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
243		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
244			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
245		}
246	}
247
248	return nil
249}