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