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}