Expand description
Guide for creating custom builtins to extend Bashkit.
This guide covers:
- Implementing the
Builtintrait - Accessing execution context (
BuiltinContext) - Working with arguments, environment, and filesystem
- Best practices and examples
Related: BashBuilder::builtin, compatibility_scorecard
§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
- 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>,
}§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");§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:
// 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:
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(" "))))
}
}
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:
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