Implemented
Bashkit ships a Python package as pre-built binary wheels on PyPI. Users install with
pip install bashkit and get a native extension — no Rust toolchain needed.
crates/bashkit-python/
├── Cargo.toml # Rust crate (cdylib via PyO3)
├── pyproject.toml # Python package metadata (maturin build backend)
├── src/lib.rs # PyO3 native module (BashTool, ExecResult)
├── bashkit/
│ ├── __init__.py # Re-exports from native module
│ ├── _bashkit.pyi # Type stubs (PEP 561)
│ ├── py.typed # Marker for typed package
│ ├── 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
- Build backend: maturin (1.4–2.0)
- Rust extension: PyO3 0.24 with
extension-modulefeature - Async bridge:
pyo3-async-runtimes(tokio runtime) - Module name:
bashkit._bashkit(native), re-exported asbashkit
Python package version is read dynamically from workspace Cargo.toml via maturin.
pyproject.toml declares dynamic = ["version"] — no manual sync needed.
The version chain: Cargo.toml (workspace) → Cargo.toml (bashkit-python, inherits)
→ maturin reads it → wheel metadata.
3.9, 3.10, 3.11, 3.12, 3.13, 3.14
| OS | Architecture | Variant | CI Runner |
|---|---|---|---|
| Linux | x86_64 | manylinux (glibc) | ubuntu-latest |
| Linux | aarch64 | manylinux (glibc) | ubuntu-latest (cross) |
| Linux | x86_64 | musllinux_1_1 | ubuntu-latest (Docker) |
| Linux | aarch64 | musllinux_1_1 | ubuntu-latest (Docker) |
| macOS | x86_64 | — | macos-latest (cross) |
| macOS | aarch64 | — | macos-latest (native) |
| Windows | x86_64 | MSVC | windows-latest |
Total: ~42 wheels (7 platforms × 6 Python versions).
File: .github/workflows/publish-python.yml
GitHub Release published
├── build-sdist (source distribution)
├── build (7 platform variants × 6 Python versions)
├── inspect (twine check all artifacts)
├── test-builds (smoke test on Linux/macOS/Windows)
└── publish (uv publish → PyPI via OIDC)
Uses PyPI trusted publishing (OIDC) — no API tokens needed.
Prerequisites:
- GitHub environment
release-pythonexists in repo settings - PyPI trusted publisher configured:
- Owner:
everruns, Repo:bashkit - Workflow:
publish-python.yml, Environment:release-python
- Owner:
Each platform runs after wheel build:
from bashkit import BashTool
t = BashTool()
r = t.execute_sync('echo hello')
assert r.exit_code == 0Primary class. Wraps the Rust Bash interpreter with Arc<Mutex<>> for thread safety.
from bashkit import BashTool
tool = BashTool(
username="user", # optional, default "user"
hostname="sandbox", # optional, default "sandbox"
max_commands=10000, # optional
max_loop_iterations=100000 # optional
)
# Async
result = await tool.execute("echo hello")
# Sync
result = tool.execute_sync("echo hello")
# Reset state
tool.reset()
# Initial files accept eager strings or lazy sync callables.
tool = BashTool(files={
"/config/static.txt": "ready\n",
"/config/generated.json": lambda: '{"ok": true}\n",
})
# Snapshot / restore state
blob = tool.snapshot()
restored = BashTool.from_snapshot(blob, username="user")
# Capture shell state for prompt/UI inspection
state = tool.shell_state() # -> ShellState
# Direct VFS helpers (text-oriented convenience wrappers)
tool.read_file("/tmp/data.txt") # -> str
tool.write_file("/tmp/data.txt", "hello")
tool.append_file("/tmp/data.txt", "\nworld")
tool.mkdir("/tmp/nested", recursive=True)
tool.exists("/tmp/data.txt") # -> bool
tool.remove("/tmp/nested", recursive=True)
tool.stat("/tmp/data.txt") # -> dict
tool.chmod("/tmp/data.txt", 0o644)
tool.symlink("/tmp/data.txt", "/tmp/link.txt")
tool.read_link("/tmp/link.txt") # -> str
tool.read_dir("/tmp") # -> list[dict]
tool.ls("/tmp") # -> list[str]
tool.glob("/tmp/*.txt") # -> list[str]
# LLM metadata
tool.name # "bashkit"
tool.short_description # str
tool.description() # token-efficient description
tool.help() # Markdown help document
tool.system_prompt() # compact system prompt
tool.input_schema() # JSON schema string
tool.output_schema() # JSON schema string
tool.version # from Rust crateSnapshot/restore methods also exist on Bash and mirror the Node bindings:
from bashkit import Bash
bash = Bash()
bash.execute_sync("greet() { echo \"hi $1\"; }")
blob = bash.snapshot() # -> bytes
restored = Bash.from_snapshot(blob) # -> Bash
assert restored.execute_sync("greet agent").stdout.strip() == "hi agent"
shell_only = bash.snapshot(exclude_filesystem=True)ShellState is a read-only Python object returned by Bash.shell_state() and
BashTool.shell_state() for prompt rendering and state inspection.
It is a Python-friendly inspection view, not a full Rust ShellState mirror.
state.cwd # str
state.env # Mapping[str, str]
state.variables # Mapping[str, str]
state.arrays # Mapping[str, Mapping[int, str]]
state.assoc_arrays # Mapping[str, Mapping[str, str]]
state.last_exit_code # int
state.aliases # Mapping[str, str]
state.traps # Mapping[str, str]Use snapshot(exclude_filesystem=True) when you need shell-only restore bytes.
Transient fields follow Rust-core semantics: last_exit_code and traps are
captured on the shell state object itself, but the next top-level execute() /
execute_sync() clears them before running the new command.
result.stdout # str
result.stderr # str
result.exit_code # int
result.error # Optional[str]
result.success # bool (exit_code == 0)
result.to_dict() # dictReturns dict with name, description, args_schema for LangChain integration.
Bash and BashTool accept custom_builtins={"name": callback} where each
callback is
Callable[[BuiltinContext], str | BuiltinResult | Awaitable[str | BuiltinResult]].
Sync callbacks are called directly under the session's captured contextvars
snapshot and may return either a stdout string or a BuiltinResult with
explicit stdout, stderr, and exit_code.
Async callbacks are driven to completion by one of two mechanisms depending on whether a running asyncio event loop is present on the calling thread:
| Calling context | Mechanism |
|---|---|
await execute() |
Callback scheduled as a Task on the caller's running loop |
execute_sync() — no running loop |
Callback driven by a private event loop shared across calls on the same Bash instance |
execute_sync() — running loop present (e.g. Jupyter / IPython) |
Callback driven by a background daemon thread with its own fresh event loop |
The background-thread path is activated via asyncio.get_running_loop(). If the
call succeeds (a loop is already running on the thread), the awaitable is dispatched
to a daemon thread whose run_until_complete call is wrapped in context.run()
so ContextVars propagate correctly despite the thread switch. The helper is
cached on the PyPrivateAsyncLoop to avoid repeated module compilation.
ContextVar propagation: ContextVars set before execute() or
execute_sync() are captured at call time and replayed inside each callback
invocation regardless of which mechanism is used.
import asyncio, contextvars
from bashkit import Bash, BuiltinContext, BuiltinResult
trace_id = contextvars.ContextVar("trace_id")
trace_id.set("req-42")
async def fetch(ctx: BuiltinContext) -> str:
await asyncio.sleep(0) # simulate async I/O
return f"trace={trace_id.get()}\n"
bash = Bash(custom_builtins={"fetch": fetch})
# Works in plain Python, asyncio.run(), Jupyter, or any async framework:
result = bash.execute_sync("fetch") # "trace=req-42"
result = await bash.execute("fetch") # same, callback runs on caller loopfrom bashkit import BuiltinResult
def view_image(ctx: BuiltinContext) -> BuiltinResult:
if not ctx.argv:
return BuiltinResult(stderr="view-image: missing path\n", exit_code=1)
return BuiltinResult(stdout="")pip install bashkit[langchain] # + langchain-core, langchain-anthropic
pip install bashkit[deepagents] # + deepagents, langchain-anthropic
pip install bashkit[pydantic-ai] # + pydantic-ai
pip install bashkit[dev] # + pytest, pytest-asyncio
File: .github/workflows/python.yml
Runs on push to main and PRs (path-filtered to crates/bashkit-python/,
crates/bashkit/, examples/*.py, examples/*.ipynb, Cargo.toml,
Cargo.lock).
PR / push to main
├── lint (ruff check + ruff format --check)
├── test (maturin develop + pytest, Python 3.9/3.12/3.13/3.14)
├── examples (build wheel + run crates/bashkit-python/examples/
│ + execute examples/*.ipynb via nbconvert)
├── build-wheel (maturin build + twine check)
└── python-check (gate job for branch protection)
Notebooks in examples/ are executed with jupyter nbconvert --execute --ExecutePreprocessor.timeout=120. A cell error fails the CI job.
- Linter/formatter: ruff (config in
pyproject.toml) - Rules: E (pycodestyle), F (pyflakes), W (warnings), I (isort), UP (pyupgrade)
- Target: Python 3.9, line-length 120
ruff check crates/bashkit-python # lint
ruff format --check crates/bashkit-python # format check
ruff format crates/bashkit-python # auto-formatcd crates/bashkit-python
pip install maturin
maturin develop # debug build, installs into current venv
maturin develop --release # optimized build
pip install pytest pytest-asyncio
pytest tests/ -v # run tests
ruff check . # lint
ruff format . # format- No PGO: Profile-guided optimization adds build complexity for minimal gain. Bashkit is a thin PyO3 extension — hot paths are in Rust, not Python dispatch. Can revisit if profiling shows benefit.
- No exotic architectures: armv7, ppc64le, s390x, i686 omitted. Target audience is AI agent developers on standard server/desktop platforms.
- Dynamic version: Eliminates version drift between Rust and Python packages.
- Trusted publishing: No secrets to rotate. OIDC tokens are scoped per-workflow.