Skip to content

Commit a39471c

Browse files
chaliyclaude
andauthored
feat(python): Bash interface, examples, and docs (#442)
## Summary - Export `Bash` class as primary Python interface alongside `BashTool` - Add `bash_basics.py` example demonstrating sync/async API, pipelines, variables, filesystem persistence, resource limits, and configuration - Add `Bash` class unit tests (construction, sync, async, reset, limits) - Add examples CI job to `python.yml` workflow - Clarify `BashTool` as a convenience wrapper for AI agents in README - Add PyPI badge to README - Update spec 013 with examples dir and CI job ## Test plan - [ ] `examples` CI job runs `bash_basics.py` successfully - [ ] New `Bash` class tests pass in `test` CI job across Python 3.9/3.12/3.13 - [ ] Ruff lint and format checks pass - [ ] Existing `BashTool` and `ScriptedTool` tests still pass --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 03fc095 commit a39471c

File tree

8 files changed

+546
-28
lines changed

8 files changed

+546
-28
lines changed

.github/workflows/python.yml

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,35 @@ jobs:
9090
working-directory: crates/bashkit-python
9191
run: pytest tests/ -v
9292

93+
examples:
94+
name: Examples
95+
runs-on: ubuntu-latest
96+
steps:
97+
- uses: actions/checkout@v6
98+
99+
- uses: actions/setup-python@v6
100+
with:
101+
python-version: "3.12"
102+
103+
- name: Install Rust toolchain
104+
uses: dtolnay/rust-toolchain@stable
105+
106+
- uses: Swatinem/rust-cache@v2
107+
108+
- name: Build wheel
109+
uses: PyO3/maturin-action@v1
110+
with:
111+
command: build
112+
args: --release --out dist -i python3.12
113+
rust-toolchain: stable
114+
working-directory: crates/bashkit-python
115+
116+
- name: Install wheel
117+
run: pip install bashkit --no-index --find-links crates/bashkit-python/dist --force-reinstall
118+
119+
- name: Run examples
120+
run: python crates/bashkit-python/examples/bash_basics.py
121+
93122
# Verify wheel builds and passes twine check
94123
build-wheel:
95124
name: Build wheel
@@ -124,13 +153,14 @@ jobs:
124153
python-check:
125154
name: Python Check
126155
if: always()
127-
needs: [lint, test, build-wheel]
156+
needs: [lint, test, examples, build-wheel]
128157
runs-on: ubuntu-latest
129158
steps:
130159
- name: Verify all jobs passed
131160
run: |
132161
if [[ "${{ needs.lint.result }}" != "success" ]] || \
133162
[[ "${{ needs.test.result }}" != "success" ]] || \
163+
[[ "${{ needs.examples.result }}" != "success" ]] || \
134164
[[ "${{ needs.build-wheel.result }}" != "success" ]]; then
135165
echo "One or more Python CI jobs failed"
136166
exit 1

crates/bashkit-python/README.md

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Bashkit
22

3+
[![PyPI](https://img.shields.io/pypi/v/bashkit)](https://pypi.org/project/bashkit/)
4+
35
A sandboxed bash interpreter for AI agents.
46

57
```python
@@ -34,24 +36,22 @@ pip install 'bashkit[pydantic-ai]'
3436

3537
```python
3638
import asyncio
37-
from bashkit import BashTool
39+
from bashkit import Bash
3840

3941
async def main():
40-
tool = BashTool()
42+
bash = Bash()
4143

4244
# Simple command
43-
result = await tool.execute("echo 'Hello, World!'")
45+
result = await bash.execute("echo 'Hello, World!'")
4446
print(result.stdout) # Hello, World!
4547

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

50-
# Virtual filesystem
51-
result = await tool.execute("""
52-
echo 'data' > /tmp/file.txt
53-
cat /tmp/file.txt
54-
""")
52+
# Virtual filesystem persists between calls
53+
await bash.execute("echo 'data' > /tmp/file.txt")
54+
result = await bash.execute("cat /tmp/file.txt")
5555
print(result.stdout) # data
5656

5757
asyncio.run(main())
@@ -70,14 +70,28 @@ print(result.stdout)
7070
### Configuration
7171

7272
```python
73-
tool = BashTool(
73+
bash = Bash(
7474
username="agent", # Custom username (whoami)
7575
hostname="sandbox", # Custom hostname
7676
max_commands=1000, # Limit total commands
7777
max_loop_iterations=10000, # Limit loop iterations
7878
)
7979
```
8080

81+
### BashTool — Convenience Wrapper for AI Agents
82+
83+
`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.
84+
85+
```python
86+
from bashkit import BashTool
87+
88+
tool = BashTool()
89+
print(tool.input_schema()) # JSON schema for LLM tool-use
90+
print(tool.system_prompt()) # Token-efficient prompt
91+
92+
result = await tool.execute("echo 'Hello!'")
93+
```
94+
8195
### Scripted Tool Orchestration
8296

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

126+
## ScriptedTool — Multi-Tool Orchestration
127+
128+
Compose Python callbacks as bash builtins. An LLM writes a single bash script that pipes, loops, and branches across all registered tools.
129+
130+
```python
131+
from bashkit import ScriptedTool
132+
133+
def get_user(params, stdin=None):
134+
return '{"id": 1, "name": "Alice"}'
135+
136+
tool = ScriptedTool("api")
137+
tool.add_tool("get_user", "Fetch user by ID",
138+
callback=get_user,
139+
schema={"type": "object", "properties": {"id": {"type": "integer"}}})
140+
141+
result = tool.execute_sync("get_user --id 1 | jq -r '.name'")
142+
print(result.stdout) # Alice
143+
```
144+
145+
## Features
146+
147+
- **Sandboxed, in-process execution**: All commands run in isolation with a virtual filesystem
148+
- **68+ built-in commands**: echo, cat, grep, sed, awk, jq, curl, find, and more
149+
- **Full bash syntax**: Variables, pipelines, redirects, loops, functions, arrays
150+
- **Resource limits**: Protect against infinite loops and runaway scripts
151+
112152
## API Reference
113153

114-
### BashTool
154+
### Bash
115155

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

164+
### BashTool
165+
166+
Convenience wrapper for AI agents. Inherits all execution methods from `Bash`, plus:
167+
168+
- `system_prompt() -> str` — token-efficient system prompt for LLM integration
169+
124170
### ExecResult
125171

126172
- `stdout: str` — standard output

crates/bashkit-python/bashkit/__init__.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,24 @@
88
>>> print(result.stdout)
99
Hello, World!
1010
11-
For scripted multi-tool orchestration:
11+
LLM tool wrapper (adds schema, description, system_prompt):
12+
>>> from bashkit import BashTool
13+
>>> tool = BashTool()
14+
>>> print(tool.input_schema())
15+
16+
Multi-tool orchestration:
1217
>>> from bashkit import ScriptedTool
1318
>>> tool = ScriptedTool("api")
1419
>>> tool.add_tool("greet", "Greet user", callback=lambda p, s=None: f"hello {p.get('name', 'world')}")
1520
>>> result = tool.execute_sync("greet --name Alice")
1621
17-
For LangChain integration:
22+
Framework integrations:
1823
>>> from bashkit.langchain import create_bash_tool, create_scripted_tool
19-
20-
For Deep Agents integration:
21-
>>> from bashkit.deepagents import create_bash_middleware
22-
23-
For PydanticAI integration:
2424
>>> from bashkit.pydantic_ai import create_bash_tool
2525
"""
2626

2727
from bashkit._bashkit import (
28+
Bash,
2829
BashTool,
2930
ExecResult,
3031
ScriptedTool,
@@ -33,6 +34,7 @@
3334

3435
__version__ = "0.1.2"
3536
__all__ = [
37+
"Bash",
3638
"BashTool",
3739
"ExecResult",
3840
"ScriptedTool",

crates/bashkit-python/bashkit/_bashkit.pyi

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,31 @@
1-
"""Type stubs for bashkit_py native module."""
1+
"""Type stubs for bashkit native module."""
22

33
from typing import Any, Callable
44

5+
class Bash:
6+
"""Core bash interpreter with virtual filesystem.
7+
8+
State persists between calls — files created in one execute() are
9+
available in subsequent calls.
10+
11+
Example:
12+
>>> bash = Bash()
13+
>>> result = await bash.execute("echo 'Hello!'")
14+
>>> print(result.stdout)
15+
Hello!
16+
"""
17+
18+
def __init__(
19+
self,
20+
username: str | None = None,
21+
hostname: str | None = None,
22+
max_commands: int | None = None,
23+
max_loop_iterations: int | None = None,
24+
) -> None: ...
25+
async def execute(self, commands: str) -> ExecResult: ...
26+
def execute_sync(self, commands: str) -> ExecResult: ...
27+
def reset(self) -> None: ...
28+
529
class ExecResult:
630
"""Result from executing bash commands."""
731

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
#!/usr/bin/env python3
2+
"""Basic usage of the Bash interface.
3+
4+
Demonstrates core Bash features: command execution, pipelines, variables,
5+
loops, virtual filesystem persistence, and resource limits.
6+
7+
Run directly:
8+
cd crates/bashkit-python && maturin develop && python examples/bash_basics.py
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import asyncio
14+
15+
from bashkit import Bash
16+
17+
18+
def demo_sync():
19+
"""Synchronous API basics."""
20+
print("=== Sync API ===\n")
21+
22+
bash = Bash()
23+
24+
# Simple command
25+
r = bash.execute_sync("echo 'Hello from Bash!'")
26+
print(f"echo: {r.stdout.strip()}")
27+
assert r.success
28+
29+
# Pipeline
30+
r = bash.execute_sync("echo -e 'banana\\napple\\ncherry' | sort")
31+
print(f"sort: {r.stdout.strip()}")
32+
assert r.stdout.strip() == "apple\nbanana\ncherry"
33+
34+
# Variables persist across calls
35+
bash.execute_sync("MY_VAR='persistent'")
36+
r = bash.execute_sync("echo $MY_VAR")
37+
print(f"var: {r.stdout.strip()}")
38+
assert r.stdout.strip() == "persistent"
39+
40+
# Virtual filesystem persists
41+
bash.execute_sync("mkdir -p /tmp/demo && echo 'data' > /tmp/demo/file.txt")
42+
r = bash.execute_sync("cat /tmp/demo/file.txt")
43+
print(f"file: {r.stdout.strip()}")
44+
assert r.stdout.strip() == "data"
45+
46+
# Loops and arithmetic
47+
r = bash.execute_sync("""
48+
total=0
49+
for i in 1 2 3 4 5; do
50+
total=$((total + i))
51+
done
52+
echo $total
53+
""")
54+
print(f"sum: {r.stdout.strip()}")
55+
assert r.stdout.strip() == "15"
56+
57+
# Error handling
58+
r = bash.execute_sync("exit 42")
59+
print(f"exit: code={r.exit_code}, success={r.success}")
60+
assert r.exit_code == 42
61+
assert not r.success
62+
63+
# Text processing pipeline
64+
r = bash.execute_sync("""
65+
cat << 'EOF' | grep -c 'error'
66+
info: all good
67+
error: disk full
68+
info: recovered
69+
error: timeout
70+
EOF
71+
""")
72+
print(f"grep: {r.stdout.strip()} errors found")
73+
assert r.stdout.strip() == "2"
74+
75+
# Reset clears state
76+
bash.reset()
77+
r = bash.execute_sync("echo ${MY_VAR:-unset}")
78+
print(f"reset: {r.stdout.strip()}")
79+
assert r.stdout.strip() == "unset"
80+
81+
print()
82+
83+
84+
async def demo_async():
85+
"""Async API basics."""
86+
print("=== Async API ===\n")
87+
88+
bash = Bash()
89+
90+
# Async execution
91+
r = await bash.execute("echo 'async hello'")
92+
print(f"async: {r.stdout.strip()}")
93+
assert r.success
94+
95+
# Build a JSON report with jq
96+
await bash.execute("""
97+
cat > /tmp/users.json << 'EOF'
98+
[
99+
{"name": "Alice", "role": "admin"},
100+
{"name": "Bob", "role": "user"},
101+
{"name": "Carol", "role": "admin"}
102+
]
103+
EOF
104+
""")
105+
r = await bash.execute("cat /tmp/users.json | jq '[.[] | select(.role == \"admin\")] | length'")
106+
print(f"admins: {r.stdout.strip()}")
107+
assert r.stdout.strip() == "2"
108+
109+
# ExecResult as dict
110+
r = await bash.execute("echo ok")
111+
d = r.to_dict()
112+
print(f"dict: stdout={d['stdout'].strip()!r}, exit_code={d['exit_code']}")
113+
assert d["exit_code"] == 0
114+
115+
print()
116+
117+
118+
def demo_config():
119+
"""Custom configuration."""
120+
print("=== Configuration ===\n")
121+
122+
bash = Bash(username="agent", hostname="sandbox")
123+
r = bash.execute_sync("whoami")
124+
print(f"whoami: {r.stdout.strip()}")
125+
assert r.stdout.strip() == "agent"
126+
127+
r = bash.execute_sync("hostname")
128+
print(f"hostname: {r.stdout.strip()}")
129+
assert r.stdout.strip() == "sandbox"
130+
131+
# Resource limits
132+
limited = Bash(max_loop_iterations=50)
133+
r = limited.execute_sync("i=0; while true; do i=$((i+1)); done; echo $i")
134+
print(f"limited: stopped (exit_code={r.exit_code})")
135+
assert r.exit_code != 0 or int(r.stdout.strip() or "0") <= 100
136+
137+
print()
138+
139+
140+
def main():
141+
print("Bashkit — Bash interface examples\n")
142+
demo_sync()
143+
asyncio.run(demo_async())
144+
demo_config()
145+
print("All examples passed.")
146+
147+
148+
if __name__ == "__main__":
149+
main()

0 commit comments

Comments
 (0)