refactor: Align frork-cli with rust-style and rust-binary conventions
Reorder assertions.rs to lead with the central AssertionType trait
(M-ITEM-TOC), split compound imports (M-SINGLE-USE), and move
Lua custom types to the end. In main.rs, place callers before
callees (M-CALLER-CALLEE) and group all Frork inherent impls
before the IntoLua trait impl (M-TYPE-ASSOC).

Rename errors.rs to error.rs (singular, per rust-binary skill).
Add clap_complete with a --completions flag for bash/zsh/fish.
Running frork with no subcommand now prints help instead of
erroring. Add setup-uv to CI for hegeltest's Hypothesis backend.

Assisted-by: Claude Opus 4.6 via pi
change zunnrknztszkntvnrvosltxukvnvnysu
commit 401d349eeb21113501ed8bd456e098679785be93
author Alpha Chen <alpha@kejadlen.dev>
date
parent ystzmyvm
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7d6fa3d..3b7e8c0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,6 +12,7 @@ jobs:
       - uses: actions/checkout@v4
       - run: rustup component add clippy rustfmt llvm-tools
       - run: cargo install grcov just
+      - uses: astral-sh/setup-uv@v6
       - run: cargo fmt --check
       - run: just clippy coverage
       # - run: just mutants
diff --git a/AGENTS.md b/AGENTS.md
index 8af6bf3..b4f8b01 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,7 +16,7 @@ frork-cli/
     main.rs             # Entrypoint, clap CLI, Lua/Fennel setup
     lib.rs              # Library root — re-exports modules
     assertions.rs       # Assertion types (symlink, directory, git, brew, lua)
-    errors.rs           # thiserror enum (FrorkError)
+    error.rs            # thiserror enum (FrorkError)
     utils.rs            # Shell helpers, path expansion, Lua bindings
   tests/
     cli.rs              # Integration tests via assert_cmd
diff --git a/Cargo.lock b/Cargo.lock
index 7e0b94c..219b98d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -203,6 +203,15 @@ dependencies = [
  "strsim",
 ]
 
+[[package]]
+name = "clap_complete"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19c9f1dde76b736e3681f28cec9d5a61299cbaae0fce80a68e43724ad56031eb"
+dependencies = [
+ "clap",
+]
+
 [[package]]
 name = "clap_derive"
 version = "4.5.49"
@@ -315,6 +324,7 @@ version = "0.1.0"
 dependencies = [
  "assert_cmd",
  "clap",
+ "clap_complete",
  "frork-lib",
  "fs-err",
  "hegeltest",
diff --git a/frork-cli/Cargo.toml b/frork-cli/Cargo.toml
index 0f47c93..ee02148 100644
--- a/frork-cli/Cargo.toml
+++ b/frork-cli/Cargo.toml
@@ -12,6 +12,7 @@ frork-lib = { path = "../frork-lib" }
 miette = { version = "*", features = ["fancy"] }
 fs-err = "*"
 clap = { version = "*", features = ["derive", "env"] }
+clap_complete = "*"
 mlua = { version = "*", features = ["lua54", "serde", "vendored"] }
 regex = "*"
 serde = { version = "*", features = ["derive"] }
diff --git a/frork-cli/src/assertions.rs b/frork-cli/src/assertions.rs
index 1c4382c..c4f983c 100644
--- a/frork-cli/src/assertions.rs
+++ b/frork-cli/src/assertions.rs
@@ -5,50 +5,21 @@ use miette::miette;
 use mlua::prelude::*;
 use serde::Deserialize;
 use std::path::Path;
-use tracing::{debug, error, info};
+use tracing::debug;
+use tracing::error;
+use tracing::info;
 
-use crate::errors::FrorkError;
-use crate::utils::{ExpandedPath, Utils};
+use crate::error::FrorkError;
+use crate::utils::ExpandedPath;
+use crate::utils::Utils;
 
-pub trait AssertionTypeFactory {
-    fn create(&self, lua: &Lua, args: LuaMultiValue) -> Result<Box<dyn AssertionType>>;
-}
-
-pub struct TypedFactory<T>(std::marker::PhantomData<T>);
-
-impl<T> TypedFactory<T> {
-    pub fn new() -> Self {
-        Self(std::marker::PhantomData)
-    }
-}
-
-impl<T> Default for TypedFactory<T> {
-    fn default() -> Self {
-        Self::new()
-    }
-}
-
-impl<T> AssertionTypeFactory for TypedFactory<T>
-where
-    T: AssertionType + FromLuaMulti + 'static,
-{
-    fn create(&self, lua: &Lua, args: LuaMultiValue) -> Result<Box<dyn AssertionType>> {
-        T::from_lua_multi(args, lua)
-            .map(|t| Box::new(t) as Box<dyn AssertionType>)
-            .map_err(|e| miette!("Failed to create assertion type: {e}"))
-    }
-}
-
-#[derive(Debug, Deserialize)]
-pub struct Conflict {
-    pub expected: String,
-    pub actual: String,
-}
-
-impl FromLua for Conflict {
-    fn from_lua(value: LuaValue, lua: &Lua) -> LuaResult<Self> {
-        lua.from_value(value)
-    }
+pub trait AssertionType: std::fmt::Display {
+    fn status(&self) -> Result<Status>;
+    fn install(&self) -> Result<()>;
+    fn upgrade(&self) -> Result<()>;
+    // Not yet called — will be used when `frork remove` is implemented.
+    #[allow(dead_code)]
+    fn remove(&self) -> Result<()>;
 }
 
 #[derive(Debug)]
@@ -60,7 +31,7 @@ pub enum Status {
 
 impl FromLuaMulti for Status {
     fn from_lua_multi(values: LuaMultiValue, lua: &Lua) -> LuaResult<Self> {
-        // Try two values: string and conflict for conflict-upgrade
+        // Try two values: string and conflict for conflict-upgrade.
         if let Ok((status_str, conflict)) =
             <(String, Conflict)>::from_lua_multi(values.clone(), lua)
         {
@@ -77,7 +48,7 @@ impl FromLuaMulti for Status {
             };
         }
 
-        // Try single string
+        // Try single string.
         if let Ok(status_str) = String::from_lua_multi(values, lua) {
             return match status_str.as_str() {
                 "ok" => Ok(Status::Ok),
@@ -98,38 +69,49 @@ impl FromLuaMulti for Status {
     }
 }
 
-pub trait AssertionType: std::fmt::Display {
-    fn status(&self) -> Result<Status>;
-    fn install(&self) -> Result<()>;
-    fn upgrade(&self) -> Result<()>;
-    // Not yet called — will be used when `frork remove` is implemented.
-    #[allow(dead_code)]
-    fn remove(&self) -> Result<()>;
+#[derive(Debug, Deserialize)]
+pub struct Conflict {
+    pub expected: String,
+    pub actual: String,
 }
 
-#[derive(Clone)]
-pub struct LuaAssertionType {
-    pub display_fn: Option<LuaFunction>,
-    pub status_fn: LuaFunction,
-    pub install_fn: LuaFunction,
+impl FromLua for Conflict {
+    fn from_lua(value: LuaValue, lua: &Lua) -> LuaResult<Self> {
+        lua.from_value(value)
+    }
 }
 
-impl FromLua for LuaAssertionType {
-    fn from_lua(value: LuaValue, lua: &Lua) -> LuaResult<Self> {
-        let table = LuaTable::from_lua(value, lua)?;
+pub trait AssertionTypeFactory {
+    fn create(&self, lua: &Lua, args: LuaMultiValue) -> Result<Box<dyn AssertionType>>;
+}
 
-        let display_fn: Option<LuaFunction> = table.get("display").ok();
-        let status_fn: LuaFunction = table.get("status")?;
-        let install_fn: LuaFunction = table.get("install")?;
+pub struct TypedFactory<T>(std::marker::PhantomData<T>);
 
-        Ok(Self {
-            display_fn,
-            status_fn,
-            install_fn,
-        })
+impl<T> TypedFactory<T> {
+    pub fn new() -> Self {
+        Self(std::marker::PhantomData)
     }
 }
 
+impl<T> Default for TypedFactory<T> {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl<T> AssertionTypeFactory for TypedFactory<T>
+where
+    T: AssertionType + FromLuaMulti + 'static,
+{
+    fn create(&self, lua: &Lua, args: LuaMultiValue) -> Result<Box<dyn AssertionType>> {
+        T::from_lua_multi(args, lua)
+            .map(|t| Box::new(t) as Box<dyn AssertionType>)
+            .map_err(|e| miette!("Failed to create assertion type: {e}"))
+    }
+}
+
+// --- Built-in assertion types ---
+
 pub struct Symlink {
     pub target: ExpandedPath,
     pub source: ExpandedPath,
@@ -241,62 +223,6 @@ impl AssertionType for Directory {
     }
 }
 
-pub struct Debug {
-    pub status_fn: Option<LuaFunction>,
-    pub install_fn: Option<LuaFunction>,
-}
-
-impl FromLuaMulti for Debug {
-    fn from_lua_multi(args: LuaMultiValue, lua: &Lua) -> LuaResult<Self> {
-        let table = LuaTable::from_lua_multi(args, lua)?;
-        let status_fn: Option<LuaFunction> = table.get("status").ok();
-        let install_fn: Option<LuaFunction> = table.get("install").ok();
-        Ok(Self {
-            status_fn,
-            install_fn,
-        })
-    }
-}
-
-impl std::fmt::Display for Debug {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "debug")
-    }
-}
-
-impl AssertionType for Debug {
-    fn status(&self) -> Result<Status> {
-        if let Some(ref status_fn) = self.status_fn {
-            let result = status_fn
-                .call::<Status>(LuaMultiValue::new())
-                .map_err(|e| miette!("Debug status function failed: {e}"))?;
-            Ok(result)
-        } else {
-            Ok(Status::Ok)
-        }
-    }
-
-    fn install(&self) -> Result<()> {
-        info!("debug: installing");
-        let install_fn = self
-            .install_fn
-            .as_ref()
-            .ok_or_else(|| miette!("Install not implemented for debug assertion"))?;
-        install_fn
-            .call::<()>(LuaMultiValue::new())
-            .map_err(|e| miette!("Debug install function failed: {e}"))?;
-        Ok(())
-    }
-
-    fn upgrade(&self) -> Result<()> {
-        todo!()
-    }
-
-    fn remove(&self) -> Result<()> {
-        todo!()
-    }
-}
-
 pub struct Git {
     pub dir: ExpandedPath,
     pub remote_url: String,
@@ -317,17 +243,14 @@ impl std::fmt::Display for Git {
 
 impl AssertionType for Git {
     fn status(&self) -> Result<Status> {
-        // First, ensure git binary is available
         Utils::assert_bin("git")?;
 
         let dir_path = Path::new(&self.dir);
 
-        // Check if directory exists
         if !dir_path.exists() {
             return Ok(Status::Missing);
         }
 
-        // Check if it's a directory
         if !dir_path.is_dir() {
             return Ok(Status::ConflictUpgrade(Conflict {
                 expected: "directory".to_string(),
@@ -335,13 +258,12 @@ impl AssertionType for Git {
             }));
         }
 
-        // Check if directory is empty (only . and ..)
+        // Check if the directory is empty.
         let mut entries = fs::read_dir(dir_path).into_diagnostic()?;
         if entries.next().is_none() {
             return Ok(Status::Missing);
         }
 
-        // Check if we can get the git remote
         let (remote_output, exit_code) = Utils::sh(
             "git",
             &["-C", &self.dir, "config", "--get", "remote.origin.url"],
@@ -366,7 +288,6 @@ impl AssertionType for Git {
     }
 
     fn install(&self) -> Result<()> {
-        // Ensure git binary is available before attempting clone
         Utils::assert_bin("git")?;
 
         let (_output, exit_code) = Utils::sh("git", &["clone", &self.remote_url, &self.dir])?;
@@ -388,76 +309,6 @@ impl AssertionType for Git {
     }
 }
 
-pub struct LuaAssertion {
-    pub name: String,
-    pub args: LuaMultiValue,
-    pub assertion_type: LuaAssertionType,
-}
-
-impl LuaAssertion {
-    pub fn new(name: &str, args: LuaMultiValue, assertion_type: LuaAssertionType) -> Self {
-        Self {
-            name: name.to_string(),
-            args,
-            assertion_type,
-        }
-    }
-}
-
-impl std::fmt::Display for LuaAssertion {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        let default_display = || {
-            let args_str = self
-                .args
-                .iter()
-                .map(|v| v.to_string().unwrap_or_else(|_| "?".to_string()))
-                .collect::<Vec<_>>()
-                .join(" ");
-            format!("{} {}", self.name, args_str)
-        };
-
-        if let Some(ref display_fn) = self.assertion_type.display_fn {
-            let result = display_fn
-                .call::<String>(self.args.clone())
-                .unwrap_or_else(|err| {
-                    error!("Display function failed for {}: {}", self.name, err);
-                    default_display()
-                });
-            write!(f, "{}", result)
-        } else {
-            write!(f, "{}", default_display())
-        }
-    }
-}
-
-impl AssertionType for LuaAssertion {
-    fn status(&self) -> Result<Status> {
-        let result = self
-            .assertion_type
-            .status_fn
-            .call::<Status>(self.args.clone())
-            .map_err(FrorkError::from)?;
-        Ok(result)
-    }
-
-    fn install(&self) -> Result<()> {
-        self.assertion_type
-            .install_fn
-            .call::<()>(self.args.clone())
-            .map_err(FrorkError::from)?;
-        debug!("installed: {}", self);
-        Ok(())
-    }
-
-    fn upgrade(&self) -> Result<()> {
-        todo!()
-    }
-
-    fn remove(&self) -> Result<()> {
-        todo!()
-    }
-}
-
 pub struct Brew;
 
 impl FromLuaMulti for Brew {
@@ -474,7 +325,7 @@ impl std::fmt::Display for Brew {
 
 impl AssertionType for Brew {
     fn status(&self) -> Result<Status> {
-        // Early return if brew command is not in PATH
+        // Early return if brew command is not in PATH.
         if Utils::assert_bin("brew").is_err() {
             return Ok(Status::Missing);
         }
@@ -487,7 +338,7 @@ impl AssertionType for Brew {
         }
     }
 
-    // This needs sudo - figure out how to make this work through frork
+    // This needs sudo — figure out how to make this work through frork.
     fn install(&self) -> Result<()> {
         let (output, exit_code) = Utils::sh(
             "bash",
@@ -541,7 +392,7 @@ impl AssertionType for BrewBundle {
     fn status(&self) -> Result<Status> {
         Utils::assert_bin("brew")?;
 
-        // First check: brew bundle check --no-upgrade
+        // First check: brew bundle check --no-upgrade.
         let file_arg = format!("--file={}", self.brewfile);
         let (_output, exit_code) = Utils::sh_with_envs(
             "brew",
@@ -553,7 +404,7 @@ impl AssertionType for BrewBundle {
             return Ok(Status::Missing);
         }
 
-        // Second check: brew bundle check (without --no-upgrade)
+        // Second check: brew bundle check (without --no-upgrade).
         let file_arg = format!("--file={}", self.brewfile);
         let (_output, exit_code) = Utils::sh_with_envs(
             "brew",
@@ -599,3 +450,154 @@ impl AssertionType for BrewBundle {
         todo!()
     }
 }
+
+pub struct Debug {
+    pub status_fn: Option<LuaFunction>,
+    pub install_fn: Option<LuaFunction>,
+}
+
+impl FromLuaMulti for Debug {
+    fn from_lua_multi(args: LuaMultiValue, lua: &Lua) -> LuaResult<Self> {
+        let table = LuaTable::from_lua_multi(args, lua)?;
+        let status_fn: Option<LuaFunction> = table.get("status").ok();
+        let install_fn: Option<LuaFunction> = table.get("install").ok();
+        Ok(Self {
+            status_fn,
+            install_fn,
+        })
+    }
+}
+
+impl std::fmt::Display for Debug {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "debug")
+    }
+}
+
+impl AssertionType for Debug {
+    fn status(&self) -> Result<Status> {
+        if let Some(ref status_fn) = self.status_fn {
+            let result = status_fn
+                .call::<Status>(LuaMultiValue::new())
+                .map_err(|e| miette!("Debug status function failed: {e}"))?;
+            Ok(result)
+        } else {
+            Ok(Status::Ok)
+        }
+    }
+
+    fn install(&self) -> Result<()> {
+        info!("debug: installing");
+        let install_fn = self
+            .install_fn
+            .as_ref()
+            .ok_or_else(|| miette!("Install not implemented for debug assertion"))?;
+        install_fn
+            .call::<()>(LuaMultiValue::new())
+            .map_err(|e| miette!("Debug install function failed: {e}"))?;
+        Ok(())
+    }
+
+    fn upgrade(&self) -> Result<()> {
+        todo!()
+    }
+
+    fn remove(&self) -> Result<()> {
+        todo!()
+    }
+}
+
+// --- Lua custom assertion types ---
+
+#[derive(Clone)]
+pub struct LuaAssertionType {
+    pub display_fn: Option<LuaFunction>,
+    pub status_fn: LuaFunction,
+    pub install_fn: LuaFunction,
+}
+
+impl FromLua for LuaAssertionType {
+    fn from_lua(value: LuaValue, lua: &Lua) -> LuaResult<Self> {
+        let table = LuaTable::from_lua(value, lua)?;
+
+        let display_fn: Option<LuaFunction> = table.get("display").ok();
+        let status_fn: LuaFunction = table.get("status")?;
+        let install_fn: LuaFunction = table.get("install")?;
+
+        Ok(Self {
+            display_fn,
+            status_fn,
+            install_fn,
+        })
+    }
+}
+
+pub struct LuaAssertion {
+    pub name: String,
+    pub args: LuaMultiValue,
+    pub assertion_type: LuaAssertionType,
+}
+
+impl LuaAssertion {
+    pub fn new(name: &str, args: LuaMultiValue, assertion_type: LuaAssertionType) -> Self {
+        Self {
+            name: name.to_string(),
+            args,
+            assertion_type,
+        }
+    }
+}
+
+impl std::fmt::Display for LuaAssertion {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let default_display = || {
+            let args_str = self
+                .args
+                .iter()
+                .map(|v| v.to_string().unwrap_or_else(|_| "?".to_string()))
+                .collect::<Vec<_>>()
+                .join(" ");
+            format!("{} {}", self.name, args_str)
+        };
+
+        if let Some(ref display_fn) = self.assertion_type.display_fn {
+            let result = display_fn
+                .call::<String>(self.args.clone())
+                .unwrap_or_else(|err| {
+                    error!("Display function failed for {}: {}", self.name, err);
+                    default_display()
+                });
+            write!(f, "{}", result)
+        } else {
+            write!(f, "{}", default_display())
+        }
+    }
+}
+
+impl AssertionType for LuaAssertion {
+    fn status(&self) -> Result<Status> {
+        let result = self
+            .assertion_type
+            .status_fn
+            .call::<Status>(self.args.clone())
+            .map_err(FrorkError::from)?;
+        Ok(result)
+    }
+
+    fn install(&self) -> Result<()> {
+        self.assertion_type
+            .install_fn
+            .call::<()>(self.args.clone())
+            .map_err(FrorkError::from)?;
+        debug!("installed: {}", self);
+        Ok(())
+    }
+
+    fn upgrade(&self) -> Result<()> {
+        todo!()
+    }
+
+    fn remove(&self) -> Result<()> {
+        todo!()
+    }
+}
diff --git a/frork-cli/src/errors.rs b/frork-cli/src/error.rs
similarity index 100%
rename from frork-cli/src/errors.rs
rename to frork-cli/src/error.rs
diff --git a/frork-cli/src/lib.rs b/frork-cli/src/lib.rs
index 08a4294..2c6ff14 100644
--- a/frork-cli/src/lib.rs
+++ b/frork-cli/src/lib.rs
@@ -1,3 +1,5 @@
+#![warn(clippy::self_named_module_files)]
+
 pub mod assertions;
-pub mod errors;
+pub mod error;
 pub mod utils;
diff --git a/frork-cli/src/main.rs b/frork-cli/src/main.rs
index cde9f24..aa4052c 100644
--- a/frork-cli/src/main.rs
+++ b/frork-cli/src/main.rs
@@ -1,5 +1,5 @@
 mod assertions;
-mod errors;
+mod error;
 mod utils;
 
 use std::cell::RefCell;
@@ -18,9 +18,12 @@ use assertions::LuaAssertionType;
 use assertions::Status;
 use assertions::Symlink;
 use assertions::TypedFactory;
+use clap::CommandFactory;
 use clap::Parser;
 use clap::Subcommand;
-use errors::FrorkError;
+use clap_complete::Shell;
+use error::FrorkError;
+use miette::IntoDiagnostic as _;
 use miette::Result;
 use miette::miette;
 use mlua::prelude::*;
@@ -31,8 +34,12 @@ use utils::Utils;
 #[command(name = "frork", version)]
 #[command(about = "A Fennel-based configuration management tool")]
 struct Cli {
+    /// Generate shell completions and exit.
+    #[arg(long, value_enum)]
+    completions: Option<Shell>,
+
     #[command(subcommand)]
-    command: Commands,
+    command: Option<Commands>,
 }
 
 #[derive(Subcommand)]
@@ -43,166 +50,83 @@ enum Commands {
     Satisfy { script: String },
 }
 
-struct LuaAssertionFactory {
-    assertion_type: String,
-    lua_assertion_type: LuaAssertionType,
-}
-
-impl AssertionTypeFactory for LuaAssertionFactory {
-    fn create(&self, _lua: &Lua, args: LuaMultiValue) -> Result<Box<dyn AssertionType>> {
-        Ok(Box::new(LuaAssertion::new(
-            &self.assertion_type,
-            args,
-            self.lua_assertion_type.clone(),
-        )))
-    }
-}
+fn main() -> Result<()> {
+    tracing_subscriber::fmt::init();
 
-#[derive(Default)]
-struct Registry {
-    lua_assertion_types: HashMap<String, LuaAssertionType>,
-}
+    let cli = Cli::parse();
 
-impl Registry {
-    fn register(&mut self, name: &str, lua_assertion_type: LuaAssertionType) {
-        self.lua_assertion_types
-            .insert(name.to_string(), lua_assertion_type);
+    if let Some(shell) = cli.completions {
+        clap_complete::generate(shell, &mut Cli::command(), "frork", &mut std::io::stdout());
+        return Ok(());
     }
 
-    fn get_factory(&self, assertion_type: &str) -> Result<Box<dyn AssertionTypeFactory>> {
-        // Check Lua assertions first
-        if let Some(lua_assertion) = self.lua_assertion_types.get(assertion_type) {
-            return Ok(Box::new(LuaAssertionFactory {
-                assertion_type: assertion_type.to_string(),
-                lua_assertion_type: lua_assertion.clone(),
-            }));
-        }
-
-        // Return factory for built-in types
-        match assertion_type {
-            "brew" => Ok(Box::new(TypedFactory::<Brew>::new())),
-            "brew-bundle" => Ok(Box::new(TypedFactory::<BrewBundle>::new())),
-            "debug" => Ok(Box::new(TypedFactory::<Debug>::new())),
-            "directory" => Ok(Box::new(TypedFactory::<Directory>::new())),
-            "git" => Ok(Box::new(TypedFactory::<Git>::new())),
-            "symlink" => Ok(Box::new(TypedFactory::<Symlink>::new())),
-            _ => Err(FrorkError::UnknownAssertionType {
-                assertion_type: assertion_type.to_string(),
-            }
-            .into()),
-        }
-    }
-}
+    let Some(command) = cli.command else {
+        Cli::command().print_help().into_diagnostic()?;
+        return Ok(());
+    };
 
-struct Frork<F> {
-    // RefCell needed for interior mutability - register() method needs to add
-    // new assertion types at runtime when called from Lua/Fennel code
-    registry: RefCell<Registry>,
-    handle_status: F,
-    lua: Lua,
+    run(&command)
 }
 
-impl<F> Frork<F> {
-    fn new(handle_status: F, lua: Lua) -> Self {
-        Self {
-            registry: RefCell::new(Registry::default()),
-            handle_status,
-            lua,
-        }
+fn run(command: &Commands) -> Result<()> {
+    match command {
+        Commands::Check { code } => run_code(code, status),
+        Commands::Do { code } => run_code(code, satisfy),
+        Commands::Status { script } => run_script(script, status),
+        Commands::Satisfy { script } => run_script(script, satisfy),
     }
 }
 
-impl<F> IntoLua for Frork<F>
-where
-    F: Fn(&Status, &dyn AssertionType) -> Result<()> + Clone + 'static,
-{
-    fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
-        let frork_table = lua.create_table()?;
-        let frork = Rc::new(self);
-
-        let frork_clone = frork.clone();
-        let register_fn = lua.create_function(
-            move |_lua, (name, lua_assertion_type): (String, LuaAssertionType)| {
-                frork_clone.register(&name, lua_assertion_type)
-            },
-        )?;
-
-        frork_table.set("register", register_fn)?;
-
-        let frork_clone = frork.clone();
-        let ok_fn = lua.create_function(move |_lua, args: LuaMultiValue| frork_clone.ok(args))?;
-        frork_table.set("ok", ok_fn)?;
-
-        frork_table.set("utils", Utils {})?;
-
-        Ok(LuaValue::Table(frork_table))
+fn status(status: &Status, assertion: &dyn AssertionType) -> Result<()> {
+    match status {
+        Status::Ok => println!("ok: {}", assertion),
+        Status::Missing => println!("missing: {}", assertion),
+        // TODO: show a nicer diff?
+        Status::ConflictUpgrade(conflict) => {
+            println!("conflict (upgradable): {}", assertion);
+            println!("  expected: {}", conflict.expected);
+            println!("    actual: {}", conflict.actual);
+        }
     }
+    Ok(())
 }
 
-impl<F> Frork<F>
-where
-    F: Fn(&Status, &dyn AssertionType) -> Result<()> + Clone + 'static,
-{
-    fn register(&self, name: &str, lua_assertion_type: LuaAssertionType) -> LuaResult<()> {
-        self.registry
-            .borrow_mut()
-            .register(name, lua_assertion_type);
-        info!("Registered assertion type: {}", name);
-        Ok(())
-    }
-
-    fn ok(&self, args: LuaMultiValue) -> LuaResult<()> {
-        if args.is_empty() {
-            return Err(LuaError::external(FrorkError::NoOperation));
+fn satisfy(status: &Status, assertion: &dyn AssertionType) -> Result<()> {
+    match status {
+        Status::Ok => println!("ok: {}", assertion),
+        Status::Missing => {
+            println!("missing: {}", assertion);
+            assertion.install()?;
+            println!("ok: {}", assertion);
         }
+        Status::ConflictUpgrade(conflict) => {
+            println!("conflict (upgradable): {}", assertion);
+            println!("  expected: {}", conflict.expected);
+            println!("    actual: {}", conflict.actual);
 
-        let mut args_iter = args.into_iter();
-        let assertion_type = args_iter
-            .next()
-            .and_then(|v| v.to_string().ok())
-            .ok_or_else(|| LuaError::external(FrorkError::MissingAssertionType))?;
-
-        let assertion_args: LuaMultiValue = args_iter.collect();
+            use std::io::Write as _;
+            print!("Upgrade? [y/N]: ");
+            std::io::stdout()
+                .flush()
+                .map_err(|e| miette!("Failed to flush stdout: {e}"))?;
 
-        let factory = self
-            .registry
-            .borrow()
-            .get_factory(&assertion_type)
-            .map_err(LuaError::external)?;
-        let assertion = factory
-            .create(&self.lua, assertion_args)
-            .map_err(LuaError::external)?;
-        let status = assertion.status().map_err(LuaError::external)?;
+            let mut input = String::new();
+            std::io::stdin()
+                .read_line(&mut input)
+                .map_err(|e| miette!("Failed to read input: {e}"))?;
 
-        (self.handle_status)(&status, assertion.as_ref()).map_err(LuaError::external)?;
-        Ok(())
+            match input.trim().to_lowercase().as_str() {
+                "y" | "yes" => {
+                    assertion.upgrade()?;
+                    println!("ok: {}", assertion);
+                }
+                _ => {
+                    println!("skipped: {}", assertion);
+                }
+            }
+        }
     }
-}
-
-fn setup_lua(
-    handle_status: impl Fn(&Status, &dyn AssertionType) -> Result<()> + Clone + 'static,
-) -> Result<(Lua, LuaTable, LuaTable)> {
-    let lua = Lua::new();
-
-    let fennel_code = include_str!("../fennel-1.6.0.lua");
-    let fennel_module: LuaTable = lua
-        .load(fennel_code)
-        .eval()
-        .map_err(|e| miette!("Failed to load Fennel: {e}"))?;
-    lua.register_module("fennel", &fennel_module)
-        .map_err(|e| miette!("Failed to register fennel module: {e}"))?;
-
-    let frork_table = match Frork::new(handle_status, lua.clone())
-        .into_lua(&lua)
-        .map_err(|e| miette!("Failed to create frork table: {e}"))?
-    {
-        LuaValue::Table(table) => table,
-        _ => unreachable!(),
-    };
-    lua.register_module("frork", &frork_table)
-        .map_err(|e| miette!("Failed to register frork module: {e}"))?;
-
-    Ok((lua, frork_table, fennel_module))
+    Ok(())
 }
 
 fn run_code(
@@ -235,7 +159,7 @@ fn run_script(
 ) -> Result<()> {
     let (lua, _frork_module, fennel_module) = setup_lua(handle_status)?;
 
-    // Add script directory to Lua and Fennel search paths
+    // Add script directory to Lua and Fennel search paths.
     let script_path = std::path::Path::new(script);
     if let Some(script_dir) = script_path.parent()
         && let Some(script_dir_str) = script_dir.to_str()
@@ -245,7 +169,7 @@ fn run_script(
             .get("package")
             .map_err(|e| miette!("Failed to get package table: {e}"))?;
 
-        // Add to Lua search path
+        // Add to Lua search path.
         let current_path: String = package_table
             .get("path")
             .map_err(|e| miette!("Failed to get current Lua path: {e}"))?;
@@ -257,7 +181,7 @@ fn run_script(
             .set("path", new_path)
             .map_err(|e| miette!("Failed to set Lua path: {e}"))?;
 
-        // Add to Fennel search path
+        // Add to Fennel search path.
         let current_fennel_path: String = fennel_module
             .get("path")
             .unwrap_or_else(|_| "./?.fnl;./?/init.fnl".to_string());
@@ -280,72 +204,164 @@ fn run_script(
     Ok(())
 }
 
-fn status(status: &Status, assertion: &dyn AssertionType) -> Result<()> {
-    match status {
-        Status::Ok => println!("ok: {}", assertion),
-        Status::Missing => println!("missing: {}", assertion),
-        // TODO show a nicer diff?
-        Status::ConflictUpgrade(conflict) => {
-            println!("conflict (upgradable): {}", assertion);
-            println!("  expected: {}", conflict.expected);
-            println!("    actual: {}", conflict.actual);
+fn setup_lua(
+    handle_status: impl Fn(&Status, &dyn AssertionType) -> Result<()> + Clone + 'static,
+) -> Result<(Lua, LuaTable, LuaTable)> {
+    let lua = Lua::new();
+
+    let fennel_code = include_str!("../fennel-1.6.0.lua");
+    let fennel_module: LuaTable = lua
+        .load(fennel_code)
+        .eval()
+        .map_err(|e| miette!("Failed to load Fennel: {e}"))?;
+    lua.register_module("fennel", &fennel_module)
+        .map_err(|e| miette!("Failed to register fennel module: {e}"))?;
+
+    let frork_table = match Frork::new(handle_status, lua.clone())
+        .into_lua(&lua)
+        .map_err(|e| miette!("Failed to create frork table: {e}"))?
+    {
+        LuaValue::Table(table) => table,
+        _ => unreachable!(),
+    };
+    lua.register_module("frork", &frork_table)
+        .map_err(|e| miette!("Failed to register frork module: {e}"))?;
+
+    Ok((lua, frork_table, fennel_module))
+}
+
+struct Frork<F> {
+    // RefCell needed for interior mutability — register() adds new assertion
+    // types at runtime when called from Lua/Fennel code.
+    registry: RefCell<Registry>,
+    handle_status: F,
+    lua: Lua,
+}
+
+impl<F> Frork<F> {
+    fn new(handle_status: F, lua: Lua) -> Self {
+        Self {
+            registry: RefCell::new(Registry::default()),
+            handle_status,
+            lua,
         }
     }
-    Ok(())
 }
 
-fn satisfy(status: &Status, assertion: &dyn AssertionType) -> Result<()> {
-    match status {
-        Status::Ok => println!("ok: {}", assertion),
-        Status::Missing => {
-            println!("missing: {}", assertion);
-            assertion.install()?;
-            println!("ok: {}", assertion);
+impl<F> Frork<F>
+where
+    F: Fn(&Status, &dyn AssertionType) -> Result<()> + Clone + 'static,
+{
+    fn register(&self, name: &str, lua_assertion_type: LuaAssertionType) -> LuaResult<()> {
+        self.registry
+            .borrow_mut()
+            .register(name, lua_assertion_type);
+        info!("Registered assertion type: {}", name);
+        Ok(())
+    }
+
+    fn ok(&self, args: LuaMultiValue) -> LuaResult<()> {
+        if args.is_empty() {
+            return Err(LuaError::external(FrorkError::NoOperation));
         }
-        Status::ConflictUpgrade(conflict) => {
-            println!("conflict (upgradable): {}", assertion);
-            println!("  expected: {}", conflict.expected);
-            println!("    actual: {}", conflict.actual);
 
-            use std::io::Write as _;
-            print!("Upgrade? [y/N]: ");
-            std::io::stdout()
-                .flush()
-                .map_err(|e| miette!("Failed to flush stdout: {e}"))?;
+        let mut args_iter = args.into_iter();
+        let assertion_type = args_iter
+            .next()
+            .and_then(|v| v.to_string().ok())
+            .ok_or_else(|| LuaError::external(FrorkError::MissingAssertionType))?;
 
-            let mut input = String::new();
-            std::io::stdin()
-                .read_line(&mut input)
-                .map_err(|e| miette!("Failed to read input: {e}"))?;
+        let assertion_args: LuaMultiValue = args_iter.collect();
 
-            match input.trim().to_lowercase().as_str() {
-                "y" | "yes" => {
-                    assertion.upgrade()?;
-                    println!("ok: {}", assertion);
-                }
-                _ => {
-                    println!("skipped: {}", assertion);
-                }
-            }
-        }
+        let factory = self
+            .registry
+            .borrow()
+            .get_factory(&assertion_type)
+            .map_err(LuaError::external)?;
+        let assertion = factory
+            .create(&self.lua, assertion_args)
+            .map_err(LuaError::external)?;
+        let status = assertion.status().map_err(LuaError::external)?;
+
+        (self.handle_status)(&status, assertion.as_ref()).map_err(LuaError::external)?;
+        Ok(())
     }
-    Ok(())
 }
 
-fn run(command: &Commands) -> Result<()> {
-    match command {
-        Commands::Check { code } => run_code(code, status),
-        Commands::Do { code } => run_code(code, satisfy),
-        Commands::Status { script } => run_script(script, status),
-        Commands::Satisfy { script } => run_script(script, satisfy),
+impl<F> IntoLua for Frork<F>
+where
+    F: Fn(&Status, &dyn AssertionType) -> Result<()> + Clone + 'static,
+{
+    fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
+        let frork_table = lua.create_table()?;
+        let frork = Rc::new(self);
+
+        let frork_clone = frork.clone();
+        let register_fn = lua.create_function(
+            move |_lua, (name, lua_assertion_type): (String, LuaAssertionType)| {
+                frork_clone.register(&name, lua_assertion_type)
+            },
+        )?;
+
+        frork_table.set("register", register_fn)?;
+
+        let frork_clone = frork.clone();
+        let ok_fn = lua.create_function(move |_lua, args: LuaMultiValue| frork_clone.ok(args))?;
+        frork_table.set("ok", ok_fn)?;
+
+        frork_table.set("utils", Utils {})?;
+
+        Ok(LuaValue::Table(frork_table))
     }
 }
 
-fn main() -> Result<()> {
-    tracing_subscriber::fmt::init();
+#[derive(Default)]
+struct Registry {
+    lua_assertion_types: HashMap<String, LuaAssertionType>,
+}
 
-    let cli = Cli::parse();
-    run(&cli.command)?;
+impl Registry {
+    fn register(&mut self, name: &str, lua_assertion_type: LuaAssertionType) {
+        self.lua_assertion_types
+            .insert(name.to_string(), lua_assertion_type);
+    }
 
-    Ok(())
+    fn get_factory(&self, assertion_type: &str) -> Result<Box<dyn AssertionTypeFactory>> {
+        // Check Lua assertions first.
+        if let Some(lua_assertion) = self.lua_assertion_types.get(assertion_type) {
+            return Ok(Box::new(LuaAssertionFactory {
+                assertion_type: assertion_type.to_string(),
+                lua_assertion_type: lua_assertion.clone(),
+            }));
+        }
+
+        // Return factory for built-in types.
+        match assertion_type {
+            "brew" => Ok(Box::new(TypedFactory::<Brew>::new())),
+            "brew-bundle" => Ok(Box::new(TypedFactory::<BrewBundle>::new())),
+            "debug" => Ok(Box::new(TypedFactory::<Debug>::new())),
+            "directory" => Ok(Box::new(TypedFactory::<Directory>::new())),
+            "git" => Ok(Box::new(TypedFactory::<Git>::new())),
+            "symlink" => Ok(Box::new(TypedFactory::<Symlink>::new())),
+            _ => Err(FrorkError::UnknownAssertionType {
+                assertion_type: assertion_type.to_string(),
+            }
+            .into()),
+        }
+    }
+}
+
+struct LuaAssertionFactory {
+    assertion_type: String,
+    lua_assertion_type: LuaAssertionType,
+}
+
+impl AssertionTypeFactory for LuaAssertionFactory {
+    fn create(&self, _lua: &Lua, args: LuaMultiValue) -> Result<Box<dyn AssertionType>> {
+        Ok(Box::new(LuaAssertion::new(
+            &self.assertion_type,
+            args,
+            self.lua_assertion_type.clone(),
+        )))
+    }
 }
diff --git a/frork-cli/src/utils.rs b/frork-cli/src/utils.rs
index 2d88a38..95ca14b 100644
--- a/frork-cli/src/utils.rs
+++ b/frork-cli/src/utils.rs
@@ -10,7 +10,7 @@ use std::process::Command;
 use std::sync::LazyLock;
 use tracing::debug;
 
-use crate::errors::FrorkError;
+use crate::error::FrorkError;
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct ExpandedPath(String);
diff --git a/frork-cli/tests/cli.rs b/frork-cli/tests/cli.rs
index 32810cc..86fcfbd 100644
--- a/frork-cli/tests/cli.rs
+++ b/frork-cli/tests/cli.rs
@@ -1,5 +1,6 @@
 use assert_cmd::Command;
 use assert_cmd::cargo::cargo_bin_cmd;
+use predicates::str::contains;
 
 fn cmd() -> Command {
     Command::from(cargo_bin_cmd!("frork"))
@@ -9,3 +10,26 @@ fn cmd() -> Command {
 fn shows_help() {
     cmd().arg("--help").assert().success();
 }
+
+#[test]
+fn no_args_shows_help() {
+    cmd().assert().success().stdout(contains("Usage"));
+}
+
+#[test]
+fn generates_bash_completions() {
+    cmd()
+        .args(["--completions", "bash"])
+        .assert()
+        .success()
+        .stdout(contains("frork"));
+}
+
+#[test]
+fn generates_zsh_completions() {
+    cmd()
+        .args(["--completions", "zsh"])
+        .assert()
+        .success()
+        .stdout(contains("frork"));
+}