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	"time"
 15
 16	tea "charm.land/bubbletea/v2"
 17	"charm.land/lipgloss/v2"
 18	"github.com/charmbracelet/colorprofile"
 19	"github.com/charmbracelet/crush/internal/app"
 20	"github.com/charmbracelet/crush/internal/config"
 21	"github.com/charmbracelet/crush/internal/db"
 22	"github.com/charmbracelet/crush/internal/event"
 23	"github.com/charmbracelet/crush/internal/stringext"
 24	termutil "github.com/charmbracelet/crush/internal/term"
 25	"github.com/charmbracelet/crush/internal/tui"
 26	"github.com/charmbracelet/crush/internal/update"
 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		updateCmd,
 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		ui := tui.New(app)
 94		ui.QueryVersion = shouldQueryTerminalVersion(env)
 95
 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	versionTemplate := defaultVersionTemplate
142	if term.IsTerminal(os.Stdout.Fd()) {
143		var b bytes.Buffer
144		w := colorprofile.NewWriter(os.Stdout, os.Environ())
145		w.Forward = &b
146		_, _ = w.WriteString(heartbit.String())
147		versionTemplate = b.String() + "\n" + defaultVersionTemplate
148	}
149
150	// Check if version flag is present and add update notification if available.
151	if hasVersionFlag() {
152		if updateMsg := checkForUpdateSync(); updateMsg != "" {
153			versionTemplate += updateMsg
154		}
155	}
156
157	rootCmd.SetVersionTemplate(versionTemplate)
158
159	if err := fang.Execute(
160		context.Background(),
161		rootCmd,
162		fang.WithVersion(version.Version),
163		fang.WithNotifySignal(os.Interrupt),
164	); err != nil {
165		os.Exit(1)
166	}
167}
168
169// hasVersionFlag checks if the version flag is present in os.Args.
170func hasVersionFlag() bool {
171	for _, arg := range os.Args {
172		if arg == "-v" || arg == "--version" {
173			return true
174		}
175	}
176	return false
177}
178
179// checkForUpdateSync performs a synchronous update check with a short timeout.
180// Returns a formatted update message if an update is available, empty string otherwise.
181func checkForUpdateSync() string {
182	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
183	defer cancel()
184
185	info, err := update.Check(ctx, version.Version, update.Default)
186	if err != nil || !info.Available() {
187		return ""
188	}
189
190	if info.IsDevelopment() {
191		return fmt.Sprintf("\nThis is a development version of Crush. The latest stable release is v%s.\nRun 'crush update' to learn more.\n", info.Latest)
192	}
193
194	return fmt.Sprintf("\nUpdate available: v%s → v%s\nRun 'crush update apply' to install.\n", info.Current, info.Latest)
195}
196
197func setupAppWithProgressBar(cmd *cobra.Command) (*app.App, error) {
198	if termutil.SupportsProgressBar() {
199		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
200		defer func() { _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar) }()
201	}
202
203	return setupApp(cmd)
204}
205
206// setupApp handles the common setup logic for both interactive and non-interactive modes.
207// It returns the app instance, config, cleanup function, and any error.
208func setupApp(cmd *cobra.Command) (*app.App, error) {
209	debug, _ := cmd.Flags().GetBool("debug")
210	yolo, _ := cmd.Flags().GetBool("yolo")
211	dataDir, _ := cmd.Flags().GetString("data-dir")
212	ctx := cmd.Context()
213
214	cwd, err := ResolveCwd(cmd)
215	if err != nil {
216		return nil, err
217	}
218
219	cfg, err := config.Init(cwd, dataDir, debug)
220	if err != nil {
221		return nil, err
222	}
223
224	if cfg.Permissions == nil {
225		cfg.Permissions = &config.Permissions{}
226	}
227	cfg.Permissions.SkipRequests = yolo
228
229	if err := createDotCrushDir(cfg.Options.DataDirectory); err != nil {
230		return nil, err
231	}
232
233	// Connect to DB; this will also run migrations.
234	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
235	if err != nil {
236		return nil, err
237	}
238
239	appInstance, err := app.New(ctx, conn, cfg)
240	if err != nil {
241		slog.Error("Failed to create app instance", "error", err)
242		return nil, err
243	}
244
245	if shouldEnableMetrics() {
246		event.Init()
247	}
248
249	return appInstance, nil
250}
251
252func shouldEnableMetrics() bool {
253	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
254		return false
255	}
256	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
257		return false
258	}
259	if config.Get().Options.DisableMetrics {
260		return false
261	}
262	return true
263}
264
265func MaybePrependStdin(prompt string) (string, error) {
266	if term.IsTerminal(os.Stdin.Fd()) {
267		return prompt, nil
268	}
269	fi, err := os.Stdin.Stat()
270	if err != nil {
271		return prompt, err
272	}
273	if fi.Mode()&os.ModeNamedPipe == 0 {
274		return prompt, nil
275	}
276	bts, err := io.ReadAll(os.Stdin)
277	if err != nil {
278		return prompt, err
279	}
280	return string(bts) + "\n\n" + prompt, nil
281}
282
283func ResolveCwd(cmd *cobra.Command) (string, error) {
284	cwd, _ := cmd.Flags().GetString("cwd")
285	if cwd != "" {
286		err := os.Chdir(cwd)
287		if err != nil {
288			return "", fmt.Errorf("failed to change directory: %v", err)
289		}
290		return cwd, nil
291	}
292	cwd, err := os.Getwd()
293	if err != nil {
294		return "", fmt.Errorf("failed to get current working directory: %v", err)
295	}
296	return cwd, nil
297}
298
299func createDotCrushDir(dir string) error {
300	if err := os.MkdirAll(dir, 0o700); err != nil {
301		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
302	}
303
304	gitIgnorePath := filepath.Join(dir, ".gitignore")
305	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
306		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
307			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
308		}
309	}
310
311	return nil
312}
313
314func shouldQueryTerminalVersion(env uv.Environ) bool {
315	termType := env.Getenv("TERM")
316	termProg, okTermProg := env.LookupEnv("TERM_PROGRAM")
317	_, okSSHTTY := env.LookupEnv("SSH_TTY")
318	return (!okTermProg && !okSSHTTY) ||
319		(!strings.Contains(termProg, "Apple") && !okSSHTTY) ||
320		// Terminals that do support XTVERSION.
321		stringext.ContainsAny(termType, "alacritty", "ghostty", "kitty", "rio", "wezterm")
322}