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	"git.secluded.site/crush/internal/app"
 19	"git.secluded.site/crush/internal/config"
 20	"git.secluded.site/crush/internal/db"
 21	"git.secluded.site/crush/internal/event"
 22	"git.secluded.site/crush/internal/stringext"
 23	termutil "git.secluded.site/crush/internal/term"
 24	"git.secluded.site/crush/internal/tui"
 25	"git.secluded.site/crush/internal/tui/styles"
 26	"git.secluded.site/crush/internal/version"
 27	"github.com/charmbracelet/fang"
 28	uv "github.com/charmbracelet/ultraviolet"
 29	"github.com/charmbracelet/x/ansi"
 30	"github.com/charmbracelet/x/exp/charmtone"
 31	"github.com/charmbracelet/x/term"
 32	"github.com/spf13/cobra"
 33)
 34
 35func init() {
 36	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
 37	rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
 38	rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
 39	rootCmd.PersistentFlags().BoolP("light", "l", false, "Use light theme")
 40	rootCmd.Flags().BoolP("help", "h", false, "Help")
 41	rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
 42
 43	rootCmd.AddCommand(
 44		runCmd,
 45		dirsCmd,
 46		updateProvidersCmd,
 47		logsCmd,
 48		schemaCmd,
 49		loginCmd,
 50	)
 51}
 52
 53var rootCmd = &cobra.Command{
 54	Use:   "crush",
 55	Short: "Terminal-based AI assistant for software development",
 56	Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
 57It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
 58to assist developers in writing, debugging, and understanding code directly from the terminal.`,
 59	Example: `
 60# Run in interactive mode
 61crush
 62
 63# Run with debug logging
 64crush -d
 65
 66# Run with debug logging in a specific directory
 67crush -d -c /path/to/project
 68
 69# Run with custom data directory
 70crush -D /path/to/custom/.crush
 71
 72# Print version
 73crush -v
 74
 75# Run a single non-interactive prompt
 76crush run "Explain the use of context in Go"
 77
 78# Run in dangerous mode (auto-accept all permissions)
 79crush -y
 80  `,
 81	RunE: func(cmd *cobra.Command, args []string) error {
 82		app, err := setupAppWithProgressBar(cmd)
 83		if err != nil {
 84			return err
 85		}
 86		defer app.Shutdown()
 87
 88		event.AppInitialized()
 89
 90		// Set up the TUI.
 91		var env uv.Environ = os.Environ()
 92		ui := tui.New(app)
 93		ui.QueryVersion = shouldQueryTerminalVersion(env)
 94
 95		program := tea.NewProgram(
 96			ui,
 97			tea.WithEnvironment(env),
 98			tea.WithContext(cmd.Context()),
 99			tea.WithFilter(tui.MouseEventFilter)) // Filter mouse events based on focus state
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://git.secluded.site/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
157func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
158	if termutil.SupportsProgressBar() {
159		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
160		defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
161	}
162
163	return setupApp(cmd)
164}
165
166// setupApp handles the common setup logic for both interactive and non-interactive modes.
167// It returns the app instance, config, cleanup function, and any error.
168func setupApp(cmd *cobra.Command) (*app.App, error) {
169	debug, _ := cmd.Flags().GetBool("debug")
170	yolo, _ := cmd.Flags().GetBool("yolo")
171	light, _ := cmd.Flags().GetBool("light")
172	dataDir, _ := cmd.Flags().GetString("data-dir")
173	ctx := cmd.Context()
174
175	// Set light theme if requested.
176	if light {
177		if err := styles.DefaultManager().SetTheme("light"); err != nil {
178			slog.Warn("Failed to set light theme", "error", err)
179		}
180	}
181
182	cwd, err := ResolveCwd(cmd)
183	if err != nil {
184		return nil, err
185	}
186
187	cfg, err := config.Init(cwd, dataDir, debug)
188	if err != nil {
189		return nil, err
190	}
191
192	if cfg.Permissions == nil {
193		cfg.Permissions = &config.Permissions{}
194	}
195	cfg.Permissions.SkipRequests = yolo
196
197	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
198		return nil, err
199	}
200
201	// Connect to DB; this will also run migrations.
202	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
203	if err != nil {
204		return nil, err
205	}
206
207	appInstance, err := app.New(ctx, conn, cfg)
208	if err != nil {
209		slog.Error("Failed to create app instance", "error", err)
210		return nil, err
211	}
212
213	if shouldEnableMetrics() {
214		event.Init()
215	}
216
217	return appInstance, nil
218}
219
220func shouldEnableMetrics() bool {
221	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
222		return false
223	}
224	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
225		return false
226	}
227	if config.Get().Options.DisableMetrics {
228		return false
229	}
230	return false
231}
232
233func MaybePrependStdin(prompt string) (string, error) {
234	if term.IsTerminal(os.Stdin.Fd()) {
235		return prompt, nil
236	}
237	fi, err := os.Stdin.Stat()
238	if err != nil {
239		return prompt, err
240	}
241	if fi.Mode()&os.ModeNamedPipe == 0 {
242		return prompt, nil
243	}
244	bts, err := io.ReadAll(os.Stdin)
245	if err != nil {
246		return prompt, err
247	}
248	return string(bts) + "\n\n" + prompt, nil
249}
250
251func ResolveCwd(cmd *cobra.Command) (string, error) {
252	cwd, _ := cmd.Flags().GetString("cwd")
253	if cwd != "" {
254		err := os.Chdir(cwd)
255		if err != nil {
256			return "", fmt.Errorf("failed to change directory: %v", err)
257		}
258		return cwd, nil
259	}
260	cwd, err := os.Getwd()
261	if err != nil {
262		return "", fmt.Errorf("failed to get current working directory: %v", err)
263	}
264	return cwd, nil
265}
266
267func createDotCrushDir(dir string) error {
268	if err := os.MkdirAll(dir, 0o700); err != nil {
269		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
270	}
271
272	gitIgnorePath := filepath.Join(dir, ".gitignore")
273	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
274		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
275			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
276		}
277	}
278
279	return nil
280}
281
282func shouldQueryTerminalVersion(env uv.Environ) bool {
283	termType := env.Getenv("TERM")
284	termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
285	_, okSSHTTY := env.LookupEnv("SSH_TTY")
286	return (!okTermProg && !okSSHTTY) ||
287		(!strings.Contains(termProg, "Apple") && !okSSHTTY) ||
288		// Terminals that do support XTVERSION.
289		stringext.ContainsAny(termType, "alacritty", "ghostty", "kitty", "rio", "wezterm")
290}