root.go

  1package cmd
  2
  3import (
  4	"bytes"
  5	"context"
  6	"errors"
  7	"fmt"
  8	"io"
  9	"io/fs"
 10	"log/slog"
 11	"net/url"
 12	"os"
 13	"os/exec"
 14	"path/filepath"
 15	"regexp"
 16	"strconv"
 17	"strings"
 18	"time"
 19
 20	tea "charm.land/bubbletea/v2"
 21	fang "charm.land/fang/v2"
 22	"charm.land/lipgloss/v2"
 23	"github.com/charmbracelet/colorprofile"
 24	"github.com/charmbracelet/crush/internal/app"
 25	"github.com/charmbracelet/crush/internal/client"
 26	"github.com/charmbracelet/crush/internal/config"
 27	"github.com/charmbracelet/crush/internal/db"
 28	"github.com/charmbracelet/crush/internal/event"
 29	crushlog "github.com/charmbracelet/crush/internal/log"
 30	"github.com/charmbracelet/crush/internal/projects"
 31	"github.com/charmbracelet/crush/internal/proto"
 32	"github.com/charmbracelet/crush/internal/server"
 33	"github.com/charmbracelet/crush/internal/session"
 34	"github.com/charmbracelet/crush/internal/ui/common"
 35	ui "github.com/charmbracelet/crush/internal/ui/model"
 36	"github.com/charmbracelet/crush/internal/version"
 37	"github.com/charmbracelet/crush/internal/workspace"
 38	uv "github.com/charmbracelet/ultraviolet"
 39	"github.com/charmbracelet/x/ansi"
 40	"github.com/charmbracelet/x/exp/charmtone"
 41	"github.com/charmbracelet/x/term"
 42	"github.com/spf13/cobra"
 43)
 44
 45var clientHost string
 46
 47func init() {
 48	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
 49	rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
 50	rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
 51	rootCmd.PersistentFlags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
 52	rootCmd.Flags().BoolP("help", "h", false, "Help")
 53	rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
 54	rootCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
 55	rootCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
 56	rootCmd.MarkFlagsMutuallyExclusive("session", "continue")
 57
 58	rootCmd.AddCommand(
 59		runCmd,
 60		dirsCmd,
 61		projectsCmd,
 62		updateProvidersCmd,
 63		logsCmd,
 64		schemaCmd,
 65		loginCmd,
 66		statsCmd,
 67		sessionCmd,
 68	)
 69}
 70
 71var rootCmd = &cobra.Command{
 72	Use:   "crush",
 73	Short: "A terminal-first AI assistant for software development",
 74	Long:  "A glamorous, terminal-first AI assistant for software development and adjacent tasks",
 75	Example: `
 76# Run in interactive mode
 77crush
 78
 79# Run non-interactively
 80crush run "Guess my 5 favorite PokΓ©mon"
 81
 82# Run a non-interactively with pipes and redirection
 83cat README.md | crush run "make this more glamorous" > GLAMOROUS_README.md
 84
 85# Run with debug logging in a specific directory
 86crush --debug --cwd /path/to/project
 87
 88# Run in yolo mode (auto-accept all permissions; use with care)
 89crush --yolo
 90
 91# Run with custom data directory
 92crush --data-dir /path/to/custom/.crush
 93
 94# Continue a previous session
 95crush --session {session-id}
 96
 97# Continue the most recent session
 98crush --continue
 99  `,
100	RunE: func(cmd *cobra.Command, args []string) error {
101		sessionID, _ := cmd.Flags().GetString("session")
102		continueLast, _ := cmd.Flags().GetBool("continue")
103
104		ws, cleanup, err := setupWorkspaceWithProgressBar(cmd)
105		if err != nil {
106			return err
107		}
108		defer cleanup()
109
110		if sessionID != "" {
111			sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
112			if err != nil {
113				return err
114			}
115			sessionID = sess.ID
116		}
117
118		event.AppInitialized()
119
120		com := common.DefaultCommon(ws)
121		model := ui.New(com, sessionID, continueLast)
122
123		var env uv.Environ = os.Environ()
124		program := tea.NewProgram(
125			model,
126			tea.WithEnvironment(env),
127			tea.WithContext(cmd.Context()),
128			tea.WithFilter(ui.MouseEventFilter),
129		)
130		go ws.Subscribe(program)
131
132		if _, err := program.Run(); err != nil {
133			event.Error(err)
134			slog.Error("TUI run error", "error", err)
135			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
136		}
137		return nil
138	},
139}
140
141var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
142    β–„β–„β–„β–„β–„β–„β–„β–„    β–„β–„β–„β–„β–„β–„β–„β–„
143  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
144β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
145β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
146β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
147β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
148β–€β–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–„β–ˆβ–ˆβ–ˆβ–ˆβ–„β–„β–ˆβ–ˆβ–ˆβ–ˆβ–„β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–€
149  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
150    β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
151       β–€β–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–€
152           β–€β–€β–€β–€β–€β–€
153`)
154
155// copied from cobra:
156const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
157`
158
159func Execute() {
160	// FIXME: config.Load uses slog internally during provider resolution,
161	// but the file-based logger isn't set up until after config is loaded
162	// (because the log path depends on the data directory from config).
163	// This creates a window where slog calls in config.Load leak to
164	// stderr. We discard early logs here as a workaround. The proper
165	// fix is to remove slog calls from config.Load and have it return
166	// warnings/diagnostics instead of logging them as a side effect.
167	slog.SetDefault(slog.New(slog.DiscardHandler))
168
169	// NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
170	// it forward to a bytes.Buffer, write the colored heartbit to it, and then
171	// finally prepend it in the version template.
172	// Unfortunately cobra doesn't give us a way to set a function to handle
173	// printing the version, and PreRunE runs after the version is already
174	// handled, so that doesn't work either.
175	// This is the only way I could find that works relatively well.
176	if term.IsTerminal(os.Stdout.Fd()) {
177		var b bytes.Buffer
178		w := colorprofile.NewWriter(os.Stdout, os.Environ())
179		w.Forward = &b
180		_, _ = w.WriteString(heartbit.String())
181		rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
182	}
183	if err := fang.Execute(
184		context.Background(),
185		rootCmd,
186		fang.WithVersion(version.Version),
187		fang.WithNotifySignal(os.Interrupt),
188	); err != nil {
189		os.Exit(1)
190	}
191}
192
193// supportsProgressBar tries to determine whether the current terminal supports
194// progress bars by looking into environment variables.
195func supportsProgressBar() bool {
196	if !term.IsTerminal(os.Stderr.Fd()) {
197		return false
198	}
199	termProg := os.Getenv("TERM_PROGRAM")
200	_, isWindowsTerminal := os.LookupEnv("WT_SESSION")
201
202	return isWindowsTerminal || strings.Contains(strings.ToLower(termProg), "ghostty")
203}
204
205// useClientServer returns true when the client/server architecture is
206// enabled via the CRUSH_CLIENT_SERVER environment variable.
207func useClientServer() bool {
208	v, _ := strconv.ParseBool(os.Getenv("CRUSH_CLIENT_SERVER"))
209	return v
210}
211
212// setupWorkspaceWithProgressBar wraps setupWorkspace with an optional
213// terminal progress bar shown during initialization.
214func setupWorkspaceWithProgressBar(cmd *cobra.Command) (workspace.Workspace, func(), error) {
215	showProgress := supportsProgressBar()
216	if showProgress {
217		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
218	}
219
220	ws, cleanup, err := setupWorkspace(cmd)
221
222	if showProgress {
223		_, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
224	}
225
226	return ws, cleanup, err
227}
228
229// setupWorkspace returns a Workspace and cleanup function. When
230// CRUSH_CLIENT_SERVER=1, it connects to a server process and returns a
231// ClientWorkspace. Otherwise it creates an in-process app.App and
232// returns an AppWorkspace.
233func setupWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
234	if useClientServer() {
235		return setupClientServerWorkspace(cmd)
236	}
237	return setupLocalWorkspace(cmd)
238}
239
240// setupLocalWorkspace creates an in-process app.App and wraps it in an
241// AppWorkspace.
242func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
243	debug, _ := cmd.Flags().GetBool("debug")
244	yolo, _ := cmd.Flags().GetBool("yolo")
245	dataDir, _ := cmd.Flags().GetString("data-dir")
246	ctx := cmd.Context()
247
248	cwd, err := ResolveCwd(cmd)
249	if err != nil {
250		return nil, nil, err
251	}
252
253	store, err := config.Init(cwd, dataDir, debug)
254	if err != nil {
255		return nil, nil, err
256	}
257
258	cfg := store.Config()
259	store.Overrides().SkipPermissionRequests = yolo
260
261	if err := os.MkdirAll(cfg.Options.DataDirectory, 0o700); err != nil {
262		return nil, nil, fmt.Errorf("failed to create data directory: %q %w", cfg.Options.DataDirectory, err)
263	}
264
265	gitIgnorePath := filepath.Join(cfg.Options.DataDirectory, ".gitignore")
266	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
267		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
268			return nil, nil, fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
269		}
270	}
271
272	if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
273		slog.Warn("Failed to register project", "error", err)
274	}
275
276	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
277	if err != nil {
278		return nil, nil, err
279	}
280
281	logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
282	crushlog.Setup(logFile, debug)
283
284	appInstance, err := app.New(ctx, conn, store)
285	if err != nil {
286		_ = conn.Close()
287		slog.Error("Failed to create app instance", "error", err)
288		return nil, nil, err
289	}
290
291	if shouldEnableMetrics(cfg) {
292		event.Init()
293	}
294
295	ws := workspace.NewAppWorkspace(appInstance, store)
296	cleanup := func() { appInstance.Shutdown() }
297	return ws, cleanup, nil
298}
299
300// setupClientServerWorkspace connects to a server process and wraps the
301// result in a ClientWorkspace.
302func setupClientServerWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
303	c, protoWs, cleanupServer, err := connectToServer(cmd)
304	if err != nil {
305		return nil, nil, err
306	}
307
308	clientWs := workspace.NewClientWorkspace(c, *protoWs)
309
310	if protoWs.Config.IsConfigured() {
311		if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
312			slog.Error("Failed to initialize coder agent", "error", err)
313		}
314	}
315
316	return clientWs, cleanupServer, nil
317}
318
319// connectToServer ensures the server is running, creates a client and
320// workspace, and returns a cleanup function that deletes the workspace.
321func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
322	hostURL, err := server.ParseHostURL(clientHost)
323	if err != nil {
324		return nil, nil, nil, fmt.Errorf("invalid host URL: %v", err)
325	}
326
327	if err := ensureServer(cmd, hostURL); err != nil {
328		return nil, nil, nil, err
329	}
330
331	debug, _ := cmd.Flags().GetBool("debug")
332	yolo, _ := cmd.Flags().GetBool("yolo")
333	dataDir, _ := cmd.Flags().GetString("data-dir")
334	ctx := cmd.Context()
335
336	cwd, err := ResolveCwd(cmd)
337	if err != nil {
338		return nil, nil, nil, err
339	}
340
341	c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
342	if err != nil {
343		return nil, nil, nil, err
344	}
345
346	wsReq := proto.Workspace{
347		Path:    cwd,
348		DataDir: dataDir,
349		Debug:   debug,
350		YOLO:    yolo,
351		Version: version.Version,
352		Env:     os.Environ(),
353	}
354
355	ws, err := c.CreateWorkspace(ctx, wsReq)
356	if err != nil {
357		// The server socket may exist before the HTTP handler is ready.
358		// Retry a few times with a short backoff.
359		for range 5 {
360			select {
361			case <-ctx.Done():
362				return nil, nil, nil, ctx.Err()
363			case <-time.After(200 * time.Millisecond):
364			}
365			ws, err = c.CreateWorkspace(ctx, wsReq)
366			if err == nil {
367				break
368			}
369		}
370		if err != nil {
371			return nil, nil, nil, fmt.Errorf("failed to create workspace: %v", err)
372		}
373	}
374
375	if shouldEnableMetrics(ws.Config) {
376		event.Init()
377	}
378
379	if ws.Config != nil {
380		logFile := filepath.Join(ws.Config.Options.DataDirectory, "logs", "crush.log")
381		crushlog.Setup(logFile, debug)
382	}
383
384	cleanup := func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }
385	return c, ws, cleanup, nil
386}
387
388// ensureServer auto-starts a detached server if the socket file does not
389// exist. When the socket exists, it verifies that the running server
390// version matches the client; on mismatch it shuts down the old server
391// and starts a fresh one.
392func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
393	switch hostURL.Scheme {
394	case "unix", "npipe":
395		needsStart := false
396		if _, err := os.Stat(hostURL.Host); err != nil && errors.Is(err, fs.ErrNotExist) {
397			needsStart = true
398		} else if err == nil {
399			if err := restartIfStale(cmd, hostURL); err != nil {
400				slog.Warn("Failed to check server version, restarting", "error", err)
401				needsStart = true
402			}
403		}
404
405		if needsStart {
406			if err := startDetachedServer(cmd); err != nil {
407				return err
408			}
409		}
410
411		var err error
412		for range 10 {
413			_, err = os.Stat(hostURL.Host)
414			if err == nil {
415				break
416			}
417			select {
418			case <-cmd.Context().Done():
419				return cmd.Context().Err()
420			case <-time.After(100 * time.Millisecond):
421			}
422		}
423		if err != nil {
424			return fmt.Errorf("failed to initialize crush server: %v", err)
425		}
426	}
427
428	return nil
429}
430
431// restartIfStale checks whether the running server matches the current
432// client version. When they differ, it sends a shutdown command and
433// removes the stale socket so the caller can start a fresh server.
434func restartIfStale(cmd *cobra.Command, hostURL *url.URL) error {
435	c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
436	if err != nil {
437		return err
438	}
439	vi, err := c.VersionInfo(cmd.Context())
440	if err != nil {
441		return err
442	}
443	if vi.Version == version.Version {
444		return nil
445	}
446	slog.Info("Server version mismatch, restarting",
447		"server", vi.Version,
448		"client", version.Version,
449	)
450	_ = c.ShutdownServer(cmd.Context())
451	// Give the old process a moment to release the socket.
452	for range 20 {
453		if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
454			break
455		}
456		select {
457		case <-cmd.Context().Done():
458			return cmd.Context().Err()
459		case <-time.After(100 * time.Millisecond):
460		}
461	}
462	// Force-remove if the socket is still lingering.
463	_ = os.Remove(hostURL.Host)
464	return nil
465}
466
467var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
468
469func startDetachedServer(cmd *cobra.Command) error {
470	exe, err := os.Executable()
471	if err != nil {
472		return fmt.Errorf("failed to get executable path: %v", err)
473	}
474
475	safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
476	chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
477	if err := os.MkdirAll(chDir, 0o700); err != nil {
478		return fmt.Errorf("failed to create server working directory: %v", err)
479	}
480
481	cmdArgs := []string{"server"}
482	if clientHost != server.DefaultHost() {
483		cmdArgs = append(cmdArgs, "--host", clientHost)
484	}
485
486	c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
487	stdoutPath := filepath.Join(chDir, "stdout.log")
488	stderrPath := filepath.Join(chDir, "stderr.log")
489	detachProcess(c)
490
491	stdout, err := os.Create(stdoutPath)
492	if err != nil {
493		return fmt.Errorf("failed to create stdout log file: %v", err)
494	}
495	defer stdout.Close()
496	c.Stdout = stdout
497
498	stderr, err := os.Create(stderrPath)
499	if err != nil {
500		return fmt.Errorf("failed to create stderr log file: %v", err)
501	}
502	defer stderr.Close()
503	c.Stderr = stderr
504
505	if err := c.Start(); err != nil {
506		return fmt.Errorf("failed to start crush server: %v", err)
507	}
508
509	if err := c.Process.Release(); err != nil {
510		return fmt.Errorf("failed to detach crush server process: %v", err)
511	}
512
513	return nil
514}
515
516func shouldEnableMetrics(cfg *config.Config) bool {
517	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
518		return false
519	}
520	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
521		return false
522	}
523	if cfg.Options.DisableMetrics {
524		return false
525	}
526	return true
527}
528
529func MaybePrependStdin(prompt string) (string, error) {
530	if term.IsTerminal(os.Stdin.Fd()) {
531		return prompt, nil
532	}
533	fi, err := os.Stdin.Stat()
534	if err != nil {
535		return prompt, err
536	}
537	// Check if stdin is a named pipe ( | ) or regular file ( < ).
538	if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
539		return prompt, nil
540	}
541	bts, err := io.ReadAll(os.Stdin)
542	if err != nil {
543		return prompt, err
544	}
545	return string(bts) + "\n\n" + prompt, nil
546}
547
548// resolveWorkspaceSessionID resolves a session ID that may be a full
549// UUID, full hash, or hash prefix. Works against the Workspace
550// interface so both local and client/server paths get hash prefix
551// support.
552func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
553	if sess, err := ws.GetSession(ctx, id); err == nil {
554		return sess, nil
555	}
556
557	sessions, err := ws.ListSessions(ctx)
558	if err != nil {
559		return session.Session{}, err
560	}
561
562	var matches []session.Session
563	for _, s := range sessions {
564		hash := session.HashID(s.ID)
565		if hash == id || strings.HasPrefix(hash, id) {
566			matches = append(matches, s)
567		}
568	}
569
570	switch len(matches) {
571	case 0:
572		return session.Session{}, fmt.Errorf("session not found: %s", id)
573	case 1:
574		return matches[0], nil
575	default:
576		return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
577	}
578}
579
580func ResolveCwd(cmd *cobra.Command) (string, error) {
581	cwd, _ := cmd.Flags().GetString("cwd")
582	if cwd != "" {
583		err := os.Chdir(cwd)
584		if err != nil {
585			return "", fmt.Errorf("failed to change directory: %v", err)
586		}
587		return cwd, nil
588	}
589	cwd, err := os.Getwd()
590	if err != nil {
591		return "", fmt.Errorf("failed to get current working directory: %v", err)
592	}
593	return cwd, nil
594}