run.go

  1package cmd
  2
  3import (
  4	"context"
  5	"fmt"
  6	"log/slog"
  7	"os"
  8	"os/signal"
  9	"strings"
 10	"time"
 11
 12	"charm.land/lipgloss/v2"
 13	"charm.land/log/v2"
 14	"github.com/charmbracelet/crush/internal/client"
 15	"github.com/charmbracelet/crush/internal/config"
 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/session"
 21	"github.com/charmbracelet/crush/internal/ui/anim"
 22	"github.com/charmbracelet/crush/internal/ui/styles"
 23	"github.com/charmbracelet/x/ansi"
 24	"github.com/charmbracelet/x/exp/charmtone"
 25	"github.com/charmbracelet/x/term"
 26	"github.com/spf13/cobra"
 27)
 28
 29var runCmd = &cobra.Command{
 30	Aliases: []string{"r"},
 31	Use:     "run [prompt...]",
 32	Short:   "Run a single non-interactive prompt",
 33	Long: `Run a single prompt in non-interactive mode and exit.
 34The prompt can be provided as arguments or piped from stdin.`,
 35	Example: `
 36# Run a simple prompt
 37crush run "Guess my 5 favorite Pokรฉmon"
 38
 39# Pipe input from stdin
 40curl https://charm.land | crush run "Summarize this website"
 41
 42# Read from a file
 43crush run "What is this code doing?" <<< prrr.go
 44
 45# Redirect output to a file
 46crush run "Generate a hot README for this project" > MY_HOT_README.md
 47
 48# Run in quiet mode (hide the spinner)
 49crush run --quiet "Generate a README for this project"
 50
 51# Run in verbose mode (show logs)
 52crush run --verbose "Generate a README for this project"
 53
 54# Continue a previous session
 55crush run --session {session-id} "Follow up on your last response"
 56
 57# Continue the most recent session
 58crush run --continue "Follow up on your last response"
 59
 60  `,
 61	RunE: func(cmd *cobra.Command, args []string) error {
 62		var (
 63			quiet, _      = cmd.Flags().GetBool("quiet")
 64			verbose, _    = cmd.Flags().GetBool("verbose")
 65			largeModel, _ = cmd.Flags().GetString("model")
 66			smallModel, _ = cmd.Flags().GetString("small-model")
 67			sessionID, _  = cmd.Flags().GetString("session")
 68			useLast, _    = cmd.Flags().GetBool("continue")
 69		)
 70
 71		// Cancel on SIGINT or SIGTERM.
 72		ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill)
 73		defer cancel()
 74
 75		c, ws, cleanup, err := connectToServer(cmd)
 76		if err != nil {
 77			return err
 78		}
 79		defer cleanup()
 80
 81		if sessionID != "" {
 82			sess, err := resolveSessionByID(ctx, c, ws.ID, sessionID)
 83			if err != nil {
 84				return err
 85			}
 86			sessionID = sess.ID
 87		}
 88
 89		if !ws.Config.IsConfigured() {
 90			return fmt.Errorf("no providers configured - please run 'crush' to set up a provider interactively")
 91		}
 92
 93		if verbose {
 94			slog.SetDefault(slog.New(log.New(os.Stderr)))
 95		}
 96
 97		prompt := strings.Join(args, " ")
 98
 99		prompt, err = MaybePrependStdin(prompt)
100		if err != nil {
101			slog.Error("Failed to read from stdin", "error", err)
102			return err
103		}
104
105		if prompt == "" {
106			return fmt.Errorf("no prompt provided")
107		}
108
109		event.SetNonInteractive(true)
110		event.AppInitialized()
111
112		switch {
113		case sessionID != "":
114			event.SetContinueBySessionID(true)
115		case useLast:
116			event.SetContinueLastSession(true)
117		}
118
119		return runNonInteractive(ctx, c, ws, prompt, largeModel, smallModel, quiet || verbose, sessionID, useLast)
120	},
121}
122
123func init() {
124	runCmd.Flags().BoolP("quiet", "q", false, "Hide spinner")
125	runCmd.Flags().BoolP("verbose", "v", false, "Show logs")
126	runCmd.Flags().StringP("model", "m", "", "Model to use. Accepts 'model' or 'provider/model' to disambiguate models with the same name across providers")
127	runCmd.Flags().String("small-model", "", "Small model to use. If not provided, uses the default small model for the provider")
128	runCmd.Flags().StringP("session", "s", "", "Continue a previous session by ID")
129	runCmd.Flags().BoolP("continue", "C", false, "Continue the most recent session")
130	runCmd.MarkFlagsMutuallyExclusive("session", "continue")
131}
132
133// runNonInteractive executes the agent via the server and streams output
134// to stdout.
135func runNonInteractive(
136	ctx context.Context,
137	c *client.Client,
138	ws *proto.Workspace,
139	prompt, largeModel, smallModel string,
140	hideSpinner bool,
141	continueSessionID string,
142	useLast bool,
143) error {
144	slog.Info("Running in non-interactive mode")
145
146	ctx, cancel := context.WithCancel(ctx)
147	defer cancel()
148
149	if largeModel != "" || smallModel != "" {
150		if err := overrideModels(ctx, c, ws, largeModel, smallModel); err != nil {
151			return fmt.Errorf("failed to override models: %w", err)
152		}
153	}
154
155	var (
156		spinner   *format.Spinner
157		stdoutTTY bool
158		stderrTTY bool
159		stdinTTY  bool
160		progress  bool
161	)
162
163	stdoutTTY = term.IsTerminal(os.Stdout.Fd())
164	stderrTTY = term.IsTerminal(os.Stderr.Fd())
165	stdinTTY = term.IsTerminal(os.Stdin.Fd())
166	progress = ws.Config.Options.Progress == nil || *ws.Config.Options.Progress
167
168	if !hideSpinner && stderrTTY {
169		t := styles.DefaultStyles()
170
171		hasDarkBG := true
172		if stdinTTY && stdoutTTY {
173			hasDarkBG = lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
174		}
175		defaultFG := lipgloss.LightDark(hasDarkBG)(charmtone.Pepper, t.FgBase)
176
177		spinner = format.NewSpinner(ctx, cancel, anim.Settings{
178			Size:        10,
179			Label:       "Generating",
180			LabelColor:  defaultFG,
181			GradColorA:  t.Primary,
182			GradColorB:  t.Secondary,
183			CycleColors: true,
184		})
185		spinner.Start()
186	}
187
188	stopSpinner := func() {
189		if !hideSpinner && spinner != nil {
190			spinner.Stop()
191			spinner = nil
192		}
193	}
194
195	// Wait for the agent to become ready (MCP init, etc).
196	if err := waitForAgent(ctx, c, ws.ID); err != nil {
197		stopSpinner()
198		return fmt.Errorf("agent not ready: %w", err)
199	}
200
201	// Force-update agent models so MCP tools are loaded.
202	if err := c.UpdateAgent(ctx, ws.ID); err != nil {
203		slog.Warn("Failed to update agent", "error", err)
204	}
205
206	defer stopSpinner()
207
208	sess, err := resolveSession(ctx, c, ws.ID, continueSessionID, useLast)
209	if err != nil {
210		return fmt.Errorf("failed to resolve session: %w", err)
211	}
212	if continueSessionID != "" || useLast {
213		slog.Info("Continuing session for non-interactive run", "session_id", sess.ID)
214	} else {
215		slog.Info("Created session for non-interactive run", "session_id", sess.ID)
216	}
217
218	events, err := c.SubscribeEvents(ctx, ws.ID)
219	if err != nil {
220		return fmt.Errorf("failed to subscribe to events: %w", err)
221	}
222
223	if err := c.SendMessage(ctx, ws.ID, sess.ID, prompt); err != nil {
224		return fmt.Errorf("failed to send message: %w", err)
225	}
226
227	messageReadBytes := make(map[string]int)
228	var printed bool
229
230	defer func() {
231		if progress && stderrTTY {
232			_, _ = fmt.Fprintf(os.Stderr, ansi.ResetProgressBar)
233		}
234		_, _ = fmt.Fprintln(os.Stdout)
235	}()
236
237	for {
238		if progress && stderrTTY {
239			_, _ = fmt.Fprintf(os.Stderr, ansi.SetIndeterminateProgressBar)
240		}
241
242		select {
243		case ev, ok := <-events:
244			if !ok {
245				stopSpinner()
246				return nil
247			}
248
249			switch e := ev.(type) {
250			case pubsub.Event[proto.Message]:
251				msg := e.Payload
252				if msg.SessionID != sess.ID || msg.Role != proto.Assistant || len(msg.Parts) == 0 {
253					continue
254				}
255				stopSpinner()
256
257				content := msg.Content().String()
258				readBytes := messageReadBytes[msg.ID]
259
260				if len(content) < readBytes {
261					slog.Error("Non-interactive: message content shorter than read bytes",
262						"message_length", len(content), "read_bytes", readBytes)
263					return fmt.Errorf("message content is shorter than read bytes: %d < %d", len(content), readBytes)
264				}
265
266				part := content[readBytes:]
267				if readBytes == 0 {
268					part = strings.TrimLeft(part, " \t")
269				}
270				if printed || strings.TrimSpace(part) != "" {
271					printed = true
272					fmt.Fprint(os.Stdout, part)
273				}
274				messageReadBytes[msg.ID] = len(content)
275
276				if msg.IsFinished() {
277					return nil
278				}
279
280			case pubsub.Event[proto.AgentEvent]:
281				if e.Payload.Error != nil {
282					stopSpinner()
283					return fmt.Errorf("agent error: %w", e.Payload.Error)
284				}
285			}
286
287		case <-ctx.Done():
288			stopSpinner()
289			return ctx.Err()
290		}
291	}
292}
293
294// waitForAgent polls GetAgentInfo until the agent is ready, with a
295// timeout.
296func waitForAgent(ctx context.Context, c *client.Client, wsID string) error {
297	timeout := time.After(30 * time.Second)
298	for {
299		info, err := c.GetAgentInfo(ctx, wsID)
300		if err == nil && info.IsReady {
301			return nil
302		}
303		select {
304		case <-timeout:
305			if err != nil {
306				return fmt.Errorf("timeout waiting for agent: %w", err)
307			}
308			return fmt.Errorf("timeout waiting for agent readiness")
309		case <-ctx.Done():
310			return ctx.Err()
311		case <-time.After(200 * time.Millisecond):
312		}
313	}
314}
315
316// overrideModels resolves model strings and updates the workspace
317// configuration via the server.
318func overrideModels(
319	ctx context.Context,
320	c *client.Client,
321	ws *proto.Workspace,
322	largeModel, smallModel string,
323) error {
324	cfg, err := c.GetConfig(ctx, ws.ID)
325	if err != nil {
326		return fmt.Errorf("failed to get config: %w", err)
327	}
328
329	providers := cfg.Providers.Copy()
330
331	largeMatches, smallMatches := findModelMatches(providers, largeModel, smallModel)
332
333	var largeProviderID string
334
335	if largeModel != "" {
336		found, err := validateModelMatches(largeMatches, largeModel, "large")
337		if err != nil {
338			return err
339		}
340		largeProviderID = found.provider
341		slog.Info("Overriding large model", "provider", found.provider, "model", found.modelID)
342		if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeLarge, config.SelectedModel{
343			Provider: found.provider,
344			Model:    found.modelID,
345		}); err != nil {
346			return fmt.Errorf("failed to set large model: %w", err)
347		}
348	}
349
350	switch {
351	case smallModel != "":
352		found, err := validateModelMatches(smallMatches, smallModel, "small")
353		if err != nil {
354			return err
355		}
356		slog.Info("Overriding small model", "provider", found.provider, "model", found.modelID)
357		if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeSmall, config.SelectedModel{
358			Provider: found.provider,
359			Model:    found.modelID,
360		}); err != nil {
361			return fmt.Errorf("failed to set small model: %w", err)
362		}
363
364	case largeModel != "":
365		sm, err := c.GetDefaultSmallModel(ctx, ws.ID, largeProviderID)
366		if err != nil {
367			slog.Warn("Failed to get default small model", "error", err)
368		} else if sm != nil {
369			if err := c.UpdatePreferredModel(ctx, ws.ID, config.ScopeWorkspace, config.SelectedModelTypeSmall, *sm); err != nil {
370				return fmt.Errorf("failed to set small model: %w", err)
371			}
372		}
373	}
374
375	return c.UpdateAgent(ctx, ws.ID)
376}
377
378type modelMatch struct {
379	provider string
380	modelID  string
381}
382
383// findModelMatches searches providers for matching large/small model
384// strings.
385func findModelMatches(providers map[string]config.ProviderConfig, largeModel, smallModel string) ([]modelMatch, []modelMatch) {
386	largeFilter, largeID := parseModelString(largeModel)
387	smallFilter, smallID := parseModelString(smallModel)
388
389	var largeMatches, smallMatches []modelMatch
390	for name, provider := range providers {
391		if provider.Disable {
392			continue
393		}
394		for _, m := range provider.Models {
395			if matchesModel(largeID, largeFilter, m.ID, name) {
396				largeMatches = append(largeMatches, modelMatch{provider: name, modelID: m.ID})
397			}
398			if matchesModel(smallID, smallFilter, m.ID, name) {
399				smallMatches = append(smallMatches, modelMatch{provider: name, modelID: m.ID})
400			}
401		}
402	}
403	return largeMatches, smallMatches
404}
405
406// parseModelString splits "provider/model" into (provider, model) or
407// ("", model).
408func parseModelString(s string) (string, string) {
409	if s == "" {
410		return "", ""
411	}
412	if idx := strings.Index(s, "/"); idx >= 0 {
413		return s[:idx], s[idx+1:]
414	}
415	return "", s
416}
417
418// matchesModel returns true if the model ID matches the filter
419// criteria.
420func matchesModel(wantID, wantProvider, modelID, providerName string) bool {
421	if wantID == "" {
422		return false
423	}
424	if wantProvider != "" && wantProvider != providerName {
425		return false
426	}
427	return strings.EqualFold(modelID, wantID)
428}
429
430// validateModelMatches ensures exactly one match exists.
431func validateModelMatches(matches []modelMatch, modelID, label string) (modelMatch, error) {
432	switch {
433	case len(matches) == 0:
434		return modelMatch{}, fmt.Errorf("%s model %q not found", label, modelID)
435	case len(matches) > 1:
436		names := make([]string, len(matches))
437		for i, m := range matches {
438			names[i] = m.provider
439		}
440		return modelMatch{}, fmt.Errorf(
441			"%s model: model %q found in multiple providers: %s. Please specify provider using 'provider/model' format",
442			label, modelID, strings.Join(names, ", "),
443		)
444	}
445	return matches[0], nil
446}
447
448// resolveSession returns the session to use for a non-interactive run.
449// If continueSessionID is set it fetches that session; if useLast is set it
450// returns the most recently updated top-level session; otherwise it creates a
451// new one.
452func resolveSession(ctx context.Context, c *client.Client, wsID, continueSessionID string, useLast bool) (*proto.Session, error) {
453	switch {
454	case continueSessionID != "":
455		sess, err := c.GetSession(ctx, wsID, continueSessionID)
456		if err != nil {
457			return nil, fmt.Errorf("session not found: %s", continueSessionID)
458		}
459		if sess.ParentSessionID != "" {
460			return nil, fmt.Errorf("cannot continue a child session: %s", continueSessionID)
461		}
462		return sess, nil
463
464	case useLast:
465		sessions, err := c.ListSessions(ctx, wsID)
466		if err != nil || len(sessions) == 0 {
467			return nil, fmt.Errorf("no sessions found to continue")
468		}
469		last := sessions[0]
470		for _, s := range sessions[1:] {
471			if s.UpdatedAt > last.UpdatedAt && s.ParentSessionID == "" {
472				last = s
473			}
474		}
475		return &last, nil
476
477	default:
478		return c.CreateSession(ctx, wsID, "non-interactive")
479	}
480}
481
482// resolveSessionByID resolves a session ID that may be a full UUID or a hash
483// prefix returned by crush session list.
484func resolveSessionByID(ctx context.Context, c *client.Client, wsID, id string) (*proto.Session, error) {
485	if sess, err := c.GetSession(ctx, wsID, id); err == nil {
486		return sess, nil
487	}
488
489	sessions, err := c.ListSessions(ctx, wsID)
490	if err != nil {
491		return nil, err
492	}
493
494	var matches []proto.Session
495	for _, s := range sessions {
496		hash := session.HashID(s.ID)
497		if hash == id || strings.HasPrefix(hash, id) {
498			matches = append(matches, s)
499		}
500	}
501
502	switch len(matches) {
503	case 0:
504		return nil, fmt.Errorf("session %q not found", id)
505	case 1:
506		return &matches[0], nil
507	default:
508		return nil, fmt.Errorf("session ID %q is ambiguous (%d matches)", id, len(matches))
509	}
510}