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