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