Initial commit
diff --git a/claudetool/bash.go b/claudetool/bash.go
new file mode 100644
index 0000000..d76d7f1
--- /dev/null
+++ b/claudetool/bash.go
@@ -0,0 +1,163 @@
+package claudetool
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "math"
+ "os/exec"
+ "strings"
+ "syscall"
+ "time"
+
+ "sketch.dev/ant"
+ "sketch.dev/claudetool/bashkit"
+)
+
+// The Bash tool executes shell commands with bash -c and optional timeout
+var Bash = &ant.Tool{
+ Name: bashName,
+ Description: strings.TrimSpace(bashDescription),
+ InputSchema: ant.MustSchema(bashInputSchema),
+ Run: BashRun,
+}
+
+const (
+ bashName = "bash"
+ bashDescription = `
+Executes a shell command using bash -c with an optional timeout, returning combined stdout and stderr.
+
+Executables pre-installed in this environment include:
+- standard unix tools
+- go
+- git
+- rg
+- jq
+- gopls
+- sqlite
+- fzf
+- gh
+- python3
+`
+ // If you modify this, update the termui template for prettier rendering.
+ bashInputSchema = `
+{
+ "type": "object",
+ "required": ["command"],
+ "properties": {
+ "command": {
+ "type": "string",
+ "description": "Shell script to execute"
+ },
+ "timeout": {
+ "type": "string",
+ "description": "Timeout as a Go duration string, defaults to '1m'"
+ }
+ }
+}
+`
+)
+
+type bashInput struct {
+ Command string `json:"command"`
+ Timeout string `json:"timeout,omitempty"`
+}
+
+func (i *bashInput) timeout() time.Duration {
+ dur, err := time.ParseDuration(i.Timeout)
+ if err != nil {
+ return 1 * time.Minute
+ }
+ return dur
+}
+
+func BashRun(ctx context.Context, m json.RawMessage) (string, error) {
+ var req bashInput
+ if err := json.Unmarshal(m, &req); err != nil {
+ return "", fmt.Errorf("failed to unmarshal bash command input: %w", err)
+ }
+ // do a quick permissions check (NOT a security barrier)
+ err := bashkit.Check(req.Command)
+ if err != nil {
+ return "", err
+ }
+ out, execErr := executeBash(ctx, req)
+ if execErr == nil {
+ return out, nil
+ }
+ return "", execErr
+}
+
+const maxBashOutputLength = 131072
+
+func executeBash(ctx context.Context, req bashInput) (string, error) {
+ execCtx, cancel := context.WithTimeout(ctx, req.timeout())
+ defer cancel()
+
+ // Can't do the simple thing and call CombinedOutput because of the need to kill the process group.
+ cmd := exec.CommandContext(execCtx, "bash", "-c", req.Command)
+ cmd.Dir = WorkingDir(ctx)
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+
+ var output bytes.Buffer
+ cmd.Stdin = nil
+ cmd.Stdout = &output
+ cmd.Stderr = &output
+ if err := cmd.Start(); err != nil {
+ return "", fmt.Errorf("command failed: %w", err)
+ }
+ proc := cmd.Process
+ done := make(chan struct{})
+ go func() {
+ select {
+ case <-execCtx.Done():
+ if execCtx.Err() == context.DeadlineExceeded && proc != nil {
+ // Kill the entire process group.
+ syscall.Kill(-proc.Pid, syscall.SIGKILL)
+ }
+ case <-done:
+ }
+ }()
+
+ err := cmd.Wait()
+ close(done)
+
+ if execCtx.Err() == context.DeadlineExceeded {
+ return "", fmt.Errorf("command timed out after %s", req.timeout())
+ }
+ longOutput := output.Len() > maxBashOutputLength
+ var outstr string
+ if longOutput {
+ outstr = fmt.Sprintf("output too long: got %v, max is %v\ninitial bytes of output:\n%s",
+ humanizeBytes(output.Len()), humanizeBytes(maxBashOutputLength),
+ output.Bytes()[:1024],
+ )
+ } else {
+ outstr = output.String()
+ }
+
+ if err != nil {
+ return "", fmt.Errorf("command failed: %w\n%s", err, outstr)
+ }
+
+ if longOutput {
+ return "", fmt.Errorf("%s", outstr)
+ }
+
+ return output.String(), nil
+}
+
+func humanizeBytes(bytes int) string {
+ switch {
+ case bytes < 4*1024:
+ return fmt.Sprintf("%dB", bytes)
+ case bytes < 1024*1024:
+ kb := int(math.Round(float64(bytes) / 1024.0))
+ return fmt.Sprintf("%dkB", kb)
+ case bytes < 1024*1024*1024:
+ mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
+ return fmt.Sprintf("%dMB", mb)
+ }
+ return "more than 1GB"
+}