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 initialize 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		for range 10 {
116			err = c.Health()
117			if err == nil {
118				break
119			}
120			select {
121			case <-cmd.Context().Done():
122				return cmd.Context().Err()
123			case <-time.After(100 * time.Millisecond):
124			}
125		}
126		if err != nil {
127			return fmt.Errorf("failed to connect to crush server: %v", err)
128		}
129
130		m, err := tui.New(c)
131		if err != nil {
132			return fmt.Errorf("failed to create TUI model: %v", err)
133		}
134
135		defer func() { c.DeleteInstance(cmd.Context(), c.ID()) }()
136
137		// Set up the TUI.
138		program := tea.NewProgram(
139			m,
140			tea.WithAltScreen(),
141			tea.WithContext(cmd.Context()),
142			tea.WithMouseCellMotion(),            // Use cell motion instead of all motion to reduce event flooding
143			tea.WithFilter(tui.MouseEventFilter), // Filter mouse events based on focus state
144		)
145
146		evc, err := c.SubscribeEvents(cmd.Context())
147		if err != nil {
148			return fmt.Errorf("failed to subscribe to events: %v", err)
149		}
150
151		go streamEvents(cmd.Context(), evc, program)
152
153		if _, err := program.Run(); err != nil {
154			slog.Error("TUI run error", "error", err)
155			return fmt.Errorf("TUI error: %v", err)
156		}
157		return nil
158	},
159}
160
161func Execute() {
162	if err := fang.Execute(
163		context.Background(),
164		rootCmd,
165		fang.WithVersion(version.Version),
166		fang.WithNotifySignal(os.Interrupt),
167	); err != nil {
168		os.Exit(1)
169	}
170}
171
172func streamEvents(ctx context.Context, evc <-chan any, p *tea.Program) {
173	defer log.RecoverPanic("app.Subscribe", func() {
174		slog.Info("TUI subscription panic: attempting graceful shutdown")
175		p.Quit()
176	})
177
178	for {
179		select {
180		case <-ctx.Done():
181			slog.Debug("TUI message handler shutting down")
182			return
183		case ev, ok := <-evc:
184			if !ok {
185				slog.Debug("TUI message channel closed")
186				return
187			}
188			p.Send(ev)
189		}
190	}
191}
192
193// setupApp handles the common setup logic for both interactive and non-interactive modes.
194// It returns the app instance, config, cleanup function, and any error.
195func setupApp(cmd *cobra.Command, hostURL *url.URL) (*client.Client, error) {
196	debug, _ := cmd.Flags().GetBool("debug")
197	yolo, _ := cmd.Flags().GetBool("yolo")
198	dataDir, _ := cmd.Flags().GetString("data-dir")
199	ctx := cmd.Context()
200
201	cwd, err := ResolveCwd(cmd)
202	if err != nil {
203		return nil, err
204	}
205
206	c, err := client.NewClient(cwd, hostURL.Scheme, hostURL.Host)
207	if err != nil {
208		return nil, err
209	}
210
211	ins, err := c.CreateInstance(ctx, proto.Instance{
212		Path:    cwd,
213		DataDir: dataDir,
214		Debug:   debug,
215		YOLO:    yolo,
216	})
217	if err != nil {
218		return nil, fmt.Errorf("failed to create or connect to instance: %v", err)
219	}
220
221	c.SetID(ins.ID)
222
223	return c, nil
224}
225
226var safeNameRegexp = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
227
228func startDetachedServer(cmd *cobra.Command) error {
229	// Start the server as a detached process if the socket does not exist.
230	exe, err := os.Executable()
231	if err != nil {
232		return fmt.Errorf("failed to get executable path: %v", err)
233	}
234
235	safeClientHost := safeNameRegexp.ReplaceAllString(clientHost, "_")
236	chDir := filepath.Join(config.GlobalCacheDir(), "server-"+safeClientHost)
237	if err := os.MkdirAll(chDir, 0o700); err != nil {
238		return fmt.Errorf("failed to create server working directory: %v", err)
239	}
240
241	c := exec.CommandContext(cmd.Context(), exe, "server")
242	stdoutPath := filepath.Join(chDir, "stdout.log")
243	stderrPath := filepath.Join(chDir, "stderr.log")
244	detachProcess(c)
245
246	stdout, err := os.Create(stdoutPath)
247	if err != nil {
248		return fmt.Errorf("failed to create stdout log file: %v", err)
249	}
250	defer stdout.Close()
251	c.Stdout = stdout
252
253	stderr, err := os.Create(stderrPath)
254	if err != nil {
255		return fmt.Errorf("failed to create stderr log file: %v", err)
256	}
257	defer stderr.Close()
258	c.Stderr = stderr
259
260	if err := c.Start(); err != nil {
261		return fmt.Errorf("failed to start crush server: %v", err)
262	}
263
264	if err := c.Process.Release(); err != nil {
265		return fmt.Errorf("failed to detach crush server process: %v", err)
266	}
267
268	return nil
269}
270
271func MaybePrependStdin(prompt string) (string, error) {
272	if term.IsTerminal(os.Stdin.Fd()) {
273		return prompt, nil
274	}
275	fi, err := os.Stdin.Stat()
276	if err != nil {
277		return prompt, err
278	}
279	if fi.Mode()&os.ModeNamedPipe == 0 {
280		return prompt, nil
281	}
282	bts, err := io.ReadAll(os.Stdin)
283	if err != nil {
284		return prompt, err
285	}
286	return string(bts) + "\n\n" + prompt, nil
287}
288
289func ResolveCwd(cmd *cobra.Command) (string, error) {
290	cwd, _ := cmd.Flags().GetString("cwd")
291	if cwd != "" {
292		err := os.Chdir(cwd)
293		if err != nil {
294			return "", fmt.Errorf("failed to change directory: %v", err)
295		}
296		return cwd, nil
297	}
298	cwd, err := os.Getwd()
299	if err != nil {
300		return "", fmt.Errorf("failed to get current working directory: %v", err)
301	}
302	return cwd, nil
303}
304
305func createDotCrushDir(dir string) error {
306	if err := os.MkdirAll(dir, 0o700); err != nil {
307		return fmt.Errorf("failed to create data directory: %q %w", dir, err)
308	}
309
310	gitIgnorePath := filepath.Join(dir, ".gitignore")
311	if _, err := os.Stat(gitIgnorePath); os.IsNotExist(err) {
312		if err := os.WriteFile(gitIgnorePath, []byte("*\n"), 0o644); err != nil {
313			return fmt.Errorf("failed to create .gitignore file: %q %w", gitIgnorePath, err)
314		}
315	}
316
317	return nil
318}