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