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