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