1package claudetool
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "log/slog"
10 "math"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "slices"
15 "strings"
16 "sync"
17 "syscall"
18 "time"
19
20 "shelley.exe.dev/claudetool/bashkit"
21 "shelley.exe.dev/llm"
22)
23
24// PermissionCallback is a function type for checking if a command is allowed to run
25type PermissionCallback func(command string) error
26
27// BashTool specifies an llm.Tool for executing shell commands.
28type BashTool struct {
29 // CheckPermission is called before running any command, if set
30 CheckPermission PermissionCallback
31 // EnableJITInstall enables just-in-time tool installation for missing commands
32 EnableJITInstall bool
33 // Timeouts holds the configurable timeout values (uses defaults if nil)
34 Timeouts *Timeouts
35 // WorkingDir is the shared mutable working directory.
36 WorkingDir *MutableWorkingDir
37 // LLMProvider provides access to LLM services for tool validation
38 LLMProvider LLMServiceProvider
39 // ConversationID is the ID of the conversation this tool belongs to.
40 // It is exposed to invoked commands via SHELLEY_CONVERSATION_ID.
41 ConversationID string
42}
43
44const (
45 EnableBashToolJITInstall = true
46 NoBashToolJITInstall = false
47
48 DefaultFastTimeout = 30 * time.Second
49 DefaultSlowTimeout = 15 * time.Minute
50 DefaultBackgroundTimeout = 24 * time.Hour
51)
52
53// Timeouts holds the configurable timeout values for bash commands.
54type Timeouts struct {
55 Fast time.Duration // regular commands (e.g., ls, echo, simple scripts)
56 Slow time.Duration // commands that may reasonably take longer (e.g., downloads, builds, tests)
57 Background time.Duration // background commands (e.g., servers, long-running processes)
58}
59
60// Fast returns t's fast timeout, or DefaultFastTimeout if t is nil.
61func (t *Timeouts) fast() time.Duration {
62 if t == nil {
63 return DefaultFastTimeout
64 }
65 return t.Fast
66}
67
68// Slow returns t's slow timeout, or DefaultSlowTimeout if t is nil.
69func (t *Timeouts) slow() time.Duration {
70 if t == nil {
71 return DefaultSlowTimeout
72 }
73 return t.Slow
74}
75
76// Background returns t's background timeout, or DefaultBackgroundTimeout if t is nil.
77func (t *Timeouts) background() time.Duration {
78 if t == nil {
79 return DefaultBackgroundTimeout
80 }
81 return t.Background
82}
83
84// Tool returns an llm.Tool based on b.
85func (b *BashTool) Tool() *llm.Tool {
86 return &llm.Tool{
87 Name: bashName,
88 Description: strings.TrimSpace(bashDescription),
89 InputSchema: llm.MustSchema(bashInputSchema),
90 Run: b.Run,
91 }
92}
93
94// getWorkingDir returns the current working directory.
95func (b *BashTool) getWorkingDir() string {
96 return b.WorkingDir.Get()
97}
98
99// isNoTrailerSet checks if user has disabled co-author trailer via git config.
100func (b *BashTool) isNoTrailerSet() bool {
101 out, err := exec.Command("git", "config", "--get", "shelley.no-trailer").Output()
102 if err != nil {
103 return false
104 }
105 return strings.TrimSpace(string(out)) == "true"
106}
107
108const (
109 bashName = "bash"
110 bashDescription = `Executes shell commands via bash -c, returning combined stdout/stderr.
111Bash state changes (working dir, variables, aliases) don't persist between calls.
112
113With background=true, returns immediately, with output redirected to a file.
114Use background for servers/demos that need to stay running.
115
116MUST set slow_ok=true for potentially slow commands: builds, downloads,
117installs, tests, or any other substantive operation.
118
119Avoid overly destructive cleanup commands. Commands that could delete .git
120directories, home directories, or use broad wildcards require explicit paths.
121Confirm with the user before running destructive operations.
122
123To change the working directory persistently, use the change_dir tool.
124
125IMPORTANT: Keep commands concise. The command input must be less than 60k tokens.
126For complex scripts, write them to a file first and then execute the file.
127`
128 // If you modify this, update the termui template for prettier rendering.
129 bashInputSchema = `
130{
131 "type": "object",
132 "required": ["command"],
133 "properties": {
134 "command": {
135 "type": "string",
136 "description": "Shell to execute"
137 },
138 "slow_ok": {
139 "type": "boolean",
140 "description": "Use extended timeout"
141 },
142 "background": {
143 "type": "boolean",
144 "description": "Execute in background"
145 }
146 }
147}
148`
149)
150
151type bashInput struct {
152 Command string `json:"command"`
153 SlowOK bool `json:"slow_ok,omitempty"`
154 Background bool `json:"background,omitempty"`
155}
156
157// BashDisplayData is the display data sent to the UI for bash tool results.
158type BashDisplayData struct {
159 WorkingDir string `json:"workingDir"`
160}
161
162type BackgroundResult struct {
163 PID int
164 OutFile string
165}
166
167func (r *BackgroundResult) XMLish() string {
168 return fmt.Sprintf("<pid>%d</pid>\n<output_file>%s</output_file>\n<reminder>To stop the process: `kill -9 -%d`</reminder>\n",
169 r.PID, r.OutFile, r.PID)
170}
171
172func (i *bashInput) timeout(t *Timeouts) time.Duration {
173 switch {
174 case i.Background:
175 return t.background()
176 case i.SlowOK:
177 return t.slow()
178 default:
179 return t.fast()
180 }
181}
182
183func (b *BashTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
184 var req bashInput
185 if err := json.Unmarshal(m, &req); err != nil {
186 return llm.ErrorfToolOut("failed to unmarshal bash command input: %w", err)
187 }
188
189 // Check that the working directory exists
190 wd := b.getWorkingDir()
191 if _, err := os.Stat(wd); err != nil {
192 if os.IsNotExist(err) {
193 return llm.ErrorfToolOut("working directory does not exist: %s (use change_dir to switch to a valid directory)", wd)
194 }
195 return llm.ErrorfToolOut("cannot access working directory %s: %w", wd, err)
196 }
197
198 // do a quick permissions check (NOT a security barrier)
199 err := bashkit.Check(req.Command)
200 if err != nil {
201 return llm.ErrorToolOut(err)
202 }
203
204 // Custom permission callback if set
205 if b.CheckPermission != nil {
206 if err := b.CheckPermission(req.Command); err != nil {
207 return llm.ErrorToolOut(err)
208 }
209 }
210
211 // Check for missing tools and try to install them if needed, best effort only
212 if b.EnableJITInstall {
213 err := b.checkAndInstallMissingTools(ctx, req.Command)
214 if err != nil {
215 slog.DebugContext(ctx, "failed to auto-install missing tools", "error", err)
216 }
217 }
218
219 // Add co-author trailer to git commits unless user has disabled it
220 if !b.isNoTrailerSet() {
221 req.Command = bashkit.AddCoauthorTrailer(req.Command, "Co-authored-by: Shelley <shelley@exe.dev>")
222 }
223
224 timeout := req.timeout(b.Timeouts)
225
226 display := BashDisplayData{WorkingDir: wd}
227
228 // If Background is set to true, use executeBackgroundBash
229 if req.Background {
230 result, err := b.executeBackgroundBash(ctx, req, timeout)
231 if err != nil {
232 return llm.ErrorToolOut(err)
233 }
234 return llm.ToolOut{LLMContent: llm.TextContent(result.XMLish()), Display: display}
235 }
236
237 // For foreground commands, use executeBash
238 out, execErr := b.executeBash(ctx, req, timeout)
239 if execErr != nil {
240 return llm.ErrorToolOut(execErr)
241 }
242 return llm.ToolOut{LLMContent: llm.TextContent(out), Display: display}
243}
244
245const (
246 largeOutputThreshold = 50 * 1024 // 50KB - threshold for saving to file
247 firstLinesCount = 2
248 lastLinesCount = 5
249 maxLineLength = 200 // truncate displayed lines to this length
250)
251
252func (b *BashTool) makeBashCommand(ctx context.Context, command string, out io.Writer) *exec.Cmd {
253 cmd := exec.CommandContext(ctx, "bash", "-c", command)
254 // Use shared WorkingDir if available, then context, then Pwd fallback
255 cmd.Dir = b.getWorkingDir()
256 cmd.Stdin = nil
257 cmd.Stdout = out
258 cmd.Stderr = out
259 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // set up for killing the process group
260 cmd.Cancel = func() error {
261 if cmd.Process == nil {
262 // Process hasn't started yet.
263 // Not sure whether this is possible in practice,
264 // but it is possible in theory, and it doesn't hurt to handle it gracefully.
265 return nil
266 }
267 return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // kill entire process group
268 }
269 cmd.WaitDelay = 15 * time.Second // prevent indefinite hangs when child processes keep pipes open
270 // Remove SKETCH_MODEL_URL, SKETCH_PUB_KEY, SKETCH_MODEL_API_KEY,
271 // and any other future SKETCH_ goodies from the environment.
272 // ...except for SKETCH_PROXY_ID, which is intentionally available.
273 env := slices.DeleteFunc(os.Environ(), func(s string) bool {
274 return strings.HasPrefix(s, "SKETCH_") && s != "SKETCH_PROXY_ID"
275 })
276 env = append(env, "SKETCH=1") // signal that this has been run by Sketch, sometimes useful for scripts
277 env = append(env, "EDITOR=/bin/false") // interactive editors won't work
278 if b.ConversationID != "" {
279 env = append(env, "SHELLEY_CONVERSATION_ID="+b.ConversationID)
280 }
281 cmd.Env = env
282 return cmd
283}
284
285func cmdWait(cmd *exec.Cmd) error {
286 err := cmd.Wait()
287 // We used to kill the process group here, but it's not clear that
288 // this is correct in the case of self-daemonizing processes,
289 // and I encountered issues where daemons that I tried to run
290 // as background tasks would mysteriously exit.
291 return err
292}
293
294func (b *BashTool) executeBash(ctx context.Context, req bashInput, timeout time.Duration) (string, error) {
295 execCtx, cancel := context.WithTimeout(ctx, timeout)
296 defer cancel()
297
298 output := new(bytes.Buffer)
299 cmd := b.makeBashCommand(execCtx, req.Command, output)
300 // TODO: maybe detect simple interactive git rebase commands and auto-background them?
301 // Would need to hint to the agent what is happening.
302 // We might also be able to do this for other simple interactive commands that use EDITOR.
303 cmd.Env = append(cmd.Env, `GIT_SEQUENCE_EDITOR=echo "To do an interactive rebase, run it as a background task and check the output file." && exit 1`)
304 if err := cmd.Start(); err != nil {
305 return "", fmt.Errorf("command failed: %w", err)
306 }
307
308 err := cmdWait(cmd)
309
310 out, formatErr := formatForegroundBashOutput(output.String())
311 if formatErr != nil {
312 return "", formatErr
313 }
314
315 if execCtx.Err() == context.DeadlineExceeded {
316 return "", fmt.Errorf("[command timed out after %s, showing output until timeout]\n%s", timeout, out)
317 }
318 if err != nil {
319 return "", fmt.Errorf("[command failed: %w]\n%s", err, out)
320 }
321
322 return out, nil
323}
324
325// formatForegroundBashOutput formats the output of a foreground bash command for display to the agent.
326// If output exceeds largeOutputThreshold, it saves to a file and returns a summary.
327func formatForegroundBashOutput(out string) (string, error) {
328 if len(out) <= largeOutputThreshold {
329 return out, nil
330 }
331
332 // Save full output to a temp file
333 tmpDir, err := os.MkdirTemp("", "shelley-output-")
334 if err != nil {
335 return "", fmt.Errorf("failed to create temp dir for large output: %w", err)
336 }
337
338 outFile := filepath.Join(tmpDir, "output")
339 if err := os.WriteFile(outFile, []byte(out), 0o644); err != nil {
340 os.RemoveAll(tmpDir)
341 return "", fmt.Errorf("failed to write large output to file: %w", err)
342 }
343
344 // Split into lines
345 lines := strings.Split(out, "\n")
346
347 // If fewer than 3 lines total, likely binary or single-line output
348 if len(lines) < 3 {
349 return fmt.Sprintf("[output too large (%s, %d lines), saved to: %s]",
350 humanizeBytes(len(out)), len(lines), outFile), nil
351 }
352
353 var result strings.Builder
354 result.WriteString(fmt.Sprintf("[output too large (%s, %d lines), saved to: %s]\n\n",
355 humanizeBytes(len(out)), len(lines), outFile))
356
357 // First N lines
358 result.WriteString("First lines:\n")
359 firstN := min(firstLinesCount, len(lines))
360 for i := 0; i < firstN; i++ {
361 result.WriteString(fmt.Sprintf("%5d: %s\n", i+1, truncateLine(lines[i])))
362 }
363
364 // Last N lines
365 result.WriteString("\n...\n\nLast lines:\n")
366 startIdx := max(0, len(lines)-lastLinesCount)
367 for i := startIdx; i < len(lines); i++ {
368 result.WriteString(fmt.Sprintf("%5d: %s\n", i+1, truncateLine(lines[i])))
369 }
370
371 return result.String(), nil
372}
373
374// truncateLine truncates a line to maxLineLength characters, appending "..." if truncated.
375func truncateLine(line string) string {
376 if len(line) <= maxLineLength {
377 return line
378 }
379 return line[:maxLineLength] + "..."
380}
381
382func humanizeBytes(bytes int) string {
383 switch {
384 case bytes < 4*1024:
385 return fmt.Sprintf("%dB", bytes)
386 case bytes < 1024*1024:
387 kb := int(math.Round(float64(bytes) / 1024.0))
388 return fmt.Sprintf("%dkB", kb)
389 case bytes < 1024*1024*1024:
390 mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
391 return fmt.Sprintf("%dMB", mb)
392 }
393 return "more than 1GB"
394}
395
396// executeBackgroundBash executes a command in the background and returns the pid and output file locations
397func (b *BashTool) executeBackgroundBash(ctx context.Context, req bashInput, timeout time.Duration) (*BackgroundResult, error) {
398 // Create temp output files
399 tmpDir, err := os.MkdirTemp("", "sketch-bg-")
400 if err != nil {
401 return nil, fmt.Errorf("failed to create temp directory: %w", err)
402 }
403 // We can't really clean up tempDir, because we have no idea
404 // how far into the future the agent might want to read the output.
405
406 outFile := filepath.Join(tmpDir, "output")
407 out, err := os.Create(outFile)
408 if err != nil {
409 return nil, fmt.Errorf("failed to create output file: %w", err)
410 }
411
412 execCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) // detach from tool use context
413 cmd := b.makeBashCommand(execCtx, req.Command, out)
414 cmd.Env = append(cmd.Env, `GIT_SEQUENCE_EDITOR=python3 -c "import os, sys, signal, threading; print(f\"Send USR1 to pid {os.getpid()} after editing {sys.argv[1]}\", flush=True); signal.signal(signal.SIGUSR1, lambda *_: sys.exit(0)); threading.Event().wait()"`)
415
416 if err := cmd.Start(); err != nil {
417 cancel()
418 out.Close()
419 os.RemoveAll(tmpDir) // clean up temp dir -- didn't start means we don't need the output
420 return nil, fmt.Errorf("failed to start background command: %w", err)
421 }
422
423 // Wait for completion in the background, then do cleanup.
424 go func() {
425 err := cmdWait(cmd)
426 // Leave a note to the agent so that it knows that the process has finished.
427 if err != nil {
428 fmt.Fprintf(out, "\n\n[background process failed: %v]\n", err)
429 } else {
430 fmt.Fprintf(out, "\n\n[background process completed]\n")
431 }
432 out.Close()
433 cancel()
434 }()
435
436 return &BackgroundResult{
437 PID: cmd.Process.Pid,
438 OutFile: outFile,
439 }, nil
440}
441
442// checkAndInstallMissingTools analyzes a bash command and attempts to automatically install any missing tools.
443func (b *BashTool) checkAndInstallMissingTools(ctx context.Context, command string) error {
444 commands, err := bashkit.ExtractCommands(command)
445 if err != nil {
446 return err
447 }
448
449 autoInstallMu.Lock()
450 defer autoInstallMu.Unlock()
451
452 var missing []string
453 for _, cmd := range commands {
454 if doNotAttemptToolInstall[cmd] {
455 continue
456 }
457 _, err := exec.LookPath(cmd)
458 if err == nil {
459 doNotAttemptToolInstall[cmd] = true // spare future LookPath calls
460 continue
461 }
462 missing = append(missing, cmd)
463 }
464
465 if len(missing) == 0 {
466 return nil
467 }
468
469 for _, cmd := range missing {
470 err := b.installTool(ctx, cmd)
471 if err != nil {
472 slog.WarnContext(ctx, "failed to install tool", "tool", cmd, "error", err)
473 }
474 doNotAttemptToolInstall[cmd] = true // either it's installed or it's not--either way, we're done with it
475 }
476 return nil
477}
478
479// Command safety check cache to avoid repeated LLM calls
480var (
481 autoInstallMu sync.Mutex
482 doNotAttemptToolInstall = make(map[string]bool) // set to true if the tool should not be auto-installed
483)
484
485// autodetectPackageManager returns the first packageāmanager binary
486// found in PATH, or an empty string if none are present.
487func autodetectPackageManager() string {
488 // TODO: cache this result with a sync.OnceValue
489
490 managers := []string{
491 "apt", "apt-get", // Debian/Ubuntu
492 "brew", "port", // macOS (Homebrew / MacPorts)
493 "apk", // Alpine
494 "yum", "dnf", // RHEL/Fedora
495 "pacman", // Arch
496 "zypper", // openSUSE
497 "xbps-install", // Void
498 "emerge", // Gentoo
499 "nix-env", "guix", // NixOS / Guix
500 "pkg", // FreeBSD
501 "slackpkg", // Slackware
502 }
503
504 for _, m := range managers {
505 if _, err := exec.LookPath(m); err == nil {
506 return m
507 }
508 }
509 return ""
510}
511
512// installTool attempts to install a single missing tool using LLM validation and system package manager.
513func (b *BashTool) installTool(ctx context.Context, cmd string) error {
514 slog.InfoContext(ctx, "attempting to install tool", "tool", cmd)
515
516 packageManager := autodetectPackageManager()
517 if packageManager == "" {
518 return fmt.Errorf("no known package manager found in PATH")
519 }
520 // Use LLM to validate and get package name
521 if b.LLMProvider == nil {
522 return fmt.Errorf("no LLM provider available for tool validation")
523 }
524 llmService, err := b.selectBestLLM()
525 if err != nil {
526 return fmt.Errorf("failed to get LLM service for tool validation: %w", err)
527 }
528
529 query := fmt.Sprintf(`Do you know this command/package/tool? Is it legitimate, clearly non-harmful, and commonly used? Can it be installed with package manager %s?
530
531Command: %s
532
533- YES: Respond ONLY with the package name used to install it
534- NO or UNSURE: Respond ONLY with the word NO`, packageManager, cmd)
535
536 req := &llm.Request{
537 Messages: []llm.Message{{
538 Role: llm.MessageRoleUser,
539 Content: []llm.Content{llm.StringContent(query)},
540 }},
541 System: []llm.SystemContent{{
542 Type: "text",
543 Text: "You are an expert in software developer tools.",
544 }},
545 }
546
547 resp, err := llmService.Do(ctx, req)
548 if err != nil {
549 return fmt.Errorf("failed to validate tool with LLM: %w", err)
550 }
551
552 if len(resp.Content) == 0 {
553 return fmt.Errorf("empty response from LLM for tool validation")
554 }
555
556 response := strings.TrimSpace(resp.Content[0].Text)
557 if response == "NO" || response == "UNSURE" {
558 slog.InfoContext(ctx, "tool installation declined by LLM", "tool", cmd, "response", response)
559 return fmt.Errorf("tool %s not approved for installation", cmd)
560 }
561
562 packageName := strings.TrimSpace(response)
563 if packageName == "" {
564 return fmt.Errorf("no package name provided for tool %s", cmd)
565 }
566
567 return b.installPackage(ctx, cmd, packageName, packageManager)
568}
569
570// installPackage handles the actual package installation
571func (b *BashTool) installPackage(ctx context.Context, cmd, packageName, packageManager string) error {
572 // Install the package (with update command first if needed)
573 // TODO: these invocations create zombies when we are PID 1.
574 // We should give them the same zombie-reaping treatment as above,
575 // if/when we care enough to put in the effort. Not today.
576 var updateCmd, installCmd string
577 switch packageManager {
578 case "apt", "apt-get":
579 updateCmd = fmt.Sprintf("sudo %s update", packageManager)
580 installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName)
581 case "brew":
582 // brew handles updates automatically, no explicit update needed
583 installCmd = fmt.Sprintf("brew install %s", packageName)
584 case "apk":
585 updateCmd = "sudo apk update"
586 installCmd = fmt.Sprintf("sudo apk add %s", packageName)
587 case "yum", "dnf":
588 // For yum/dnf, we don't need a separate update command as the package cache is usually fresh enough
589 // and install will fetch the latest available packages
590 installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName)
591 case "pacman":
592 updateCmd = "sudo pacman -Sy"
593 installCmd = fmt.Sprintf("sudo pacman -S --noconfirm %s", packageName)
594 case "zypper":
595 updateCmd = "sudo zypper refresh"
596 installCmd = fmt.Sprintf("sudo zypper install -y %s", packageName)
597 case "xbps-install":
598 updateCmd = "sudo xbps-install -S"
599 installCmd = fmt.Sprintf("sudo xbps-install -y %s", packageName)
600 case "emerge":
601 // Note: emerge --sync is expensive, so we skip it for JIT installs
602 // Users should manually sync if needed
603 installCmd = fmt.Sprintf("sudo emerge %s", packageName)
604 case "nix-env":
605 // nix-env doesn't require explicit updates for JIT installs
606 installCmd = fmt.Sprintf("nix-env -i %s", packageName)
607 case "guix":
608 // guix doesn't require explicit updates for JIT installs
609 installCmd = fmt.Sprintf("guix install %s", packageName)
610 case "pkg":
611 updateCmd = "sudo pkg update"
612 installCmd = fmt.Sprintf("sudo pkg install -y %s", packageName)
613 case "slackpkg":
614 updateCmd = "sudo slackpkg update"
615 installCmd = fmt.Sprintf("sudo slackpkg install %s", packageName)
616 default:
617 return fmt.Errorf("unsupported package manager: %s", packageManager)
618 }
619
620 slog.InfoContext(ctx, "installing tool", "tool", cmd, "package", packageName, "update_command", updateCmd, "install_command", installCmd)
621
622 // Execute the update command first if needed
623 if updateCmd != "" {
624 slog.InfoContext(ctx, "updating package cache", "command", updateCmd)
625 updateCmdExec := exec.CommandContext(ctx, "sh", "-c", updateCmd)
626 updateOutput, err := updateCmdExec.CombinedOutput()
627 if err != nil {
628 slog.WarnContext(ctx, "package cache update failed, proceeding with install anyway", "error", err, "output", string(updateOutput))
629 }
630 }
631
632 // Execute the install command
633 cmdExec := exec.CommandContext(ctx, "sh", "-c", installCmd)
634 output, err := cmdExec.CombinedOutput()
635 if err != nil {
636 return fmt.Errorf("failed to install %s: %w\nOutput: %s", packageName, err, string(output))
637 }
638
639 slog.InfoContext(ctx, "tool installation successful", "tool", cmd, "package", packageName)
640 return nil
641}
642
643// selectBestLLM selects the best available LLM service for bash tool validation
644func (b *BashTool) selectBestLLM() (llm.Service, error) {
645 if b.LLMProvider == nil {
646 return nil, fmt.Errorf("no LLM provider available")
647 }
648
649 // Preferred models in order of preference for tool validation (fast, cheap models preferred)
650 preferredModels := []string{"qwen3-coder-fireworks", "gpt-5-thinking-mini", "gpt5-mini", "claude-sonnet-4.5", "predictable"}
651
652 for _, model := range preferredModels {
653 svc, err := b.LLMProvider.GetService(model)
654 if err == nil {
655 return svc, nil
656 }
657 }
658
659 // If no preferred model is available, try any available model
660 available := b.LLMProvider.GetAvailableModels()
661 if len(available) > 0 {
662 return b.LLMProvider.GetService(available[0])
663 }
664
665 return nil, fmt.Errorf("no LLM services available")
666}