diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index f323c1aa..ed76aac3 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -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 @@ -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 diff --git a/crates/bashkit-python/README.md b/crates/bashkit-python/README.md index 46c86383..78d362d6 100644 --- a/crates/bashkit-python/README.md +++ b/crates/bashkit-python/README.md @@ -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 @@ -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()) @@ -70,7 +70,7 @@ print(result.stdout) ### Configuration ```python -tool = BashTool( +bash = Bash( username="agent", # Custom username (whoami) hostname="sandbox", # Custom hostname max_commands=1000, # Limit total commands @@ -78,6 +78,20 @@ tool = BashTool( ) ``` +### 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: @@ -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 @@ -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 diff --git a/crates/bashkit-python/bashkit/__init__.py b/crates/bashkit-python/bashkit/__init__.py index 171b2c3c..416b468c 100644 --- a/crates/bashkit-python/bashkit/__init__.py +++ b/crates/bashkit-python/bashkit/__init__.py @@ -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, @@ -33,6 +34,7 @@ __version__ = "0.1.2" __all__ = [ + "Bash", "BashTool", "ExecResult", "ScriptedTool", diff --git a/crates/bashkit-python/bashkit/_bashkit.pyi b/crates/bashkit-python/bashkit/_bashkit.pyi index e9155ad8..a7dcad5a 100644 --- a/crates/bashkit-python/bashkit/_bashkit.pyi +++ b/crates/bashkit-python/bashkit/_bashkit.pyi @@ -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.""" diff --git a/crates/bashkit-python/examples/bash_basics.py b/crates/bashkit-python/examples/bash_basics.py new file mode 100644 index 00000000..872144f5 --- /dev/null +++ b/crates/bashkit-python/examples/bash_basics.py @@ -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() diff --git a/crates/bashkit-python/src/lib.rs b/crates/bashkit-python/src/lib.rs index 1a0451bf..c49a8405 100644 --- a/crates/bashkit-python/src/lib.rs +++ b/crates/bashkit-python/src/lib.rs @@ -1,9 +1,8 @@ //! Bashkit Python package //! -//! Exposes the Bash interpreter and ScriptedTool as Python classes for use in -//! AI agent frameworks. BashTool provides stateful execution (filesystem persists -//! between calls). ScriptedTool composes Python callbacks as bash builtins for -//! multi-tool orchestration in a single script. +//! Primary interface: `Bash` — the core interpreter with virtual filesystem. +//! Convenience wrapper: `BashTool` — adds LLM tool metadata (schema, description, system_prompt). +//! Orchestration: `ScriptedTool` — composes Python callbacks as bash builtins. use bashkit::tool::VERSION; use bashkit::{ @@ -146,12 +145,158 @@ impl ExecResult { } // ============================================================================ -// BashTool — stateful interpreter +// Bash — core interpreter // ============================================================================ -/// Virtual bash interpreter for AI agents +/// Core bash interpreter with virtual filesystem. /// -/// BashTool provides a safe execution environment for running bash commands +/// State persists between calls — files created in one `execute()` are +/// available in subsequent calls. This is the primary interface. +/// +/// Example: +/// ```python +/// from bashkit import Bash +/// +/// bash = Bash() +/// result = await bash.execute("echo 'Hello, World!'") +/// print(result.stdout) # Hello, World! +/// ``` +#[pyclass(name = "Bash")] +#[allow(dead_code)] +pub struct PyBash { + inner: Arc>, + username: Option, + hostname: Option, + max_commands: Option, + max_loop_iterations: Option, +} + +#[pymethods] +impl PyBash { + #[new] + #[pyo3(signature = (username=None, hostname=None, max_commands=None, max_loop_iterations=None))] + fn new( + username: Option, + hostname: Option, + max_commands: Option, + max_loop_iterations: Option, + ) -> PyResult { + let mut builder = Bash::builder(); + + if let Some(ref u) = username { + builder = builder.username(u); + } + if let Some(ref h) = hostname { + builder = builder.hostname(h); + } + + let mut limits = ExecutionLimits::new(); + if let Some(mc) = max_commands { + limits = limits.max_commands(mc as usize); + } + if let Some(mli) = max_loop_iterations { + limits = limits.max_loop_iterations(mli as usize); + } + builder = builder.limits(limits); + + let bash = builder.build(); + + Ok(Self { + inner: Arc::new(Mutex::new(bash)), + username, + hostname, + max_commands, + max_loop_iterations, + }) + } + + /// Execute commands asynchronously. + fn execute<'py>(&self, py: Python<'py>, commands: String) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + let mut bash = inner.lock().await; + match bash.exec(&commands).await { + Ok(result) => Ok(ExecResult { + stdout: result.stdout, + stderr: result.stderr, + exit_code: result.exit_code, + error: None, + }), + Err(e) => Ok(ExecResult { + stdout: String::new(), + stderr: String::new(), + exit_code: 1, + error: Some(e.to_string()), + }), + } + }) + } + + /// Execute commands synchronously (blocking). + fn execute_sync(&self, commands: String) -> PyResult { + let inner = self.inner.clone(); + let rt = tokio::runtime::Runtime::new() + .map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?; + + rt.block_on(async move { + let mut bash = inner.lock().await; + match bash.exec(&commands).await { + Ok(result) => Ok(ExecResult { + stdout: result.stdout, + stderr: result.stderr, + exit_code: result.exit_code, + error: None, + }), + Err(e) => Ok(ExecResult { + stdout: String::new(), + stderr: String::new(), + exit_code: 1, + error: Some(e.to_string()), + }), + } + }) + } + + /// Reset interpreter to fresh state. + fn reset(&self) -> PyResult<()> { + let inner = self.inner.clone(); + let rt = tokio::runtime::Runtime::new() + .map_err(|e| PyRuntimeError::new_err(format!("Failed to create runtime: {}", e)))?; + + rt.block_on(async move { + let mut bash = inner.lock().await; + let builder = Bash::builder(); + *bash = builder.build(); + Ok(()) + }) + } + + fn __repr__(&self) -> String { + format!( + "Bash(username={:?}, hostname={:?})", + self.username.as_deref().unwrap_or("user"), + self.hostname.as_deref().unwrap_or("sandbox") + ) + } +} + +// ============================================================================ +// BashTool — interpreter + LLM tool metadata +// ============================================================================ + +/// Bash interpreter with LLM tool metadata (schema, description, system_prompt). +/// +/// Extends `Bash` with methods required by LLM tool-use protocols. +/// Use this when integrating with LangChain, PydanticAI, or similar frameworks. +/// +/// Example: +/// ```python +/// from bashkit import BashTool +/// +/// tool = BashTool() +/// print(tool.input_schema()) # JSON schema for LLM +/// result = await tool.execute("echo 'Hello!'") +/// ``` /// with a virtual filesystem. State persists between calls - files created /// in one call are available in subsequent calls. /// @@ -621,6 +766,7 @@ fn create_langchain_tool_spec() -> PyResult> { #[pymodule] fn _bashkit(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/crates/bashkit-python/tests/test_bashkit.py b/crates/bashkit-python/tests/test_bashkit.py index ebf02b96..76b369fb 100644 --- a/crates/bashkit-python/tests/test_bashkit.py +++ b/crates/bashkit-python/tests/test_bashkit.py @@ -4,7 +4,124 @@ import pytest -from bashkit import BashTool, ScriptedTool, create_langchain_tool_spec +from bashkit import Bash, BashTool, ScriptedTool, create_langchain_tool_spec + +# =========================================================================== +# Bash: Core interpreter +# =========================================================================== + + +# -- Bash: Construction ---------------------------------------------------- + + +def test_bash_default_construction(): + bash = Bash() + assert bash is not None + + +def test_bash_custom_construction(): + bash = Bash(username="alice", hostname="box", max_commands=100, max_loop_iterations=500) + assert bash is not None + + +# -- Bash: Sync execution -------------------------------------------------- + + +def test_bash_echo(): + bash = Bash() + r = bash.execute_sync("echo hello") + assert r.exit_code == 0 + assert r.stdout.strip() == "hello" + assert r.success is True + + +def test_bash_exit_code(): + bash = Bash() + r = bash.execute_sync("exit 42") + assert r.exit_code == 42 + assert r.success is False + + +def test_bash_stderr(): + bash = Bash() + r = bash.execute_sync("echo err >&2") + assert "err" in r.stderr + + +def test_bash_pipeline(): + bash = Bash() + r = bash.execute_sync("echo -e 'banana\\napple\\ncherry' | sort") + assert r.stdout.strip() == "apple\nbanana\ncherry" + + +def test_bash_state_persists(): + """Variables persist across calls.""" + bash = Bash() + bash.execute_sync("export FOO=bar") + r = bash.execute_sync("echo $FOO") + assert r.stdout.strip() == "bar" + + +def test_bash_file_persistence(): + """Files created in one call are visible in the next.""" + bash = Bash() + bash.execute_sync("echo content > /tmp/test.txt") + r = bash.execute_sync("cat /tmp/test.txt") + assert r.stdout.strip() == "content" + + +def test_bash_reset(): + bash = Bash() + bash.execute_sync("export KEEP=1") + bash.reset() + r = bash.execute_sync("echo ${KEEP:-empty}") + assert r.stdout.strip() == "empty" + + +# -- Bash: Async execution ------------------------------------------------- + + +@pytest.mark.asyncio +async def test_bash_async_execute(): + bash = Bash() + r = await bash.execute("echo async_hello") + assert r.exit_code == 0 + assert r.stdout.strip() == "async_hello" + + +@pytest.mark.asyncio +async def test_bash_async_state_persists(): + bash = Bash() + await bash.execute("X=123") + r = await bash.execute("echo $X") + assert r.stdout.strip() == "123" + + +# -- Bash: Resource limits ------------------------------------------------- + + +def test_bash_max_loop_iterations(): + bash = Bash(max_loop_iterations=10) + r = bash.execute_sync("i=0; while true; do i=$((i+1)); done; echo $i") + assert r.exit_code != 0 or int(r.stdout.strip() or "0") <= 100 + + +def test_bash_empty_input(): + bash = Bash() + r = bash.execute_sync("") + assert r.exit_code == 0 + assert r.stdout == "" + + +def test_bash_nonexistent_command(): + bash = Bash() + r = bash.execute_sync("nonexistent_xyz_cmd_12345") + assert r.exit_code == 127 + + +# =========================================================================== +# BashTool tests +# =========================================================================== # -- BashTool: Construction ------------------------------------------------- diff --git a/specs/013-python-package.md b/specs/013-python-package.md index b2c6796d..e0342369 100644 --- a/specs/013-python-package.md +++ b/specs/013-python-package.md @@ -19,6 +19,9 @@ crates/bashkit-python/ │ ├── langchain.py # LangChain integration │ ├── deepagents.py # Deep Agents integration │ └── pydantic_ai.py # PydanticAI integration +├── examples/ +│ ├── bash_basics.py # Bash interface walkthrough (runs in CI) +│ └── k8s_orchestrator.py # ScriptedTool multi-tool demo └── tests/ └── test_bashkit.py # Pytest suite ``` @@ -164,6 +167,7 @@ Runs on push to main and PRs (path-filtered to `crates/bashkit-python/`, `crates PR / push to main ├── lint (ruff check + ruff format --check) ├── test (maturin develop + pytest, Python 3.9/3.12/3.13) + ├── examples (build wheel + run crates/bashkit-python/examples/) ├── build-wheel (maturin build + twine check) └── python-check (gate job for branch protection) ```