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