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	// Initialize the persistent log here so stale-socket diagnostics
420	// emitted before connectToServer runs are captured in the per-host
421	// server log file. crushlog.Setup uses sync.Once internally, so the
422	// later call from connectToServer becomes a no-op.
423	debug, _ := cmd.Flags().GetBool("debug")
424	logFile := filepath.Join(config.GlobalCacheDir(), "server-"+safeHostName(hostURL), "crush.log")
425	crushlog.Setup(logFile, debug)
426
427	switch hostURL.Scheme {
428	case "unix", "npipe":
429		needsStart := false
430		_, statErr := os.Stat(hostURL.Host)
431		switch {
432		case statErr == nil:
433			// Probe the socket explicitly before the version-check
434			// path. A stale unix socket file (the previous server
435			// exited without cleaning up) would otherwise make
436			// restartIfStale spin on a non-responsive endpoint; here
437			// we detect it with a short DialTimeout and remove the
438			// orphaned file so the normal spawn path can run.
439			if hostURL.Scheme == "unix" {
440				conn, dialErr := net.DialTimeout(
441					hostURL.Scheme, hostURL.Host, 200*time.Millisecond,
442				)
443				if dialErr == nil {
444					conn.Close()
445				} else if server.IsStaleSocketErr(dialErr) {
446					slog.Warn("Stale socket detected, removing",
447						"path", hostURL.Host, "error", dialErr)
448					if err := os.Remove(hostURL.Host); err != nil && !errors.Is(err, fs.ErrNotExist) {
449						return fmt.Errorf("failed to remove stale server socket %q: %v", hostURL.Host, err)
450					}
451					needsStart = true
452					break
453				}
454			}
455			restarted, err := restartIfStale(cmd, hostURL)
456			if err != nil {
457				slog.Warn("Failed to check server version", "error", err)
458			}
459			needsStart = restarted || err != nil
460		case errors.Is(statErr, fs.ErrNotExist):
461			needsStart = true
462		default:
463			slog.Warn("Unexpected error stat'ing server socket, attempting cleanup",
464				"path", hostURL.Host, "error", statErr)
465			if err := os.Remove(hostURL.Host); err != nil && !errors.Is(err, fs.ErrNotExist) {
466				return fmt.Errorf("failed to remove stale server socket %q: %v", hostURL.Host, err)
467			}
468			needsStart = true
469		}
470
471		if needsStart {
472			if err := spawnAndWaitReady(cmd, hostURL); err != nil {
473				return fmt.Errorf("failed to initialize crush server: %v", err)
474			}
475			return nil
476		}
477
478		if err := waitForServerReady(cmd.Context(), hostURL); err != nil {
479			return fmt.Errorf("failed to initialize crush server: %v", err)
480		}
481	}
482
483	return nil
484}
485
486// spawnAndWaitReady serializes the spawn-and-wait-for-readiness sequence
487// across concurrent clients via an exclusive flock on
488// $XDG_CACHE_HOME/crush/server-<safeHost>/start.lock.
489//
490// After acquiring the lock it re-probes readiness so that a client that
491// blocked while another client was spawning can skip its own spawn and
492// just use the now-running server. The lock is held only for the
493// duration of "spawn + readiness probe" and released before the caller
494// resumes its normal lifetime.
495func spawnAndWaitReady(cmd *cobra.Command, hostURL *url.URL) error {
496	chDir, err := perHostServerDir(hostURL)
497	if err != nil {
498		return err
499	}
500	release, err := lock.File(cmd.Context(), filepath.Join(chDir, "start.lock"))
501	if err != nil {
502		// If the lock itself is unavailable, fall back to the
503		// unsynchronized path rather than blocking the user.
504		slog.Warn("Failed to acquire spawn lock, proceeding without single-flight", "error", err)
505		if err := startDetachedServer(cmd, hostURL); err != nil {
506			return err
507		}
508		return waitForServerReady(cmd.Context(), hostURL)
509	}
510	defer release()
511
512	// Another client may have just finished spawning while we were
513	// waiting on the lock; if the server is already responsive, skip
514	// the spawn entirely.
515	probeCtx, cancel := context.WithTimeout(cmd.Context(), 200*time.Millisecond)
516	probeErr := quickHealthProbe(probeCtx, hostURL)
517	cancel()
518	if probeErr == nil {
519		return nil
520	}
521
522	if err := startDetachedServer(cmd, hostURL); err != nil {
523		return err
524	}
525	return waitForServerReady(cmd.Context(), hostURL)
526}
527
528// quickHealthProbe issues a single readiness request with the caller's
529// context and returns nil iff the server is responsive right now.
530func quickHealthProbe(ctx context.Context, hostURL *url.URL) error {
531	httpClient, reqURL, err := readinessHTTPClient(hostURL)
532	if err != nil {
533		return err
534	}
535	return probeHealth(ctx, httpClient, reqURL, hostURL)
536}
537
538// perHostServerDir returns (and creates) the cache directory used for
539// per-host server state (logs, start.lock, etc.). The path is derived
540// from the parsed host URL rather than the global flag so the same key
541// is computed regardless of where the host came from.
542func perHostServerDir(hostURL *url.URL) (string, error) {
543	chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeHostName(hostURL))
544	if err := os.MkdirAll(chDir, 0o700); err != nil {
545		return "", fmt.Errorf("failed to create server working directory: %v", err)
546	}
547	return chDir, nil
548}
549
550// safeHostName returns a filesystem-safe identifier for hostURL,
551// suitable for use as a directory name. It mirrors the input shape of
552// the --host flag so client and server compute the same key.
553func safeHostName(hostURL *url.URL) string {
554	return safeNameRegexp.ReplaceAllString(
555		hostURL.Scheme+"://"+hostURL.Host+hostURL.Path, "_",
556	)
557}
558
559// serverReadyTimeout returns the total budget for the readiness probe.
560// Overridable via CRUSH_SERVER_READY_TIMEOUT (parsed as a Go duration).
561func serverReadyTimeout() time.Duration {
562	const def = 10 * time.Second
563	v := os.Getenv("CRUSH_SERVER_READY_TIMEOUT")
564	if v == "" {
565		return def
566	}
567	d, err := time.ParseDuration(v)
568	if err != nil || d <= 0 {
569		return def
570	}
571	return d
572}
573
574// waitForServerReady polls GET /v1/health until the server responds with
575// any 2xx status or the total timeout elapses. Each attempt uses a short
576// per-attempt timeout so a hung listener doesn't burn the whole budget.
577//
578// The HTTP transport is built to mirror how *client.Client dials so the
579// same unix socket / npipe / tcp setups all work uniformly here.
580func waitForServerReady(ctx context.Context, hostURL *url.URL) error {
581	httpClient, reqURL, err := readinessHTTPClient(hostURL)
582	if err != nil {
583		return err
584	}
585
586	const perAttempt = 100 * time.Millisecond
587	deadline := time.Now().Add(serverReadyTimeout())
588
589	var lastErr error
590	for {
591		if err := ctx.Err(); err != nil {
592			return err
593		}
594		if time.Now().After(deadline) {
595			if lastErr != nil {
596				return lastErr
597			}
598			return fmt.Errorf("timed out waiting for server readiness")
599		}
600
601		attemptCtx, cancel := context.WithTimeout(ctx, perAttempt)
602		err := probeHealth(attemptCtx, httpClient, reqURL, hostURL)
603		cancel()
604		if err == nil {
605			return nil
606		}
607		lastErr = err
608
609		select {
610		case <-ctx.Done():
611			return ctx.Err()
612		case <-time.After(perAttempt):
613		}
614	}
615}
616
617// readinessHTTPClient builds an *http.Client whose transport dials the
618// server using the same scheme-aware logic as *client.Client (unix
619// socket, named pipe, or tcp).
620func readinessHTTPClient(hostURL *url.URL) (*http.Client, string, error) {
621	c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
622	if err != nil {
623		return nil, "", err
624	}
625
626	tr := http.DefaultTransport.(*http.Transport).Clone()
627	tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
628		return c.Dial(ctx, network, addr)
629	}
630	if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
631		tr.DisableCompression = true
632	}
633
634	httpClient := &http.Client{Transport: tr}
635
636	// For unix sockets / named pipes we still need a syntactically valid
637	// HTTP URL; the actual address is resolved by the dialer.
638	host := hostURL.Host
639	if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
640		host = client.DummyHost
641	}
642	reqURL := (&url.URL{Scheme: "http", Host: host, Path: "/v1/health"}).String()
643	return httpClient, reqURL, nil
644}
645
646// probeHealth issues a single GET to the readiness endpoint and treats
647// any 2xx response as success.
648func probeHealth(ctx context.Context, h *http.Client, reqURL string, hostURL *url.URL) error {
649	req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
650	if err != nil {
651		return err
652	}
653	if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
654		req.Host = client.DummyHost
655	}
656	rsp, err := h.Do(req)
657	if err != nil {
658		return err
659	}
660	defer rsp.Body.Close()
661	_, _ = io.Copy(io.Discard, rsp.Body)
662	if rsp.StatusCode < 200 || rsp.StatusCode >= 300 {
663		return fmt.Errorf("server health check failed: %s", rsp.Status)
664	}
665	return nil
666}
667
668// restartIfStale checks whether the running server matches the current
669// client version. When they differ, it sends a shutdown command and
670// removes the stale socket so the caller can start a fresh server.
671//
672// It returns restarted=true when it has shut down a stale server and the
673// caller must spawn a new one. When the server matches the client version
674// (or the check itself fails), restarted is false.
675func restartIfStale(cmd *cobra.Command, hostURL *url.URL) (restarted bool, err error) {
676	c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
677	if err != nil {
678		return false, err
679	}
680	vi, err := c.VersionInfo(cmd.Context())
681	if err != nil {
682		return false, err
683	}
684	if vi.Version == version.Version && vi.BuildID == version.BuildID {
685		return false, nil
686	}
687	slog.Info(
688		"Server version mismatch, restarting",
689		"server_version", vi.Version,
690		"client_version", version.Version,
691		"server_build_id", vi.BuildID,
692		"client_build_id", version.BuildID,
693	)
694	_ = c.ShutdownServer(cmd.Context())
695	// Give the old process a moment to release the socket.
696	for range 20 {
697		if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
698			break
699		}
700		select {
701		case <-cmd.Context().Done():
702			return true, cmd.Context().Err()
703		case <-time.After(100 * time.Millisecond):
704		}
705	}
706	// Force-remove if the socket is still lingering.
707	_ = os.Remove(hostURL.Host)
708	return true, nil
709}
710
711var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
712
713func startDetachedServer(cmd *cobra.Command, hostURL *url.URL) error {
714	exe, err := os.Executable()
715	if err != nil {
716		return fmt.Errorf("failed to get executable path: %v", err)
717	}
718
719	chDir, err := perHostServerDir(hostURL)
720	if err != nil {
721		return err
722	}
723
724	cmdArgs := []string{"server"}
725	if clientHost != server.DefaultHost() {
726		cmdArgs = append(cmdArgs, "--host", clientHost)
727	}
728
729	// Use context.Background() so the parent's context cancellation does not
730	// kill the spawned server. detachProcess (Setsid on !windows,
731	// DETACHED_PROCESS on windows) is what truly detaches the child from
732	// this process's lifetime.
733	c := exec.CommandContext(context.Background(), exe, cmdArgs...)
734	stdoutPath := filepath.Join(chDir, "stdout.log")
735	stderrPath := filepath.Join(chDir, "stderr.log")
736	detachProcess(c)
737
738	stdout, err := os.Create(stdoutPath)
739	if err != nil {
740		return fmt.Errorf("failed to create stdout log file: %v", err)
741	}
742	defer stdout.Close()
743	c.Stdout = stdout
744
745	stderr, err := os.Create(stderrPath)
746	if err != nil {
747		return fmt.Errorf("failed to create stderr log file: %v", err)
748	}
749	defer stderr.Close()
750	c.Stderr = stderr
751
752	if err := c.Start(); err != nil {
753		return fmt.Errorf("failed to start crush server: %v", err)
754	}
755
756	if err := c.Process.Release(); err != nil {
757		return fmt.Errorf("failed to detach crush server process: %v", err)
758	}
759
760	return nil
761}
762
763func shouldEnableMetrics(cfg *config.Config) bool {
764	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
765		return false
766	}
767	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
768		return false
769	}
770	if cfg.Options.DisableMetrics {
771		return false
772	}
773	return true
774}
775
776func MaybePrependStdin(prompt string) (string, error) {
777	if term.IsTerminal(os.Stdin.Fd()) {
778		return prompt, nil
779	}
780	fi, err := os.Stdin.Stat()
781	if err != nil {
782		return prompt, err
783	}
784	// Check if stdin is a named pipe ( | ) or regular file ( < ).
785	if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
786		return prompt, nil
787	}
788	bts, err := io.ReadAll(os.Stdin)
789	if err != nil {
790		return prompt, err
791	}
792	return string(bts) + "\n\n" + prompt, nil
793}
794
795// resolveWorkspaceSessionID resolves a session ID that may be a full
796// UUID, full hash, or hash prefix. Works against the Workspace
797// interface so both local and client/server paths get hash prefix
798// support.
799func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
800	if sess, err := ws.GetSession(ctx, id); err == nil {
801		return sess, nil
802	}
803
804	sessions, err := ws.ListSessions(ctx)
805	if err != nil {
806		return session.Session{}, err
807	}
808
809	var matches []session.Session
810	for _, s := range sessions {
811		hash := session.HashID(s.ID)
812		if hash == id || strings.HasPrefix(hash, id) {
813			matches = append(matches, s)
814		}
815	}
816
817	switch len(matches) {
818	case 0:
819		return session.Session{}, fmt.Errorf("session not found: %s", id)
820	case 1:
821		return matches[0], nil
822	default:
823		return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
824	}
825}
826
827func ResolveCwd(cmd *cobra.Command) (string, error) {
828	cwd, _ := cmd.Flags().GetString("cwd")
829	if cwd != "" {
830		err := os.Chdir(cwd)
831		if err != nil {
832			return "", fmt.Errorf("failed to change directory: %v", err)
833		}
834		return cwd, nil
835	}
836	cwd, err := os.Getwd()
837	if err != nil {
838		return "", fmt.Errorf("failed to get current working directory: %v", err)
839	}
840	return cwd, nil
841}
842
843func createDotCrushDir(dir string) error {
844	if err := os.MkdirAll(dir, 0o700); err != nil {
845		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
846	}
847
848	gitIgnorePath := filepath.Join(dir, ".gitignore")
849	content, err := os.ReadFile(gitIgnorePath)
850
851	// create or update if old version
852	if os.IsNotExist(err) || string(content) == oldGitIgnore {
853		if err := os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0o644); err != nil {
854			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
855		}
856	}
857
858	return nil
859}
860
861//go:embed gitignore/old
862var oldGitIgnore string
863
864//go:embed gitignore/default
865var defaultGitIgnore string