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