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