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	c := exec.CommandContext(cmd.Context(), exe, cmdArgs...)
573	stdoutPath := filepath.Join(chDir, "stdout.log")
574	stderrPath := filepath.Join(chDir, "stderr.log")
575	detachProcess(c)
576
577	stdout, err := os.Create(stdoutPath)
578	if err != nil {
579		return fmt.Errorf("failed to create stdout log file: %v", err)
580	}
581	defer stdout.Close()
582	c.Stdout = stdout
583
584	stderr, err := os.Create(stderrPath)
585	if err != nil {
586		return fmt.Errorf("failed to create stderr log file: %v", err)
587	}
588	defer stderr.Close()
589	c.Stderr = stderr
590
591	if err := c.Start(); err != nil {
592		return fmt.Errorf("failed to start crush server: %v", err)
593	}
594
595	if err := c.Process.Release(); err != nil {
596		return fmt.Errorf("failed to detach crush server process: %v", err)
597	}
598
599	return nil
600}
601
602func shouldEnableMetrics(cfg *config.Config) bool {
603	if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
604		return false
605	}
606	if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
607		return false
608	}
609	if cfg.Options.DisableMetrics {
610		return false
611	}
612	return true
613}
614
615func MaybePrependStdin(prompt string) (string, error) {
616	if term.IsTerminal(os.Stdin.Fd()) {
617		return prompt, nil
618	}
619	fi, err := os.Stdin.Stat()
620	if err != nil {
621		return prompt, err
622	}
623	// Check if stdin is a named pipe ( | ) or regular file ( < ).
624	if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
625		return prompt, nil
626	}
627	bts, err := io.ReadAll(os.Stdin)
628	if err != nil {
629		return prompt, err
630	}
631	return string(bts) + "\n\n" + prompt, nil
632}
633
634// resolveWorkspaceSessionID resolves a session ID that may be a full
635// UUID, full hash, or hash prefix. Works against the Workspace
636// interface so both local and client/server paths get hash prefix
637// support.
638func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
639	if sess, err := ws.GetSession(ctx, id); err == nil {
640		return sess, nil
641	}
642
643	sessions, err := ws.ListSessions(ctx)
644	if err != nil {
645		return session.Session{}, err
646	}
647
648	var matches []session.Session
649	for _, s := range sessions {
650		hash := session.HashID(s.ID)
651		if hash == id || strings.HasPrefix(hash, id) {
652			matches = append(matches, s)
653		}
654	}
655
656	switch len(matches) {
657	case 0:
658		return session.Session{}, fmt.Errorf("session not found: %s", id)
659	case 1:
660		return matches[0], nil
661	default:
662		return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
663	}
664}
665
666func ResolveCwd(cmd *cobra.Command) (string, error) {
667	cwd, _ := cmd.Flags().GetString("cwd")
668	if cwd != "" {
669		err := os.Chdir(cwd)
670		if err != nil {
671			return "", fmt.Errorf("failed to change directory: %v", err)
672		}
673		return cwd, nil
674	}
675	cwd, err := os.Getwd()
676	if err != nil {
677		return "", fmt.Errorf("failed to get current working directory: %v", err)
678	}
679	return cwd, nil
680}
681
682func createDotCrushDir(dir string) error {
683	if err := os.MkdirAll(dir, 0o700); err != nil {
684		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
685	}
686
687	gitIgnorePath := filepath.Join(dir, ".gitignore")
688	content, err := os.ReadFile(gitIgnorePath)
689
690	// create or update if old version
691	if os.IsNotExist(err) || string(content) == oldGitIgnore {
692		if err := os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0o644); err != nil {
693			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
694		}
695	}
696
697	return nil
698}
699
700//go:embed gitignore/old
701var oldGitIgnore string
702
703//go:embed gitignore/default
704var defaultGitIgnore string