Builtin Commands
June 27, 2026 · View on GitHub
Decision
Bashkit provides built-in commands for script execution in a virtual environment.
All builtins operate on the virtual filesystem. For the complete list, see
the generated specs/status/builtins.json; for known gaps, specs/limitations.md.
Standard Flags
All external-style builtins support --help and --version flags via the
check_help_version() helper in builtins/mod.rs (long flags only — short
flags -h/-V have different meanings in many tools). Tools where -h/-V
genuinely mean help/version handle them directly in execute().
Command Dispatch Order
functions → special commands → builtins → path execution → $PATH search → "command not found"
Scripts containing / are resolved against VFS. Commands without / are
searched in $PATH directories. Shebang lines are stripped; content executed
as bash. Exit 127: not found; Exit 126: not executable or is a directory.
Builtin Trait
Builtin trait (execute(ctx) + optional execution_plan(ctx), default
Ok(None)) and Context (args, env, variables, cwd, fs, stdin,
feature-gated http/git clients, pub(crate) shell: Option<ShellRef> — None
for custom builtins, public execution_extension::<T>() accessor): see
crates/bashkit/src/builtins/mod.rs / rustdoc.
Clap-Backed Custom Builtins
Custom Rust builtins can implement ClapBuiltin instead of Builtin when
their arguments are better represented as a #[derive(clap::Parser)] struct
(see builtins/mod.rs / rustdoc for the trait and an example). clap is an
unconditional dependency of bashkit (also used by ported coreutils argument
surfaces — see specs/coreutils-args-port.md), so this trait is always
available. Bashkit parses Context::args through clap, passes parsed args
plus a mutable BashkitContext to the handler, maps --help/--version to
successful stdout results, and maps clap parse failures to stderr with clap's
exit code. Parse diagnostics are capped to 1 KB to preserve TM-INF-022 stderr
constraints.
Extension Trait
Extensions bundle a related set of builtins so embedders can add one capability
to BashBuilder or BashToolBuilder instead of registering each command
manually: Extension::builtins() -> Vec<(String, Box<dyn Builtin>)>
(builtins/mod.rs).
Rules:
BashBuilder::extension(ext)/BashToolBuilder::extension(ext)expand each returned builtin into the builder's custom builtin map/list- For
BashBuilder, later registrations with the same command name override earlier registrations, matchingBashBuilder::builtin - Extensions must construct fresh builtin values or use shared ownership
internally; builders may call
builtins()when configuring reusable tools
Current extension: TypeScriptExtension registers ts/typescript and, when
enabled by TypeScriptConfig, node/deno/bun.
BuiltinRegistry — Host-Owned Mutable Builtins
BashBuilder::builtin(name, ...) and Extension::builtins() are both
build-time registration: the set of builtins is frozen once the Bash
instance is built. For embedders that need to register or remove builtins
after construction (FFI bindings, REPLs, plugin systems),
BuiltinRegistry provides a host-owned mutable registry consulted at
command-dispatch time. API (insert/remove/lookup/names/is_empty):
see builtins/mod.rs / rustdoc.
Wired in via BashBuilder::builtin_registry(registry). The handle is
Clone; clones share the same underlying storage, so the embedder keeps a
clone for runtime mutation while the builder takes another.
Command-resolution order (see Interpreter::dispatch_command):
- Shell functions (defined in scripts)
- POSIX special builtins (
exec,set,:,eval, …) - Host registry (
BuiltinRegistry::lookup) - Baked-in + builder-registered builtins
- Script execution by path /
$PATHsearch
So registry entries can override baked-in commands (e.g. wrap cat with
tracing) but shell functions still win — matching standard bash
precedence. command -v / command -V / command name args… consult
the registry too.
Implementation notes:
- Storage is
Arc<RwLock<HashMap<String, Arc<dyn Builtin>>>>(std only, no extra deps). Lookup clones theArcout of the lock, releasing it before execution. Interpreter::builtinswas migrated fromHashMap<String, Box<dyn Builtin>>toHashMap<String, Arc<dyn Builtin>>so registered and host-registry paths share one execution helper (execute_builtin_arc).- The registry is host-owned: not part of interpreter state, so
reset_transient_stateleaves it untouched and snapshots do not serialize it. Restoring from a snapshot requires re-attaching the registry handle.
Execution Extensions
Bash::exec_with_extensions() and Bash::exec_streaming_with_extensions()
accept a typed, per-call extension bag. Builtins read values from it via
ctx.execution_extension::<T>().
Use this for request-scoped data that is not shell state: tracing/request IDs, auth or tenant context, host-language runtime sessions (Python/JS callback bridges), metrics/audit sinks for one execution.
Rules:
- Extensions live for exactly one
exec*()call - Builtins may read them but must not retain references beyond execution
- Long-lived builtin registrations must not store request-scoped data themselves
Shell State Access (ShellRef)
Internal builtins that need interpreter state receive it via Context.shell:
Design rationale:
- Direct mutation for aliases/traps — simple HashMaps with no invariants
- Side effects for arrays (budget checks), positional params (call stack), history (VFS persistence) — state with invariants the interpreter must enforce
- Read-only methods for introspection (functions, builtins, keywords, call stack, history, jobs) — builtins shouldn't mutate these
pub(crate)keeps ShellRef out of the public API; custom builtins use publicexecution_extension()instead of direct shell access- No dynamic dispatch — concrete struct, not trait
Builtins using ShellRef:
type,which— read-only: check builtin/function/keyword namesalias,unalias— direct mutation ofshell.aliasestrap— direct mutation ofshell.trapscaller— read call stack depth/frame nameshistory— read history entries, clear viaClearHistoryside effectwait— read job table, set exit code viaSetLastExitCodeside effectmapfile/readarray— set arrays viaSetIndexedArrayside effect
Builtins still in interpreter dispatch chain (fundamentally need interpreter):
exec— redirect management, VFS I/Olocal— call frame locals mutationsource/.,eval— parse and execute in current contextbash/sh— script executioncommand— dispatch to builtins/functionsdeclare/typeset— arrays, assoc arrays, variable attributesunset— functions, arrays, namerefs, call stack localslet— arithmetic evaluation with assignmentgetopts— complex variable + call stack interaction
Execution Plans (Sub-Command Delegation)
Builtins cannot access the interpreter directly. When a builtin needs to run
other commands (e.g. timeout, xargs, find -exec), it returns a declarative
ExecutionPlan from execution_plan(). The interpreter checks this method
before execute() — when it returns Some(plan), the interpreter fulfills the
plan instead of using the execute() result.
Variants: Timeout { duration, preserve_status, command },
Batch { commands } (builtins/mod.rs).
Each SubCommand carries optional command-scoped assignments
(VAR=value cmd ...), which the interpreter applies as the inner command's
environment. xargs --process-slot-var=VAR uses this to expose a
per-invocation parallel-slot index.
Current users: timeout → Timeout, xargs → Batch, find -exec → Batch.
xargs -P / --process-slot-var (parallelism)
bashkit runs a single Bash interpreter sequentially — even background &
jobs run synchronously for deterministic output (see
specs/parallel-execution.md). So xargs -P N / --max-procs=N does not
spawn N OS processes for wall-clock speedup. Instead it allocates N
round-robin slots and the commands still run in order, with the slot index
(0..N-1, idx % N) surfaced via --process-slot-var. This is the behaviour
sharding logic depends on (worker $SLOT of $N) and matches GNU's
--process-slot-var for the deterministic case (single slot ⇒ index always
0). -P 0 means "as many as possible" (one slot per command).
Adding new execution plans: Add a variant to ExecutionPlan and handle it
in the interpreter's plan fulfillment code (interpreter/mod.rs).
Adding Internal Builtins
Simple builtins (zero-arg unit structs) are registered via the register_builtins!
macro in interpreter/mod.rs. To add a new one:
- Create the builtin module in
crates/bashkit/src/builtins/(implementBuiltintrait) - Add
mod mycommand;andpub use mycommand::MyCommand;inbuiltins/mod.rs - Add one line to the
register_builtins!table ininterpreter/mod.rs - Add spec tests in
tests/spec_cases/ - Run
just regen-builtins; record any gaps inspecs/limitations.md
Network Builtins
curl, wget, http require the http_client feature + URL allowlist.
When bot-auth feature is enabled, all outbound HTTP requests are transparently
signed with Ed25519 per RFC 9421 (see specs/request-signing.md).