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