17 "github.com/charmbracelet/crush/internal/config"
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."`
29type BashPermissionsParams struct {
30 Description string `json:"description"`
31 Command string `json:"command"`
32 WorkingDir string `json:"working_dir"`
33 RunInBackground bool `json:"run_in_background"`
36type BashResponseMetadata struct {
37 StartTime int64 `json:"start_time"`
38 EndTime int64 `json:"end_time"`
39 Output string `json:"output"`
40 Description string `json:"description"`
41 WorkingDirectory string `json:"working_directory"`
42 Background bool `json:"background,omitempty"`
43 ShellID string `json:"shell_id,omitempty"`
49 AutoBackgroundThreshold = 1 * time.Minute // Commands taking longer automatically become background jobs
50 MaxOutputLength = 30000
51 BashNoOutput = "no output"
55var bashDescriptionTmpl []byte
57var bashDescriptionTpl = template.Must(
58 template.New("bashDescription").
59 Parse(string(bashDescriptionTmpl)),
62type bashDescriptionData struct {
65 Attribution config.Attribution
69var bannedCommands = []string{
70 // Network/Download tools
91 // System administration
118 // System modification
131 // Network configuration
142func bashDescription(attribution *config.Attribution, modelName string) string {
143 bannedCommandsStr := strings.Join(bannedCommands, ", ")
145 if err := bashDescriptionTpl.Execute(&out, bashDescriptionData{
146 BannedCommands: bannedCommandsStr,
147 MaxOutputLength: MaxOutputLength,
148 Attribution: *attribution,
149 ModelName: modelName,
151 // this should never happen.
152 panic("failed to execute bash description template: " + err.Error())
157func blockFuncs() []shell.BlockFunc {
158 return []shell.BlockFunc{
159 shell.CommandsBlocker(bannedCommands),
161 // System package managers
162 shell.ArgumentsBlocker("apk", []string{"add"}, nil),
163 shell.ArgumentsBlocker("apt", []string{"install"}, nil),
164 shell.ArgumentsBlocker("apt-get", []string{"install"}, nil),
165 shell.ArgumentsBlocker("dnf", []string{"install"}, nil),
166 shell.ArgumentsBlocker("pacman", nil, []string{"-S"}),
167 shell.ArgumentsBlocker("pkg", []string{"install"}, nil),
168 shell.ArgumentsBlocker("yum", []string{"install"}, nil),
169 shell.ArgumentsBlocker("zypper", []string{"install"}, nil),
171 // Language-specific package managers
172 shell.ArgumentsBlocker("brew", []string{"install"}, nil),
173 shell.ArgumentsBlocker("cargo", []string{"install"}, nil),
174 shell.ArgumentsBlocker("gem", []string{"install"}, nil),
175 shell.ArgumentsBlocker("go", []string{"install"}, nil),
176 shell.ArgumentsBlocker("npm", []string{"install"}, []string{"--global"}),
177 shell.ArgumentsBlocker("npm", []string{"install"}, []string{"-g"}),
178 shell.ArgumentsBlocker("pip", []string{"install"}, []string{"--user"}),
179 shell.ArgumentsBlocker("pip3", []string{"install"}, []string{"--user"}),
180 shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"--global"}),
181 shell.ArgumentsBlocker("pnpm", []string{"add"}, []string{"-g"}),
182 shell.ArgumentsBlocker("yarn", []string{"global", "add"}, nil),
184 // `go test -exec` can run arbitrary commands
185 shell.ArgumentsBlocker("go", []string{"test"}, []string{"-exec"}),
189func NewBashTool(permissions permission.Service, workingDir string, attribution *config.Attribution, modelName string) fantasy.AgentTool {
190 return fantasy.NewAgentTool(
192 string(bashDescription(attribution, modelName)),
193 func(ctx context.Context, params BashParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
194 if params.Command == "" {
195 return fantasy.NewTextErrorResponse("missing command"), nil
198 // Determine working directory
199 execWorkingDir := cmp.Or(params.WorkingDir, workingDir)
201 isSafeReadOnly := false
202 cmdLower := strings.ToLower(params.Command)
204 for _, safe := range safeCommands {
205 if strings.HasPrefix(cmdLower, safe) {
206 if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
207 isSafeReadOnly = true
213 sessionID := GetSessionFromContext(ctx)
215 return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command")
218 p, err := permissions.Request(ctx,
219 permission.CreatePermissionRequest{
220 SessionID: sessionID,
221 Path: execWorkingDir,
223 ToolName: BashToolName,
225 Description: fmt.Sprintf("Execute command: %s", params.Command),
226 Params: BashPermissionsParams(params),
230 return fantasy.ToolResponse{}, err
233 return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
237 // If explicitly requested as background, start immediately with detached context
238 if params.RunInBackground {
239 startTime := time.Now()
240 bgManager := shell.GetBackgroundShellManager()
242 // Use background context so it continues after tool returns
243 bgShell, err := bgManager.Start(context.Background(), execWorkingDir, blockFuncs(), params.Command, params.Description)
245 return fantasy.ToolResponse{}, fmt.Errorf("error starting background shell: %w", err)
248 // Wait a short time to detect fast failures (blocked commands, syntax errors, etc.)
249 time.Sleep(1 * time.Second)
250 stdout, stderr, done, execErr := bgShell.GetOutput()
253 // Command failed or completed very quickly
254 bgManager.Remove(bgShell.ID)
256 interrupted := shell.IsInterrupt(execErr)
257 exitCode := shell.ExitCode(execErr)
258 if exitCode == 0 && !interrupted && execErr != nil {
259 return fantasy.ToolResponse{}, fmt.Errorf("[Job %s] error executing command: %w", bgShell.ID, execErr)
262 stdout = formatOutput(stdout, stderr, execErr)
264 metadata := BashResponseMetadata{
265 StartTime: startTime.UnixMilli(),
266 EndTime: time.Now().UnixMilli(),
268 Description: params.Description,
269 Background: params.RunInBackground,
270 WorkingDirectory: bgShell.WorkingDir,
273 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
275 stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(bgShell.WorkingDir))
276 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
279 // Still running after fast-failure check - return as background job
280 metadata := BashResponseMetadata{
281 StartTime: startTime.UnixMilli(),
282 EndTime: time.Now().UnixMilli(),
283 Description: params.Description,
284 WorkingDirectory: bgShell.WorkingDir,
288 response := fmt.Sprintf("Background shell started with ID: %s\n\nUse job_output tool to view output or job_kill to terminate.", bgShell.ID)
289 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
292 // Start synchronous execution with auto-background support
293 startTime := time.Now()
295 // Start with detached context so it can survive if moved to background
296 bgManager := shell.GetBackgroundShellManager()
298 bgShell, err := bgManager.Start(context.Background(), execWorkingDir, blockFuncs(), params.Command, params.Description)
300 return fantasy.ToolResponse{}, fmt.Errorf("error starting shell: %w", err)
303 // Wait for either completion, auto-background threshold, or context cancellation
304 ticker := time.NewTicker(100 * time.Millisecond)
306 timeout := time.After(AutoBackgroundThreshold)
308 var stdout, stderr string
316 stdout, stderr, done, execErr = bgShell.GetOutput()
321 stdout, stderr, done, execErr = bgShell.GetOutput()
324 // Incoming context was cancelled before we moved to background
325 // Kill the shell and return error
326 bgManager.Kill(bgShell.ID)
327 return fantasy.ToolResponse{}, ctx.Err()
332 // Command completed within threshold - return synchronously
333 // Remove from background manager since we're returning directly
334 // Don't call Kill() as it cancels the context and corrupts the exit code
335 bgManager.Remove(bgShell.ID)
337 interrupted := shell.IsInterrupt(execErr)
338 exitCode := shell.ExitCode(execErr)
339 if exitCode == 0 && !interrupted && execErr != nil {
340 return fantasy.ToolResponse{}, fmt.Errorf("[Job %s] error executing command: %w", bgShell.ID, execErr)
343 stdout = formatOutput(stdout, stderr, execErr)
345 metadata := BashResponseMetadata{
346 StartTime: startTime.UnixMilli(),
347 EndTime: time.Now().UnixMilli(),
349 Description: params.Description,
350 Background: params.RunInBackground,
351 WorkingDirectory: bgShell.WorkingDir,
354 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(BashNoOutput), metadata), nil
356 stdout += fmt.Sprintf("\n\n<cwd>%s</cwd>", normalizeWorkingDir(bgShell.WorkingDir))
357 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(stdout), metadata), nil
360 // Still running - keep as background job
361 metadata := BashResponseMetadata{
362 StartTime: startTime.UnixMilli(),
363 EndTime: time.Now().UnixMilli(),
364 Description: params.Description,
365 WorkingDirectory: bgShell.WorkingDir,
369 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)
370 return fantasy.WithResponseMetadata(fantasy.NewTextResponse(response), metadata), nil
374// formatOutput formats the output of a completed command with error handling
375func formatOutput(stdout, stderr string, execErr error) string {
376 interrupted := shell.IsInterrupt(execErr)
377 exitCode := shell.ExitCode(execErr)
379 stdout = truncateOutput(stdout)
380 stderr = truncateOutput(stderr)
382 errorMessage := stderr
383 if errorMessage == "" && execErr != nil {
384 errorMessage = execErr.Error()
388 if errorMessage != "" {
391 errorMessage += "Command was aborted before completion"
392 } else if exitCode != 0 {
393 if errorMessage != "" {
396 errorMessage += fmt.Sprintf("Exit code %d", exitCode)
399 hasBothOutputs := stdout != "" && stderr != ""
405 if errorMessage != "" {
406 stdout += "\n" + errorMessage
412func truncateOutput(content string) string {
413 if len(content) <= MaxOutputLength {
417 halfLength := MaxOutputLength / 2
418 start := content[:halfLength]
419 end := content[len(content)-halfLength:]
421 truncatedLinesCount := countLines(content[halfLength : len(content)-halfLength])
422 return fmt.Sprintf("%s\n\n... [%d lines truncated] ...\n\n%s", start, truncatedLinesCount, end)
425func countLines(s string) int {
429 return len(strings.Split(s, "\n"))
432func normalizeWorkingDir(path string) string {
433 if runtime.GOOS == "windows" {
434 cwd, err := os.Getwd()
438 path = strings.ReplaceAll(path, filepath.VolumeName(cwd), "")
441 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