Custom Builtins
Bashkit supports registering custom builtin commands to extend the shell with domain-specific functionality. Custom builtins have full access to the execution context including arguments, environment variables, shell variables, and the virtual filesystem.
See also:
- API Documentation - Full API reference
- Clap Builtins - Derive parser structs for custom builtin args
- Hooks - Interceptor hooks for the execution pipeline
- Compatibility Reference - Supported bash features
- Threat Model - Security considerations
Quick Start
use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, async_trait};
struct MyCommand;
#[async_trait]
impl Builtin for MyCommand {
async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
let name = ctx.args.first().map(|s| s.as_str()).unwrap_or("World");
Ok(ExecResult::ok(format!("Hello, {}!\n", name)))
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let mut bash = Bash::builder()
.builtin("greet", Box::new(MyCommand))
.build();
let result = bash.exec("greet Alice").await?;
assert_eq!(result.stdout, "Hello, Alice!\n");
Ok(())
}
The Builtin Trait
All custom builtins must implement the Builtin trait:
#[async_trait]
pub trait Builtin: Send + Sync {
async fn execute(&self, ctx: BuiltinContext<'_>) -> Result<ExecResult>;
}
The trait is async-first (via async_trait) and requires Send + Sync for
thread safety in async contexts.
Execution Context
The BuiltinContext provides access to the execution environment:
pub struct BuiltinContext<'a> {
/// Command arguments (not including the command name)
pub args: &'a [String],
/// Environment variables
pub env: &'a HashMap<String, String>,
/// Shell variables (mutable)
pub variables: &'a mut HashMap<String, String>,
/// Current working directory (mutable)
pub cwd: &'a mut PathBuf,
/// Virtual filesystem
pub fs: Arc<dyn FileSystem>,
/// Standard input (from pipeline)
pub stdin: Option<&'a str>,
}
Per-Execution Extensions
For request-scoped data that should not live on the builtin itself, use
Bash::exec_with_extensions() (or exec_streaming_with_extensions()) and read
the value inside the builtin with ctx.execution_extension::<T>().
use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, ExecutionExtensions, async_trait};
struct RequestId;
#[async_trait]
impl Builtin for RequestId {
async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
let req = ctx
.execution_extension::<String>()
.cloned()
.unwrap_or_else(|| "missing".to_string());
Ok(ExecResult::ok(format!("{req}\n")))
}
}
let mut bash = Bash::builder()
.builtin("request-id", Box::new(RequestId))
.build();
let result = bash
.exec_with_extensions(
"request-id",
ExecutionExtensions::new().with("req-123".to_string()),
)
.await?;
assert_eq!(result.stdout, "req-123\n");
Extensions
Use an Extension when one capability contributes multiple builtins.
use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, Extension, async_trait};
struct Hello;
#[async_trait]
impl Builtin for Hello {
async fn execute(&self, _ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
Ok(ExecResult::ok("hello\n".to_string()))
}
}
struct MyExtension;
impl Extension for MyExtension {
fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)> {
vec![("hello".to_string(), Box::new(Hello))]
}
}
let mut bash = Bash::builder().extension(MyExtension).build();
let result = bash.exec("hello").await?;
assert_eq!(result.stdout, "hello\n");
The same extension can be added to BashTool::builder().extension(...).
TypeScriptExtension uses this model to register the embedded
TypeScript/JavaScript builtins.
Arguments
Arguments are passed as a slice of strings, excluding the command name itself:
// For "mycommand arg1 arg2", ctx.args = ["arg1", "arg2"]
let first_arg = ctx.args.first().map(|s| s.as_str()).unwrap_or("default");
For custom builtins with richer flags, options, or subcommands, implement
ClapBuiltin with a #[derive(clap::Parser)] argument type. See the
clap_builtins_guide rustdoc module for tested examples.
Environment Variables
Read-only access to environment variables set via BashBuilder::env() or export:
let home = ctx.env.get("HOME").map(|s| s.as_str()).unwrap_or("/");
Shell Variables
Mutable access to shell variables allows builtins to set variables:
ctx.variables.insert("RESULT".to_string(), "computed_value".to_string());
Filesystem Access
The virtual filesystem supports all standard operations:
// Read a file
let content = ctx.fs.read_file(Path::new("/data/input.txt")).await?;
// Write a file
ctx.fs.write_file(Path::new("/output/result.txt"), b"output").await?;
// Check existence
if ctx.fs.exists(Path::new("/config")).await? {
// ...
}
Standard Input
When the builtin is invoked in a pipeline, stdin contains the output from the previous command:
// echo "hello" | mycommand
let input = ctx.stdin.unwrap_or("");
let processed = input.to_uppercase();
Return Values
Builtins return Result<ExecResult>:
pub struct ExecResult {
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
}
Helper constructors:
# use bashkit::ExecResult;
// Success with output
ExecResult::ok("output\n".to_string());
// Error with message and exit code
ExecResult::err("error message\n".to_string(), 1);
Examples
Database Query Builtin
use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, async_trait};
use sqlx::PgPool;
use std::sync::Arc;
struct Psql {
pool: Arc<PgPool>,
}
#[async_trait]
impl Builtin for Psql {
async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
// Parse -c "query" argument
let query = match ctx.args.iter().position(|a| a == "-c") {
Some(i) => ctx.args.get(i + 1).map(|s| s.as_str()).unwrap_or(""),
None => return Ok(ExecResult::err("Usage: psql -c 'query'\n".into(), 1)),
};
// Execute query (simplified - real impl would format results)
match sqlx::query(query).fetch_all(&*self.pool).await {
Ok(rows) => Ok(ExecResult::ok(format!("{} rows\n", rows.len()))),
Err(e) => Ok(ExecResult::err(format!("ERROR: {}\n", e), 1)),
}
}
}
// Usage
let pool = Arc::new(PgPool::connect("postgres://...").await?);
let mut bash = Bash::builder()
.builtin("psql", Box::new(Psql { pool }))
.build();
bash.exec("psql -c 'SELECT * FROM users'").await?;
HTTP Client Builtin
struct HttpGet {
client: reqwest::Client,
}
#[async_trait]
impl Builtin for HttpGet {
async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
let url = match ctx.args.first() {
Some(url) => url,
None => return Ok(ExecResult::err("Usage: httpget <url>\n".into(), 1)),
};
match self.client.get(url).send().await {
Ok(resp) => {
let body = resp.text().await.unwrap_or_default();
Ok(ExecResult::ok(body))
}
Err(e) => Ok(ExecResult::err(format!("Error: {}\n", e), 1)),
}
}
}
Overriding Default Builtins
Custom builtins can override default builtins by using the same name:
use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, async_trait};
struct SecureEcho;
#[async_trait]
impl Builtin for SecureEcho {
async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
// Redact sensitive patterns
let output: Vec<_> = ctx.args.iter()
.map(|s| if s.contains("password") { "[REDACTED]" } else { s.as_str() })
.collect();
Ok(ExecResult::ok(format!("{}\n", output.join(" "))))
}
}
# fn main() {
let bash = Bash::builder()
.builtin("echo", Box::new(SecureEcho)) // Overrides default echo
.build();
# }
Best Practices
- Return proper exit codes: Use 0 for success, non-zero for errors
- Include newlines: Output should end with
\nfor proper formatting - Handle missing args gracefully: Provide usage messages for incorrect invocations
- Use stderr for errors: Write error messages to
ExecResult::stderr - Keep builtins stateless when possible: Use
Arcfor shared state that needs mutation
Thread Safety
The Builtin trait requires Send + Sync. For builtins with mutable state, use
appropriate synchronization:
use bashkit::{Builtin, BuiltinContext, ExecResult, async_trait};
use std::sync::Arc;
struct Counter {
count: Arc<std::sync::atomic::AtomicU64>,
}
#[async_trait]
impl Builtin for Counter {
async fn execute(&self, _ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
let n = self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(ExecResult::ok(format!("{}\n", n)))
}
}
Integration with Scripts
Custom builtins integrate seamlessly with bash scripting:
# Variables work
NAME="Alice"
greet $NAME
# Pipelines work
echo "hello world" | upper | head -1
# Conditionals work
if mycheck; then
echo "passed"
else
echo "failed"
fi
# Loops work
for item in a b c; do
process $item
done