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