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