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