root.go

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