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