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