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		_, statErr := os.Stat(hostURL.Host)
386		switch {
387		case statErr == nil:
388			restarted, err := restartIfStale(cmd, hostURL)
389			if err != nil {
390				slog.Warn("Failed to check server version", "error", err)
391			}
392			needsStart = restarted || err != nil
393		case errors.Is(statErr, fs.ErrNotExist):
394			needsStart = true
395		default:
396			slog.Warn("Unexpected error stat'ing server socket, attempting cleanup",
397				"path", hostURL.Host, "error", statErr)
398			if err := os.Remove(hostURL.Host); err != nil && !errors.Is(err, fs.ErrNotExist) {
399				return fmt.Errorf("failed to remove stale server socket %q: %v", hostURL.Host, err)
400			}
401			needsStart = true
402		}
403
404		if needsStart {
405			if err := startDetachedServer(cmd); err != nil {
406				return err
407			}
408		}
409
410		if err := waitForServerReady(cmd.Context(), hostURL); err != nil {
411			return fmt.Errorf("failed to initialize crush server: %v", err)
412		}
413	}
414
415	return nil
416}
417
418// serverReadyTimeout returns the total budget for the readiness probe.
419// Overridable via CRUSH_SERVER_READY_TIMEOUT (parsed as a Go duration).
420func serverReadyTimeout() time.Duration {
421	const def = 10 * time.Second
422	v := os.Getenv("CRUSH_SERVER_READY_TIMEOUT")
423	if v == "" {
424		return def
425	}
426	d, err := time.ParseDuration(v)
427	if err != nil || d <= 0 {
428		return def
429	}
430	return d
431}
432
433// waitForServerReady polls GET /v1/health until the server responds with
434// any 2xx status or the total timeout elapses. Each attempt uses a short
435// per-attempt timeout so a hung listener doesn't burn the whole budget.
436//
437// The HTTP transport is built to mirror how *client.Client dials so the
438// same unix socket / npipe / tcp setups all work uniformly here.
439func waitForServerReady(ctx context.Context, hostURL *url.URL) error {
440	httpClient, reqURL, err := readinessHTTPClient(hostURL)
441	if err != nil {
442		return err
443	}
444
445	const perAttempt = 100 * time.Millisecond
446	deadline := time.Now().Add(serverReadyTimeout())
447
448	var lastErr error
449	for {
450		if err := ctx.Err(); err != nil {
451			return err
452		}
453		if time.Now().After(deadline) {
454			if lastErr != nil {
455				return lastErr
456			}
457			return fmt.Errorf("timed out waiting for server readiness")
458		}
459
460		attemptCtx, cancel := context.WithTimeout(ctx, perAttempt)
461		err := probeHealth(attemptCtx, httpClient, reqURL, hostURL)
462		cancel()
463		if err == nil {
464			return nil
465		}
466		lastErr = err
467
468		select {
469		case <-ctx.Done():
470			return ctx.Err()
471		case <-time.After(perAttempt):
472		}
473	}
474}
475
476// readinessHTTPClient builds an *http.Client whose transport dials the
477// server using the same scheme-aware logic as *client.Client (unix
478// socket, named pipe, or tcp).
479func readinessHTTPClient(hostURL *url.URL) (*http.Client, string, error) {
480	c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
481	if err != nil {
482		return nil, "", err
483	}
484
485	tr := http.DefaultTransport.(*http.Transport).Clone()
486	tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
487		return c.Dial(ctx, network, addr)
488	}
489	if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
490		tr.DisableCompression = true
491	}
492
493	httpClient := &http.Client{Transport: tr}
494
495	// For unix sockets / named pipes we still need a syntactically valid
496	// HTTP URL; the actual address is resolved by the dialer.
497	host := hostURL.Host
498	if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
499		host = client.DummyHost
500	}
501	reqURL := (&url.URL{Scheme: "http", Host: host, Path: "/v1/health"}).String()
502	return httpClient, reqURL, nil
503}
504
505// probeHealth issues a single GET to the readiness endpoint and treats
506// any 2xx response as success.
507func probeHealth(ctx context.Context, h *http.Client, reqURL string, hostURL *url.URL) error {
508	req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
509	if err != nil {
510		return err
511	}
512	if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
513		req.Host = client.DummyHost
514	}
515	rsp, err := h.Do(req)
516	if err != nil {
517		return err
518	}
519	defer rsp.Body.Close()
520	_, _ = io.Copy(io.Discard, rsp.Body)
521	if rsp.StatusCode < 200 || rsp.StatusCode >= 300 {
522		return fmt.Errorf("server health check failed: %s", rsp.Status)
523	}
524	return nil
525}
526
527// restartIfStale checks whether the running server matches the current
528// client version. When they differ, it sends a shutdown command and
529// removes the stale socket so the caller can start a fresh server.
530//
531// It returns restarted=true when it has shut down a stale server and the
532// caller must spawn a new one. When the server matches the client version
533// (or the check itself fails), restarted is false.
534func restartIfStale(cmd *cobra.Command, hostURL *url.URL) (restarted bool, err error) {
535	c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
536	if err != nil {
537		return false, err
538	}
539	vi, err := c.VersionInfo(cmd.Context())
540	if err != nil {
541		return false, err
542	}
543	if vi.Version == version.Version {
544		return false, nil
545	}
546	slog.Info("Server version mismatch, restarting",
547		"server", vi.Version,
548		"client", version.Version,
549	)
550	_ = c.ShutdownServer(cmd.Context())
551	// Give the old process a moment to release the socket.
552	for range 20 {
553		if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
554			break
555		}
556		select {
557		case <-cmd.Context().Done():
558			return true, cmd.Context().Err()
559		case <-time.After(100 * time.Millisecond):
560		}
561	}
562	// Force-remove if the socket is still lingering.
563	_ = os.Remove(hostURL.Host)
564	return true, nil
565}
566
567var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
568
569func startDetachedServer(cmd *cobra.Command) error {
570	exe, err := os.Executable()
571	if err != nil {
572		return fmt.Errorf("failed to get executable path: %v", err)
573	}
574
575	safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
576	chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
577	if err := os.MkdirAll(chDir, 0o700); err != nil {
578		return fmt.Errorf("failed to create server working directory: %v", err)
579	}
580
581	cmdArgs := []string{"server"}
582	if clientHost != server.DefaultHost() {
583		cmdArgs = append(cmdArgs, "--host", clientHost)
584	}
585
586	// Use exec.Command (not exec.CommandContext) so the parent's context
587	// cancellation does not kill the spawned server. detachProcess
588	// (Setsid on !windows, DETACHED_PROCESS on windows) is what truly
589	// detaches the child from this process's lifetime.
590	c := exec.Command(exe, cmdArgs...)
591	stdoutPath := filepath.Join(chDir, "stdout.log")
592	stderrPath := filepath.Join(chDir, "stderr.log")
593	detachProcess(c)
594
595	stdout, err := os.Create(stdoutPath)
596	if err != nil {
597		return fmt.Errorf("failed to create stdout log file: %v", err)
598	}
599	defer stdout.Close()
600	c.Stdout = stdout
601
602	stderr, err := os.Create(stderrPath)
603	if err != nil {
604		return fmt.Errorf("failed to create stderr log file: %v", err)
605	}
606	defer stderr.Close()
607	c.Stderr = stderr
608
609	if err := c.Start(); err != nil {
610		return fmt.Errorf("failed to start crush server: %v", err)
611	}
612
613	if err := c.Process.Release(); err != nil {
614		return fmt.Errorf("failed to detach crush server process: %v", err)
615	}
616
617	return nil
618}
619
620func shouldEnableMetrics(cfg *config.Config) bool {
621	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
622		return false
623	}
624	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
625		return false
626	}
627	if cfg.Options.DisableMetrics {
628		return false
629	}
630	return true
631}
632
633func MaybePrependStdin(prompt string) (string, error) {
634	if term.IsTerminal(os.Stdin.Fd()) {
635		return prompt, nil
636	}
637	fi, err := os.Stdin.Stat()
638	if err != nil {
639		return prompt, err
640	}
641	// Check if stdin is a named pipe ( | ) or regular file ( < ).
642	if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
643		return prompt, nil
644	}
645	bts, err := io.ReadAll(os.Stdin)
646	if err != nil {
647		return prompt, err
648	}
649	return string(bts) + "\n\n" + prompt, nil
650}
651
652// resolveWorkspaceSessionID resolves a session ID that may be a full
653// UUID, full hash, or hash prefix. Works against the Workspace
654// interface so both local and client/server paths get hash prefix
655// support.
656func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
657	if sess, err := ws.GetSession(ctx, id); err == nil {
658		return sess, nil
659	}
660
661	sessions, err := ws.ListSessions(ctx)
662	if err != nil {
663		return session.Session{}, err
664	}
665
666	var matches []session.Session
667	for _, s := range sessions {
668		hash := session.HashID(s.ID)
669		if hash == id || strings.HasPrefix(hash, id) {
670			matches = append(matches, s)
671		}
672	}
673
674	switch len(matches) {
675	case 0:
676		return session.Session{}, fmt.Errorf("session not found: %s", id)
677	case 1:
678		return matches[0], nil
679	default:
680		return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
681	}
682}
683
684func ResolveCwd(cmd *cobra.Command) (string, error) {
685	cwd, _ := cmd.Flags().GetString("cwd")
686	if cwd != "" {
687		err := os.Chdir(cwd)
688		if err != nil {
689			return "", fmt.Errorf("failed to change directory: %v", err)
690		}
691		return cwd, nil
692	}
693	cwd, err := os.Getwd()
694	if err != nil {
695		return "", fmt.Errorf("failed to get current working directory: %v", err)
696	}
697	return cwd, nil
698}
699
700func createDotCrushDir(dir string) error {
701	if err := os.MkdirAll(dir, 0o700); err != nil {
702		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
703	}
704
705	gitIgnorePath := filepath.Join(dir, ".gitignore")
706	content, err := os.ReadFile(gitIgnorePath)
707
708	// create or update if old version
709	if os.IsNotExist(err) || string(content) == oldGitIgnore {
710		if err := os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0o644); err != nil {
711			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
712		}
713	}
714
715	return nil
716}
717
718//go:embed gitignore/old
719var oldGitIgnore string
720
721//go:embed gitignore/default
722var defaultGitIgnore string