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