16 "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/fsext"
18 "github.com/charmbracelet/crush/internal/permission"
19 "github.com/charmbracelet/crush/internal/shell"
22type BashParams struct {
23 Description string `json:"description" description:"A brief description of what the command does, try to keep it under 30 characters or so"`
24 Command string `json:"command" description:"The command to execute"`
25 WorkingDir string `json:"working_dir,omitempty" description:"The working directory to execute the command in (defaults to current directory)"`
26 RunInBackground bool `json:"run_in_background,omitempty" description:"Set to true (boolean) to run this command in the background. Use job_output to read the output later."`
27 AutoBackgroundAfter int `json:"auto_background_after,omitempty" description:"Seconds to wait before automatically moving the command to a background job (default: 60)"`
30type BashPermissionsParams struct {
31 Description string `json:"description"`
32 Command string `json:"command"`
33 WorkingDir string `json:"working_dir"`
34 RunInBackground bool `json:"run_in_background"`
35 AutoBackgroundAfter int `json:"auto_background_after"`
38type BashResponseMetadata struct {
39 StartTime int64 `json:"start_time"`
40 EndTime int64 `json:"end_time"`
41 Output string `json:"output"`
42 Description string `json:"description"`
43 WorkingDirectory string `json:"working_directory"`
44 Background bool `json:"background,omitempty"`
45 ShellID string `json:"shell_id,omitempty"`
51 DefaultAutoBackgroundAfter = 60 // Commands taking longer automatically become background jobs
52 MaxOutputLength = 30000
53 BashNoOutput = "no output"
57var bashDescriptionTmpl []byte
59var bashDescriptionTpl = template.Must(
60 template.New("bashDescription").
61 Parse(string(bashDescriptionTmpl)),
64type bashDescriptionData struct {
67 Attribution config.Attribution
72var bannedCommands = []string{
73 // Network/Download tools
94 // System administration
121 // System modification
134 // Network configuration
145func bashDescription(attribution *config.Attribution, modelID string) string {
146 bannedCommandsStr := strings.Join(bannedCommands, ", ")
148 if err := bashDescriptionTpl.Execute(&out, bashDescriptionData{
149 BannedCommands: bannedCommandsStr,
150 MaxOutputLength: MaxOutputLength,
151 Attribution: *attribution,
153 RgAvailable: getRg() != "",
155 // this should never happen.
156 panic("failed to execute bash description template: " + err.Error())
161func blockFuncs() []shell.BlockFunc {
162 return []shell.BlockFunc{
163 shell.CommandsBlocker(bannedCommands),
165 // System package managers
166 shell.ArgumentsBlocker("apk", []string{"add"}, nil),
167 shell.ArgumentsBlocker("apt", []string{"install"}, nil),
168 shell.ArgumentsBlocker("apt-get", []string{"install"}, nil),
169 shell.ArgumentsBlocker("dnf", []string{"install"}, nil),
170 shell.ArgumentsBlocker("pacman", nil, []string{"-S"}),
171 shell.ArgumentsBlocker("pkg", []string{"install"}, nil),
172 shell.ArgumentsBlocker("yum", []string{"install"}, nil),
173 shell.ArgumentsBlocker("zypper", []string{"install"}, nil),
175 // Language-specific package managers
176 shell.ArgumentsBlocker("brew", []string{"install"}, nil),
177 shell.ArgumentsBlocker("cargo", []string{"install"}, nil),
178 shell.ArgumentsBlocker("gem", []string{"install"}, nil),
179 shell.ArgumentsBlocker("go", []string{"install"}, nil),
180 shell.ArgumentsBlocker("npm", []string{"install"}, []string{"--global"}),
181 shell.ArgumentsBlocker("npm", []string{"install"}, []string{"-g"}),
182 shell.ArgumentsBlocker("pip", []string{"install"}, []string{"--user"}),
183 shell.ArgumentsBlocker("pip3", []string{"install"}, []string{"--user"}),
184 shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"--global"}),
185 shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"-g"}),
186 shell.ArgumentsBlocker("yarn", []string{"global", "add"}, nil),
188 // `go test -exec` can run arbitrary commands
189 shell.ArgumentsBlocker("go", []string{"test"}, []string{"-exec"}),
193func NewBashTool(permissions permission.Service, workingDir string, attribution *config.Attribution, modelID string) fantasy.AgentTool {
194 return fantasy.NewAgentTool(
196 string(bashDescription(attribution, modelID)),
197 func(ctx context.Context, params BashParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
198 if params.Command == "" {
199 return fantasy.NewTextErrorResponse("missing command"), nil
202 // Determine working directory
203 execWorkingDir := cmp.Or(params.WorkingDir, workingDir)
205 isSafeReadOnly := false
206 cmdLower := strings.ToLower(params.Command)
208 if !containsCommandChaining(params.Command) {
209 for _, safe := range safeCommands {
210 if strings.HasPrefix(cmdLower, safe) {
211 if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
212 isSafeReadOnly = true
219 sessionID := GetSessionFromContext(ctx)
221 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command")
224 p, err := permissions.Request(
226 permission.CreatePermissionRequest{
227 SessionID: sessionID,
228 Path: execWorkingDir,
230 ToolName: BashToolName,
232 Description: fmt.Sprintf("Execute command: %s", params.Command),
233 Params: BashPermissionsParams(params),
237 return fantasy.ToolResponse{}, err
240 return NewPermissionDeniedResponse(), nil
244 // If explicitly requested as background, start immediately with detached context
245 if params.RunInBackground {
246 startTime := time.Now()
247 bgManager := shell.GetBackgroundShellManager()
249 // Use background context so it continues after tool returns
250 bgShell, err := bgManager.Start(context.Background(), execWorkingDir, blockFuncs(), params.Command, params.Description)
252 return fantasy.ToolResponse{}, fmt.Errorf("error starting background shell: %w", err)
255 // Wait a short time to detect fast failures (blocked commands, syntax errors, etc.)
256 time.Sleep(1 * time.Second)
257 stdout, stderr, done, execErr := bgShell.GetOutput()
260 // Command failed or completed very quickly
261 bgManager.Remove(bgShell.ID)
263 interrupted := shell.IsInterrupt(execErr)
264 exitCode := shell.ExitCode(execErr)
265 if exitCode == 0 && !interrupted && execErr != nil {
266 return fantasy.ToolResponse{}, fmt.Errorf("[Job %s] error executing command: %w", bgShell.ID, execErr)
269 stdout = formatOutput(stdout, stderr, execErr)
271 metadata := BashResponseMetadata{
272 StartTime: startTime.UnixMilli(),
273 EndTime: time.Now().UnixMilli(),
275 Description: params.Description,
276 Background: params.RunInBackground,
277 WorkingDirectory: bgShell.WorkingDir,
280 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
282 stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(bgShell.WorkingDir))
283 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
286 // Still running after fast-failure check - return as background job
287 metadata := BashResponseMetadata{
288 StartTime: startTime.UnixMilli(),
289 EndTime: time.Now().UnixMilli(),
290 Description: params.Description,
291 WorkingDirectory: bgShell.WorkingDir,
295 response := fmt.Sprintf("Background shell started with ID: %s\n\nUse job_output tool to view output or job_kill to terminate.", bgShell.ID)
296 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
299 // Start synchronous execution with auto-background support
300 startTime := time.Now()
302 // Start with detached context so it can survive if moved to background
303 bgManager := shell.GetBackgroundShellManager()
305 bgShell, err := bgManager.Start(context.Background(), execWorkingDir, blockFuncs(), params.Command, params.Description)
307 return fantasy.ToolResponse{}, fmt.Errorf("error starting shell: %w", err)
310 // Wait for either completion, auto-background threshold, or context cancellation
311 ticker := time.NewTicker(100 * time.Millisecond)
314 autoBackgroundAfter := cmp.Or(params.AutoBackgroundAfter, DefaultAutoBackgroundAfter)
315 autoBackgroundThreshold := time.Duration(autoBackgroundAfter) * time.Second
316 timeout := time.After(autoBackgroundThreshold)
318 var stdout, stderr string
326 stdout, stderr, done, execErr = bgShell.GetOutput()
331 stdout, stderr, done, execErr = bgShell.GetOutput()
334 // Incoming context was cancelled before we moved to background
335 // Kill the shell and return error
336 bgManager.Kill(bgShell.ID)
337 return fantasy.ToolResponse{}, ctx.Err()
342 // Command completed within threshold - return synchronously
343 // Remove from background manager since we're returning directly
344 // Don't call Kill() as it cancels the context and corrupts the exit code
345 bgManager.Remove(bgShell.ID)
347 interrupted := shell.IsInterrupt(execErr)
348 exitCode := shell.ExitCode(execErr)
349 if exitCode == 0 && !interrupted && execErr != nil {
350 return fantasy.ToolResponse{}, fmt.Errorf("[Job %s] error executing command: %w", bgShell.ID, execErr)
353 stdout = formatOutput(stdout, stderr, execErr)
355 metadata := BashResponseMetadata{
356 StartTime: startTime.UnixMilli(),
357 EndTime: time.Now().UnixMilli(),
359 Description: params.Description,
360 Background: params.RunInBackground,
361 WorkingDirectory: bgShell.WorkingDir,
364 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
366 stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(bgShell.WorkingDir))
367 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
370 // Still running - keep as background job
371 metadata := BashResponseMetadata{
372 StartTime: startTime.UnixMilli(),
373 EndTime: time.Now().UnixMilli(),
374 Description: params.Description,
375 WorkingDirectory: bgShell.WorkingDir,
379 response := fmt.Sprintf("Command is taking longer than expected and has been moved to background.\n\nBackground shell ID: %s\n\nUse job_output tool to view output or job_kill to terminate.", bgShell.ID)
380 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
385// formatOutput formats the output of a completed command with error handling
386func formatOutput(stdout, stderr string, execErr error) string {
387 interrupted := shell.IsInterrupt(execErr)
388 exitCode := shell.ExitCode(execErr)
390 stdout = truncateOutput(stdout)
391 stderr = truncateOutput(stderr)
393 errorMessage := stderr
394 if errorMessage == "" && execErr != nil {
395 errorMessage = execErr.Error()
399 if errorMessage != "" {
402 errorMessage += "Command was aborted before completion"
403 } else if exitCode != 0 {
404 if errorMessage != "" {
407 errorMessage += fmt.Sprintf("Exit code %d", exitCode)
410 hasBothOutputs := stdout != "" && stderr != ""
416 if errorMessage != "" {
417 stdout += "\n" + errorMessage
423func TruncateOutput(content string) string {
424 if len(content) <= MaxOutputLength {
428 halfLength := MaxOutputLength / 2
429 start := content[:halfLength]
430 end := content[len(content)-halfLength:]
432 truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
433 return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
436func truncateOutput(content string) string {
437 return TruncateOutput(content)
440func countLines(s string) int {
444 return len(strings.Split(s, "\n"))
447func normalizeWorkingDir(path string) string {
448 if runtime.GOOS == "windows" {
449 path = strings.ReplaceAll(path, fsext.WindowsWorkingDirDrive(), "")
451 return filepath.ToSlash(path)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list