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