root.go

  1package cmd
  2
  3import (
  4	"bytes"
  5	"context"
  6	_ "embed"
  7	"errors"
  8	"fmt"
  9	"io"
 10	"io/fs"
 11	"log/slog"
 12	"net"
 13	"net/http"
 14	"net/url"
 15	"os"
 16	"os/exec"
 17	"path/filepath"
 18	"regexp"
 19	"strconv"
 20	"strings"
 21	"time"
 22
 23	tea "charm.land/bubbletea/v2"
 24	fang "charm.land/fang/v2"
 25	"charm.land/lipgloss/v2"
 26	"github.com/charmbracelet/colorprofile"
 27	"github.com/charmbracelet/crush/internal/app"
 28	"github.com/charmbracelet/crush/internal/client"
 29	"github.com/charmbracelet/crush/internal/config"
 30	"github.com/charmbracelet/crush/internal/db"
 31	"github.com/charmbracelet/crush/internal/event"
 32	crushlog "github.com/charmbracelet/crush/internal/log"
 33	"github.com/charmbracelet/crush/internal/projects"
 34	"github.com/charmbracelet/crush/internal/proto"
 35	"github.com/charmbracelet/crush/internal/server"
 36	"github.com/charmbracelet/crush/internal/session"
 37	"github.com/charmbracelet/crush/internal/ui/common"
 38	ui "github.com/charmbracelet/crush/internal/ui/model"
 39	"github.com/charmbracelet/crush/internal/version"
 40	"github.com/charmbracelet/crush/internal/workspace"
 41	uv "github.com/charmbracelet/ultraviolet"
 42	"github.com/charmbracelet/x/ansi"
 43	"github.com/charmbracelet/x/exp/charmtone"
 44	xstrings "github.com/charmbracelet/x/exp/strings"
 45	"github.com/charmbracelet/x/term"
 46	"github.com/spf13/cobra"
 47)
 48
 49var clientHost string
 50
 51func init() {
 52	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
 53	rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
 54	rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
 55	rootCmd.PersistentFlags().StringVarP(&clientHost, "host", "H", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
 56	rootCmd.Flags().BoolP("help", "h", false, "Help")
 57	rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
 58	rootCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
 59	rootCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
 60	rootCmd.MarkFlagsMutuallyExclusive("session", "continue")
 61
 62	rootCmd.AddCommand(
 63		runCmd,
 64		dirsCmd,
 65		projectsCmd,
 66		updateProvidersCmd,
 67		logsCmd,
 68		logoutCmd,
 69		schemaCmd,
 70		loginCmd,
 71		statsCmd,
 72		sessionCmd,
 73	)
 74}
 75
 76var rootCmd = &cobra.Command{
 77	Use:   "crush",
 78	Short: "A terminal-first AI assistant for software development",
 79	Long:  "A glamorous, terminal-first AI assistant for software development and adjacent tasks",
 80	Example: `
 81# Run in interactive mode
 82crush
 83
 84# Run non-interactively
 85crush run "Guess my 5 favorite PokΓ©mon"
 86
 87# Run a non-interactively with pipes and redirection
 88cat README.md | crush run "make this more glamorous" > GLAMOROUS_README.md
 89
 90# Run with debug logging in a specific directory
 91crush --debug --cwd /path/to/project
 92
 93# Run in yolo mode (auto-accept all permissions; use with care)
 94crush --yolo
 95
 96# Run with custom data directory
 97crush --data-dir /path/to/custom/.crush
 98
 99# Continue a previous session
100crush --session {session-id}
101
102# Continue the most recent session
103crush --continue
104  `,
105	RunE: func(cmd *cobra.Command, args []string) error {
106		sessionID, _ := cmd.Flags().GetString("session")
107		continueLast, _ := cmd.Flags().GetBool("continue")
108
109		ws, cleanup, err := setupWorkspaceWithProgressBar(cmd)
110		if err != nil {
111			return err
112		}
113		defer cleanup()
114
115		if sessionID != "" {
116			sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
117			if err != nil {
118				return err
119			}
120			sessionID = sess.ID
121		}
122
123		event.AppInitialized()
124
125		com := common.DefaultCommon(ws)
126		model := ui.New(com, sessionID, continueLast)
127
128		var env uv.Environ = os.Environ()
129		program := tea.NewProgram(
130			model,
131			tea.WithEnvironment(env),
132			tea.WithContext(cmd.Context()),
133			tea.WithFilter(ui.MouseEventFilter),
134		)
135		go ws.Subscribe(program)
136
137		if _, err := program.Run(); err != nil {
138			event.Error(err)
139			slog.Error("TUI run error", "error", err)
140			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
141		}
142		return nil
143	},
144}
145
146var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
147    β–„β–„β–„β–„β–„β–„β–„β–„    β–„β–„β–„β–„β–„β–„β–„β–„
148  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
149β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
150β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
151β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
152β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
153β–€β–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–„β–ˆβ–ˆβ–ˆβ–ˆβ–„β–„β–ˆβ–ˆβ–ˆβ–ˆβ–„β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–€
154  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
155    β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
156       β–€β–€β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–€β–€
157           β–€β–€β–€β–€β–€β–€
158`)
159
160// copied from cobra:
161const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
162`
163
164func Execute() {
165	// FIXME: config.Load uses slog internally during provider resolution,
166	// but the file-based logger isn't set up until after config is loaded
167	// (because the log path depends on the data directory from config).
168	// This creates a window where slog calls in config.Load leak to
169	// stderr. We discard early logs here as a workaround. The proper
170	// fix is to remove slog calls from config.Load and have it return
171	// warnings/diagnostics instead of logging them as a side effect.
172	slog.SetDefault(slog.New(slog.DiscardHandler))
173
174	// NOTE: very hacky: we create a colorprofile writer with STDOUT, then make
175	// it forward to a bytes.Buffer, write the colored heartbit to it, and then
176	// finally prepend it in the version template.
177	// Unfortunately cobra doesn't give us a way to set a function to handle
178	// printing the version, and PreRunE runs after the version is already
179	// handled, so that doesn't work either.
180	// This is the only way I could find that works relatively well.
181	if term.IsTerminal(os.Stdout.Fd()) {
182		var b bytes.Buffer
183		w := colorprofile.NewWriter(os.Stdout, os.Environ())
184		w.Forward = &b
185		_, _ = w.WriteString(heartbit.String())
186		rootCmd.SetVersionTemplate(b.String() + "\n" + defaultVersionTemplate)
187	}
188	if err := fang.Execute(
189		context.Background(),
190		rootCmd,
191		fang.WithVersion(version.Version),
192		fang.WithNotifySignal(os.Interrupt),
193	); err != nil {
194		os.Exit(1)
195	}
196}
197
198// supportsProgressBar tries to determine whether the current terminal supports
199// progress bars by looking into environment variables.
200func supportsProgressBar() bool {
201	if !term.IsTerminal(os.Stderr.Fd()) {
202		return false
203	}
204	termProg := os.Getenv("TERM_PROGRAM")
205	_, isWindowsTerminal := os.LookupEnv("WT_SESSION")
206
207	return isWindowsTerminal || xstrings.ContainsAnyOf(strings.ToLower(termProg), "ghostty", "iterm2", "rio")
208}
209
210// useClientServer returns true when the client/server architecture is
211// enabled via the CRUSH_CLIENT_SERVER environment variable.
212func useClientServer() bool {
213	v, _ := strconv.ParseBool(os.Getenv("CRUSH_CLIENT_SERVER"))
214	return v
215}
216
217// setupWorkspaceWithProgressBar wraps setupWorkspace with an optional
218// terminal progress bar shown during initialization.
219func setupWorkspaceWithProgressBar(cmd *cobra.Command) (workspace.Workspace, func(), error) {
220	showProgress := supportsProgressBar()
221	if showProgress {
222		_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
223	}
224
225	ws, cleanup, err := setupWorkspace(cmd)
226
227	if showProgress {
228		_, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
229	}
230
231	return ws, cleanup, err
232}
233
234// setupWorkspace returns a Workspace and cleanup function. When
235// CRUSH_CLIENT_SERVER=1, it connects to a server process and returns a
236// ClientWorkspace. Otherwise it creates an in-process app.App and
237// returns an AppWorkspace.
238func setupWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
239	if useClientServer() {
240		return setupClientServerWorkspace(cmd)
241	}
242	return setupLocalWorkspace(cmd)
243}
244
245// setupLocalWorkspace creates an in-process app.App and wraps it in an
246// AppWorkspace.
247func setupLocalWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
248	debug, _ := cmd.Flags().GetBool("debug")
249	yolo, _ := cmd.Flags().GetBool("yolo")
250	dataDir, _ := cmd.Flags().GetString("data-dir")
251	ctx := cmd.Context()
252
253	cwd, err := ResolveCwd(cmd)
254	if err != nil {
255		return nil, nil, err
256	}
257
258	store, err := config.Init(cwd, dataDir, debug)
259	if err != nil {
260		return nil, nil, err
261	}
262
263	cfg := store.Config()
264	store.Overrides().SkipPermissionRequests = yolo
265
266	if err := os.MkdirAll(cfg.Options.DataDirectory, 0o700); err != nil {
267		return nil, nil, fmt.Errorf("failed to create data directory: %q %w", cfg.Options.DataDirectory, err)
268	}
269
270	gitIgnorePath := filepath.Join(cfg.Options.DataDirectory, ".gitignore")
271	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
272		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
273			return nil, nil, fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
274		}
275	}
276
277	if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
278		slog.Warn("Failed to register project", "error", err)
279	}
280
281	conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
282	if err != nil {
283		return nil, nil, err
284	}
285
286	logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
287	crushlog.Setup(logFile, debug)
288
289	appInstance, err := app.New(ctx, conn, store)
290	if err != nil {
291		_ = conn.Close()
292		slog.Error("Failed to create app instance", "error", err)
293		return nil, nil, err
294	}
295
296	if shouldEnableMetrics(cfg) {
297		event.Init()
298	}
299
300	ws := workspace.NewAppWorkspace(appInstance, store)
301	cleanup := func() { appInstance.Shutdown() }
302	return ws, cleanup, nil
303}
304
305// setupClientServerWorkspace connects to a server process and wraps the
306// result in a ClientWorkspace.
307func setupClientServerWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
308	c, protoWs, cleanupServer, err := connectToServer(cmd)
309	if err != nil {
310		return nil, nil, err
311	}
312
313	clientWs := workspace.NewClientWorkspace(c, *protoWs)
314
315	if protoWs.Config.IsConfigured() {
316		if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
317			slog.Error("Failed to initialize coder agent", "error", err)
318		}
319	}
320
321	return clientWs, cleanupServer, nil
322}
323
324// connectToServer ensures the server is running, creates a client and
325// workspace, and returns a cleanup function that deletes the workspace.
326func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
327	hostURL, err := server.ParseHostURL(clientHost)
328	if err != nil {
329		return nil, nil, nil, fmt.Errorf("invalid host URL: %v", err)
330	}
331
332	if err := ensureServer(cmd, hostURL); err != nil {
333		return nil, nil, nil, err
334	}
335
336	debug, _ := cmd.Flags().GetBool("debug")
337	yolo, _ := cmd.Flags().GetBool("yolo")
338	dataDir, _ := cmd.Flags().GetString("data-dir")
339	ctx := cmd.Context()
340
341	cwd, err := ResolveCwd(cmd)
342	if err != nil {
343		return nil, nil, nil, err
344	}
345
346	c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
347	if err != nil {
348		return nil, nil, nil, err
349	}
350
351	wsReq := proto.Workspace{
352		Path:    cwd,
353		DataDir: dataDir,
354		Debug:   debug,
355		YOLO:    yolo,
356		Version: version.Version,
357		Env:     os.Environ(),
358	}
359
360	ws, err := c.CreateWorkspace(ctx, wsReq)
361	if err != nil {
362		return nil, nil, nil, fmt.Errorf("failed to create workspace: %v", err)
363	}
364
365	if shouldEnableMetrics(ws.Config) {
366		event.Init()
367	}
368
369	if ws.Config != nil {
370		logFile := filepath.Join(ws.Config.Options.DataDirectory, "logs", "crush.log")
371		crushlog.Setup(logFile, debug)
372	}
373
374	cleanup := func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }
375	return c, ws, cleanup, nil
376}
377
378// ensureServer auto-starts a detached server if the socket file does not
379// exist. When the socket exists, it verifies that the running server
380// version matches the client; on mismatch it shuts down the old server
381// and starts a fresh one.
382func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
383	switch hostURL.Scheme {
384	case "unix", "npipe":
385		needsStart := false
386		_, statErr := os.Stat(hostURL.Host)
387		switch {
388		case statErr == nil:
389			restarted, err := restartIfStale(cmd, hostURL)
390			if err != nil {
391				slog.Warn("Failed to check server version", "error", err)
392			}
393			needsStart = restarted || err != nil
394		case errors.Is(statErr, fs.ErrNotExist):
395			needsStart = true
396		default:
397			slog.Warn("Unexpected error stat'ing server socket, attempting cleanup",
398				"path", hostURL.Host, "error", statErr)
399			if err := os.Remove(hostURL.Host); err != nil && !errors.Is(err, fs.ErrNotExist) {
400				return fmt.Errorf("failed to remove stale server socket %q: %v", hostURL.Host, err)
401			}
402			needsStart = true
403		}
404
405		if needsStart {
406			if err := spawnAndWaitReady(cmd, hostURL); err != nil {
407				return fmt.Errorf("failed to initialize crush server: %v", err)
408			}
409			return nil
410		}
411
412		if err := waitForServerReady(cmd.Context(), hostURL); err != nil {
413			return fmt.Errorf("failed to initialize crush server: %v", err)
414		}
415	}
416
417	return nil
418}
419
420// spawnAndWaitReady serializes the spawn-and-wait-for-readiness sequence
421// across concurrent clients via an exclusive flock on
422// $XDG_CACHE_HOME/crush/server-<safeHost>/start.lock.
423//
424// After acquiring the lock it re-probes readiness so that a client that
425// blocked while another client was spawning can skip its own spawn and
426// just use the now-running server. The lock is held only for the
427// duration of "spawn + readiness probe" and released before the caller
428// resumes its normal lifetime.
429func spawnAndWaitReady(cmd *cobra.Command, hostURL *url.URL) error {
430	chDir, err := perHostServerDir(hostURL)
431	if err != nil {
432		return err
433	}
434	release, err := acquireSpawnLock(filepath.Join(chDir, "start.lock"))
435	if err != nil {
436		// If the lock itself is unavailable, fall back to the
437		// unsynchronized path rather than blocking the user.
438		slog.Warn("Failed to acquire spawn lock, proceeding without single-flight", "error", err)
439		if err := startDetachedServer(cmd, hostURL); err != nil {
440			return err
441		}
442		return waitForServerReady(cmd.Context(), hostURL)
443	}
444	defer release()
445
446	// Another client may have just finished spawning while we were
447	// waiting on the lock; if the server is already responsive, skip
448	// the spawn entirely.
449	probeCtx, cancel := context.WithTimeout(cmd.Context(), 200*time.Millisecond)
450	probeErr := quickHealthProbe(probeCtx, hostURL)
451	cancel()
452	if probeErr == nil {
453		return nil
454	}
455
456	if err := startDetachedServer(cmd, hostURL); err != nil {
457		return err
458	}
459	return waitForServerReady(cmd.Context(), hostURL)
460}
461
462// quickHealthProbe issues a single readiness request with the caller's
463// context and returns nil iff the server is responsive right now.
464func quickHealthProbe(ctx context.Context, hostURL *url.URL) error {
465	httpClient, reqURL, err := readinessHTTPClient(hostURL)
466	if err != nil {
467		return err
468	}
469	return probeHealth(ctx, httpClient, reqURL, hostURL)
470}
471
472// perHostServerDir returns (and creates) the cache directory used for
473// per-host server state (logs, start.lock, etc.). The path is derived
474// from the parsed host URL rather than the global flag so the same key
475// is computed regardless of where the host came from.
476func perHostServerDir(hostURL *url.URL) (string, error) {
477	chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeHostName(hostURL))
478	if err := os.MkdirAll(chDir, 0o700); err != nil {
479		return "", fmt.Errorf("failed to create server working directory: %v", err)
480	}
481	return chDir, nil
482}
483
484// safeHostName returns a filesystem-safe identifier for hostURL,
485// suitable for use as a directory name. It mirrors the input shape of
486// the --host flag so client and server compute the same key.
487func safeHostName(hostURL *url.URL) string {
488	return safeNameRegexp.ReplaceAllString(
489		hostURL.Scheme+"://"+hostURL.Host+hostURL.Path, "_")
490}
491
492// serverReadyTimeout returns the total budget for the readiness probe.
493// Overridable via CRUSH_SERVER_READY_TIMEOUT (parsed as a Go duration).
494func serverReadyTimeout() time.Duration {
495	const def = 10 * time.Second
496	v := os.Getenv("CRUSH_SERVER_READY_TIMEOUT")
497	if v == "" {
498		return def
499	}
500	d, err := time.ParseDuration(v)
501	if err != nil || d <= 0 {
502		return def
503	}
504	return d
505}
506
507// waitForServerReady polls GET /v1/health until the server responds with
508// any 2xx status or the total timeout elapses. Each attempt uses a short
509// per-attempt timeout so a hung listener doesn't burn the whole budget.
510//
511// The HTTP transport is built to mirror how *client.Client dials so the
512// same unix socket / npipe / tcp setups all work uniformly here.
513func waitForServerReady(ctx context.Context, hostURL *url.URL) error {
514	httpClient, reqURL, err := readinessHTTPClient(hostURL)
515	if err != nil {
516		return err
517	}
518
519	const perAttempt = 100 * time.Millisecond
520	deadline := time.Now().Add(serverReadyTimeout())
521
522	var lastErr error
523	for {
524		if err := ctx.Err(); err != nil {
525			return err
526		}
527		if time.Now().After(deadline) {
528			if lastErr != nil {
529				return lastErr
530			}
531			return fmt.Errorf("timed out waiting for server readiness")
532		}
533
534		attemptCtx, cancel := context.WithTimeout(ctx, perAttempt)
535		err := probeHealth(attemptCtx, httpClient, reqURL, hostURL)
536		cancel()
537		if err == nil {
538			return nil
539		}
540		lastErr = err
541
542		select {
543		case <-ctx.Done():
544			return ctx.Err()
545		case <-time.After(perAttempt):
546		}
547	}
548}
549
550// readinessHTTPClient builds an *http.Client whose transport dials the
551// server using the same scheme-aware logic as *client.Client (unix
552// socket, named pipe, or tcp).
553func readinessHTTPClient(hostURL *url.URL) (*http.Client, string, error) {
554	c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
555	if err != nil {
556		return nil, "", err
557	}
558
559	tr := http.DefaultTransport.(*http.Transport).Clone()
560	tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
561		return c.Dial(ctx, network, addr)
562	}
563	if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
564		tr.DisableCompression = true
565	}
566
567	httpClient := &http.Client{Transport: tr}
568
569	// For unix sockets / named pipes we still need a syntactically valid
570	// HTTP URL; the actual address is resolved by the dialer.
571	host := hostURL.Host
572	if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
573		host = client.DummyHost
574	}
575	reqURL := (&url.URL{Scheme: "http", Host: host, Path: "/v1/health"}).String()
576	return httpClient, reqURL, nil
577}
578
579// probeHealth issues a single GET to the readiness endpoint and treats
580// any 2xx response as success.
581func probeHealth(ctx context.Context, h *http.Client, reqURL string, hostURL *url.URL) error {
582	req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
583	if err != nil {
584		return err
585	}
586	if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
587		req.Host = client.DummyHost
588	}
589	rsp, err := h.Do(req)
590	if err != nil {
591		return err
592	}
593	defer rsp.Body.Close()
594	_, _ = io.Copy(io.Discard, rsp.Body)
595	if rsp.StatusCode < 200 || rsp.StatusCode >= 300 {
596		return fmt.Errorf("server health check failed: %s", rsp.Status)
597	}
598	return nil
599}
600
601// restartIfStale checks whether the running server matches the current
602// client version. When they differ, it sends a shutdown command and
603// removes the stale socket so the caller can start a fresh server.
604//
605// It returns restarted=true when it has shut down a stale server and the
606// caller must spawn a new one. When the server matches the client version
607// (or the check itself fails), restarted is false.
608func restartIfStale(cmd *cobra.Command, hostURL *url.URL) (restarted bool, err error) {
609	c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
610	if err != nil {
611		return false, err
612	}
613	vi, err := c.VersionInfo(cmd.Context())
614	if err != nil {
615		return false, err
616	}
617	if vi.Version == version.Version && vi.BuildID == version.BuildID {
618		return false, nil
619	}
620	slog.Info(
621		"Server version mismatch, restarting",
622		"server_version", vi.Version,
623		"client_version", version.Version,
624		"server_build_id", vi.BuildID,
625		"client_build_id", version.BuildID,
626	)
627	_ = c.ShutdownServer(cmd.Context())
628	// Give the old process a moment to release the socket.
629	for range 20 {
630		if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
631			break
632		}
633		select {
634		case <-cmd.Context().Done():
635			return true, cmd.Context().Err()
636		case <-time.After(100 * time.Millisecond):
637		}
638	}
639	// Force-remove if the socket is still lingering.
640	_ = os.Remove(hostURL.Host)
641	return true, nil
642}
643
644var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
645
646func startDetachedServer(cmd *cobra.Command, hostURL *url.URL) error {
647	exe, err := os.Executable()
648	if err != nil {
649		return fmt.Errorf("failed to get executable path: %v", err)
650	}
651
652	chDir, err := perHostServerDir(hostURL)
653	if err != nil {
654		return err
655	}
656
657	cmdArgs := []string{"server"}
658	if clientHost != server.DefaultHost() {
659		cmdArgs = append(cmdArgs, "--host", clientHost)
660	}
661
662	// Use context.Background() so the parent's context cancellation does not
663	// kill the spawned server. detachProcess (Setsid on !windows,
664	// DETACHED_PROCESS on windows) is what truly detaches the child from
665	// this process's lifetime.
666	c := exec.CommandContext(context.Background(), exe, cmdArgs...)
667	stdoutPath := filepath.Join(chDir, "stdout.log")
668	stderrPath := filepath.Join(chDir, "stderr.log")
669	detachProcess(c)
670
671	stdout, err := os.Create(stdoutPath)
672	if err != nil {
673		return fmt.Errorf("failed to create stdout log file: %v", err)
674	}
675	defer stdout.Close()
676	c.Stdout = stdout
677
678	stderr, err := os.Create(stderrPath)
679	if err != nil {
680		return fmt.Errorf("failed to create stderr log file: %v", err)
681	}
682	defer stderr.Close()
683	c.Stderr = stderr
684
685	if err := c.Start(); err != nil {
686		return fmt.Errorf("failed to start crush server: %v", err)
687	}
688
689	if err := c.Process.Release(); err != nil {
690		return fmt.Errorf("failed to detach crush server process: %v", err)
691	}
692
693	return nil
694}
695
696func shouldEnableMetrics(cfg *config.Config) bool {
697	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
698		return false
699	}
700	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
701		return false
702	}
703	if cfg.Options.DisableMetrics {
704		return false
705	}
706	return true
707}
708
709func MaybePrependStdin(prompt string) (string, error) {
710	if term.IsTerminal(os.Stdin.Fd()) {
711		return prompt, nil
712	}
713	fi, err := os.Stdin.Stat()
714	if err != nil {
715		return prompt, err
716	}
717	// Check if stdin is a named pipe ( | ) or regular file ( < ).
718	if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
719		return prompt, nil
720	}
721	bts, err := io.ReadAll(os.Stdin)
722	if err != nil {
723		return prompt, err
724	}
725	return string(bts) + "\n\n" + prompt, nil
726}
727
728// resolveWorkspaceSessionID resolves a session ID that may be a full
729// UUID, full hash, or hash prefix. Works against the Workspace
730// interface so both local and client/server paths get hash prefix
731// support.
732func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
733	if sess, err := ws.GetSession(ctx, id); err == nil {
734		return sess, nil
735	}
736
737	sessions, err := ws.ListSessions(ctx)
738	if err != nil {
739		return session.Session{}, err
740	}
741
742	var matches []session.Session
743	for _, s := range sessions {
744		hash := session.HashID(s.ID)
745		if hash == id || strings.HasPrefix(hash, id) {
746			matches = append(matches, s)
747		}
748	}
749
750	switch len(matches) {
751	case 0:
752		return session.Session{}, fmt.Errorf("session not found: %s", id)
753	case 1:
754		return matches[0], nil
755	default:
756		return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
757	}
758}
759
760func ResolveCwd(cmd *cobra.Command) (string, error) {
761	cwd, _ := cmd.Flags().GetString("cwd")
762	if cwd != "" {
763		err := os.Chdir(cwd)
764		if err != nil {
765			return "", fmt.Errorf("failed to change directory: %v", err)
766		}
767		return cwd, nil
768	}
769	cwd, err := os.Getwd()
770	if err != nil {
771		return "", fmt.Errorf("failed to get current working directory: %v", err)
772	}
773	return cwd, nil
774}
775
776func createDotCrushDir(dir string) error {
777	if err := os.MkdirAll(dir, 0o700); err != nil {
778		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
779	}
780
781	gitIgnorePath := filepath.Join(dir, ".gitignore")
782	content, err := os.ReadFile(gitIgnorePath)
783
784	// create or update if old version
785	if os.IsNotExist(err) || string(content) == oldGitIgnore {
786		if err := os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0o644); err != nil {
787			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
788		}
789	}
790
791	return nil
792}
793
794//go:embed gitignore/old
795var oldGitIgnore string
796
797//go:embed gitignore/default
798var defaultGitIgnore string