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