root.go

  1package cmd
  2
  3import (
  4	"context"
  5	"errors"
  6	"fmt"
  7	"io"
  8	"io/fs"
  9	"log/slog"
 10	"net/url"
 11	"os"
 12	"os/exec"
 13	"path/filepath"
 14	"regexp"
 15	"time"
 16
 17	tea "github.com/charmbracelet/bubbletea/v2"
 18	"github.com/charmbracelet/crush/internal/client"
 19	"github.com/charmbracelet/crush/internal/config"
 20	"github.com/charmbracelet/crush/internal/log"
 21	"github.com/charmbracelet/crush/internal/proto"
 22	"github.com/charmbracelet/crush/internal/server"
 23	"github.com/charmbracelet/crush/internal/tui"
 24	"github.com/charmbracelet/crush/internal/version"
 25	"github.com/charmbracelet/fang"
 26	"github.com/charmbracelet/x/term"
 27	"github.com/spf13/cobra"
 28)
 29
 30var clientHost string
 31
 32func init() {
 33	rootCmd.PersistentFlags().StringP("cwd", "c", "", "Current working directory")
 34	rootCmd.PersistentFlags().StringP("data-dir", "D", "", "Custom crush data directory")
 35	rootCmd.PersistentFlags().BoolP("debug", "d", false, "Debug")
 36
 37	rootCmd.Flags().BoolP("help", "h", false, "Help")
 38	rootCmd.Flags().BoolP("yolo", "y", false, "Automatically accept all permissions (dangerous mode)")
 39
 40	rootCmd.Flags().StringVar(&clientHost, "host", server.DefaultHost(), "Connect to a specific crush server host (for advanced users)")
 41
 42	rootCmd.AddCommand(runCmd)
 43	rootCmd.AddCommand(updateProvidersCmd)
 44}
 45
 46var rootCmd = &cobra.Command{
 47	Use:   "crush",
 48	Short: "Terminal-based AI assistant for software development",
 49	Long: `Crush is a powerful terminal-based AI assistant that helps with software development tasks.
 50It provides an interactive chat interface with AI capabilities, code analysis, and LSP integration
 51to assist developers in writing, debugging, and understanding code directly from the terminal.`,
 52	Example: `
 53# Run in interactive mode
 54crush
 55
 56# Run with debug logging
 57crush -d
 58
 59# Run with debug logging in a specific directory
 60crush -d -c /path/to/project
 61
 62# Run with custom data directory
 63crush -D /path/to/custom/.crush
 64
 65# Print version
 66crush -v
 67
 68# Run a single non-interactive prompt
 69crush run "Explain the use of context in Go"
 70
 71# Run in dangerous mode (auto-accept all permissions)
 72crush -y
 73  `,
 74	RunE: func(cmd *cobra.Command, args []string) error {
 75		hostURL, err := server.ParseHostURL(clientHost)
 76		if err != nil {
 77			return fmt.Errorf("invalid host URL: %v", err)
 78		}
 79
 80		switch hostURL.Scheme {
 81		case "unix", "npipe":
 82			_, err := os.Stat(hostURL.Host)
 83			if err != nil && errors.Is(err, fs.ErrNotExist) {
 84				slog.Info("Starting server...", "host", clientHost)
 85				if err := startDetachedServer(cmd); err != nil {
 86					return err
 87				}
 88			}
 89
 90			// Wait for the file to appear
 91			for range 10 {
 92				_, err = os.Stat(hostURL.Host)
 93				if err == nil {
 94					break
 95				}
 96				select {
 97				case <-cmd.Context().Done():
 98					return cmd.Context().Err()
 99				case <-time.After(100 * time.Millisecond):
100				}
101			}
102			if err != nil {
103				return fmt.Errorf("failed to connect to crush server: %v", err)
104			}
105
106		default:
107			// TODO: implement TCP support
108		}
109
110		c, err := setupApp(cmd, hostURL)
111		if err != nil {
112			return err
113		}
114
115		tries := 5
116		for i := range tries {
117			err := c.Health()
118			if err == nil {
119				break
120			}
121			select {
122			case <-cmd.Context().Done():
123				return cmd.Context().Err()
124			case <-time.After(100 * time.Millisecond):
125			}
126			if i == tries-1 {
127				return fmt.Errorf("failed to connect to crush server after %d attempts: %v", tries, err)
128			}
129		}
130
131		m, err := tui.New(c)
132		if err != nil {
133			return fmt.Errorf("failed to create TUI model: %v", err)
134		}
135
136		defer func() { c.DeleteInstance(cmd.Context(), c.ID()) }()
137
138		// Set up the TUI.
139		program := tea.NewProgram(
140			m,
141			tea.WithAltScreen(),
142			tea.WithContext(cmd.Context()),
143			tea.WithMouseCellMotion(),            // Use cell motion instead of all motion to reduce event flooding
144			tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
145		)
146
147		evc, err := c.SubscribeEvents(cmd.Context())
148		if err != nil {
149			return fmt.Errorf("failed to subscribe to events: %v", err)
150		}
151
152		go streamEvents(cmd.Context(), evc, program)
153
154		if _, err := program.Run(); err != nil {
155			slog.Error("TUI run error", "error", err)
156			return fmt.Errorf("TUI error: %v", err)
157		}
158		return nil
159	},
160}
161
162func Execute() {
163	if err := fang.Execute(
164		context.Background(),
165		rootCmd,
166		fang.WithVersion(version.Version),
167		fang.WithNotifySignal(os.Interrupt),
168	); err != nil {
169		os.Exit(1)
170	}
171}
172
173func streamEvents(ctx context.Context, evc <-chan any, p *tea.Program) {
174	defer log.RecoverPanic("app.Subscribe", func() {
175		slog.Info("TUI subscription panic: attempting graceful shutdown")
176		p.Quit()
177	})
178
179	for {
180		select {
181		case <-ctx.Done():
182			slog.Debug("TUI message handler shutting down")
183			return
184		case ev, ok := <-evc:
185			if !ok {
186				slog.Debug("TUI message channel closed")
187				return
188			}
189			p.Send(ev)
190		}
191	}
192}
193
194// setupApp handles the common setup logic for both interactive and non-interactive modes.
195// It returns the app instance, config, cleanup function, and any error.
196func setupApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, error) {
197	debug, _ := cmd.Flags().GetBool("debug")
198	yolo, _ := cmd.Flags().GetBool("yolo")
199	dataDir, _ := cmd.Flags().GetString("data-dir")
200	ctx := cmd.Context()
201
202	cwd, err := ResolveCwd(cmd)
203	if err != nil {
204		return nil, err
205	}
206
207	c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
208	if err != nil {
209		return nil, err
210	}
211
212	ins, err := c.CreateInstance(ctx, proto.Instance{
213		Path:    cwd,
214		DataDir: dataDir,
215		Debug:   debug,
216		YOLO:    yolo,
217	})
218	if err != nil {
219		return nil, fmt.Errorf("failed to create or connect to instance: %v", err)
220	}
221
222	c.SetID(ins.ID)
223
224	return c, nil
225}
226
227var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
228
229func startDetachedServer(cmd *cobra.Command) error {
230	// Start the server as a detached process if the socket does not exist.
231	exe, err := os.Executable()
232	if err != nil {
233		return fmt.Errorf("failed to get executable path: %v", err)
234	}
235
236	safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
237	chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
238	if err := os.MkdirAll(chDir, 0o700); err != nil {
239		return fmt.Errorf("failed to create server working directory: %v", err)
240	}
241
242	c := exec.CommandContext(cmd.Context(), exe, "server")
243	stdoutPath := filepath.Join(chDir, "stdout.log")
244	stderrPath := filepath.Join(chDir, "stderr.log")
245	detachProcess(c)
246
247	stdout, err := os.Create(stdoutPath)
248	if err != nil {
249		return fmt.Errorf("failed to create stdout log file: %v", err)
250	}
251	defer stdout.Close()
252	c.Stdout = stdout
253
254	stderr, err := os.Create(stderrPath)
255	if err != nil {
256		return fmt.Errorf("failed to create stderr log file: %v", err)
257	}
258	defer stderr.Close()
259	c.Stderr = stderr
260
261	if err := c.Start(); err != nil {
262		return fmt.Errorf("failed to start crush server: %v", err)
263	}
264
265	if err := c.Process.Release(); err != nil {
266		return fmt.Errorf("failed to detach crush server process: %v", err)
267	}
268
269	return nil
270}
271
272func MaybePrependStdin(prompt string) (string, error) {
273	if term.IsTerminal(os.Stdin.Fd()) {
274		return prompt, nil
275	}
276	fi, err := os.Stdin.Stat()
277	if err != nil {
278		return prompt, err
279	}
280	if fi.Mode()&os.ModeNamedPipe == 0 {
281		return prompt, nil
282	}
283	bts, err := io.ReadAll(os.Stdin)
284	if err != nil {
285		return prompt, err
286	}
287	return string(bts) + "\n\n" + prompt, nil
288}
289
290func ResolveCwd(cmd *cobra.Command) (string, error) {
291	cwd, _ := cmd.Flags().GetString("cwd")
292	if cwd != "" {
293		err := os.Chdir(cwd)
294		if err != nil {
295			return "", fmt.Errorf("failed to change directory: %v", err)
296		}
297		return cwd, nil
298	}
299	cwd, err := os.Getwd()
300	if err != nil {
301		return "", fmt.Errorf("failed to get current working directory: %v", err)
302	}
303	return cwd, nil
304}
305
306func createDotCrushDir(dir string) error {
307	if err := os.MkdirAll(dir, 0o700); err != nil {
308		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
309	}
310
311	gitIgnorePath := filepath.Join(dir, ".gitignore")
312	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
313		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
314			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
315		}
316	}
317
318	return nil
319}