@@ -7,9 +7,21 @@ import (
8 "os/signal"
8 "os/signal"
12+ "charm.land/lipgloss/v2"
11 "charm.land/log/v2"
13 "charm.land/log/v2"
14+ "github.com/charmbracelet/crush/internal/client"
15+ "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/event"
16 "github.com/charmbracelet/crush/internal/event"
17+ "github.com/charmbracelet/crush/internal/format"
18+ "github.com/charmbracelet/crush/internal/proto"
19+ "github.com/charmbracelet/crush/internal/pubsub"
20+ "github.com/charmbracelet/crush/internal/ui/anim"
21+ "github.com/charmbracelet/crush/internal/ui/styles"
22+ "github.com/charmbracelet/x/ansi"
23+ "github.com/charmbracelet/x/exp/charmtone"
24+ "github.com/charmbracelet/x/term"
13 "github.com/spf13/cobra"
25 "github.com/spf13/cobra"
@@ -47,13 +59,13 @@ crush run --verbose "Generate a README for this project"
47 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
59 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
48 defer cancel()
60 defer cancel()
50− app, err := setupApp(cmd)
62+ c, ws, cleanup, err := connectToServer(cmd)
51 if err != nil {
63 if err != nil {
52 return err
64 return err
54− defer app.Shutdown()
66+ defer cleanup()
56− if !app.Config().IsConfigured() {
68+ if !ws.Config.IsConfigured() {
57 return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
69 return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
@@ -76,7 +88,7 @@ crush run --verbose "Generate a README for this project"
76 event.SetNonInteractive(true)
88 event.SetNonInteractive(true)
77 event.AppInitialized()
89 event.AppInitialized()
79− return app.RunNonInteractive(ctx, os.Stdout, prompt, largeModel, smallModel, quiet || verbose)
91+ return runNonInteractive(ctx, c, ws, prompt, largeModel, smallModel, quiet || verbose)
@@ -86,3 +98,312 @@ func init() {
86 runCmd.Flags().StringP("model", "m", "", "Model to use. Accepts 'model' or 'provider/model' to disambiguate models with the same name across providers")
98 runCmd.Flags().StringP("model", "m", "", "Model to use. Accepts 'model' or 'provider/model' to disambiguate models with the same name across providers")
87 runCmd.Flags().String("small-model", "", "Small model to use. If not provided, uses the default small model for the provider")
99 runCmd.Flags().String("small-model", "", "Small model to use. If not provided, uses the default small model for the provider")
102+// runNonInteractive executes the agent via the server and streams output
104+func runNonInteractive(
105+ ctx context.Context,
107+ ws *proto.Workspace,
108+ prompt, largeModel, smallModel string,
111+ slog.Info("Running in non-interactive mode")
113+ ctx, cancel := context.WithCancel(ctx)
116+ if largeModel != "" || smallModel != "" {
117+ if err := overrideModels(ctx, c, ws, largeModel, smallModel); err != nil {
118+ return fmt.Errorf("failed to override models: %w", err)
123+ spinner *format.Spinner
130+ stdoutTTY = term.IsTerminal(os.Stdout.Fd())
131+ stderrTTY = term.IsTerminal(os.Stderr.Fd())
132+ stdinTTY = term.IsTerminal(os.Stdin.Fd())
133+ progress = ws.Config.Options.Progress == nil || *ws.Config.Options.Progress
135+ if !hideSpinner && stderrTTY {
136+ t := styles.DefaultStyles()
139+ if stdinTTY && stdoutTTY {
140+ hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
142+ defaultFG := lipgloss.LightDark(hasDarkBG)(charmtone.Pepper, t.FgBase)
144+ spinner = format.NewSpinner(ctx, cancel, anim.Settings{
146+ Label: "Generating",
147+ LabelColor: defaultFG,
148+ GradColorA: t.Primary,
149+ GradColorB: t.Secondary,
155+ stopSpinner := func() {
156+ if !hideSpinner && spinner != nil {
162+ // Wait for the agent to become ready (MCP init, etc).
163+ if err := waitForAgent(ctx, c, ws.ID); err != nil {
165+ return fmt.Errorf("agent not ready: %w", err)
168+ // Force-update agent models so MCP tools are loaded.
169+ if err := c.UpdateAgent(ctx, ws.ID); err != nil {
170+ slog.Warn("Failed to update agent", "error", err)
175+ sess, err := c.CreateSession(ctx, ws.ID, "non-interactive")
177+ return fmt.Errorf("failed to create session: %w", err)
179+ slog.Info("Created session for non-interactive run", "session_id", sess.ID)
181+ events, err := c.SubscribeEvents(ctx, ws.ID)
183+ return fmt.Errorf("failed to subscribe to events: %w", err)
186+ if err := c.SendMessage(ctx, ws.ID, sess.ID, prompt); err != nil {
187+ return fmt.Errorf("failed to send message: %w", err)
190+ messageReadBytes := make(map[string]int)
194+ if progress && stderrTTY {
195+ _, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
197+ _, _ = fmt.Fprintln(os.Stdout)
201+ if progress && stderrTTY {
202+ _, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
206+ case ev, ok := <-events:
212+ switch e := ev.(type) {
213+ case pubsub.Event[proto.Message]:
215+ if msg.SessionID != sess.ID || msg.Role != proto.Assistant || len(msg.Parts) == 0 {
220+ content := msg.Content().String()
221+ readBytes := messageReadBytes[msg.ID]
223+ if len(content) < readBytes {
224+ slog.Error("Non-interactive: message content shorter than read bytes",
225+ "message_length", len(content), "read_bytes", readBytes)
226+ return fmt.Errorf("message content is shorter than read bytes: %d < %d", len(content), readBytes)
229+ part := content[readBytes:]
231+ part = strings.TrimLeft(part, " \t")
233+ if printed || strings.TrimSpace(part) != "" {
235+ fmt.Fprint(os.Stdout, part)
237+ messageReadBytes[msg.ID] = len(content)
239+ if msg.IsFinished() {
243+ case pubsub.Event[proto.AgentEvent]:
244+ if e.Payload.Error != nil {
246+ return fmt.Errorf("agent error: %w", e.Payload.Error)
257+// waitForAgent polls GetAgentInfo until the agent is ready, with a
259+func waitForAgent(ctx context.Context, c *client.Client, wsID string) error {
260+ timeout := time.After(30 * time.Second)
262+ info, err := c.GetAgentInfo(ctx, wsID)
263+ if err == nil && info.IsReady {
269+ return fmt.Errorf("timeout waiting for agent: %w", err)
271+ return fmt.Errorf("timeout waiting for agent readiness")
274+ case <-time.After(200 * time.Millisecond):
279+// overrideModels resolves model strings and updates the workspace
280+// configuration via the server.
282+ ctx context.Context,
284+ ws *proto.Workspace,
285+ largeModel, smallModel string,
287+ cfg, err := c.GetConfig(ctx, ws.ID)
289+ return fmt.Errorf("failed to get config: %w", err)
292+ providers := cfg.Providers.Copy()
294+ largeMatches, smallMatches := findModelMatches(providers, largeModel, smallModel)
296+ var largeProviderID string
298+ if largeModel != "" {
299+ found, err := validateModelMatches(largeMatches, largeModel, "large")
303+ largeProviderID = found.provider
304+ slog.Info("Overriding large model", "provider", found.provider, "model", found.modelID)
305+ if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeLarge, config.SelectedModel{
306+ Provider: found.provider,
307+ Model: found.modelID,
309+ return fmt.Errorf("failed to set large model: %w", err)
314+ case smallModel != "":
315+ found, err := validateModelMatches(smallMatches, smallModel, "small")
319+ slog.Info("Overriding small model", "provider", found.provider, "model", found.modelID)
320+ if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeSmall, config.SelectedModel{
321+ Provider: found.provider,
322+ Model: found.modelID,
324+ return fmt.Errorf("failed to set small model: %w", err)
327+ case largeModel != "":
328+ sm, err := c.GetDefaultSmallModel(ctx, ws.ID, largeProviderID)
330+ slog.Warn("Failed to get default small model", "error", err)
331+ } else if sm != nil {
332+ if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeSmall, *sm); err != nil {
333+ return fmt.Errorf("failed to set small model: %w", err)
338+ return c.UpdateAgent(ctx, ws.ID)
341+type modelMatch struct {
346+// findModelMatches searches providers for matching large/small model
348+func findModelMatches(providers map[string]config.ProviderConfig, largeModel, smallModel string) ([]modelMatch, []modelMatch) {
349+ largeFilter, largeID := parseModelString(largeModel)
350+ smallFilter, smallID := parseModelString(smallModel)
352+ var largeMatches, smallMatches []modelMatch
353+ for name, provider := range providers {
354+ if provider.Disable {
357+ for _, m := range provider.Models {
358+ if matchesModel(largeID, largeFilter, m.ID, name) {
359+ largeMatches = append(largeMatches, modelMatch{provider: name, modelID: m.ID})
361+ if matchesModel(smallID, smallFilter, m.ID, name) {
362+ smallMatches = append(smallMatches, modelMatch{provider: name, modelID: m.ID})
366+ return largeMatches, smallMatches
369+// parseModelString splits "provider/model" into (provider, model) or
371+func parseModelString(s string) (string, string) {
375+ if idx := strings.Index(s, "/"); idx >= 0 {
376+ return s[:idx], s[idx+1:]
381+// matchesModel returns true if the model ID matches the filter
383+func matchesModel(wantID, wantProvider, modelID, providerName string) bool {
387+ if wantProvider != "" && wantProvider != providerName {
390+ return strings.EqualFold(modelID, wantID)
393+// validateModelMatches ensures exactly one match exists.
394+func validateModelMatches(matches []modelMatch, modelID, label string) (modelMatch, error) {
396+ case len(matches) == 0:
397+ return modelMatch{}, fmt.Errorf("%s model %q not found", label, modelID)
398+ case len(matches) > 1:
399+ names := make([]string, len(matches))
400+ for i, m := range matches {
401+ names[i] = m.provider
403+ return modelMatch{}, fmt.Errorf(
404+ "%s model: model %q found in multiple providers: %s. Please specify provider using 'provider/model' format",
405+ label, modelID, strings.Join(names, ", "),
408+ return matches[0], nil