Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,35 @@ jobs:
working-directory: crates/bashkit-python
run: pytest tests/ -v

examples:
name: Examples
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: actions/setup-python@v6
with:
python-version: "3.12"

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable

- uses: Swatinem/rust-cache@v2

- name: Build wheel
uses: PyO3/maturin-action@v1
with:
command: build
args: --release --out dist -i python3.12
rust-toolchain: stable
working-directory: crates/bashkit-python

- name: Install wheel
run: pip install bashkit --no-index --find-links crates/bashkit-python/dist --force-reinstall

- name: Run examples
run: python crates/bashkit-python/examples/bash_basics.py

# Verify wheel builds and passes twine check
build-wheel:
name: Build wheel
Expand Down Expand Up @@ -124,13 +153,14 @@ jobs:
python-check:
name: Python Check
if: always()
needs: [lint, test, build-wheel]
needs: [lint, test, examples, build-wheel]
runs-on: ubuntu-latest
steps:
- name: Verify all jobs passed
run: |
if [[ "${{ needs.lint.result }}" != "success" ]] || \
[[ "${{ needs.test.result }}" != "success" ]] || \
[[ "${{ needs.examples.result }}" != "success" ]] || \
[[ "${{ needs.build-wheel.result }}" != "success" ]]; then
echo "One or more Python CI jobs failed"
exit 1
Expand Down
68 changes: 57 additions & 11 deletions crates/bashkit-python/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Bashkit

[![PyPI](https://img.shields.io/pypi/v/bashkit)](https://pypi.org/project/bashkit/)

A sandboxed bash interpreter for AI agents.

```python
Expand Down Expand Up @@ -34,24 +36,22 @@ pip install 'bashkit[pydantic-ai]'

```python
import asyncio
from bashkit import BashTool
from bashkit import Bash

async def main():
tool = BashTool()
bash = Bash()

# Simple command
result = await tool.execute("echo 'Hello, World!'")
result = await bash.execute("echo 'Hello, World!'")
print(result.stdout) # Hello, World!

# Pipeline
result = await tool.execute("echo -e 'banana\\napple\\ncherry' | sort")
result = await bash.execute("echo -e 'banana\\napple\\ncherry' | sort")
print(result.stdout) # apple\nbanana\ncherry

# Virtual filesystem
result = await tool.execute("""
echo 'data' > /tmp/file.txt
cat /tmp/file.txt
""")
# Virtual filesystem persists between calls
await bash.execute("echo 'data' > /tmp/file.txt")
result = await bash.execute("cat /tmp/file.txt")
print(result.stdout) # data

asyncio.run(main())
Expand All @@ -70,14 +70,28 @@ print(result.stdout)
### Configuration

```python
tool = BashTool(
bash = Bash(
username="agent", # Custom username (whoami)
hostname="sandbox", # Custom hostname
max_commands=1000, # Limit total commands
max_loop_iterations=10000, # Limit loop iterations
)
```

### BashTool — Convenience Wrapper for AI Agents

`BashTool` is a convenience wrapper specifically designed for AI agents. It wraps `Bash` and adds LLM tool metadata (schema, description, system prompt) needed by tool-use protocols. Use this when integrating with LangChain, PydanticAI, or similar agent frameworks.

```python
from bashkit import BashTool

tool = BashTool()
print(tool.input_schema()) # JSON schema for LLM tool-use
print(tool.system_prompt()) # Token-efficient prompt

result = await tool.execute("echo 'Hello!'")
```

### Scripted Tool Orchestration

Compose multiple tools into a single bash-scriptable interface:
Expand Down Expand Up @@ -109,9 +123,35 @@ bash_tool = create_bash_tool()
# Use with any PydanticAI agent
```

## ScriptedTool — Multi-Tool Orchestration

Compose Python callbacks as bash builtins. An LLM writes a single bash script that pipes, loops, and branches across all registered tools.

```python
from bashkit import ScriptedTool

def get_user(params, stdin=None):
return '{"id": 1, "name": "Alice"}'

tool = ScriptedTool("api")
tool.add_tool("get_user", "Fetch user by ID",
callback=get_user,
schema={"type": "object", "properties": {"id": {"type": "integer"}}})

result = tool.execute_sync("get_user --id 1 | jq -r '.name'")
print(result.stdout) # Alice
```

## Features

- **Sandboxed, in-process execution**: All commands run in isolation with a virtual filesystem
- **68+ built-in commands**: echo, cat, grep, sed, awk, jq, curl, find, and more
- **Full bash syntax**: Variables, pipelines, redirects, loops, functions, arrays
- **Resource limits**: Protect against infinite loops and runaway scripts

## API Reference

### BashTool
### Bash

- `execute(commands: str) -> ExecResult` — execute commands asynchronously
- `execute_sync(commands: str) -> ExecResult` — execute commands synchronously
Expand All @@ -121,6 +161,12 @@ bash_tool = create_bash_tool()
- `input_schema() -> str` — JSON input schema
- `output_schema() -> str` — JSON output schema

### BashTool

Convenience wrapper for AI agents. Inherits all execution methods from `Bash`, plus:

- `system_prompt() -> str` — token-efficient system prompt for LLM integration

### ExecResult

- `stdout: str` — standard output
Expand Down
16 changes: 9 additions & 7 deletions crates/bashkit-python/bashkit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,24 @@
>>> print(result.stdout)
Hello, World!

For scripted multi-tool orchestration:
LLM tool wrapper (adds schema, description, system_prompt):
>>> from bashkit import BashTool
>>> tool = BashTool()
>>> print(tool.input_schema())

Multi-tool orchestration:
>>> from bashkit import ScriptedTool
>>> tool = ScriptedTool("api")
>>> tool.add_tool("greet", "Greet user", callback=lambda p, s=None: f"hello {p.get('name', 'world')}")
>>> result = tool.execute_sync("greet --name Alice")

For LangChain integration:
Framework integrations:
>>> from bashkit.langchain import create_bash_tool, create_scripted_tool

For Deep Agents integration:
>>> from bashkit.deepagents import create_bash_middleware

For PydanticAI integration:
>>> from bashkit.pydantic_ai import create_bash_tool
"""

from bashkit._bashkit import (
Bash,
BashTool,
ExecResult,
ScriptedTool,
Expand All @@ -33,6 +34,7 @@

__version__ = "0.1.2"
__all__ = [
"Bash",
"BashTool",
"ExecResult",
"ScriptedTool",
Expand Down
26 changes: 25 additions & 1 deletion crates/bashkit-python/bashkit/_bashkit.pyi
Original file line number Diff line number Diff line change
@@ -1,7 +1,31 @@
"""Type stubs for bashkit_py native module."""
"""Type stubs for bashkit native module."""

from typing import Any, Callable

class Bash:
"""Core bash interpreter with virtual filesystem.

State persists between calls — files created in one execute() are
available in subsequent calls.

Example:
>>> bash = Bash()
>>> result = await bash.execute("echo 'Hello!'")
>>> print(result.stdout)
Hello!
"""

def __init__(
self,
username: str | None = None,
hostname: str | None = None,
max_commands: int | None = None,
max_loop_iterations: int | None = None,
) -> None: ...
async def execute(self, commands: str) -> ExecResult: ...
def execute_sync(self, commands: str) -> ExecResult: ...
def reset(self) -> None: ...

class ExecResult:
"""Result from executing bash commands."""

Expand Down
149 changes: 149 additions & 0 deletions crates/bashkit-python/examples/bash_basics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""Basic usage of the Bash interface.

Demonstrates core Bash features: command execution, pipelines, variables,
loops, virtual filesystem persistence, and resource limits.

Run directly:
cd crates/bashkit-python && maturin develop && python examples/bash_basics.py
"""

from __future__ import annotations

import asyncio

from bashkit import Bash


def demo_sync():
"""Synchronous API basics."""
print("=== Sync API ===\n")

bash = Bash()

# Simple command
r = bash.execute_sync("echo 'Hello from Bash!'")
print(f"echo: {r.stdout.strip()}")
assert r.success

# Pipeline
r = bash.execute_sync("echo -e 'banana\\napple\\ncherry' | sort")
print(f"sort: {r.stdout.strip()}")
assert r.stdout.strip() == "apple\nbanana\ncherry"

# Variables persist across calls
bash.execute_sync("MY_VAR='persistent'")
r = bash.execute_sync("echo $MY_VAR")
print(f"var: {r.stdout.strip()}")
assert r.stdout.strip() == "persistent"

# Virtual filesystem persists
bash.execute_sync("mkdir -p /tmp/demo && echo 'data' > /tmp/demo/file.txt")
r = bash.execute_sync("cat /tmp/demo/file.txt")
print(f"file: {r.stdout.strip()}")
assert r.stdout.strip() == "data"

# Loops and arithmetic
r = bash.execute_sync("""
total=0
for i in 1 2 3 4 5; do
total=$((total + i))
done
echo $total
""")
print(f"sum: {r.stdout.strip()}")
assert r.stdout.strip() == "15"

# Error handling
r = bash.execute_sync("exit 42")
print(f"exit: code={r.exit_code}, success={r.success}")
assert r.exit_code == 42
assert not r.success

# Text processing pipeline
r = bash.execute_sync("""
cat << 'EOF' | grep -c 'error'
info: all good
error: disk full
info: recovered
error: timeout
EOF
""")
print(f"grep: {r.stdout.strip()} errors found")
assert r.stdout.strip() == "2"

# Reset clears state
bash.reset()
r = bash.execute_sync("echo ${MY_VAR:-unset}")
print(f"reset: {r.stdout.strip()}")
assert r.stdout.strip() == "unset"

print()


async def demo_async():
"""Async API basics."""
print("=== Async API ===\n")

bash = Bash()

# Async execution
r = await bash.execute("echo 'async hello'")
print(f"async: {r.stdout.strip()}")
assert r.success

# Build a JSON report with jq
await bash.execute("""
cat > /tmp/users.json << 'EOF'
[
{"name": "Alice", "role": "admin"},
{"name": "Bob", "role": "user"},
{"name": "Carol", "role": "admin"}
]
EOF
""")
r = await bash.execute("cat /tmp/users.json | jq '[.[] | select(.role == \"admin\")] | length'")
print(f"admins: {r.stdout.strip()}")
assert r.stdout.strip() == "2"

# ExecResult as dict
r = await bash.execute("echo ok")
d = r.to_dict()
print(f"dict: stdout={d['stdout'].strip()!r}, exit_code={d['exit_code']}")
assert d["exit_code"] == 0

print()


def demo_config():
"""Custom configuration."""
print("=== Configuration ===\n")

bash = Bash(username="agent", hostname="sandbox")
r = bash.execute_sync("whoami")
print(f"whoami: {r.stdout.strip()}")
assert r.stdout.strip() == "agent"

r = bash.execute_sync("hostname")
print(f"hostname: {r.stdout.strip()}")
assert r.stdout.strip() == "sandbox"

# Resource limits
limited = Bash(max_loop_iterations=50)
r = limited.execute_sync("i=0; while true; do i=$((i+1)); done; echo $i")
print(f"limited: stopped (exit_code={r.exit_code})")
assert r.exit_code != 0 or int(r.stdout.strip() or "0") <= 100

print()


def main():
print("Bashkit — Bash interface examples\n")
demo_sync()
asyncio.run(demo_async())
demo_config()
print("All examples passed.")


if __name__ == "__main__":
main()
Loading
Loading