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
493// serverReadyTimeout returns the total budget for the readiness probe.
494// Overridable via CRUSH_SERVER_READY_TIMEOUT (parsed as a Go duration).
495func serverReadyTimeout() time.Duration {
496	const def = 10 * time.Second
497	v := os.Getenv("CRUSH_SERVER_READY_TIMEOUT")
498	if v == "" {
499		return def
500	}
501	d, err := time.ParseDuration(v)
502	if err != nil || d <= 0 {
503		return def
504	}
505	return d
506}
507
508// waitForServerReady polls GET /v1/health until the server responds with
509// any 2xx status or the total timeout elapses. Each attempt uses a short
510// per-attempt timeout so a hung listener doesn't burn the whole budget.
511//
512// The HTTP transport is built to mirror how *client.Client dials so the
513// same unix socket / npipe / tcp setups all work uniformly here.
514func waitForServerReady(ctx context.Context, hostURL *url.URL) error {
515	httpClient, reqURL, err := readinessHTTPClient(hostURL)
516	if err != nil {
517		return err
518	}
519
520	const perAttempt = 100 * time.Millisecond
521	deadline := time.Now().Add(serverReadyTimeout())
522
523	var lastErr error
524	for {
525		if err := ctx.Err(); err != nil {
526			return err
527		}
528		if time.Now().After(deadline) {
529			if lastErr != nil {
530				return lastErr
531			}
532			return fmt.Errorf("timed out waiting for server readiness")
533		}
534
535		attemptCtx, cancel := context.WithTimeout(ctx, perAttempt)
536		err := probeHealth(attemptCtx, httpClient, reqURL, hostURL)
537		cancel()
538		if err == nil {
539			return nil
540		}
541		lastErr = err
542
543		select {
544		case <-ctx.Done():
545			return ctx.Err()
546		case <-time.After(perAttempt):
547		}
548	}
549}
550
551// readinessHTTPClient builds an *http.Client whose transport dials the
552// server using the same scheme-aware logic as *client.Client (unix
553// socket, named pipe, or tcp).
554func readinessHTTPClient(hostURL *url.URL) (*http.Client, string, error) {
555	c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
556	if err != nil {
557		return nil, "", err
558	}
559
560	tr := http.DefaultTransport.(*http.Transport).Clone()
561	tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
562		return c.Dial(ctx, network, addr)
563	}
564	if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
565		tr.DisableCompression = true
566	}
567
568	httpClient := &http.Client{Transport: tr}
569
570	// For unix sockets / named pipes we still need a syntactically valid
571	// HTTP URL; the actual address is resolved by the dialer.
572	host := hostURL.Host
573	if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
574		host = client.DummyHost
575	}
576	reqURL := (&url.URL{Scheme: "http", Host: host, Path: "/v1/health"}).String()
577	return httpClient, reqURL, nil
578}
579
580// probeHealth issues a single GET to the readiness endpoint and treats
581// any 2xx response as success.
582func probeHealth(ctx context.Context, h *http.Client, reqURL string, hostURL *url.URL) error {
583	req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
584	if err != nil {
585		return err
586	}
587	if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
588		req.Host = client.DummyHost
589	}
590	rsp, err := h.Do(req)
591	if err != nil {
592		return err
593	}
594	defer rsp.Body.Close()
595	_, _ = io.Copy(io.Discard, rsp.Body)
596	if rsp.StatusCode < 200 || rsp.StatusCode >= 300 {
597		return fmt.Errorf("server health check failed: %s", rsp.Status)
598	}
599	return nil
600}
601
602// restartIfStale checks whether the running server matches the current
603// client version. When they differ, it sends a shutdown command and
604// removes the stale socket so the caller can start a fresh server.
605//
606// It returns restarted=true when it has shut down a stale server and the
607// caller must spawn a new one. When the server matches the client version
608// (or the check itself fails), restarted is false.
609func restartIfStale(cmd *cobra.Command, hostURL *url.URL) (restarted bool, err error) {
610	c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
611	if err != nil {
612		return false, err
613	}
614	vi, err := c.VersionInfo(cmd.Context())
615	if err != nil {
616		return false, err
617	}
618	if vi.Version == version.Version && vi.BuildID == version.BuildID {
619		return false, nil
620	}
621	slog.Info(
622		"Server version mismatch, restarting",
623		"server_version", vi.Version,
624		"client_version", version.Version,
625		"server_build_id", vi.BuildID,
626		"client_build_id", version.BuildID,
627	)
628	_ = c.ShutdownServer(cmd.Context())
629	// Give the old process a moment to release the socket.
630	for range 20 {
631		if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
632			break
633		}
634		select {
635		case <-cmd.Context().Done():
636			return true, cmd.Context().Err()
637		case <-time.After(100 * time.Millisecond):
638		}
639	}
640	// Force-remove if the socket is still lingering.
641	_ = os.Remove(hostURL.Host)
642	return true, nil
643}
644
645var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
646
647func startDetachedServer(cmd *cobra.Command, hostURL *url.URL) error {
648	exe, err := os.Executable()
649	if err != nil {
650		return fmt.Errorf("failed to get executable path: %v", err)
651	}
652
653	chDir, err := perHostServerDir(hostURL)
654	if err != nil {
655		return err
656	}
657
658	cmdArgs := []string{"server"}
659	if clientHost != server.DefaultHost() {
660		cmdArgs = append(cmdArgs, "--host", clientHost)
661	}
662
663	// Use context.Background() so the parent's context cancellation does not
664	// kill the spawned server. detachProcess (Setsid on !windows,
665	// DETACHED_PROCESS on windows) is what truly detaches the child from
666	// this process's lifetime.
667	c := exec.CommandContext(context.Background(), exe, cmdArgs...)
668	stdoutPath := filepath.Join(chDir, "stdout.log")
669	stderrPath := filepath.Join(chDir, "stderr.log")
670	detachProcess(c)
671
672	stdout, err := os.Create(stdoutPath)
673	if err != nil {
674		return fmt.Errorf("failed to create stdout log file: %v", err)
675	}
676	defer stdout.Close()
677	c.Stdout = stdout
678
679	stderr, err := os.Create(stderrPath)
680	if err != nil {
681		return fmt.Errorf("failed to create stderr log file: %v", err)
682	}
683	defer stderr.Close()
684	c.Stderr = stderr
685
686	if err := c.Start(); err != nil {
687		return fmt.Errorf("failed to start crush server: %v", err)
688	}
689
690	if err := c.Process.Release(); err != nil {
691		return fmt.Errorf("failed to detach crush server process: %v", err)
692	}
693
694	return nil
695}
696
697func shouldEnableMetrics(cfg *config.Config) bool {
698	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
699		return false
700	}
701	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
702		return false
703	}
704	if cfg.Options.DisableMetrics {
705		return false
706	}
707	return true
708}
709
710func MaybePrependStdin(prompt string) (string, error) {
711	if term.IsTerminal(os.Stdin.Fd()) {
712		return prompt, nil
713	}
714	fi, err := os.Stdin.Stat()
715	if err != nil {
716		return prompt, err
717	}
718	// Check if stdin is a named pipe ( | ) or regular file ( < ).
719	if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
720		return prompt, nil
721	}
722	bts, err := io.ReadAll(os.Stdin)
723	if err != nil {
724		return prompt, err
725	}
726	return string(bts) + "\n\n" + prompt, nil
727}
728
729// resolveWorkspaceSessionID resolves a session ID that may be a full
730// UUID, full hash, or hash prefix. Works against the Workspace
731// interface so both local and client/server paths get hash prefix
732// support.
733func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
734	if sess, err := ws.GetSession(ctx, id); err == nil {
735		return sess, nil
736	}
737
738	sessions, err := ws.ListSessions(ctx)
739	if err != nil {
740		return session.Session{}, err
741	}
742
743	var matches []session.Session
744	for _, s := range sessions {
745		hash := session.HashID(s.ID)
746		if hash == id || strings.HasPrefix(hash, id) {
747			matches = append(matches, s)
748		}
749	}
750
751	switch len(matches) {
752	case 0:
753		return session.Session{}, fmt.Errorf("session not found: %s", id)
754	case 1:
755		return matches[0], nil
756	default:
757		return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
758	}
759}
760
761func ResolveCwd(cmd *cobra.Command) (string, error) {
762	cwd, _ := cmd.Flags().GetString("cwd")
763	if cwd != "" {
764		err := os.Chdir(cwd)
765		if err != nil {
766			return "", fmt.Errorf("failed to change directory: %v", err)
767		}
768		return cwd, nil
769	}
770	cwd, err := os.Getwd()
771	if err != nil {
772		return "", fmt.Errorf("failed to get current working directory: %v", err)
773	}
774	return cwd, nil
775}
776
777func createDotCrushDir(dir string) error {
778	if err := os.MkdirAll(dir, 0o700); err != nil {
779		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
780	}
781
782	gitIgnorePath := filepath.Join(dir, ".gitignore")
783	content, err := os.ReadFile(gitIgnorePath)
784
785	// create or update if old version
786	if os.IsNotExist(err) || string(content) == oldGitIgnore {
787		if err := os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0o644); err != nil {
788			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
789		}
790	}
791
792	return nil
793}
794
795//go:embed gitignore/old
796var oldGitIgnore string
797
798//go:embed gitignore/default
799var defaultGitIgnore string