on 2faa467 help sign in
crush/internal/cmd/root.go
827 lines 26 kB history raw
1package cmd
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"
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"
50var clientHost string
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")
63 rootCmd.AddCommand(
64 runCmd,
65 dirsCmd,
66 projectsCmd,
67 updateProvidersCmd,
68 logsCmd,
69 logoutCmd,
70 schemaCmd,
71 loginCmd,
72 statsCmd,
73 sessionCmd,
74 )
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
85# Run non-interactively
86crush run "Guess my 5 favorite Pokémon"
88# Run a non-interactively with pipes and redirection
89cat README.md | crush run "make this more glamorous" > GLAMOROUS_README.md
91# Run with debug logging in a specific directory
92crush --debug --cwd /path/to/project
94# Run in yolo mode (auto-accept all permissions; use with care)
95crush --yolo
97# Run with custom data directory
98crush --data-dir /path/to/custom/.crush
100# Continue a previous session
101crush --session {session-id}
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")
110 ws, cleanup, err := setupWorkspaceWithProgressBar(cmd)
111 if err != nil {
112 return err
113 }
114 defer cleanup()
116 if sessionID != "" {
117 sess, err := resolveWorkspaceSessionID(cmd.Context(), ws, sessionID)
118 if err != nil {
119 return err
120 }
121 sessionID = sess.ID
122 }
124 event.AppInitialized()
126 com := common.DefaultCommon(ws)
127 model := ui.New(com, sessionID, continueLast)
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)
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 },
147var heartbit = lipgloss.NewStyle().Foreground(charmtone.Dolly).SetString(`
148 ▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄
149 ███████████ ███████████
150████████████████████████████
151████████████████████████████
152██████████▀██████▀██████████
153██████████ ██████ ██████████
154▀▀██████▄████▄▄████▄██████▀▀
155 ████████████████████████
156 ████████████████████
157 ▀▀██████████▀▀
158 ▀▀▀▀▀▀
159`)
161// copied from cobra:
162const defaultVersionTemplate = `{{with .DisplayName}}{{printf "%s " .}}{{end}}{{printf "version %s" .Version}}
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))
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 }
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")
208 return isWindowsTerminal || xstrings.ContainsAnyOf(strings.ToLower(termProg), "ghostty", "iterm2", "rio")
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
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 }
226 ws, cleanup, err := setupWorkspace(cmd)
228 if showProgress {
229 _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
230 }
232 return ws, cleanup, err
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)
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()
254 cwd, err := ResolveCwd(cmd)
255 if err != nil {
256 return nil, nil, err
257 }
259 store, err := config.Init(cwd, dataDir, debug)
260 if err != nil {
261 return nil, nil, err
262 }
264 cfg := store.Config()
265 store.Overrides().SkipPermissionRequests = yolo
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 }
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 }
278 if err := projects.Register(cwd, cfg.Options.DataDirectory); err != nil {
279 slog.Warn("Failed to register project", "error", err)
280 }
282 conn, err := db.Connect(ctx, cfg.Options.DataDirectory)
283 if err != nil {
284 return nil, nil, err
285 }
287 logFile := filepath.Join(cfg.Options.DataDirectory, "logs", "crush.log")
288 crushlog.Setup(logFile, debug)
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 allSkills, activeSkills, skillStates := skills.DiscoverFromConfig(localSkillsDiscoveryConfig(store))
295 skillsMgr := skills.NewManager(allSkills, activeSkills, skillStates, skills.WithGlobalMirror())
297 appInstance, err := app.New(ctx, conn, store, skillsMgr)
298 if err != nil {
299 _ = conn.Close()
300 slog.Error("Failed to create app instance", "error", err)
301 return nil, nil, err
302 }
304 if shouldEnableMetrics(cfg) {
305 event.Init()
306 }
308 ws := workspace.NewAppWorkspace(appInstance, store)
309 cleanup := func() { appInstance.Shutdown() }
310 return ws, cleanup, nil
313// localSkillsDiscoveryConfig adapts a *config.ConfigStore to the inputs
314// skills.DiscoverFromConfig expects.
315func localSkillsDiscoveryConfig(store *config.ConfigStore) skills.DiscoveryConfig {
316 opts := store.Config().Options
317 var paths, disabled []string
318 if opts != nil {
319 paths = opts.SkillsPaths
320 disabled = opts.DisabledSkills
321 }
322 var resolver func(string) (string, error)
323 if r := store.Resolver(); r != nil {
324 resolver = r.ResolveValue
325 }
326 return skills.DiscoveryConfig{
327 SkillsPaths: paths,
328 DisabledSkills: disabled,
329 Resolver: resolver,
330 }
333// setupClientServerWorkspace connects to a server process and wraps the
334// result in a ClientWorkspace.
335func setupClientServerWorkspace(cmd *cobra.Command) (workspace.Workspace, func(), error) {
336 c, protoWs, cleanupServer, err := connectToServer(cmd)
337 if err != nil {
338 return nil, nil, err
339 }
341 clientWs := workspace.NewClientWorkspace(c, *protoWs)
343 if protoWs.Config.IsConfigured() {
344 if err := clientWs.InitCoderAgent(cmd.Context()); err != nil {
345 slog.Error("Failed to initialize coder agent", "error", err)
346 }
347 }
349 return clientWs, cleanupServer, nil
352// connectToServer ensures the server is running, creates a client and
353// workspace, and returns a cleanup function that deletes the workspace.
354func connectToServer(cmd *cobra.Command) (*client.Client, *proto.Workspace, func(), error) {
355 hostURL, err := server.ParseHostURL(clientHost)
356 if err != nil {
357 return nil, nil, nil, fmt.Errorf("invalid host URL: %v", err)
358 }
360 if err := ensureServer(cmd, hostURL); err != nil {
361 return nil, nil, nil, err
362 }
364 debug, _ := cmd.Flags().GetBool("debug")
365 yolo, _ := cmd.Flags().GetBool("yolo")
366 dataDir, _ := cmd.Flags().GetString("data-dir")
367 ctx := cmd.Context()
369 cwd, err := ResolveCwd(cmd)
370 if err != nil {
371 return nil, nil, nil, err
372 }
374 c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
375 if err != nil {
376 return nil, nil, nil, err
377 }
379 wsReq := proto.Workspace{
380 Path: cwd,
381 DataDir: dataDir,
382 Debug: debug,
383 YOLO: yolo,
384 Version: version.Version,
385 Env: os.Environ(),
386 }
388 ws, err := c.CreateWorkspace(ctx, wsReq)
389 if err != nil {
390 return nil, nil, nil, fmt.Errorf("failed to create workspace: %v", err)
391 }
393 if shouldEnableMetrics(ws.Config) {
394 event.Init()
395 }
397 if ws.Config != nil {
398 logFile := filepath.Join(ws.Config.Options.DataDirectory, "logs", "crush.log")
399 crushlog.Setup(logFile, debug)
400 }
402 cleanup := func() { _ = c.DeleteWorkspace(context.Background(), ws.ID) }
403 return c, ws, cleanup, nil
406// ensureServer auto-starts a detached server if the socket file does not
407// exist. When the socket exists, it verifies that the running server
408// version matches the client; on mismatch it shuts down the old server
409// and starts a fresh one.
410func ensureServer(cmd *cobra.Command, hostURL *url.URL) error {
411 switch hostURL.Scheme {
412 case "unix", "npipe":
413 needsStart := false
414 _, statErr := os.Stat(hostURL.Host)
415 switch {
416 case statErr == nil:
417 restarted, err := restartIfStale(cmd, hostURL)
418 if err != nil {
419 slog.Warn("Failed to check server version", "error", err)
420 }
421 needsStart = restarted || err != nil
422 case errors.Is(statErr, fs.ErrNotExist):
423 needsStart = true
424 default:
425 slog.Warn("Unexpected error stat'ing server socket, attempting cleanup",
426 "path", hostURL.Host, "error", statErr)
427 if err := os.Remove(hostURL.Host); err != nil && !errors.Is(err, fs.ErrNotExist) {
428 return fmt.Errorf("failed to remove stale server socket %q: %v", hostURL.Host, err)
429 }
430 needsStart = true
431 }
433 if needsStart {
434 if err := spawnAndWaitReady(cmd, hostURL); err != nil {
435 return fmt.Errorf("failed to initialize crush server: %v", err)
436 }
437 return nil
438 }
440 if err := waitForServerReady(cmd.Context(), hostURL); err != nil {
441 return fmt.Errorf("failed to initialize crush server: %v", err)
442 }
443 }
445 return nil
448// spawnAndWaitReady serializes the spawn-and-wait-for-readiness sequence
449// across concurrent clients via an exclusive flock on
450// $XDG_CACHE_HOME/crush/server-<safeHost>/start.lock.
451//
452// After acquiring the lock it re-probes readiness so that a client that
453// blocked while another client was spawning can skip its own spawn and
454// just use the now-running server. The lock is held only for the
455// duration of "spawn + readiness probe" and released before the caller
456// resumes its normal lifetime.
457func spawnAndWaitReady(cmd *cobra.Command, hostURL *url.URL) error {
458 chDir, err := perHostServerDir(hostURL)
459 if err != nil {
460 return err
461 }
462 release, err := acquireSpawnLock(filepath.Join(chDir, "start.lock"))
463 if err != nil {
464 // If the lock itself is unavailable, fall back to the
465 // unsynchronized path rather than blocking the user.
466 slog.Warn("Failed to acquire spawn lock, proceeding without single-flight", "error", err)
467 if err := startDetachedServer(cmd, hostURL); err != nil {
468 return err
469 }
470 return waitForServerReady(cmd.Context(), hostURL)
471 }
472 defer release()
474 // Another client may have just finished spawning while we were
475 // waiting on the lock; if the server is already responsive, skip
476 // the spawn entirely.
477 probeCtx, cancel := context.WithTimeout(cmd.Context(), 200*time.Millisecond)
478 probeErr := quickHealthProbe(probeCtx, hostURL)
479 cancel()
480 if probeErr == nil {
481 return nil
482 }
484 if err := startDetachedServer(cmd, hostURL); err != nil {
485 return err
486 }
487 return waitForServerReady(cmd.Context(), hostURL)
490// quickHealthProbe issues a single readiness request with the caller's
491// context and returns nil iff the server is responsive right now.
492func quickHealthProbe(ctx context.Context, hostURL *url.URL) error {
493 httpClient, reqURL, err := readinessHTTPClient(hostURL)
494 if err != nil {
495 return err
496 }
497 return probeHealth(ctx, httpClient, reqURL, hostURL)
500// perHostServerDir returns (and creates) the cache directory used for
501// per-host server state (logs, start.lock, etc.). The path is derived
502// from the parsed host URL rather than the global flag so the same key
503// is computed regardless of where the host came from.
504func perHostServerDir(hostURL *url.URL) (string, error) {
505 chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeHostName(hostURL))
506 if err := os.MkdirAll(chDir, 0o700); err != nil {
507 return "", fmt.Errorf("failed to create server working directory: %v", err)
508 }
509 return chDir, nil
512// safeHostName returns a filesystem-safe identifier for hostURL,
513// suitable for use as a directory name. It mirrors the input shape of
514// the --host flag so client and server compute the same key.
515func safeHostName(hostURL *url.URL) string {
516 return safeNameRegexp.ReplaceAllString(
517 hostURL.Scheme+"://"+hostURL.Host+hostURL.Path, "_",
518 )
521// serverReadyTimeout returns the total budget for the readiness probe.
522// Overridable via CRUSH_SERVER_READY_TIMEOUT (parsed as a Go duration).
523func serverReadyTimeout() time.Duration {
524 const def = 10 * time.Second
525 v := os.Getenv("CRUSH_SERVER_READY_TIMEOUT")
526 if v == "" {
527 return def
528 }
529 d, err := time.ParseDuration(v)
530 if err != nil || d <= 0 {
531 return def
532 }
533 return d
536// waitForServerReady polls GET /v1/health until the server responds with
537// any 2xx status or the total timeout elapses. Each attempt uses a short
538// per-attempt timeout so a hung listener doesn't burn the whole budget.
539//
540// The HTTP transport is built to mirror how *client.Client dials so the
541// same unix socket / npipe / tcp setups all work uniformly here.
542func waitForServerReady(ctx context.Context, hostURL *url.URL) error {
543 httpClient, reqURL, err := readinessHTTPClient(hostURL)
544 if err != nil {
545 return err
546 }
548 const perAttempt = 100 * time.Millisecond
549 deadline := time.Now().Add(serverReadyTimeout())
551 var lastErr error
552 for {
553 if err := ctx.Err(); err != nil {
554 return err
555 }
556 if time.Now().After(deadline) {
557 if lastErr != nil {
558 return lastErr
559 }
560 return fmt.Errorf("timed out waiting for server readiness")
561 }
563 attemptCtx, cancel := context.WithTimeout(ctx, perAttempt)
564 err := probeHealth(attemptCtx, httpClient, reqURL, hostURL)
565 cancel()
566 if err == nil {
567 return nil
568 }
569 lastErr = err
571 select {
572 case <-ctx.Done():
573 return ctx.Err()
574 case <-time.After(perAttempt):
575 }
576 }
579// readinessHTTPClient builds an *http.Client whose transport dials the
580// server using the same scheme-aware logic as *client.Client (unix
581// socket, named pipe, or tcp).
582func readinessHTTPClient(hostURL *url.URL) (*http.Client, string, error) {
583 c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
584 if err != nil {
585 return nil, "", err
586 }
588 tr := http.DefaultTransport.(*http.Transport).Clone()
589 tr.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
590 return c.Dial(ctx, network, addr)
591 }
592 if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
593 tr.DisableCompression = true
594 }
596 httpClient := &http.Client{Transport: tr}
598 // For unix sockets / named pipes we still need a syntactically valid
599 // HTTP URL; the actual address is resolved by the dialer.
600 host := hostURL.Host
601 if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
602 host = client.DummyHost
603 }
604 reqURL := (&url.URL{Scheme: "http", Host: host, Path: "/v1/health"}).String()
605 return httpClient, reqURL, nil
608// probeHealth issues a single GET to the readiness endpoint and treats
609// any 2xx response as success.
610func probeHealth(ctx context.Context, h *http.Client, reqURL string, hostURL *url.URL) error {
611 req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
612 if err != nil {
613 return err
614 }
615 if hostURL.Scheme == "unix" || hostURL.Scheme == "npipe" {
616 req.Host = client.DummyHost
617 }
618 rsp, err := h.Do(req)
619 if err != nil {
620 return err
621 }
622 defer rsp.Body.Close()
623 _, _ = io.Copy(io.Discard, rsp.Body)
624 if rsp.StatusCode < 200 || rsp.StatusCode >= 300 {
625 return fmt.Errorf("server health check failed: %s", rsp.Status)
626 }
627 return nil
630// restartIfStale checks whether the running server matches the current
631// client version. When they differ, it sends a shutdown command and
632// removes the stale socket so the caller can start a fresh server.
633//
634// It returns restarted=true when it has shut down a stale server and the
635// caller must spawn a new one. When the server matches the client version
636// (or the check itself fails), restarted is false.
637func restartIfStale(cmd *cobra.Command, hostURL *url.URL) (restarted bool, err error) {
638 c, err := client.NewClient("", hostURL.Scheme, hostURL.Host)
639 if err != nil {
640 return false, err
641 }
642 vi, err := c.VersionInfo(cmd.Context())
643 if err != nil {
644 return false, err
645 }
646 if vi.Version == version.Version && vi.BuildID == version.BuildID {
647 return false, nil
648 }
649 slog.Info(
650 "Server version mismatch, restarting",
651 "server_version", vi.Version,
652 "client_version", version.Version,
653 "server_build_id", vi.BuildID,
654 "client_build_id", version.BuildID,
655 )
656 _ = c.ShutdownServer(cmd.Context())
657 // Give the old process a moment to release the socket.
658 for range 20 {
659 if _, err := os.Stat(hostURL.Host); errors.Is(err, fs.ErrNotExist) {
660 break
661 }
662 select {
663 case <-cmd.Context().Done():
664 return true, cmd.Context().Err()
665 case <-time.After(100 * time.Millisecond):
666 }
667 }
668 // Force-remove if the socket is still lingering.
669 _ = os.Remove(hostURL.Host)
670 return true, nil
673var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
675func startDetachedServer(cmd *cobra.Command, hostURL *url.URL) error {
676 exe, err := os.Executable()
677 if err != nil {
678 return fmt.Errorf("failed to get executable path: %v", err)
679 }
681 chDir, err := perHostServerDir(hostURL)
682 if err != nil {
683 return err
684 }
686 cmdArgs := []string{"server"}
687 if clientHost != server.DefaultHost() {
688 cmdArgs = append(cmdArgs, "--host", clientHost)
689 }
691 // Use context.Background() so the parent's context cancellation does not
692 // kill the spawned server. detachProcess (Setsid on !windows,
693 // DETACHED_PROCESS on windows) is what truly detaches the child from
694 // this process's lifetime.
695 c := exec.CommandContext(context.Background(), exe, cmdArgs...)
696 stdoutPath := filepath.Join(chDir, "stdout.log")
697 stderrPath := filepath.Join(chDir, "stderr.log")
698 detachProcess(c)
700 stdout, err := os.Create(stdoutPath)
701 if err != nil {
702 return fmt.Errorf("failed to create stdout log file: %v", err)
703 }
704 defer stdout.Close()
705 c.Stdout = stdout
707 stderr, err := os.Create(stderrPath)
708 if err != nil {
709 return fmt.Errorf("failed to create stderr log file: %v", err)
710 }
711 defer stderr.Close()
712 c.Stderr = stderr
714 if err := c.Start(); err != nil {
715 return fmt.Errorf("failed to start crush server: %v", err)
716 }
718 if err := c.Process.Release(); err != nil {
719 return fmt.Errorf("failed to detach crush server process: %v", err)
720 }
722 return nil
725func shouldEnableMetrics(cfg *config.Config) bool {
726 if v, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_METRICS")); v {
727 return false
728 }
729 if v, _ := strconv.ParseBool(os.Getenv("DO_NOT_TRACK")); v {
730 return false
731 }
732 if cfg.Options.DisableMetrics {
733 return false
734 }
735 return true
738func MaybePrependStdin(prompt string) (string, error) {
739 if term.IsTerminal(os.Stdin.Fd()) {
740 return prompt, nil
741 }
742 fi, err := os.Stdin.Stat()
743 if err != nil {
744 return prompt, err
745 }
746 // Check if stdin is a named pipe ( | ) or regular file ( < ).
747 if fi.Mode()&os.ModeNamedPipe == 0 && !fi.Mode().IsRegular() {
748 return prompt, nil
749 }
750 bts, err := io.ReadAll(os.Stdin)
751 if err != nil {
752 return prompt, err
753 }
754 return string(bts) + "\n\n" + prompt, nil
757// resolveWorkspaceSessionID resolves a session ID that may be a full
758// UUID, full hash, or hash prefix. Works against the Workspace
759// interface so both local and client/server paths get hash prefix
760// support.
761func resolveWorkspaceSessionID(ctx context.Context, ws workspace.Workspace, id string) (session.Session, error) {
762 if sess, err := ws.GetSession(ctx, id); err == nil {
763 return sess, nil
764 }
766 sessions, err := ws.ListSessions(ctx)
767 if err != nil {
768 return session.Session{}, err
769 }
771 var matches []session.Session
772 for _, s := range sessions {
773 hash := session.HashID(s.ID)
774 if hash == id || strings.HasPrefix(hash, id) {
775 matches = append(matches, s)
776 }
777 }
779 switch len(matches) {
780 case 0:
781 return session.Session{}, fmt.Errorf("session not found: %s", id)
782 case 1:
783 return matches[0], nil
784 default:
785 return session.Session{}, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
786 }
789func ResolveCwd(cmd *cobra.Command) (string, error) {
790 cwd, _ := cmd.Flags().GetString("cwd")
791 if cwd != "" {
792 err := os.Chdir(cwd)
793 if err != nil {
794 return "", fmt.Errorf("failed to change directory: %v", err)
795 }
796 return cwd, nil
797 }
798 cwd, err := os.Getwd()
799 if err != nil {
800 return "", fmt.Errorf("failed to get current working directory: %v", err)
801 }
802 return cwd, nil
805func createDotCrushDir(dir string) error {
806 if err := os.MkdirAll(dir, 0o700); err != nil {
807 return fmt.Errorf("failed to create data directory: %q %w", dir, err)
808 }
810 gitIgnorePath := filepath.Join(dir, ".gitignore")
811 content, err := os.ReadFile(gitIgnorePath)
813 // create or update if old version
814 if os.IsNotExist(err) || string(content) == oldGitIgnore {
815 if err := os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0o644); err != nil {
816 return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
817 }
818 }
820 return nil
823//go:embed gitignore/old
824var oldGitIgnore string
826//go:embed gitignore/default
827var defaultGitIgnore string