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