Repository: reubeno/brush
Pull request #124 (2024-07-15 20:15:26)

feat: add brushctl builtin, seed with event toggling support
Enable custom builtin commands to be registered; add `brushctl` as a builtin specific to `brush` and add initial sub-commands to toggle verbose logging at runtime, e.g.:

```
brushctl events enable commands
```

The first commit is a preparatory set of changes before exporting more builtin-related functionality outside `brush-core`; it merges the `builtin` and `builtins` modules, which were separate and not for any terribly good reason.


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 5153442..24704d9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -344,6 +344,7 @@ dependencies = [
  "anyhow",
  "assert_cmd",
  "assert_fs",
+ "async-trait",
  "brush-core",
  "brush-interactive",
  "brush-parser",
@@ -358,6 +359,7 @@ dependencies = [
  "glob",
  "indent",
  "junit-report",
+ "lazy_static",
  "pathdiff",
  "regex",
  "serde",
diff --git a/brush-core/src/builtin.rs b/brush-core/src/builtins.rs
similarity index 93%
rename from brush-core/src/builtin.rs
rename to brush-core/src/builtins.rs
index dd99858..4fa056b 100644
--- a/brush-core/src/builtin.rs
+++ b/brush-core/src/builtins.rs
@@ -1,3 +1,5 @@
+//! Infrastructure for shell built-in commands.
+
 use clap::builder::styling;
 use clap::Parser;
 use futures::future::BoxFuture;
@@ -6,6 +8,57 @@ use crate::commands;
 use crate::error;
 use crate::ExecutionResult;
 
+mod alias;
+mod bg;
+mod break_;
+mod builtin_;
+mod cd;
+mod colon;
+mod command;
+mod complete;
+mod continue_;
+mod declare;
+mod dirs;
+mod dot;
+mod echo;
+mod enable;
+mod eval;
+#[cfg(unix)]
+mod exec;
+mod exit;
+mod export;
+mod factory;
+mod false_;
+mod fg;
+mod getopts;
+mod help;
+mod jobs;
+#[cfg(unix)]
+mod kill;
+mod let_;
+mod popd;
+mod printf;
+mod pushd;
+mod pwd;
+mod read;
+mod return_;
+mod set;
+mod shift;
+mod shopt;
+mod test;
+mod trap;
+mod true_;
+mod type_;
+#[cfg(unix)]
+mod umask;
+mod unalias;
+mod unimp;
+mod unset;
+mod wait;
+
+pub use factory::builtin;
+pub(crate) use factory::get_default_builtins;
+
 /// Macro to define a struct that represents a shell built-in flag argument that can be
 /// enabled or disabled by specifying an option with a leading '+' or '-' character.
 ///
@@ -69,7 +122,8 @@ pub enum ExitCode {
     Custom(u8),
     /// The command is requesting to exit the shell, yielding the given exit code.
     ExitShell(u8),
-    /// The command is requesting to return from a function or script, yielding the given exit code.
+    /// The command is requesting to return from a function or script, yielding the given exit
+    /// code.
     ReturnFromFunctionOrScript(u8),
     /// The command is requesting to continue a loop, identified by the given nesting count.
     ContinueLoop(u8),
diff --git a/brush-core/src/builtins/alias.rs b/brush-core/src/builtins/alias.rs
index 4e27a39..375c062 100644
--- a/brush-core/src/builtins/alias.rs
+++ b/brush-core/src/builtins/alias.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::io::Write;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Manage aliases within the shell.
 #[derive(Parser)]
@@ -16,12 +16,12 @@ pub(crate) struct AliasCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for AliasCommand {
+impl builtins::Command for AliasCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
-        let mut exit_code = builtin::ExitCode::Success;
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
+        let mut exit_code = builtins::ExitCode::Success;
 
         if self.print || self.aliases.is_empty() {
             for (name, value) in &context.shell.aliases {
@@ -42,7 +42,7 @@ impl builtin::Command for AliasCommand {
                         "{}: {alias}: not found",
                         context.command_name
                     )?;
-                    exit_code = builtin::ExitCode::Custom(1);
+                    exit_code = builtins::ExitCode::Custom(1);
                 }
             }
         }
diff --git a/brush-core/src/builtins/bg.rs b/brush-core/src/builtins/bg.rs
index 80ad650..ed14d5f 100644
--- a/brush-core/src/builtins/bg.rs
+++ b/brush-core/src/builtins/bg.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::io::Write;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Moves a job to run in the background.
 #[derive(Parser)]
@@ -11,12 +11,12 @@ pub(crate) struct BgCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for BgCommand {
+impl builtins::Command for BgCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
-        let mut exit_code = builtin::ExitCode::Success;
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
+        let mut exit_code = builtins::ExitCode::Success;
 
         if !self.job_specs.is_empty() {
             for job_spec in &self.job_specs {
@@ -29,7 +29,7 @@ impl builtin::Command for BgCommand {
                         context.command_name,
                         job_spec
                     )?;
-                    exit_code = builtin::ExitCode::Custom(1);
+                    exit_code = builtins::ExitCode::Custom(1);
                 }
             }
         } else {
@@ -37,7 +37,7 @@ impl builtin::Command for BgCommand {
                 job.move_to_background()?;
             } else {
                 writeln!(context.stderr(), "{}: no current job", context.command_name)?;
-                exit_code = builtin::ExitCode::Custom(1);
+                exit_code = builtins::ExitCode::Custom(1);
             }
         }
 
diff --git a/brush-core/src/builtins/break_.rs b/brush-core/src/builtins/break_.rs
index b709fab..15b2c24 100644
--- a/brush-core/src/builtins/break_.rs
+++ b/brush-core/src/builtins/break_.rs
@@ -1,6 +1,6 @@
 use clap::Parser;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Breaks out of a control-flow loop.
 #[derive(Parser)]
@@ -11,17 +11,17 @@ pub(crate) struct BreakCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for BreakCommand {
+impl builtins::Command for BreakCommand {
     async fn execute(
         &self,
         _context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         // If specified, which_loop needs to be positive.
         if self.which_loop <= 0 {
-            return Ok(builtin::ExitCode::InvalidUsage);
+            return Ok(builtins::ExitCode::InvalidUsage);
         }
 
         #[allow(clippy::cast_sign_loss)]
-        Ok(builtin::ExitCode::BreakLoop((self.which_loop - 1) as u8))
+        Ok(builtins::ExitCode::BreakLoop((self.which_loop - 1) as u8))
     }
 }
diff --git a/brush-core/src/builtins/builtin_.rs b/brush-core/src/builtins/builtin_.rs
index bb7d1e8..8075405 100644
--- a/brush-core/src/builtins/builtin_.rs
+++ b/brush-core/src/builtins/builtin_.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::io::Write;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Directly invokes a built-in, without going through typical search order.
 #[derive(Parser)]
@@ -15,11 +15,11 @@ pub(crate) struct BuiltinCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for BuiltinCommand {
+impl builtins::Command for BuiltinCommand {
     async fn execute(
         &self,
         mut context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         if let Some(builtin_name) = &self.builtin_name {
             if let Some(builtin) = context.shell.builtins.get(builtin_name) {
                 context.command_name.clone_from(builtin_name);
@@ -30,13 +30,13 @@ impl builtin::Command for BuiltinCommand {
 
                 (builtin.execute_func)(context, args)
                     .await
-                    .map(|res: builtin::BuiltinResult| res.exit_code)
+                    .map(|res: builtins::BuiltinResult| res.exit_code)
             } else {
                 writeln!(context.stderr(), "{builtin_name}: command not found")?;
-                Ok(builtin::ExitCode::Custom(1))
+                Ok(builtins::ExitCode::Custom(1))
             }
         } else {
-            Ok(builtin::ExitCode::Success)
+            Ok(builtins::ExitCode::Success)
         }
     }
 }
diff --git a/brush-core/src/builtins/cd.rs b/brush-core/src/builtins/cd.rs
index 050b54b..860c3c9 100644
--- a/brush-core/src/builtins/cd.rs
+++ b/brush-core/src/builtins/cd.rs
@@ -3,7 +3,7 @@ use std::path::PathBuf;
 
 use clap::Parser;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Change the current working directory.
 #[derive(Parser)]
@@ -29,11 +29,11 @@ pub(crate) struct CdCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for CdCommand {
+impl builtins::Command for CdCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         // TODO: implement options
         if self.force_follow_symlinks
             || self.use_physical_dir
@@ -50,7 +50,7 @@ impl builtin::Command for CdCommand {
                 PathBuf::from(home_var.to_string())
             } else {
                 writeln!(context.stderr(), "HOME not set")?;
-                return Ok(builtin::ExitCode::Custom(1));
+                return Ok(builtins::ExitCode::Custom(1));
             }
         };
 
@@ -58,10 +58,10 @@ impl builtin::Command for CdCommand {
             Ok(()) => {}
             Err(e) => {
                 writeln!(context.stderr(), "cd: {e}")?;
-                return Ok(builtin::ExitCode::Custom(1));
+                return Ok(builtins::ExitCode::Custom(1));
             }
         }
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
diff --git a/brush-core/src/builtins/colon.rs b/brush-core/src/builtins/colon.rs
index 64df45e..50640ee 100644
--- a/brush-core/src/builtins/colon.rs
+++ b/brush-core/src/builtins/colon.rs
@@ -1,6 +1,6 @@
 use clap::Parser;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// No-op command.
 #[derive(Parser)]
@@ -11,11 +11,11 @@ pub(crate) struct ColonCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for ColonCommand {
+impl builtins::Command for ColonCommand {
     async fn execute(
         &self,
         _context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
-        Ok(builtin::ExitCode::Success)
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
+        Ok(builtins::ExitCode::Success)
     }
 }
diff --git a/brush-core/src/builtins/command.rs b/brush-core/src/builtins/command.rs
index 59afb4b..adbe0ab 100644
--- a/brush-core/src/builtins/command.rs
+++ b/brush-core/src/builtins/command.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::{fmt::Display, io::Write};
 
-use crate::{builtin, commands, error, shell, sys::fs::PathExt};
+use crate::{builtins, commands, error, shell, sys::fs::PathExt};
 
 /// Directly invokes an external command, without going through typical search order.
 #[derive(Parser)]
@@ -27,11 +27,11 @@ pub(crate) struct CommandCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for CommandCommand {
+impl builtins::Command for CommandCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<builtin::ExitCode, error::Error> {
+    ) -> Result<builtins::ExitCode, error::Error> {
         if self.use_default_path {
             return error::unimp("command -p");
         }
@@ -50,7 +50,7 @@ impl builtin::Command for CommandCommand {
                         }
                     }
                 }
-                Ok(builtin::ExitCode::Success)
+                Ok(builtins::ExitCode::Success)
             } else {
                 if self.print_verbose_description {
                     writeln!(
@@ -59,7 +59,7 @@ impl builtin::Command for CommandCommand {
                         self.command_name
                     )?;
                 }
-                Ok(builtin::ExitCode::Custom(1))
+                Ok(builtins::ExitCode::Custom(1))
             }
         } else {
             self.execute_command(context).await
@@ -115,7 +115,7 @@ impl CommandCommand {
     async fn execute_command(
         &self,
         mut context: commands::ExecutionContext<'_>,
-    ) -> Result<builtin::ExitCode, error::Error> {
+    ) -> Result<builtins::ExitCode, error::Error> {
         let args: Vec<_> = std::iter::once(&self.command_name)
             .chain(self.args.iter())
             .map(|arg| arg.into())
@@ -130,9 +130,9 @@ impl CommandCommand {
             commands::SpawnResult::SpawnedChild(mut child) => {
                 let real_result = child.wait().await?;
                 let exit_code = real_result.code().unwrap_or(1);
-                Ok(builtin::ExitCode::Custom(exit_code as u8))
+                Ok(builtins::ExitCode::Custom(exit_code as u8))
             }
-            commands::SpawnResult::ImmediateExit(code) => Ok(builtin::ExitCode::Custom(code)),
+            commands::SpawnResult::ImmediateExit(code) => Ok(builtins::ExitCode::Custom(code)),
             commands::SpawnResult::ExitShell(_)
             | commands::SpawnResult::ReturnFromFunctionOrScript(_)
             | commands::SpawnResult::BreakLoop(_)
diff --git a/brush-core/src/builtins/complete.rs b/brush-core/src/builtins/complete.rs
index 89a7eaf..568e066 100644
--- a/brush-core/src/builtins/complete.rs
+++ b/brush-core/src/builtins/complete.rs
@@ -3,7 +3,7 @@ use std::collections::HashMap;
 use std::fmt::Write as _;
 use std::io::Write;
 
-use crate::builtin;
+use crate::builtins;
 use crate::commands;
 use crate::completion::{self, CompleteAction, CompleteOption, Spec};
 use crate::error;
@@ -211,11 +211,11 @@ pub(crate) struct CompleteCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for CompleteCommand {
+impl builtins::Command for CompleteCommand {
     async fn execute(
         &self,
         mut context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         // If -D, -E, or -I are specified, then any names provided are ignored.
         if self.use_as_default
             || self.use_for_empty_line
@@ -229,7 +229,7 @@ impl builtin::Command for CompleteCommand {
             }
         }
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
 
@@ -429,11 +429,11 @@ pub(crate) struct CompGenCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for CompGenCommand {
+impl builtins::Command for CompGenCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         let spec = self.common_args.create_spec();
 
         let token_to_complete = self.word.as_deref().unwrap_or_default();
@@ -466,7 +466,7 @@ impl builtin::Command for CompGenCommand {
             }
         }
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
 
@@ -491,11 +491,11 @@ pub(crate) struct CompOptCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for CompOptCommand {
+impl builtins::Command for CompOptCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         let mut options = HashMap::new();
         for option in &self.disabled_options {
             options.insert(option.clone(), false);
@@ -510,7 +510,7 @@ impl builtin::Command for CompOptCommand {
                     context.stderr(),
                     "compopt: cannot specify names with -D, -E, or -I"
                 )?;
-                return Ok(builtin::ExitCode::InvalidUsage);
+                return Ok(builtins::ExitCode::InvalidUsage);
             }
 
             for name in &self.names {
@@ -562,7 +562,7 @@ impl builtin::Command for CompOptCommand {
             }
         }
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
 
diff --git a/brush-core/src/builtins/continue_.rs b/brush-core/src/builtins/continue_.rs
index 3b3f5dd..117f5ff 100644
--- a/brush-core/src/builtins/continue_.rs
+++ b/brush-core/src/builtins/continue_.rs
@@ -1,6 +1,6 @@
 use clap::Parser;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Continue to the next iteration of a control-flow loop.
 #[derive(Parser)]
@@ -11,17 +11,19 @@ pub(crate) struct ContinueCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for ContinueCommand {
+impl builtins::Command for ContinueCommand {
     async fn execute(
         &self,
         _context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         // If specified, which_loop needs to be positive.
         if self.which_loop <= 0 {
-            return Ok(builtin::ExitCode::InvalidUsage);
+            return Ok(builtins::ExitCode::InvalidUsage);
         }
 
         #[allow(clippy::cast_sign_loss)]
-        Ok(builtin::ExitCode::ContinueLoop((self.which_loop - 1) as u8))
+        Ok(builtins::ExitCode::ContinueLoop(
+            (self.which_loop - 1) as u8,
+        ))
     }
 }
diff --git a/brush-core/src/builtins/declare.rs b/brush-core/src/builtins/declare.rs
index 9529be1..407af94 100644
--- a/brush-core/src/builtins/declare.rs
+++ b/brush-core/src/builtins/declare.rs
@@ -3,7 +3,7 @@ use itertools::Itertools;
 use std::io::Write;
 
 use crate::{
-    builtin, commands,
+    builtins, commands,
     env::{EnvironmentLookup, EnvironmentScope},
     error,
     variables::{
@@ -12,35 +12,35 @@ use crate::{
     },
 };
 
-builtin::minus_or_plus_flag_arg!(
+builtins::minus_or_plus_flag_arg!(
     MakeIndexedArrayFlag,
     'a',
     "Make the variable an indexed array."
 );
-builtin::minus_or_plus_flag_arg!(
+builtins::minus_or_plus_flag_arg!(
     MakeAssociativeArrayFlag,
     'A',
     "Make the variable an associative array."
 );
-builtin::minus_or_plus_flag_arg!(MakeIntegerFlag, 'i', "Mark the variable as integer-typed");
-builtin::minus_or_plus_flag_arg!(
+builtins::minus_or_plus_flag_arg!(MakeIntegerFlag, 'i', "Mark the variable as integer-typed");
+builtins::minus_or_plus_flag_arg!(
     LowercaseValueOnAssignmentFlag,
     'l',
     "Enable lowercase-on-assignment for the variable."
 );
-builtin::minus_or_plus_flag_arg!(
+builtins::minus_or_plus_flag_arg!(
     MakeNameRefFlag,
     'n',
     "Mark the variable as a name reference"
 );
-builtin::minus_or_plus_flag_arg!(MakeReadonlyFlag, 'r', "Mark the variable as read-only.");
-builtin::minus_or_plus_flag_arg!(MakeTracedFlag, 't', "Enable tracing for the variable.");
-builtin::minus_or_plus_flag_arg!(
+builtins::minus_or_plus_flag_arg!(MakeReadonlyFlag, 'r', "Mark the variable as read-only.");
+builtins::minus_or_plus_flag_arg!(MakeTracedFlag, 't', "Enable tracing for the variable.");
+builtins::minus_or_plus_flag_arg!(
     UppercaseValueOnAssignmentFlag,
     'u',
     "Enable uppercase-on-assignment for the variable."
 );
-builtin::minus_or_plus_flag_arg!(MakeExportedFlag, 'x', "Mark the variable for export.");
+builtins::minus_or_plus_flag_arg!(MakeExportedFlag, 'x', "Mark the variable for export.");
 
 /// Display or update variables and their attributes.
 #[derive(Parser)]
@@ -69,7 +69,6 @@ pub(crate) struct DeclareCommand {
 
     //
     // Attribute options
-    //
     #[clap(flatten)] // -a
     make_indexed_array: MakeIndexedArrayFlag,
     #[clap(flatten)] // -A
@@ -93,7 +92,6 @@ pub(crate) struct DeclareCommand {
     // Declarations
     //
     // N.B. These are skipped by clap, but filled in by the BuiltinDeclarationCommand trait.
-    //
     #[clap(skip)]
     declarations: Vec<commands::CommandArg>,
 }
@@ -105,7 +103,7 @@ enum DeclareVerb {
     Readonly,
 }
 
-impl builtin::DeclarationCommand for DeclareCommand {
+impl builtins::DeclarationCommand for DeclareCommand {
     fn set_declarations(&mut self, declarations: Vec<commands::CommandArg>) {
         self.declarations = declarations;
     }
@@ -113,7 +111,7 @@ impl builtin::DeclarationCommand for DeclareCommand {
 
 #[allow(clippy::too_many_lines)]
 #[async_trait::async_trait]
-impl builtin::Command for DeclareCommand {
+impl builtins::Command for DeclareCommand {
     fn takes_plus_options() -> bool {
         true
     }
@@ -121,7 +119,7 @@ impl builtin::Command for DeclareCommand {
     async fn execute(
         &self,
         mut context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         let verb = match context.command_name.as_str() {
             "local" => DeclareVerb::Local,
             "readonly" => DeclareVerb::Readonly,
@@ -134,19 +132,19 @@ impl builtin::Command for DeclareCommand {
                 context.stderr(),
                 "UNIMPLEMENTED: declare -I: locals inherit from previous scope"
             )?;
-            return Ok(builtin::ExitCode::Unimplemented);
+            return Ok(builtins::ExitCode::Unimplemented);
         }
 
-        let mut result = builtin::ExitCode::Success;
+        let mut result = builtins::ExitCode::Success;
         if !self.declarations.is_empty() {
             for declaration in &self.declarations {
                 if self.print && !matches!(verb, DeclareVerb::Readonly) {
                     if !self.try_display_declaration(&mut context, declaration, verb)? {
-                        result = builtin::ExitCode::Custom(1);
+                        result = builtins::ExitCode::Custom(1);
                     }
                 } else {
                     if !self.process_declaration(&mut context, declaration, verb)? {
-                        result = builtin::ExitCode::Custom(1);
+                        result = builtins::ExitCode::Custom(1);
                     }
                 }
             }
diff --git a/brush-core/src/builtins/dirs.rs b/brush-core/src/builtins/dirs.rs
index f17b619..4ab5dd8 100644
--- a/brush-core/src/builtins/dirs.rs
+++ b/brush-core/src/builtins/dirs.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::io::Write;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Manage the current directory stack.
 #[derive(Parser, Debug, Default)]
@@ -23,15 +23,14 @@ pub(crate) struct DirsCommand {
     print_one_per_line_with_index: bool,
     //
     // TODO: implement +N and -N
-    //
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for DirsCommand {
+impl builtins::Command for DirsCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         if self.clear {
             context.shell.directory_stack.clear();
         } else {
@@ -64,9 +63,9 @@ impl builtin::Command for DirsCommand {
                 }
             }
 
-            return Ok(builtin::ExitCode::Success);
+            return Ok(builtins::ExitCode::Success);
         }
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
diff --git a/brush-core/src/builtins/dot.rs b/brush-core/src/builtins/dot.rs
index dd288ba..faee453 100644
--- a/brush-core/src/builtins/dot.rs
+++ b/brush-core/src/builtins/dot.rs
@@ -2,7 +2,7 @@ use std::path::Path;
 
 use clap::Parser;
 
-use crate::{builtin, commands, interp::ExecutionParameters};
+use crate::{builtins, commands, interp::ExecutionParameters};
 
 /// Evalute the provided script in the current shell environment.
 #[derive(Debug, Parser)]
@@ -16,11 +16,11 @@ pub(crate) struct DotCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for DotCommand {
+impl builtins::Command for DotCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         // TODO: Handle trap inheritance.
         let script_args: Vec<_> = self.script_args.iter().map(|a| a.as_str()).collect();
 
@@ -36,9 +36,9 @@ impl builtin::Command for DotCommand {
             .await?;
 
         if result.exit_code != 0 {
-            return Ok(builtin::ExitCode::Custom(result.exit_code));
+            return Ok(builtins::ExitCode::Custom(result.exit_code));
         }
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
diff --git a/brush-core/src/builtins/echo.rs b/brush-core/src/builtins/echo.rs
index 8c2ecbe..7d1852d 100644
--- a/brush-core/src/builtins/echo.rs
+++ b/brush-core/src/builtins/echo.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::io::Write;
 
-use crate::{builtin, commands, escape};
+use crate::{builtins, commands, escape};
 
 /// Echo text to standard output.
 #[derive(Parser)]
@@ -25,11 +25,11 @@ pub(crate) struct EchoCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for EchoCommand {
+impl builtins::Command for EchoCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         let mut trailing_newline = !self.no_trailing_newline;
         let mut s;
         if self.interpret_backslash_escapes {
@@ -61,6 +61,6 @@ impl builtin::Command for EchoCommand {
         write!(context.stdout(), "{s}")?;
         context.stdout().flush()?;
 
-        return Ok(builtin::ExitCode::Success);
+        return Ok(builtins::ExitCode::Success);
     }
 }
diff --git a/brush-core/src/builtins/enable.rs b/brush-core/src/builtins/enable.rs
index b7d947a..aa1f590 100644
--- a/brush-core/src/builtins/enable.rs
+++ b/brush-core/src/builtins/enable.rs
@@ -2,7 +2,7 @@ use clap::Parser;
 use itertools::Itertools;
 use std::io::Write;
 
-use crate::builtin;
+use crate::builtins;
 use crate::commands;
 use crate::error;
 
@@ -38,12 +38,12 @@ pub(crate) struct EnableCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for EnableCommand {
+impl builtins::Command for EnableCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<builtin::ExitCode, crate::error::Error> {
-        let mut result = builtin::ExitCode::Success;
+    ) -> Result<builtins::ExitCode, crate::error::Error> {
+        let mut result = builtins::ExitCode::Success;
 
         if self.shared_object_path.is_some() {
             return error::unimp("enable -f");
@@ -58,7 +58,7 @@ impl builtin::Command for EnableCommand {
                     builtin.disabled = self.disable;
                 } else {
                     writeln!(context.stderr(), "{name}: not a shell builtin")?;
-                    result = builtin::ExitCode::Custom(1);
+                    result = builtins::ExitCode::Custom(1);
                 }
             }
         } else {
diff --git a/brush-core/src/builtins/eval.rs b/brush-core/src/builtins/eval.rs
index 2587966..7baf37b 100644
--- a/brush-core/src/builtins/eval.rs
+++ b/brush-core/src/builtins/eval.rs
@@ -1,4 +1,4 @@
-use crate::{builtin, commands, interp::ExecutionParameters};
+use crate::{builtins, commands, interp::ExecutionParameters};
 use clap::Parser;
 
 /// Evalute the given string as script.
@@ -10,11 +10,11 @@ pub(crate) struct EvalCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for EvalCommand {
+impl builtins::Command for EvalCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         if !self.args.is_empty() {
             let args_concatenated = self.args.join(" ");
 
@@ -30,9 +30,9 @@ impl builtin::Command for EvalCommand {
                 )
                 .await?;
 
-            Ok(builtin::ExitCode::Custom(exec_result.exit_code))
+            Ok(builtins::ExitCode::Custom(exec_result.exit_code))
         } else {
-            Ok(builtin::ExitCode::Success)
+            Ok(builtins::ExitCode::Success)
         }
     }
 }
diff --git a/brush-core/src/builtins/exec.rs b/brush-core/src/builtins/exec.rs
index e026c4b..3106c88 100644
--- a/brush-core/src/builtins/exec.rs
+++ b/brush-core/src/builtins/exec.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::{borrow::Cow, os::unix::process::CommandExt};
 
-use crate::{builtin, commands, error};
+use crate::{builtins, commands, error};
 
 /// Exec the provided command.
 #[derive(Parser)]
@@ -24,13 +24,13 @@ pub(crate) struct ExecCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for ExecCommand {
+impl builtins::Command for ExecCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<builtin::ExitCode, crate::error::Error> {
+    ) -> Result<builtins::ExitCode, crate::error::Error> {
         if self.args.is_empty() {
-            return Ok(builtin::ExitCode::Success);
+            return Ok(builtins::ExitCode::Success);
         }
 
         let mut argv0 = Cow::Borrowed(self.name_for_argv0.as_ref().unwrap_or(&self.args[0]));
@@ -51,7 +51,7 @@ impl builtin::Command for ExecCommand {
         let exec_error = cmd.exec();
 
         if exec_error.kind() == std::io::ErrorKind::NotFound {
-            Ok(builtin::ExitCode::Custom(127))
+            Ok(builtins::ExitCode::Custom(127))
         } else {
             Err(error::Error::from(exec_error))
         }
diff --git a/brush-core/src/builtins/exit.rs b/brush-core/src/builtins/exit.rs
index 610922a..5ce7d7e 100644
--- a/brush-core/src/builtins/exit.rs
+++ b/brush-core/src/builtins/exit.rs
@@ -1,6 +1,6 @@
 use clap::Parser;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Exit the shell.
 #[derive(Parser)]
@@ -10,11 +10,11 @@ pub(crate) struct ExitCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for ExitCommand {
+impl builtins::Command for ExitCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         let code_8bit: u8;
 
         #[allow(clippy::cast_sign_loss)]
@@ -24,6 +24,6 @@ impl builtin::Command for ExitCommand {
             code_8bit = context.shell.last_exit_status;
         }
 
-        Ok(builtin::ExitCode::ExitShell(code_8bit))
+        Ok(builtins::ExitCode::ExitShell(code_8bit))
     }
 }
diff --git a/brush-core/src/builtins/export.rs b/brush-core/src/builtins/export.rs
index d6e7149..28c48b9 100644
--- a/brush-core/src/builtins/export.rs
+++ b/brush-core/src/builtins/export.rs
@@ -3,7 +3,7 @@ use itertools::Itertools;
 use std::io::Write;
 
 use crate::{
-    builtin, commands,
+    builtins, commands,
     env::{EnvironmentLookup, EnvironmentScope},
     variables,
 };
@@ -27,23 +27,22 @@ pub(crate) struct ExportCommand {
     // Declarations
     //
     // N.B. These are skipped by clap, but filled in by the BuiltinDeclarationCommand trait.
-    //
     #[clap(skip)]
     declarations: Vec<commands::CommandArg>,
 }
 
-impl builtin::DeclarationCommand for ExportCommand {
+impl builtins::DeclarationCommand for ExportCommand {
     fn set_declarations(&mut self, declarations: Vec<commands::CommandArg>) {
         self.declarations = declarations;
     }
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for ExportCommand {
+impl builtins::Command for ExportCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         if !self.declarations.is_empty() {
             for decl in &self.declarations {
                 match decl {
@@ -59,7 +58,7 @@ impl builtin::Command for ExportCommand {
                             brush_parser::ast::AssignmentName::VariableName(name) => name,
                             brush_parser::ast::AssignmentName::ArrayElementName(_, _) => {
                                 writeln!(context.stderr(), "not a valid variable name")?;
-                                return Ok(builtin::ExitCode::InvalidUsage);
+                                return Ok(builtins::ExitCode::InvalidUsage);
                             }
                         };
 
@@ -106,6 +105,6 @@ impl builtin::Command for ExportCommand {
             }
         }
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
diff --git a/brush-core/src/builtins/mod.rs b/brush-core/src/builtins/factory.rs
similarity index 78%
rename from brush-core/src/builtins/mod.rs
rename to brush-core/src/builtins/factory.rs
index d2afae2..b9067f8 100644
--- a/brush-core/src/builtins/mod.rs
+++ b/brush-core/src/builtins/factory.rs
@@ -2,59 +2,17 @@ use futures::future::BoxFuture;
 use std::collections::HashMap;
 use std::io::Write;
 
-use crate::builtin;
+#[allow(clippy::wildcard_imports)]
+use super::*;
+
+use crate::builtins;
 use crate::commands::{self, CommandArg};
 use crate::error;
 
-mod alias;
-mod bg;
-mod break_;
-mod builtin_;
-mod cd;
-mod colon;
-mod command;
-mod complete;
-mod continue_;
-mod declare;
-mod dirs;
-mod dot;
-mod echo;
-mod enable;
-mod eval;
-#[cfg(unix)]
-mod exec;
-mod exit;
-mod export;
-mod false_;
-mod fg;
-mod getopts;
-mod help;
-mod jobs;
-#[cfg(unix)]
-mod kill;
-mod let_;
-mod popd;
-mod printf;
-mod pushd;
-mod pwd;
-mod read;
-mod return_;
-mod set;
-mod shift;
-mod shopt;
-mod test;
-mod trap;
-mod true_;
-mod type_;
-#[cfg(unix)]
-mod umask;
-mod unalias;
-mod unimp;
-mod unset;
-mod wait;
-
-fn builtin<B: builtin::Command + Send + Sync>() -> builtin::Registration {
-    builtin::Registration {
+/// Returns a built-in command registration, given an implementation of the
+/// `Command` trait.
+pub fn builtin<B: builtins::Command + Send + Sync>() -> builtins::Registration {
+    builtins::Registration {
         execute_func: exec_builtin::<B>,
         content_func: get_builtin_content::<B>,
         disabled: false,
@@ -63,8 +21,8 @@ fn builtin<B: builtin::Command + Send + Sync>() -> builtin::Registration {
     }
 }
 
-fn special_builtin<B: builtin::Command + Send + Sync>() -> builtin::Registration {
-    builtin::Registration {
+fn special_builtin<B: builtins::Command + Send + Sync>() -> builtins::Registration {
+    builtins::Registration {
         execute_func: exec_builtin::<B>,
         content_func: get_builtin_content::<B>,
         disabled: false,
@@ -73,8 +31,8 @@ fn special_builtin<B: builtin::Command + Send + Sync>() -> builtin::Registration
     }
 }
 
-fn decl_builtin<B: builtin::DeclarationCommand + Send + Sync>() -> builtin::Registration {
-    builtin::Registration {
+fn decl_builtin<B: builtins::DeclarationCommand + Send + Sync>() -> builtins::Registration {
+    builtins::Registration {
         execute_func: exec_declaration_builtin::<B>,
         content_func: get_builtin_content::<B>,
         disabled: false,
@@ -83,8 +41,8 @@ fn decl_builtin<B: builtin::DeclarationCommand + Send + Sync>() -> builtin::Regi
     }
 }
 
-fn special_decl_builtin<B: builtin::DeclarationCommand + Send + Sync>() -> builtin::Registration {
-    builtin::Registration {
+fn special_decl_builtin<B: builtins::DeclarationCommand + Send + Sync>() -> builtins::Registration {
+    builtins::Registration {
         execute_func: exec_declaration_builtin::<B>,
         content_func: get_builtin_content::<B>,
         disabled: false,
@@ -93,24 +51,24 @@ fn special_decl_builtin<B: builtin::DeclarationCommand + Send + Sync>() -> built
     }
 }
 
-fn get_builtin_content<T: builtin::Command + Send + Sync>(
+fn get_builtin_content<T: builtins::Command + Send + Sync>(
     name: &str,
-    content_type: builtin::ContentType,
+    content_type: builtins::ContentType,
 ) -> Result<String, error::Error> {
     T::get_content(name, content_type)
 }
 
-fn exec_builtin<T: builtin::Command + Send + Sync>(
+fn exec_builtin<T: builtins::Command + Send + Sync>(
     context: commands::ExecutionContext<'_>,
     args: Vec<CommandArg>,
-) -> BoxFuture<'_, Result<builtin::BuiltinResult, error::Error>> {
+) -> BoxFuture<'_, Result<builtins::BuiltinResult, error::Error>> {
     Box::pin(async move { exec_builtin_impl::<T>(context, args).await })
 }
 
-async fn exec_builtin_impl<T: builtin::Command + Send + Sync>(
+async fn exec_builtin_impl<T: builtins::Command + Send + Sync>(
     context: commands::ExecutionContext<'_>,
     args: Vec<CommandArg>,
-) -> Result<builtin::BuiltinResult, error::Error> {
+) -> Result<builtins::BuiltinResult, error::Error> {
     let plain_args = args.into_iter().map(|arg| match arg {
         CommandArg::String(s) => s,
         CommandArg::Assignment(a) => a.to_string(),
@@ -121,28 +79,28 @@ async fn exec_builtin_impl<T: builtin::Command + Send + Sync>(
         Ok(command) => command,
         Err(e) => {
             writeln!(context.stderr(), "{e}")?;
-            return Ok(builtin::BuiltinResult {
-                exit_code: builtin::ExitCode::InvalidUsage,
+            return Ok(builtins::BuiltinResult {
+                exit_code: builtins::ExitCode::InvalidUsage,
             });
         }
     };
 
-    Ok(builtin::BuiltinResult {
+    Ok(builtins::BuiltinResult {
         exit_code: command.execute(context).await?,
     })
 }
 
-fn exec_declaration_builtin<T: builtin::DeclarationCommand + Send + Sync>(
+fn exec_declaration_builtin<T: builtins::DeclarationCommand + Send + Sync>(
     context: commands::ExecutionContext<'_>,
     args: Vec<CommandArg>,
-) -> BoxFuture<'_, Result<builtin::BuiltinResult, error::Error>> {
+) -> BoxFuture<'_, Result<builtins::BuiltinResult, error::Error>> {
     Box::pin(async move { exec_declaration_builtin_impl::<T>(context, args).await })
 }
 
-async fn exec_declaration_builtin_impl<T: builtin::DeclarationCommand + Send + Sync>(
+async fn exec_declaration_builtin_impl<T: builtins::DeclarationCommand + Send + Sync>(
     context: commands::ExecutionContext<'_>,
     args: Vec<CommandArg>,
-) -> Result<builtin::BuiltinResult, error::Error> {
+) -> Result<builtins::BuiltinResult, error::Error> {
     let mut options = vec![];
     let mut declarations = vec![];
 
@@ -160,15 +118,15 @@ async fn exec_declaration_builtin_impl<T: builtin::DeclarationCommand + Send + S
         Ok(command) => command,
         Err(e) => {
             writeln!(context.stderr(), "{e}")?;
-            return Ok(builtin::BuiltinResult {
-                exit_code: builtin::ExitCode::InvalidUsage,
+            return Ok(builtins::BuiltinResult {
+                exit_code: builtins::ExitCode::InvalidUsage,
             });
         }
     };
 
     command.set_declarations(declarations);
 
-    Ok(builtin::BuiltinResult {
+    Ok(builtins::BuiltinResult {
         exit_code: command.execute(context).await?,
     })
 }
@@ -176,8 +134,8 @@ async fn exec_declaration_builtin_impl<T: builtin::DeclarationCommand + Send + S
 #[allow(clippy::too_many_lines)]
 pub(crate) fn get_default_builtins(
     options: &crate::CreateOptions,
-) -> HashMap<String, builtin::Registration> {
-    let mut m = HashMap::<String, builtin::Registration>::new();
+) -> HashMap<String, builtins::Registration> {
+    let mut m = HashMap::<String, builtins::Registration>::new();
 
     //
     // POSIX special builtins
diff --git a/brush-core/src/builtins/false_.rs b/brush-core/src/builtins/false_.rs
index d3262ea..d837939 100644
--- a/brush-core/src/builtins/false_.rs
+++ b/brush-core/src/builtins/false_.rs
@@ -1,17 +1,17 @@
 use clap::Parser;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Return a non-zero exit code.
 #[derive(Parser)]
 pub(crate) struct FalseCommand {}
 
 #[async_trait::async_trait]
-impl builtin::Command for FalseCommand {
+impl builtins::Command for FalseCommand {
     async fn execute(
         &self,
         _context: commands::ExecutionContext<'_>,
-    ) -> Result<builtin::ExitCode, crate::error::Error> {
-        Ok(builtin::ExitCode::Custom(1))
+    ) -> Result<builtins::ExitCode, crate::error::Error> {
+        Ok(builtins::ExitCode::Custom(1))
     }
 }
diff --git a/brush-core/src/builtins/fg.rs b/brush-core/src/builtins/fg.rs
index 0958cde..e2c1d16 100644
--- a/brush-core/src/builtins/fg.rs
+++ b/brush-core/src/builtins/fg.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::io::Write;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Move a specified job to the foreground.
 #[derive(Parser)]
@@ -11,11 +11,11 @@ pub(crate) struct FgCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for FgCommand {
+impl builtins::Command for FgCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         let mut stderr = context.stdout();
 
         if let Some(job_spec) = &self.job_spec {
@@ -24,14 +24,14 @@ impl builtin::Command for FgCommand {
                 writeln!(stderr, "{}", job.command_line)?;
 
                 let result = job.wait().await?;
-                Ok(builtin::ExitCode::from(result))
+                Ok(builtins::ExitCode::from(result))
             } else {
                 writeln!(
                     stderr,
                     "{}: {}: no such job",
                     job_spec, context.command_name
                 )?;
-                Ok(builtin::ExitCode::Custom(1))
+                Ok(builtins::ExitCode::Custom(1))
             }
         } else {
             if let Some(job) = context.shell.jobs.current_job_mut() {
@@ -39,10 +39,10 @@ impl builtin::Command for FgCommand {
                 writeln!(stderr, "{}", job.command_line)?;
 
                 let result = job.wait().await?;
-                Ok(builtin::ExitCode::from(result))
+                Ok(builtins::ExitCode::from(result))
             } else {
                 writeln!(stderr, "{}: no current job", context.command_name)?;
-                Ok(builtin::ExitCode::Custom(1))
+                Ok(builtins::ExitCode::Custom(1))
             }
         }
     }
diff --git a/brush-core/src/builtins/getopts.rs b/brush-core/src/builtins/getopts.rs
index 37e2263..895b926 100644
--- a/brush-core/src/builtins/getopts.rs
+++ b/brush-core/src/builtins/getopts.rs
@@ -2,7 +2,7 @@ use std::{borrow::Cow, collections::HashMap};
 
 use clap::Parser;
 
-use crate::{builtin, commands, variables};
+use crate::{builtins, commands, variables};
 
 /// Parse command options.
 #[derive(Parser)]
@@ -19,11 +19,11 @@ pub(crate) struct GetOptsCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for GetOptsCommand {
+impl builtins::Command for GetOptsCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         let mut args = HashMap::<char, bool>::new();
 
         // Build the args map
@@ -34,7 +34,7 @@ impl builtin::Command for GetOptsCommand {
                     args.insert(last_char, true);
                     continue;
                 } else {
-                    return Ok(builtin::ExitCode::InvalidUsage);
+                    return Ok(builtins::ExitCode::InvalidUsage);
                 }
             }
 
@@ -51,17 +51,17 @@ impl builtin::Command for GetOptsCommand {
             .parse()?;
 
         if next_index < 1 {
-            return Ok(builtin::ExitCode::InvalidUsage);
+            return Ok(builtins::ExitCode::InvalidUsage);
         }
 
         let mut next_index_zero_based = next_index - 1;
         if next_index_zero_based >= self.args.len() {
-            return Ok(builtin::ExitCode::Custom(1));
+            return Ok(builtins::ExitCode::Custom(1));
         }
 
         let arg = self.args[next_index_zero_based].as_str();
         if !arg.starts_with('-') || arg.len() != 2 {
-            return Ok(builtin::ExitCode::Custom(1));
+            return Ok(builtins::ExitCode::Custom(1));
         }
 
         // Single character option
@@ -72,7 +72,7 @@ impl builtin::Command for GetOptsCommand {
                 next_index_zero_based += 1;
 
                 if next_index_zero_based >= self.args.len() {
-                    return Ok(builtin::ExitCode::Custom(1));
+                    return Ok(builtins::ExitCode::Custom(1));
                 }
 
                 let opt_arg = self.args[next_index_zero_based].as_str();
@@ -102,9 +102,9 @@ impl builtin::Command for GetOptsCommand {
                 crate::env::EnvironmentScope::Global,
             )?;
         } else {
-            return Ok(builtin::ExitCode::Custom(1));
+            return Ok(builtins::ExitCode::Custom(1));
         }
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
diff --git a/brush-core/src/builtins/help.rs b/brush-core/src/builtins/help.rs
index 1bc019a..020aaa6 100644
--- a/brush-core/src/builtins/help.rs
+++ b/brush-core/src/builtins/help.rs
@@ -1,4 +1,4 @@
-use crate::{builtin, commands, error};
+use crate::{builtins, commands, error};
 use clap::Parser;
 use itertools::Itertools;
 use std::io::Write;
@@ -23,11 +23,11 @@ pub(crate) struct HelpCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for HelpCommand {
+impl builtins::Command for HelpCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         if self.topic_patterns.is_empty() {
             Self::display_general_help(&context)?;
         } else {
@@ -36,7 +36,7 @@ impl builtin::Command for HelpCommand {
             }
         }
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
 
@@ -102,16 +102,16 @@ impl HelpCommand {
         &self,
         context: &commands::ExecutionContext<'_>,
         name: &str,
-        registration: &builtin::Registration,
+        registration: &builtins::Registration,
     ) -> Result<(), error::Error> {
         let content_type = if self.short_description {
-            builtin::ContentType::ShortDescription
+            builtins::ContentType::ShortDescription
         } else if self.man_page_style {
-            builtin::ContentType::ManPage
+            builtins::ContentType::ManPage
         } else if self.short_usage {
-            builtin::ContentType::ShortUsage
+            builtins::ContentType::ShortUsage
         } else {
-            builtin::ContentType::DetailedHelp
+            builtins::ContentType::DetailedHelp
         };
 
         let content = (registration.content_func)(name, content_type)?;
@@ -125,7 +125,7 @@ impl HelpCommand {
 
 fn get_builtins_sorted_by_name<'a>(
     context: &'a commands::ExecutionContext<'_>,
-) -> Vec<(&'a String, &'a builtin::Registration)> {
+) -> Vec<(&'a String, &'a builtins::Registration)> {
     context
         .shell
         .builtins
diff --git a/brush-core/src/builtins/jobs.rs b/brush-core/src/builtins/jobs.rs
index 4e47320..e26b707 100644
--- a/brush-core/src/builtins/jobs.rs
+++ b/brush-core/src/builtins/jobs.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::io::Write;
 
-use crate::{builtin, commands, error, jobs};
+use crate::{builtins, commands, error, jobs};
 
 /// Manage jobs.
 #[derive(Parser)]
@@ -32,11 +32,11 @@ pub(crate) struct JobsCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for JobsCommand {
+impl builtins::Command for JobsCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         if self.also_show_pids {
             return error::unimp("jobs -l");
         }
@@ -52,7 +52,7 @@ impl builtin::Command for JobsCommand {
             return error::unimp("jobs with job specs");
         }
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
 
diff --git a/brush-core/src/builtins/kill.rs b/brush-core/src/builtins/kill.rs
index 81adf2d..342e3b5 100644
--- a/brush-core/src/builtins/kill.rs
+++ b/brush-core/src/builtins/kill.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::io::Write;
 
-use crate::{builtin, commands, error};
+use crate::{builtins, commands, error};
 
 /// Signal a job or process.
 #[derive(Parser)]
@@ -26,11 +26,11 @@ pub(crate) struct KillCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for KillCommand {
+impl builtins::Command for KillCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         if self.signal_name.is_some() {
             return error::unimp("kill -s");
         }
@@ -43,10 +43,10 @@ impl builtin::Command for KillCommand {
         } else {
             if self.args.len() != 1 {
                 writeln!(context.stderr(), "{}: invalid usage", context.command_name)?;
-                return Ok(builtin::ExitCode::InvalidUsage);
+                return Ok(builtins::ExitCode::InvalidUsage);
             }
 
-            let exit_code = builtin::ExitCode::Success;
+            let exit_code = builtins::ExitCode::Success;
 
             let pid_or_job_spec = &self.args[0];
             if pid_or_job_spec.starts_with('%') {
@@ -60,7 +60,7 @@ impl builtin::Command for KillCommand {
                         context.command_name,
                         pid_or_job_spec
                     )?;
-                    return Ok(builtin::ExitCode::Custom(1));
+                    return Ok(builtins::ExitCode::Custom(1));
                 }
             } else {
                 let pid = pid_or_job_spec.parse::<i32>()?;
diff --git a/brush-core/src/builtins/let_.rs b/brush-core/src/builtins/let_.rs
index a087c94..774c662 100644
--- a/brush-core/src/builtins/let_.rs
+++ b/brush-core/src/builtins/let_.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::io::Write;
 
-use crate::{arithmetic::Evaluatable, builtin, commands};
+use crate::{arithmetic::Evaluatable, builtins, commands};
 
 /// Evalute arithmetic expressions.
 #[derive(Parser)]
@@ -12,12 +12,12 @@ pub(crate) struct LetCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for LetCommand {
+impl builtins::Command for LetCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
-        let mut exit_code = builtin::ExitCode::InvalidUsage;
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
+        let mut exit_code = builtins::ExitCode::InvalidUsage;
 
         if self.exprs.is_empty() {
             writeln!(context.stderr(), "missing expression")?;
@@ -29,9 +29,9 @@ impl builtin::Command for LetCommand {
             let evaluated = parsed.eval(context.shell).await?;
 
             if evaluated == 0 {
-                exit_code = builtin::ExitCode::Custom(1);
+                exit_code = builtins::ExitCode::Custom(1);
             } else {
-                exit_code = builtin::ExitCode::Custom(0);
+                exit_code = builtins::ExitCode::Custom(0);
             }
         }
 
diff --git a/brush-core/src/builtins/popd.rs b/brush-core/src/builtins/popd.rs
index f1f1294..ed6409d 100644
--- a/brush-core/src/builtins/popd.rs
+++ b/brush-core/src/builtins/popd.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::io::Write;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Pop a path from the current directory stack.
 #[derive(Parser)]
@@ -15,11 +15,11 @@ pub(crate) struct PopdCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for PopdCommand {
+impl builtins::Command for PopdCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         if let Some(popped) = context.shell.directory_stack.pop() {
             if !self.no_directory_change {
                 context.shell.set_working_dir(&popped)?;
@@ -30,9 +30,9 @@ impl builtin::Command for PopdCommand {
             dirs_cmd.execute(context).await?;
         } else {
             writeln!(context.stderr(), "popd: directory stack empty")?;
-            return Ok(builtin::ExitCode::Custom(1));
+            return Ok(builtins::ExitCode::Custom(1));
         }
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
diff --git a/brush-core/src/builtins/printf.rs b/brush-core/src/builtins/printf.rs
index 423094d..a809ac3 100644
--- a/brush-core/src/builtins/printf.rs
+++ b/brush-core/src/builtins/printf.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::io::Write;
 
-use crate::{builtin, commands, expansion};
+use crate::{builtins, commands, expansion};
 
 /// Format a string.
 #[derive(Parser)]
@@ -20,11 +20,11 @@ pub(crate) struct PrintfCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for PrintfCommand {
+impl builtins::Command for PrintfCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         // TODO: Don't call external printf command.
         let mut cmd = std::process::Command::new("printf");
         cmd.env_clear();
@@ -42,7 +42,7 @@ impl builtin::Command for PrintfCommand {
         if !output.status.success() {
             #[allow(clippy::cast_possible_truncation)]
             #[allow(clippy::cast_sign_loss)]
-            return Ok(builtin::ExitCode::Custom(
+            return Ok(builtins::ExitCode::Custom(
                 output.status.code().unwrap() as u8
             ));
         }
@@ -54,6 +54,6 @@ impl builtin::Command for PrintfCommand {
             context.stdout().flush()?;
         }
 
-        return Ok(builtin::ExitCode::Success);
+        return Ok(builtins::ExitCode::Success);
     }
 }
diff --git a/brush-core/src/builtins/pushd.rs b/brush-core/src/builtins/pushd.rs
index 8cfc087..08240b2 100644
--- a/brush-core/src/builtins/pushd.rs
+++ b/brush-core/src/builtins/pushd.rs
@@ -1,6 +1,6 @@
 use clap::Parser;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Push a path onto the current directory stack.
 #[derive(Parser)]
@@ -17,11 +17,11 @@ pub(crate) struct PushdCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for PushdCommand {
+impl builtins::Command for PushdCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         if self.no_directory_change {
             context
                 .shell
@@ -40,6 +40,6 @@ impl builtin::Command for PushdCommand {
         let dirs_cmd = crate::builtins::dirs::DirsCommand::default();
         dirs_cmd.execute(context).await?;
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
diff --git a/brush-core/src/builtins/pwd.rs b/brush-core/src/builtins/pwd.rs
index a1321ee..72874da 100644
--- a/brush-core/src/builtins/pwd.rs
+++ b/brush-core/src/builtins/pwd.rs
@@ -1,4 +1,4 @@
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 use clap::Parser;
 use std::io::Write;
 
@@ -15,11 +15,11 @@ pub(crate) struct PwdCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for PwdCommand {
+impl builtins::Command for PwdCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         //
         // TODO: implement flags
         // TODO: look for 'physical' option in execution context
@@ -27,13 +27,13 @@ impl builtin::Command for PwdCommand {
 
         if self.physical || self.allow_symlinks {
             writeln!(context.stderr(), "UNIMPLEMENTED: pwd with -P or -L")?;
-            return Ok(builtin::ExitCode::Unimplemented);
+            return Ok(builtins::ExitCode::Unimplemented);
         }
 
         let cwd = context.shell.working_dir.to_string_lossy().into_owned();
 
         writeln!(context.stdout(), "{cwd}")?;
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
diff --git a/brush-core/src/builtins/read.rs b/brush-core/src/builtins/read.rs
index 3bd78f2..9cfe02b 100644
--- a/brush-core/src/builtins/read.rs
+++ b/brush-core/src/builtins/read.rs
@@ -3,7 +3,7 @@ use itertools::Itertools;
 use std::collections::VecDeque;
 use std::io::{Read, Write};
 
-use crate::{builtin, commands, env, error, openfiles, sys, variables};
+use crate::{builtins, commands, env, error, openfiles, sys, variables};
 
 /// Parse standard input.
 #[derive(Parser)]
@@ -60,11 +60,11 @@ pub(crate) struct ReadCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for ReadCommand {
+impl builtins::Command for ReadCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         if self.use_readline {
             return error::unimp("read -e");
         }
@@ -148,9 +148,9 @@ impl builtin::Command for ReadCommand {
                 )?;
             }
 
-            Ok(crate::builtin::ExitCode::Success)
+            Ok(crate::builtins::ExitCode::Success)
         } else {
-            Ok(crate::builtin::ExitCode::Custom(1))
+            Ok(crate::builtins::ExitCode::Custom(1))
         }
     }
 }
diff --git a/brush-core/src/builtins/return_.rs b/brush-core/src/builtins/return_.rs
index 570e93e..ebcf9ef 100644
--- a/brush-core/src/builtins/return_.rs
+++ b/brush-core/src/builtins/return_.rs
@@ -1,6 +1,6 @@
 use clap::Parser;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Return from the current function.
 #[derive(Parser)]
@@ -10,11 +10,11 @@ pub(crate) struct ReturnCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for ReturnCommand {
+impl builtins::Command for ReturnCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         let code_8bit: u8;
         #[allow(clippy::cast_sign_loss)]
         if let Some(code_32bit) = &self.code {
@@ -24,6 +24,6 @@ impl builtin::Command for ReturnCommand {
         }
 
         // TODO: only allow return invocation from a function or script
-        Ok(builtin::ExitCode::ReturnFromFunctionOrScript(code_8bit))
+        Ok(builtins::ExitCode::ReturnFromFunctionOrScript(code_8bit))
     }
 }
diff --git a/brush-core/src/builtins/set.rs b/brush-core/src/builtins/set.rs
index 6e136ca..8504e63 100644
--- a/brush-core/src/builtins/set.rs
+++ b/brush-core/src/builtins/set.rs
@@ -2,67 +2,67 @@ use std::collections::HashMap;
 
 use clap::Parser;
 
-use crate::{builtin, commands, error, namedoptions};
+use crate::{builtins, commands, error, namedoptions};
 
-builtin::minus_or_plus_flag_arg!(
+builtins::minus_or_plus_flag_arg!(
     ExportVariablesOnModification,
     'a',
     "Export variables on modification"
 );
-builtin::minus_or_plus_flag_arg!(
+builtins::minus_or_plus_flag_arg!(
     NotfyJobTerminationImmediately,
     'b',
     "Notify job termination immediately"
 );
-builtin::minus_or_plus_flag_arg!(
+builtins::minus_or_plus_flag_arg!(
     ExitOnNonzeroCommandExit,
     'e',
     "Exit on nonzero command exit"
 );
-builtin::minus_or_plus_flag_arg!(DisableFilenameGlobbing, 'f', "Disable filename globbing");
-builtin::minus_or_plus_flag_arg!(RememberCommandLocations, 'h', "Remember command locations");
-builtin::minus_or_plus_flag_arg!(
+builtins::minus_or_plus_flag_arg!(DisableFilenameGlobbing, 'f', "Disable filename globbing");
+builtins::minus_or_plus_flag_arg!(RememberCommandLocations, 'h', "Remember command locations");
+builtins::minus_or_plus_flag_arg!(
     PlaceAllAssignmentArgsInCommandEnv,
     'k',
     "Place all assignment args in command environment"
 );
-builtin::minus_or_plus_flag_arg!(EnableJobControl, 'm', "Enable job control");
-builtin::minus_or_plus_flag_arg!(DoNotExecuteCommands, 'n', "Do not execute commands");
-builtin::minus_or_plus_flag_arg!(RealEffectiveUidMismatch, 'p', "Real effective UID mismatch");
-builtin::minus_or_plus_flag_arg!(ExitAfterOneCommand, 't', "Exit after one command");
-builtin::minus_or_plus_flag_arg!(
+builtins::minus_or_plus_flag_arg!(EnableJobControl, 'm', "Enable job control");
+builtins::minus_or_plus_flag_arg!(DoNotExecuteCommands, 'n', "Do not execute commands");
+builtins::minus_or_plus_flag_arg!(RealEffectiveUidMismatch, 'p', "Real effective UID mismatch");
+builtins::minus_or_plus_flag_arg!(ExitAfterOneCommand, 't', "Exit after one command");
+builtins::minus_or_plus_flag_arg!(
     TreatUnsetVariablesAsError,
     'u',
     "Treat unset variables as error"
 );
-builtin::minus_or_plus_flag_arg!(PrintShellInputLines, 'v', "Print shell input lines");
-builtin::minus_or_plus_flag_arg!(
+builtins::minus_or_plus_flag_arg!(PrintShellInputLines, 'v', "Print shell input lines");
+builtins::minus_or_plus_flag_arg!(
     PrintCommandsAndArguments,
     'x',
     "Print commands and arguments"
 );
-builtin::minus_or_plus_flag_arg!(PerformBraceExpansion, 'B', "Perform brace expansion");
-builtin::minus_or_plus_flag_arg!(
+builtins::minus_or_plus_flag_arg!(PerformBraceExpansion, 'B', "Perform brace expansion");
+builtins::minus_or_plus_flag_arg!(
     DisallowOverwritingRegularFilesViaOutputRedirection,
     'C',
     "Disallow overwriting regular files via output redirection"
 );
-builtin::minus_or_plus_flag_arg!(
+builtins::minus_or_plus_flag_arg!(
     ShellFunctionsInheritErrTrap,
     'E',
     "Shell functions inherit ERR trap"
 );
-builtin::minus_or_plus_flag_arg!(
+builtins::minus_or_plus_flag_arg!(
     EnableBangStyleHistorySubstitution,
     'H',
     "Enable bang style history substitution"
 );
-builtin::minus_or_plus_flag_arg!(
+builtins::minus_or_plus_flag_arg!(
     DoNotResolveSymlinksWhenChangingDir,
     'P',
     "Do not resolve symlinks when changing dir"
 );
-builtin::minus_or_plus_flag_arg!(
+builtins::minus_or_plus_flag_arg!(
     ShellFunctionsInheritDebugAndReturnTraps,
     'T',
     "Shell functions inherit DEBUG and RETURN traps"
@@ -131,7 +131,7 @@ pub(crate) struct SetCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for SetCommand {
+impl builtins::Command for SetCommand {
     fn takes_plus_options() -> bool {
         true
     }
@@ -140,8 +140,8 @@ impl builtin::Command for SetCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<builtin::ExitCode, error::Error> {
-        let mut result = builtin::ExitCode::Success;
+    ) -> Result<builtins::ExitCode, error::Error> {
+        let mut result = builtins::ExitCode::Success;
 
         if let Some(value) = self.print_commands_and_arguments.to_bool() {
             context.shell.options.print_commands_and_arguments = value;
@@ -253,7 +253,7 @@ impl builtin::Command for SetCommand {
             if let Some(option_def) = namedoptions::SET_O_OPTIONS.get(option_name.as_str()) {
                 (option_def.setter)(&mut context.shell.options, value);
             } else {
-                result = builtin::ExitCode::InvalidUsage;
+                result = builtins::ExitCode::InvalidUsage;
             }
         }
 
diff --git a/brush-core/src/builtins/shift.rs b/brush-core/src/builtins/shift.rs
index ccff26a..e81bf77 100644
--- a/brush-core/src/builtins/shift.rs
+++ b/brush-core/src/builtins/shift.rs
@@ -1,6 +1,6 @@
 use clap::Parser;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Shift positional arguments.
 #[derive(Parser)]
@@ -10,26 +10,26 @@ pub(crate) struct ShiftCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for ShiftCommand {
+impl builtins::Command for ShiftCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         let n = self.n.unwrap_or(1);
 
         if n < 0 {
-            return Ok(builtin::ExitCode::InvalidUsage);
+            return Ok(builtins::ExitCode::InvalidUsage);
         }
 
         #[allow(clippy::cast_sign_loss)]
         let n = n as usize;
 
         if n > context.shell.positional_parameters.len() {
-            return Ok(builtin::ExitCode::InvalidUsage);
+            return Ok(builtins::ExitCode::InvalidUsage);
         }
 
         context.shell.positional_parameters.drain(0..n);
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
diff --git a/brush-core/src/builtins/shopt.rs b/brush-core/src/builtins/shopt.rs
index d3f5117..a3d4477 100644
--- a/brush-core/src/builtins/shopt.rs
+++ b/brush-core/src/builtins/shopt.rs
@@ -2,7 +2,7 @@ use clap::Parser;
 use itertools::Itertools;
 use std::io::Write;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Manage shopt-style options.
 #[derive(Parser)]
@@ -32,22 +32,22 @@ pub(crate) struct ShoptCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for ShoptCommand {
+impl builtins::Command for ShoptCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         if self.set && self.unset {
             writeln!(
                 context.stderr(),
                 "cannot set and unset shell options simultaneously"
             )?;
-            return Ok(builtin::ExitCode::InvalidUsage);
+            return Ok(builtins::ExitCode::InvalidUsage);
         }
 
         if self.options.is_empty() {
             if self.quiet {
-                return Ok(builtin::ExitCode::Success);
+                return Ok(builtins::ExitCode::Success);
             }
 
             // Enumerate all options of the selected type.
@@ -84,9 +84,9 @@ impl builtin::Command for ShoptCommand {
                 }
             }
 
-            Ok(builtin::ExitCode::Success)
+            Ok(builtins::ExitCode::Success)
         } else {
-            let mut return_value = builtin::ExitCode::Success;
+            let mut return_value = builtins::ExitCode::Success;
 
             // Enumerate only the specified options.
             for option_name in &self.options {
@@ -104,7 +104,7 @@ impl builtin::Command for ShoptCommand {
                     } else {
                         let option_value = (option_definition.getter)(&context.shell.options);
                         if !option_value {
-                            return_value = builtin::ExitCode::Custom(1);
+                            return_value = builtins::ExitCode::Custom(1);
                         }
 
                         if !self.quiet {
@@ -135,7 +135,7 @@ impl builtin::Command for ShoptCommand {
                         context.command_name,
                         option_name
                     )?;
-                    return_value = builtin::ExitCode::Custom(1);
+                    return_value = builtins::ExitCode::Custom(1);
                 }
             }
 
diff --git a/brush-core/src/builtins/trap.rs b/brush-core/src/builtins/trap.rs
index 6468c7b..42b80c1 100644
--- a/brush-core/src/builtins/trap.rs
+++ b/brush-core/src/builtins/trap.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::io::Write;
 
-use crate::{builtin, commands, error, sys, traps};
+use crate::{builtins, commands, error, sys, traps};
 
 /// Manage signal traps.
 #[derive(Parser)]
@@ -18,14 +18,14 @@ pub(crate) struct TrapCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for TrapCommand {
+impl builtins::Command for TrapCommand {
     async fn execute(
         &self,
         mut context: commands::ExecutionContext<'_>,
-    ) -> Result<builtin::ExitCode, crate::error::Error> {
+    ) -> Result<builtins::ExitCode, crate::error::Error> {
         if self.list_signals {
             Self::display_signals(&context)?;
-            Ok(builtin::ExitCode::Success)
+            Ok(builtins::ExitCode::Success)
         } else if self.print_trap_commands || self.args.is_empty() {
             if !self.args.is_empty() {
                 for signal_type in &self.args {
@@ -35,12 +35,12 @@ impl builtin::Command for TrapCommand {
             } else {
                 Self::display_all_handlers(&context)?;
             }
-            Ok(builtin::ExitCode::Success)
+            Ok(builtins::ExitCode::Success)
         } else if self.args.len() == 1 {
             let signal = self.args[0].as_str();
             let signal_type = parse_signal(signal)?;
             Self::remove_all_handlers(&mut context, signal_type);
-            Ok(builtin::ExitCode::Success)
+            Ok(builtins::ExitCode::Success)
         } else {
             let handler = &self.args[0];
 
@@ -50,7 +50,7 @@ impl builtin::Command for TrapCommand {
             }
 
             Self::register_handler(&mut context, signal_types, handler.as_str());
-            Ok(builtin::ExitCode::Success)
+            Ok(builtins::ExitCode::Success)
         }
     }
 }
diff --git a/brush-core/src/builtins/true_.rs b/brush-core/src/builtins/true_.rs
index 0fe73b8..99ca5a0 100644
--- a/brush-core/src/builtins/true_.rs
+++ b/brush-core/src/builtins/true_.rs
@@ -1,17 +1,17 @@
 use clap::Parser;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Return 0.
 #[derive(Parser)]
 pub(crate) struct TrueCommand {}
 
 #[async_trait::async_trait]
-impl builtin::Command for TrueCommand {
+impl builtins::Command for TrueCommand {
     async fn execute(
         &self,
         _context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
-        Ok(builtin::ExitCode::Success)
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
+        Ok(builtins::ExitCode::Success)
     }
 }
diff --git a/brush-core/src/builtins/type_.rs b/brush-core/src/builtins/type_.rs
index 95562cb..f20f74b 100644
--- a/brush-core/src/builtins/type_.rs
+++ b/brush-core/src/builtins/type_.rs
@@ -6,7 +6,7 @@ use clap::Parser;
 
 use crate::keywords;
 use crate::sys::fs::PathExt;
-use crate::{builtin, commands, Shell};
+use crate::{builtins, commands, Shell};
 
 /// Inspect the type of a named shell item.
 #[derive(Parser)]
@@ -45,12 +45,12 @@ enum ResolvedType {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for TypeCommand {
+impl builtins::Command for TypeCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
-        let mut result = builtin::ExitCode::Success;
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
+        let mut result = builtins::ExitCode::Success;
 
         for name in &self.names {
             let resolved_types = self.resolve_types(context.shell, name);
@@ -60,7 +60,7 @@ impl builtin::Command for TypeCommand {
                     writeln!(context.stderr(), "type: {name} not found")?;
                 }
 
-                result = builtin::ExitCode::Custom(1);
+                result = builtins::ExitCode::Custom(1);
                 continue;
             }
 
diff --git a/brush-core/src/builtins/umask.rs b/brush-core/src/builtins/umask.rs
index 3165378..8a83f3b 100644
--- a/brush-core/src/builtins/umask.rs
+++ b/brush-core/src/builtins/umask.rs
@@ -1,4 +1,4 @@
-use crate::{builtin, commands, error};
+use crate::{builtins, commands, error};
 use cfg_if::cfg_if;
 use clap::Parser;
 #[cfg(not(target_os = "linux"))]
@@ -21,11 +21,11 @@ pub(crate) struct UmaskCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for UmaskCommand {
+impl builtins::Command for UmaskCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         if let Some(mode) = &self.mode {
             if mode.starts_with(|c: char| c.is_digit(8)) {
                 let parsed = u32::from_str_radix(mode.as_str(), 8)?;
@@ -52,7 +52,7 @@ impl builtin::Command for UmaskCommand {
             }
         }
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
 
diff --git a/brush-core/src/builtins/unalias.rs b/brush-core/src/builtins/unalias.rs
index 6f89721..6c5c5c3 100644
--- a/brush-core/src/builtins/unalias.rs
+++ b/brush-core/src/builtins/unalias.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::io::Write;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Unset a shell alias.
 #[derive(Parser)]
@@ -15,12 +15,12 @@ pub(crate) struct UnaliasCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for UnaliasCommand {
+impl builtins::Command for UnaliasCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
-        let mut exit_code = builtin::ExitCode::Success;
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
+        let mut exit_code = builtins::ExitCode::Success;
 
         if self.remove_all {
             context.shell.aliases.clear();
@@ -33,7 +33,7 @@ impl builtin::Command for UnaliasCommand {
                         context.command_name,
                         alias
                     )?;
-                    exit_code = builtin::ExitCode::Custom(1);
+                    exit_code = builtins::ExitCode::Custom(1);
                 }
             }
         }
diff --git a/brush-core/src/builtins/unimp.rs b/brush-core/src/builtins/unimp.rs
index 46e7431..67afa64 100644
--- a/brush-core/src/builtins/unimp.rs
+++ b/brush-core/src/builtins/unimp.rs
@@ -1,4 +1,4 @@
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 use std::io::Write;
 
 use clap::Parser;
@@ -14,11 +14,11 @@ pub(crate) struct UnimplementedCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for UnimplementedCommand {
+impl builtins::Command for UnimplementedCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         writeln!(
             context.stderr(),
             "UNIMPLEMENTED: {}: built-in unimplemented: {} {}",
@@ -30,11 +30,11 @@ impl builtin::Command for UnimplementedCommand {
             context.command_name,
             self.args.join(" ")
         )?;
-        Ok(builtin::ExitCode::Unimplemented)
+        Ok(builtins::ExitCode::Unimplemented)
     }
 }
 
-impl builtin::DeclarationCommand for UnimplementedCommand {
+impl builtins::DeclarationCommand for UnimplementedCommand {
     fn set_declarations(&mut self, declarations: Vec<commands::CommandArg>) {
         self.declarations = declarations;
     }
diff --git a/brush-core/src/builtins/unset.rs b/brush-core/src/builtins/unset.rs
index 51d7bf9..b212091 100644
--- a/brush-core/src/builtins/unset.rs
+++ b/brush-core/src/builtins/unset.rs
@@ -1,6 +1,6 @@
 use clap::Parser;
 
-use crate::{builtin, commands};
+use crate::{builtins, commands};
 
 /// Unset a variable.
 #[derive(Parser)]
@@ -35,11 +35,11 @@ impl UnsetNameInterpretation {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for UnsetCommand {
+impl builtins::Command for UnsetCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         //
         // TODO: implement nameref
         //
@@ -86,6 +86,6 @@ impl builtin::Command for UnsetCommand {
             }
         }
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
diff --git a/brush-core/src/builtins/wait.rs b/brush-core/src/builtins/wait.rs
index 25b1fe0..049164b 100644
--- a/brush-core/src/builtins/wait.rs
+++ b/brush-core/src/builtins/wait.rs
@@ -1,6 +1,6 @@
 use clap::Parser;
 
-use crate::{builtin, commands, error};
+use crate::{builtins, commands, error};
 
 /// Wait for jobs to terminate.
 #[derive(Parser)]
@@ -23,11 +23,11 @@ pub(crate) struct WaitCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for WaitCommand {
+impl builtins::Command for WaitCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<builtin::ExitCode, crate::error::Error> {
+    ) -> Result<builtins::ExitCode, crate::error::Error> {
         if self.wait_for_terminate {
             return error::unimp("wait -f");
         }
@@ -43,6 +43,6 @@ impl builtin::Command for WaitCommand {
 
         context.shell.jobs.wait_all().await?;
 
-        Ok(builtin::ExitCode::Success)
+        Ok(builtins::ExitCode::Success)
     }
 }
diff --git a/brush-core/src/commands.rs b/brush-core/src/commands.rs
index da49f11..09c5fff 100644
--- a/brush-core/src/commands.rs
+++ b/brush-core/src/commands.rs
@@ -8,7 +8,7 @@ use command_fds::{CommandFdExt, FdMapping};
 use itertools::Itertools;
 
 use crate::{
-    builtin, error,
+    builtins, error,
     interp::{self, Execute},
     openfiles::{self, OpenFile, OpenFiles},
     sys, Shell,
@@ -281,22 +281,22 @@ pub(crate) fn execute_external_command(
 }
 
 async fn execute_builtin_command(
-    builtin: &builtin::Registration,
+    builtin: &builtins::Registration,
     context: ExecutionContext<'_>,
     args: Vec<CommandArg>,
 ) -> Result<SpawnResult, error::Error> {
     let exit_code = match (builtin.execute_func)(context, args).await {
         Ok(builtin_result) => match builtin_result.exit_code {
-            builtin::ExitCode::Success => 0,
-            builtin::ExitCode::InvalidUsage => 2,
-            builtin::ExitCode::Unimplemented => 99,
-            builtin::ExitCode::Custom(code) => code,
-            builtin::ExitCode::ExitShell(code) => return Ok(SpawnResult::ExitShell(code)),
-            builtin::ExitCode::ReturnFromFunctionOrScript(code) => {
+            builtins::ExitCode::Success => 0,
+            builtins::ExitCode::InvalidUsage => 2,
+            builtins::ExitCode::Unimplemented => 99,
+            builtins::ExitCode::Custom(code) => code,
+            builtins::ExitCode::ExitShell(code) => return Ok(SpawnResult::ExitShell(code)),
+            builtins::ExitCode::ReturnFromFunctionOrScript(code) => {
                 return Ok(SpawnResult::ReturnFromFunctionOrScript(code))
             }
-            builtin::ExitCode::BreakLoop(count) => return Ok(SpawnResult::BreakLoop(count)),
-            builtin::ExitCode::ContinueLoop(count) => return Ok(SpawnResult::ContinueLoop(count)),
+            builtins::ExitCode::BreakLoop(count) => return Ok(SpawnResult::BreakLoop(count)),
+            builtins::ExitCode::ContinueLoop(count) => return Ok(SpawnResult::ContinueLoop(count)),
         },
         Err(e) => {
             tracing::error!("error: {}", e);
diff --git a/brush-core/src/expansion.rs b/brush-core/src/expansion.rs
index 04e5324..8ab0e02 100644
--- a/brush-core/src/expansion.rs
+++ b/brush-core/src/expansion.rs
@@ -1556,6 +1556,7 @@ fn transform_expansion(
     })
 }
 
+#[allow(clippy::panic_in_result_fn)]
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/brush-core/src/lib.rs b/brush-core/src/lib.rs
index 45fc6e1..bccd744 100644
--- a/brush-core/src/lib.rs
+++ b/brush-core/src/lib.rs
@@ -6,8 +6,7 @@
 pub mod completion;
 
 mod arithmetic;
-mod builtin;
-mod builtins;
+pub mod builtins;
 mod commands;
 mod env;
 mod error;
@@ -30,6 +29,7 @@ mod tests;
 mod traps;
 mod variables;
 
+pub use commands::ExecutionContext;
 pub use error::Error;
 pub use interp::{ExecutionParameters, ExecutionResult};
 pub use shell::{CreateOptions, Shell};
diff --git a/brush-core/src/shell.rs b/brush-core/src/shell.rs
index 1a13bf1..0094c12 100644
--- a/brush-core/src/shell.rs
+++ b/brush-core/src/shell.rs
@@ -12,8 +12,8 @@ use crate::options::RuntimeOptions;
 use crate::sys::fs::PathExt;
 use crate::variables::{self, ShellValue, ShellVariable};
 use crate::{
-    builtin, builtins, commands, completion, env, error, expansion, functions, jobs, keywords,
-    openfiles, patterns, prompt, sys::users, traps,
+    builtins, commands, completion, env, error, expansion, functions, jobs, keywords, openfiles,
+    patterns, prompt, sys::users, traps,
 };
 
 /// Represents an instance of a shell.
@@ -70,7 +70,7 @@ pub struct Shell {
     pub completion_config: completion::Config,
 
     /// Shell built-in commands.
-    pub builtins: HashMap<String, builtin::Registration>,
+    pub builtins: HashMap<String, builtins::Registration>,
 }
 
 impl Clone for Shell {
diff --git a/brush-shell/Cargo.toml b/brush-shell/Cargo.toml
index b61d990..cf4a20e 100644
--- a/brush-shell/Cargo.toml
+++ b/brush-shell/Cargo.toml
@@ -25,6 +25,7 @@ harness = false
 workspace = true
 
 [dependencies]
+async-trait = "0.1.80"
 brush-interactive = { version = "^0.2.3", path = "../brush-interactive" }
 brush-parser = { version = "^0.2.3", path = "../brush-parser" }
 brush-core = { version = "^0.2.3", path = "../brush-core" }
@@ -32,14 +33,15 @@ brush-core = { version = "^0.2.3", path = "../brush-core" }
 clap = { version = "=4.4.18", features = ["derive", "wrap_help"] }
 const_format = "0.2.32"
 git-version = "0.3.9"
+lazy_static = "1.5.0"
 tracing = "0.1.40"
 tracing-subscriber = "0.3.18"
 
 [target.'cfg(not(any(windows, unix)))'.dependencies]
-tokio = { version = "1.37.0", features = ["rt"] }
+tokio = { version = "1.37.0", features = ["rt", "sync"] }
 
 [target.'cfg(any(windows, unix))'.dependencies]
-tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread"] }
+tokio = { version = "1.37.0", features = ["rt", "rt-multi-thread", "sync"] }
 
 [dev-dependencies]
 anyhow = "1.0.86"
diff --git a/brush-shell/src/brushctl.rs b/brush-shell/src/brushctl.rs
new file mode 100644
index 0000000..124153a
--- /dev/null
+++ b/brush-shell/src/brushctl.rs
@@ -0,0 +1,90 @@
+use brush_interactive::InteractiveShell;
+use clap::{Parser, Subcommand};
+use std::io::Write;
+
+use crate::events;
+
+pub(crate) fn register(shell: &mut InteractiveShell) {
+    shell.shell_mut().builtins.insert(
+        "brushctl".into(),
+        brush_core::builtins::builtin::<BrushCtlCommand>(),
+    );
+}
+
+/// Configure the running brush shell.
+#[derive(Parser)]
+struct BrushCtlCommand {
+    #[clap(subcommand)]
+    command_group: CommandGroup,
+}
+
+#[derive(Subcommand)]
+enum CommandGroup {
+    #[clap(subcommand)]
+    Events(EventsCommand),
+}
+
+/// Commands for configuring tracing events.
+#[derive(Subcommand)]
+enum EventsCommand {
+    /// Display status of enabled events.
+    Status,
+
+    /// Enable event.
+    Enable {
+        /// Event to enable.
+        event: events::TraceEvent,
+    },
+
+    /// Disable event.
+    Disable {
+        /// Event to disable.
+        event: events::TraceEvent,
+    },
+}
+
+#[async_trait::async_trait]
+impl brush_core::builtins::Command for BrushCtlCommand {
+    async fn execute(
+        &self,
+        context: brush_core::ExecutionContext<'_>,
+    ) -> Result<brush_core::builtins::ExitCode, brush_core::Error> {
+        match self.command_group {
+            CommandGroup::Events(ref events) => events.execute(&context),
+        }
+    }
+}
+
+impl EventsCommand {
+    fn execute(
+        &self,
+        context: &brush_core::ExecutionContext<'_>,
+    ) -> Result<brush_core::builtins::ExitCode, brush_core::Error> {
+        let event_config = crate::get_event_config();
+
+        let mut event_config = event_config.try_lock().map_err(|_| {
+            brush_core::Error::Unimplemented("Failed to acquire lock on event configuration")
+        })?;
+
+        if let Some(event_config) = event_config.as_mut() {
+            match self {
+                EventsCommand::Status => {
+                    let enabled_events = event_config.get_enabled_events();
+                    for event in enabled_events {
+                        writeln!(context.stdout(), "{event}").unwrap(); // Add .unwrap() to handle
+                                                                        // any potential write
+                                                                        // errors
+                    }
+                }
+                EventsCommand::Enable { event } => event_config.enable(event)?,
+                EventsCommand::Disable { event } => event_config.disable(event)?,
+            }
+
+            Ok(brush_core::builtins::ExitCode::Success)
+        } else {
+            Err(brush_core::Error::Unimplemented(
+                "event configuration not initialized",
+            ))
+        }
+    }
+}
diff --git a/brush-shell/src/events.rs b/brush-shell/src/events.rs
new file mode 100644
index 0000000..fbe46ad
--- /dev/null
+++ b/brush-shell/src/events.rs
@@ -0,0 +1,152 @@
+use std::{collections::HashSet, fmt::Display};
+
+use brush_core::Error;
+use tracing_subscriber::{
+    filter::Targets, layer::SubscriberExt, reload::Handle, util::SubscriberInitExt, Layer, Registry,
+};
+
+/// Type of event to trace.
+#[derive(Clone, Debug, Eq, Hash, PartialEq, clap::ValueEnum)]
+pub(crate) enum TraceEvent {
+    /// Traces parsing and evaluation of arithmetic expressions.
+    #[clap(name = "arithmetic")]
+    Arithmetic,
+    /// Traces command execution.
+    #[clap(name = "commands")]
+    Commands,
+    /// Traces command completion generation.
+    #[clap(name = "complete")]
+    Complete,
+    /// Traces word expansion.
+    #[clap(name = "expand")]
+    Expand,
+    /// Traces the process of parsing tokens into an abstract syntax tree.
+    #[clap(name = "parse")]
+    Parse,
+    /// Traces pattern matching.
+    #[clap(name = "pattern")]
+    Pattern,
+    /// Traces the process of tokenizing input text.
+    #[clap(name = "tokenize")]
+    Tokenize,
+}
+
+impl Display for TraceEvent {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            TraceEvent::Arithmetic => write!(f, "arithmetic"),
+            TraceEvent::Commands => write!(f, "commands"),
+            TraceEvent::Complete => write!(f, "complete"),
+            TraceEvent::Expand => write!(f, "expand"),
+            TraceEvent::Parse => write!(f, "parse"),
+            TraceEvent::Pattern => write!(f, "pattern"),
+            TraceEvent::Tokenize => write!(f, "tokenize"),
+        }
+    }
+}
+
+#[derive(Default)]
+pub(crate) struct TraceEventConfig {
+    enabled_trace_events: HashSet<TraceEvent>,
+    handle: Option<Handle<Targets, Registry>>,
+}
+
+impl TraceEventConfig {
+    pub fn init(enabled_log_events: &[TraceEvent]) -> TraceEventConfig {
+        let enabled_trace_events: HashSet<TraceEvent> =
+            enabled_log_events.iter().cloned().collect();
+
+        let mut config = TraceEventConfig {
+            enabled_trace_events,
+            ..Default::default()
+        };
+
+        let filter = config.compose_filter();
+
+        // Make the filter reloadable so that we can change the log level at runtime.
+        let (reload_filter, handle) = tracing_subscriber::reload::Layer::new(filter);
+
+        let layer = tracing_subscriber::fmt::layer()
+            .with_writer(std::io::stderr)
+            .without_time()
+            .with_filter(reload_filter);
+
+        if tracing_subscriber::registry()
+            .with(layer)
+            .try_init()
+            .is_ok()
+        {
+            config.handle = Some(handle);
+        } else {
+            // Something went wrong; proceed on anyway but complain audibly.
+            eprintln!("warning: failed to initialize tracing.");
+        }
+
+        config
+    }
+
+    fn compose_filter(&self) -> tracing_subscriber::filter::Targets {
+        let mut filter = tracing_subscriber::filter::Targets::new()
+            .with_default(tracing_subscriber::filter::LevelFilter::INFO);
+
+        for event in &self.enabled_trace_events {
+            let targets = match event {
+                TraceEvent::Arithmetic => vec!["parser::arithmetic"],
+                TraceEvent::Commands => vec!["commands"],
+                TraceEvent::Complete => vec![
+                    "completion",
+                    "shell::completion",
+                    "shell::builtins::complete",
+                ],
+                TraceEvent::Expand => vec!["parser::word", "shell::expansion"],
+                TraceEvent::Parse => vec!["parse"],
+                TraceEvent::Pattern => vec!["shell::pattern"],
+                TraceEvent::Tokenize => vec!["tokenize"],
+            };
+
+            filter = filter.with_targets(
+                targets
+                    .into_iter()
+                    .map(|target| (target, tracing::Level::DEBUG)),
+            );
+        }
+
+        filter
+    }
+
+    pub fn get_enabled_events(&self) -> &HashSet<TraceEvent> {
+        &self.enabled_trace_events
+    }
+
+    pub fn enable(&mut self, event: &TraceEvent) -> Result<(), Error> {
+        // Don't bother to reload config if nothing has changed.
+        if !self.enabled_trace_events.insert(event.to_owned()) {
+            return Ok(());
+        }
+
+        self.reload_filter()
+    }
+
+    pub fn disable(&mut self, event: &TraceEvent) -> Result<(), Error> {
+        // Don't bother to reload config if nothing has changed.
+        if !self.enabled_trace_events.remove(event) {
+            return Ok(());
+        }
+
+        self.reload_filter()
+    }
+
+    fn reload_filter(&mut self) -> Result<(), Error> {
+        if let Some(handle) = &self.handle {
+            if handle.reload(self.compose_filter()).is_ok() {
+                Ok(())
+            } else {
+                Err(brush_core::Error::Unimplemented(
+                    "failed to enable tracing events",
+                ))
+            }
+        } else {
+            Err(brush_core::Error::Unimplemented("tracing not initialized"))
+        }
+    }
+}
diff --git a/brush-shell/src/main.rs b/brush-shell/src/main.rs
index 96f2f57..1692dda 100644
--- a/brush-shell/src/main.rs
+++ b/brush-shell/src/main.rs
@@ -2,12 +2,12 @@
 
 #![deny(missing_docs)]
 
+mod brushctl;
+mod events;
 mod productinfo;
 
-use std::{collections::HashSet, io::IsTerminal, path::Path};
-
 use clap::{builder::styling, Parser};
-use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, Layer};
+use std::{io::IsTerminal, path::Path, sync::Arc};
 
 /// Parsed command-line arguments for the brush shell.
 #[derive(Parser)]
@@ -89,7 +89,7 @@ struct CommandLineArgs {
 
     /// Enable debug logging for classes of tracing events.
     #[clap(long = "log-enable")]
-    enabled_log_events: Vec<TraceEvent>,
+    enabled_log_events: Vec<events::TraceEvent>,
 
     /// Path to script to execute.
     script_path: Option<String>,
@@ -98,32 +98,6 @@ struct CommandLineArgs {
     script_args: Vec<String>,
 }
 
-/// Type of event to trace.
-#[derive(Clone, Debug, Eq, Hash, PartialEq, clap::ValueEnum)]
-enum TraceEvent {
-    /// Traces parsing and evaluation of arithmetic expressions.
-    #[clap(name = "arithmetic")]
-    Arithmetic,
-    /// Traces command execution.
-    #[clap(name = "commands")]
-    Commands,
-    /// Traces command completion generation.
-    #[clap(name = "complete")]
-    Complete,
-    /// Traces word expansion.
-    #[clap(name = "expand")]
-    Expand,
-    /// Traces the process of parsing tokens into an abstract syntax tree.
-    #[clap(name = "parse")]
-    Parse,
-    /// Traces pattern matching.
-    #[clap(name = "pattern")]
-    Pattern,
-    /// Traces the process of tokenizing input text.
-    #[clap(name = "tokenize")]
-    Tokenize,
-}
-
 impl CommandLineArgs {
     pub fn is_interactive(&self) -> bool {
         if self.interactive {
@@ -142,6 +116,11 @@ impl CommandLineArgs {
     }
 }
 
+lazy_static::lazy_static! {
+    static ref TRACE_EVENT_CONFIG: Arc<tokio::sync::Mutex<Option<events::TraceEventConfig>>> =
+        Arc::new(tokio::sync::Mutex::new(None));
+}
+
 /// Main entry point for the `brush` shell.
 fn main() {
     //
@@ -158,50 +137,6 @@ fn main() {
 
     let parsed_args = CommandLineArgs::parse_from(&args);
 
-    //
-    // Initializing tracing.
-    //
-    let mut filter = tracing_subscriber::filter::Targets::new()
-        .with_default(tracing_subscriber::filter::LevelFilter::INFO);
-
-    let enabled_trace_events: HashSet<TraceEvent> =
-        parsed_args.enabled_log_events.iter().cloned().collect();
-    for event in enabled_trace_events {
-        let targets = match event {
-            TraceEvent::Arithmetic => vec!["parser::arithmetic"],
-            TraceEvent::Commands => vec!["commands"],
-            TraceEvent::Complete => vec![
-                "completion",
-                "shell::completion",
-                "shell::builtins::complete",
-            ],
-            TraceEvent::Expand => vec!["parser::word", "shell::expansion"],
-            TraceEvent::Parse => vec!["parse"],
-            TraceEvent::Pattern => vec!["shell::pattern"],
-            TraceEvent::Tokenize => vec!["tokenize"],
-        };
-
-        filter = filter.with_targets(
-            targets
-                .into_iter()
-                .map(|target| (target, tracing::Level::DEBUG)),
-        );
-    }
-
-    let stderr_log_layer = tracing_subscriber::fmt::layer()
-        .with_writer(std::io::stderr)
-        .without_time()
-        .with_filter(filter);
-
-    if tracing_subscriber::registry()
-        .with(stderr_log_layer)
-        .try_init()
-        .is_err()
-    {
-        // Something went wrong; proceed on anyway but complain audibly.
-        eprintln!("warning: failed to initialize tracing.");
-    }
-
     //
     // Run.
     //
@@ -239,6 +174,48 @@ async fn run(
     cli_args: Vec<String>,
     args: CommandLineArgs,
 ) -> Result<u8, brush_interactive::ShellError> {
+    // Initializing tracing.
+    let mut event_config = TRACE_EVENT_CONFIG.try_lock().unwrap();
+    *event_config = Some(events::TraceEventConfig::init(&args.enabled_log_events));
+    drop(event_config);
+
+    // Instantiate an appropriately configured shell.
+    let mut shell = instantiate_shell(&args, cli_args).await?;
+
+    // Handle commands.
+    if let Some(command) = args.command {
+        // Pass through args.
+        if let Some(script_path) = args.script_path {
+            shell.shell_mut().shell_name = Some(script_path);
+        }
+        shell.shell_mut().positional_parameters = args.script_args;
+
+        // Execute the command string.
+        let params = shell.shell().default_exec_params();
+        shell.shell_mut().run_string(command, &params).await?;
+    } else if args.read_commands_from_stdin {
+        // We were asked to read commands from stdin; do so, even if there was a script
+        // passed on the command line.
+        shell.run_interactively().await?;
+    } else if let Some(script_path) = args.script_path {
+        // The path to a script was provided on the command line; run the script.
+        shell
+            .shell_mut()
+            .run_script(Path::new(&script_path), args.script_args.as_slice())
+            .await?;
+    } else {
+        // In all other cases, run interactively, taking input from stdin.
+        shell.run_interactively().await?;
+    }
+
+    // Make sure to return the last result observed in the shell.
+    Ok(shell.shell().last_result())
+}
+
+async fn instantiate_shell(
+    args: &CommandLineArgs,
+    cli_args: Vec<String>,
+) -> Result<brush_interactive::InteractiveShell, brush_interactive::ShellError> {
     let argv0 = if args.sh_mode {
         // Simulate having been run as "sh".
         Some(String::from("sh"))
@@ -250,12 +227,13 @@ async fn run(
 
     let read_commands_from_stdin = (args.read_commands_from_stdin && args.command.is_none())
         || (args.script_path.is_none() && args.command.is_none());
-
     let interactive = args.is_interactive();
+
+    // Compose the options we'll use to create the shell.
     let options = brush_interactive::Options {
         shell: brush_core::CreateOptions {
-            disabled_shopt_options: args.disabled_shopt_options,
-            enabled_shopt_options: args.enabled_shopt_options,
+            disabled_shopt_options: args.disabled_shopt_options.clone(),
+            enabled_shopt_options: args.enabled_shopt_options.clone(),
             do_not_execute_commands: args.do_not_execute_commands,
             login: args.login || argv0.as_ref().map_or(false, |a0| a0.starts_with('-')),
             interactive,
@@ -273,30 +251,17 @@ async fn run(
         disable_bracketed_paste: args.disable_bracketed_paste,
     };
 
+    // Create the shell.
     let mut shell = brush_interactive::InteractiveShell::new(&options).await?;
 
-    if let Some(command) = args.command {
-        // Pass through args.
-        if let Some(script_path) = args.script_path {
-            shell.shell_mut().shell_name = Some(script_path);
-        }
-        shell.shell_mut().positional_parameters = args.script_args;
+    // Register our own built-in(s) with the shell.
+    brushctl::register(&mut shell);
 
-        // Execute the command string.
-        let params = shell.shell().default_exec_params();
-        shell.shell_mut().run_string(command, &params).await?;
-    } else if args.read_commands_from_stdin {
-        shell.run_interactively().await?;
-    } else if let Some(script_path) = args.script_path {
-        shell
-            .shell_mut()
-            .run_script(Path::new(&script_path), args.script_args.as_slice())
-            .await?;
-    } else {
-        shell.run_interactively().await?;
-    }
+    Ok(shell)
+}
 
-    Ok(shell.shell().last_result())
+pub(crate) fn get_event_config() -> Arc<tokio::sync::Mutex<Option<events::TraceEventConfig>>> {
+    TRACE_EVENT_CONFIG.clone()
 }
 
 /// Returns clap styling to be used for command-line help.


Test patch:
diff --git a/brush-core/src/builtins/test.rs b/brush-core/src/builtins/test.rs
index 33da0d0..bb151fb 100644
--- a/brush-core/src/builtins/test.rs
+++ b/brush-core/src/builtins/test.rs
@@ -1,7 +1,7 @@
 use clap::Parser;
 use std::io::Write;
 
-use crate::{builtin, commands, error, tests, Shell};
+use crate::{builtins, commands, error, tests, Shell};
 
 /// Evaluate test expression.
 #[derive(Parser)]
@@ -12,11 +12,11 @@ pub(crate) struct TestCommand {
 }
 
 #[async_trait::async_trait]
-impl builtin::Command for TestCommand {
+impl builtins::Command for TestCommand {
     async fn execute(
         &self,
         context: commands::ExecutionContext<'_>,
-    ) -> Result<crate::builtin::ExitCode, crate::error::Error> {
+    ) -> Result<crate::builtins::ExitCode, crate::error::Error> {
         let mut args = self.args.as_slice();
 
         if context.command_name == "[" {
@@ -24,7 +24,7 @@ impl builtin::Command for TestCommand {
                 Some(s) if s == "]" => (),
                 None | Some(_) => {
                     writeln!(context.stderr(), "[: missing ']'")?;
-                    return Ok(builtin::ExitCode::InvalidUsage);
+                    return Ok(builtins::ExitCode::InvalidUsage);
                 }
             }
 
@@ -32,9 +32,9 @@ impl builtin::Command for TestCommand {
         }
 
         if execute_test(context.shell, args)? {
-            Ok(builtin::ExitCode::Success)
+            Ok(builtins::ExitCode::Success)
         } else {
-            Ok(builtin::ExitCode::Custom(1))
+            Ok(builtins::ExitCode::Custom(1))
         }
     }
 }
diff --git a/brush-shell/tests/cases/builtins/enable.yaml b/brush-shell/tests/cases/builtins/enable.yaml
index 909d1aa..b4a8314 100644
--- a/brush-shell/tests/cases/builtins/enable.yaml
+++ b/brush-shell/tests/cases/builtins/enable.yaml
@@ -7,7 +7,9 @@ cases:
     stdin: enable -n
 
   - name: "List all builtins"
-    stdin: enable
+    stdin: |
+      # List builtins but ignore any brush specific ones.
+      enable | grep -v brush
 
   - name: "Disable builtins"
     ignore_stderr: true