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