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(allSkills, activeSkills, skillStates,
297 skills.WithGlobalMirror(),
298 skills.WithResolvedPaths(discoveryCfg.ResolvePaths()),
299 skills.WithWorkingDir(discoveryCfg.WorkingDir),
300 )
301
302 appInstance, err := app.New(ctx, conn, store, skillsMgr)
303 if err != nil {
304 _ = conn.Close()
305 slog.Error("Failed to create app instance", "error", err)
306 return nil, nil, err
307 }
308
309 if shouldEnableMetrics(cfg) {
310 event.Init()
311 }
312
313 ws := workspace.NewAppWorkspace(appInstance, store)
314 cleanup := func() { appInstance.Shutdown() }
315 return ws, cleanup, nil
316}
317
318// localSkillsDiscoveryConfig adapts a *config.ConfigStore to the inputs
319// skills.DiscoverFromConfig expects.
320func localSkillsDiscoveryConfig(store *config.ConfigStore) skills.DiscoveryConfig {
321 opts := store.Config().Options
322 var paths, disabled []string
323 if opts != nil {
324 paths = opts.SkillsPaths
325 disabled = opts.DisabledSkills
326 }
327 var resolver func(string) (string, error)
328 if r := store.Resolver(); r != nil {
329 resolver = r.ResolveValue
330 }
331 return skills.DiscoveryConfig{
332 SkillsPaths: paths,
333 DisabledSkills: disabled,
334 WorkingDir: store.WorkingDir(),
335 Resolver: resolver,
336 }
337}
338
339// setupClientServerWorkspace connects to a server process and wraps the
340// result in a ClientWorkspace.
341func setupClientServerWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
342 c, protoWs, cleanupServer, err := connectToServer(cmd)
343 if err != nil {
344 return nil, nil, err
345 }
346
347 clientWs := workspace.NewClientWorkspace(c, *protoWs)
348
349 if protoWs.Config.IsConfigured() {
350 if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
351 slog.Error("Failed to initialize coder agent", "error", err)
352 }
353 }
354
355 return clientWs, cleanupServer, nil
356}
357
358// connectToServer ensures the server is running, creates a client and
359// workspace, and returns a cleanup function that deletes the workspace.
360func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
361 hostURL, err := server.ParseHostURL(clientHost)
362 if err != nil {
363 return nil, nil, nil, fmt.Errorf("invalid host URL: %v", err)
364 }
365
366 if err := ensureServer(cmd, hostURL); err != nil {
367 return nil, nil, nil, err
368 }
369
370 debug, _ := cmd.Flags().GetBool("debug")
371 yolo, _ := cmd.Flags().GetBool("yolo")
372 dataDir, _ := cmd.Flags().GetString("data-dir")
373 ctx := cmd.Context()
374
375 cwd, err := ResolveCwd(cmd)
376 if err != nil {
377 return nil, nil, nil, err
378 }
379
380 c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
381 if err != nil {
382 return nil, nil, nil, err
383 }
384
385 wsReq := proto.Workspace{
386 Path: cwd,
387 DataDir: dataDir,
388 Debug: debug,
389 YOLO: yolo,
390 Version: version.Version,
391 Env: os.Environ(),
392 }
393
394 ws, err := c.CreateWorkspace(ctx, wsReq)
395 if err != nil {
396 return nil, nil, nil, fmt.Errorf("failed to create workspace: %v", err)
397 }
398
399 if shouldEnableMetrics(ws.Config) {
400 event.Init()
401 }
402
403 if ws.Config != nil {
404 logFile := filepath.Join(ws.Config.Options.DataDirectory, "logs", "crush.log")
405 crushlog.Setup(logFile, debug)
406 }
407
408 cleanup := func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }
409 return c, ws, cleanup, nil
410}
411
412// ensureServer auto-starts a detached server if the socket file does not
413// exist. When the socket exists, it verifies that the running server
414// version matches the client; on mismatch it shuts down the old server
415// and starts a fresh one.
416func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
417 switch hostURL.Scheme {
418 case "unix", "npipe":
419 needsStart := false
420 _, statErr := os.Stat(hostURL.Host)
421 switch {
422 case statErr == nil:
423 restarted, err := restartIfStale(cmd, hostURL)
424 if err != nil {
425 slog.Warn("Failed to check server version", "error", err)
426 }
427 needsStart = restarted || err != nil
428 case errors.Is(statErr, fs.ErrNotExist):
429 needsStart = true
430 default:
431 slog.Warn("Unexpected error stat'ing server socket, attempting cleanup",
432 "path", hostURL.Host, "error", statErr)
433 if err := os.Remove(hostURL.Host); err != nil && !errors.Is(err, fs.ErrNotExist) {
434 return fmt.Errorf("failed to remove stale server socket %q: %v", hostURL.Host, err)
435 }
436 needsStart = true
437 }
438
439 if needsStart {
440 if err := spawnAndWaitReady(cmd, hostURL); err != nil {
441 return fmt.Errorf("failed to initialize crush server: %v", err)
442 }
443 return nil
444 }
445
446 if err := waitForServerReady(cmd.Context(), hostURL); err != nil {
447 return fmt.Errorf("failed to initialize crush server: %v", err)
448 }
449 }
450
451 return nil
452}
453
454// spawnAndWaitReady serializes the spawn-and-wait-for-readiness sequence
455// across concurrent clients via an exclusive flock on
456// $XDG_CACHE_HOME/crush/server-<safeHost>/start.lock.
457//
458// After acquiring the lock it re-probes readiness so that a client that
459// blocked while another client was spawning can skip its own spawn and
460// just use the now-running server. The lock is held only for the
461// duration of "spawn + readiness probe" and released before the caller
462// resumes its normal lifetime.
463func spawnAndWaitReady(cmd *cobra.Command, hostURL *url.URL) error {
464 chDir, err := perHostServerDir(hostURL)
465 if err != nil {
466 return err
467 }
468 release, err := acquireSpawnLock(filepath.Join(chDir, "start.lock"))
469 if err != nil {
470 // If the lock itself is unavailable, fall back to the
471 // unsynchronized path rather than blocking the user.
472 slog.Warn("Failed to acquire spawn lock, proceeding without single-flight", "error", err)
473 if err := startDetachedServer(cmd, hostURL); err != nil {
474 return err
475 }
476 return waitForServerReady(cmd.Context(), hostURL)
477 }
478 defer release()
479
480 // Another client may have just finished spawning while we were
481 // waiting on the lock; if the server is already responsive, skip
482 // the spawn entirely.
483 probeCtx, cancel := context.WithTimeout(cmd.Context(), 200*time.Millisecond)
484 probeErr := quickHealthProbe(probeCtx, hostURL)
485 cancel()
486 if probeErr == nil {
487 return nil
488 }
489
490 if err := startDetachedServer(cmd, hostURL); err != nil {
491 return err
492 }
493 return waitForServerReady(cmd.Context(), hostURL)
494}
495
496// quickHealthProbe issues a single readiness request with the caller's
497// context and returns nil iff the server is responsive right now.
498func quickHealthProbe(ctx context.Context, hostURL *url.URL) error {
499 httpClient, reqURL, err := readinessHTTPClient(hostURL)
500 if err != nil {
501 return err
502 }
503 return probeHealth(ctx, httpClient, reqURL, hostURL)
504}
505
506// perHostServerDir returns (and creates) the cache directory used for
507// per-host server state (logs, start.lock, etc.). The path is derived
508// from the parsed host URL rather than the global flag so the same key
509// is computed regardless of where the host came from.
510func perHostServerDir(hostURL *url.URL) (string, error) {
511 chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeHostName(hostURL))
512 if err := os.MkdirAll(chDir, 0o700); err != nil {
513 return "", fmt.Errorf("failed to create server working directory: %v", err)
514 }
515 return chDir, nil
516}
517
518// safeHostName returns a filesystem-safe identifier for hostURL,
519// suitable for use as a directory name. It mirrors the input shape of
520// the --host flag so client and server compute the same key.
521func safeHostName(hostURL *url.URL) string {
522 return safeNameRegexp.ReplaceAllString(
523 hostURL.Scheme+"://"+hostURL.Host+hostURL.Path, "_",
524 )
525}
526
527// serverReadyTimeout returns the total budget for the readiness probe.
528// Overridable via CRUSH_SERVER_READY_TIMEOUT (parsed as a Go duration).
529func serverReadyTimeout() time.Duration {
530 const def = 10 * time.Second
531 v := os.Getenv("CRUSH_SERVER_READY_TIMEOUT")
532 if v == "" {
533 return def
534 }
535 d, err := time.ParseDuration(v)
536 if err != nil || d <= 0 {
537 return def
538 }
539 return d
540}
541
542// waitForServerReady polls GET /v1/health until the server responds with
543// any 2xx status or the total timeout elapses. Each attempt uses a short
544// per-attempt timeout so a hung listener doesn't burn the whole budget.
545//
546// The HTTP transport is built to mirror how *client.Client dials so the
547// same unix socket / npipe / tcp setups all work uniformly here.
548func waitForServerReady(ctx context.Context, hostURL *url.URL) error {
549 httpClient, reqURL, err := readinessHTTPClient(hostURL)
550 if err != nil {
551 return err
552 }
553
554 const perAttempt = 100 * time.Millisecond
555 deadline := time.Now().Add(serverReadyTimeout())
556
557 var lastErr error
558 for {
559 if err := ctx.Err(); err != nil {
560 return err
561 }
562 if time.Now().After(deadline) {
563 if lastErr != nil {
564 return lastErr
565 }
566 return fmt.Errorf("timed out waiting for server readiness")
567 }
568
569 attemptCtx, cancel := context.WithTimeout(ctx, perAttempt)
570 err := probeHealth(attemptCtx, httpClient, reqURL, hostURL)
571 cancel()
572 if err == nil {
573 return nil
574 }
575 lastErr = err
576
577 select {
578 case <-ctx.Done():
579 return ctx.Err()
580 case <-time.After(perAttempt):
581 }
582 }
583}
584
585// readinessHTTPClient builds an *http.Client whose transport dials the
586// server using the same scheme-aware logic as *client.Client (unix
587// socket, named pipe, or tcp).
588func readinessHTTPClient(hostURL *url.URL) (*http.Client, string, error) {
589 c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
590 if err != nil {
591 return nil, "", err
592 }
593
594 tr := http.DefaultTransport.(*http.Transport).Clone()
595 tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
596 return c.Dial(ctx, network, addr)
597 }
598 if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
599 tr.DisableCompression = true
600 }
601
602 httpClient := &http.Client{Transport: tr}
603
604 // For unix sockets / named pipes we still need a syntactically valid
605 // HTTP URL; the actual address is resolved by the dialer.
606 host := hostURL.Host
607 if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
608 host = client.DummyHost
609 }
610 reqURL := (&url.URL{Scheme: "http", Host: host, Path: "/v1/health"}).String()
611 return httpClient, reqURL, nil
612}
613
614// probeHealth issues a single GET to the readiness endpoint and treats
615// any 2xx response as success.
616func probeHealth(ctx context.Context, h *http.Client, reqURL string, hostURL *url.URL) error {
617 req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
618 if err != nil {
619 return err
620 }
621 if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
622 req.Host = client.DummyHost
623 }
624 rsp, err := h.Do(req)
625 if err != nil {
626 return err
627 }
628 defer rsp.Body.Close()
629 _, _ = io.Copy(io.Discard, rsp.Body)
630 if rsp.StatusCode < 200 || rsp.StatusCode >= 300 {
631 return fmt.Errorf("server health check failed: %s", rsp.Status)
632 }
633 return nil
634}
635
636// restartIfStale checks whether the running server matches the current
637// client version. When they differ, it sends a shutdown command and
638// removes the stale socket so the caller can start a fresh server.
639//
640// It returns restarted=true when it has shut down a stale server and the
641// caller must spawn a new one. When the server matches the client version
642// (or the check itself fails), restarted is false.
643func restartIfStale(cmd *cobra.Command, hostURL *url.URL) (restarted bool, err error) {
644 c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
645 if err != nil {
646 return false, err
647 }
648 vi, err := c.VersionInfo(cmd.Context())
649 if err != nil {
650 return false, err
651 }
652 if vi.Version == version.Version && vi.BuildID == version.BuildID {
653 return false, nil
654 }
655 slog.Info(
656 "Server version mismatch, restarting",
657 "server_version", vi.Version,
658 "client_version", version.Version,
659 "server_build_id", vi.BuildID,
660 "client_build_id", version.BuildID,
661 )
662 _ = c.ShutdownServer(cmd.Context())
663 // Give the old process a moment to release the socket.
664 for range 20 {
665 if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
666 break
667 }
668 select {
669 case <-cmd.Context().Done():
670 return true, cmd.Context().Err()
671 case <-time.After(100 * time.Millisecond):
672 }
673 }
674 // Force-remove if the socket is still lingering.
675 _ = os.Remove(hostURL.Host)
676 return true, nil
677}
678
679var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
680
681func startDetachedServer(cmd *cobra.Command, hostURL *url.URL) error {
682 exe, err := os.Executable()
683 if err != nil {
684 return fmt.Errorf("failed to get executable path: %v", err)
685 }
686
687 chDir, err := perHostServerDir(hostURL)
688 if err != nil {
689 return err
690 }
691
692 cmdArgs := []string{"server"}
693 if clientHost != server.DefaultHost() {
694 cmdArgs = append(cmdArgs, "--host", clientHost)
695 }
696
697 // Use context.Background() so the parent's context cancellation does not
698 // kill the spawned server. detachProcess (Setsid on !windows,
699 // DETACHED_PROCESS on windows) is what truly detaches the child from
700 // this process's lifetime.
701 c := exec.CommandContext(context.Background(), exe, cmdArgs...)
702 stdoutPath := filepath.Join(chDir, "stdout.log")
703 stderrPath := filepath.Join(chDir, "stderr.log")
704 detachProcess(c)
705
706 stdout, err := os.Create(stdoutPath)
707 if err != nil {
708 return fmt.Errorf("failed to create stdout log file: %v", err)
709 }
710 defer stdout.Close()
711 c.Stdout = stdout
712
713 stderr, err := os.Create(stderrPath)
714 if err != nil {
715 return fmt.Errorf("failed to create stderr log file: %v", err)
716 }
717 defer stderr.Close()
718 c.Stderr = stderr
719
720 if err := c.Start(); err != nil {
721 return fmt.Errorf("failed to start crush server: %v", err)
722 }
723
724 if err := c.Process.Release(); err != nil {
725 return fmt.Errorf("failed to detach crush server process: %v", err)
726 }
727
728 return nil
729}
730
731func shouldEnableMetrics(cfg *config.Config) bool {
732 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
733 return false
734 }
735 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
736 return false
737 }
738 if cfg.Options.DisableMetrics {
739 return false
740 }
741 return true
742}
743
744func MaybePrependStdin(prompt string) (string, error) {
745 if term.IsTerminal(os.Stdin.Fd()) {
746 return prompt, nil
747 }
748 fi, err := os.Stdin.Stat()
749 if err != nil {
750 return prompt, err
751 }
752 // Check if stdin is a named pipe ( | ) or regular file ( < ).
753 if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
754 return prompt, nil
755 }
756 bts, err := io.ReadAll(os.Stdin)
757 if err != nil {
758 return prompt, err
759 }
760 return string(bts) + "\n\n" + prompt, nil
761}
762
763// resolveWorkspaceSessionID resolves a session ID that may be a full
764// UUID, full hash, or hash prefix. Works against the Workspace
765// interface so both local and client/server paths get hash prefix
766// support.
767func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
768 if sess, err := ws.GetSession(ctx, id); err == nil {
769 return sess, nil
770 }
771
772 sessions, err := ws.ListSessions(ctx)
773 if err != nil {
774 return session.Session{}, err
775 }
776
777 var matches []session.Session
778 for _, s := range sessions {
779 hash := session.HashID(s.ID)
780 if hash == id || strings.HasPrefix(hash, id) {
781 matches = append(matches, s)
782 }
783 }
784
785 switch len(matches) {
786 case 0:
787 return session.Session{}, fmt.Errorf("session not found: %s", id)
788 case 1:
789 return matches[0], nil
790 default:
791 return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
792 }
793}
794
795func ResolveCwd(cmd *cobra.Command) (string, error) {
796 cwd, _ := cmd.Flags().GetString("cwd")
797 if cwd != "" {
798 err := os.Chdir(cwd)
799 if err != nil {
800 return "", fmt.Errorf("failed to change directory: %v", err)
801 }
802 return cwd, nil
803 }
804 cwd, err := os.Getwd()
805 if err != nil {
806 return "", fmt.Errorf("failed to get current working directory: %v", err)
807 }
808 return cwd, nil
809}
810
811func createDotCrushDir(dir string) error {
812 if err := os.MkdirAll(dir, 0o700); err != nil {
813 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
814 }
815
816 gitIgnorePath := filepath.Join(dir, ".gitignore")
817 content, err := os.ReadFile(gitIgnorePath)
818
819 // create or update if old version
820 if os.IsNotExist(err) || string(content) == oldGitIgnore {
821 if err := os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0o644); err != nil {
822 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
823 }
824 }
825
826 return nil
827}
828
829//go:embed gitignore/old
830var oldGitIgnore string
831
832//go:embed gitignore/default
833var defaultGitIgnore string