diff --git a/Cargo.lock b/Cargo.lock
index ccb5359c..31d5857b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1548,7 +1548,11 @@ dependencies = [
name = "base-cli-utils"
version = "0.2.1"
dependencies = [
+ "clap",
+ "eyre",
"reth",
+ "tokio",
+ "tracing",
]
[[package]]
diff --git a/crates/shared/cli-utils/Cargo.toml b/crates/shared/cli-utils/Cargo.toml
index 99c061b2..d477a71b 100644
--- a/crates/shared/cli-utils/Cargo.toml
+++ b/crates/shared/cli-utils/Cargo.toml
@@ -13,3 +13,7 @@ workspace = true
[dependencies]
reth.workspace = true
+tokio = { workspace = true, features = ["full"] }
+eyre.workspace = true
+clap = { workspace = true, features = ["derive"] }
+tracing.workspace = true
diff --git a/crates/shared/cli-utils/README.md b/crates/shared/cli-utils/README.md
index c3d1a7b3..3252be4e 100644
--- a/crates/shared/cli-utils/README.md
+++ b/crates/shared/cli-utils/README.md
@@ -1,35 +1,44 @@
-# `base-cli`
+# `base-cli-utils`
-CLI utilities for the Base Reth node. Provides versioning and client identification for the Base node binary.
+CLI utilities for the Base Reth node.
## Overview
-- **`Version`**: Handles versioning metadata for the Base Reth node, including client version strings and P2P identification.
+- **`Version`**: Client versioning and P2P identification.
+- **`GlobalArgs`**: Common CLI arguments (chain ID, logging).
+- **`LoggingArgs`**: Verbosity levels, output formats, file rotation.
+- **`runtime`**: Tokio runtime with Ctrl+C shutdown handling.
## Usage
-Add the dependency to your `Cargo.toml`:
-
```toml
[dependencies]
-base-cli = { git = "https://github.com/base/node-reth" }
+base-cli-utils = { git = "https://github.com/base/node-reth" }
```
-Initialize versioning at node startup:
-
```rust,ignore
-use base_cli::Version;
-
-// Initialize version metadata before starting the node
-Version::init();
-
-// Access the client version string
-let client_version = Version::NODE_RETH_CLIENT_VERSION;
+use base_cli_utils::{GlobalArgs, Version};
+use base_cli_utils::runtime::{build_runtime, run_until_ctrl_c};
+use clap::Parser;
+
+#[derive(Parser)]
+struct MyCli {
+ #[command(flatten)]
+ global: GlobalArgs,
+}
+
+fn main() -> eyre::Result<()> {
+ Version::init();
+ let cli = MyCli::parse();
+
+ let runtime = build_runtime()?;
+ runtime.block_on(run_until_ctrl_c(async { /* ... */ }))
+}
```
## License
-Licensed under the [MIT License](https://github.com/base/node-reth/blob/main/LICENSE).
+[MIT License](https://github.com/base/node-reth/blob/main/LICENSE)
diff --git a/crates/shared/cli-utils/src/args.rs b/crates/shared/cli-utils/src/args.rs
new file mode 100644
index 00000000..de4895f1
--- /dev/null
+++ b/crates/shared/cli-utils/src/args.rs
@@ -0,0 +1,17 @@
+//! Global CLI arguments for the Base Reth node.
+
+use super::LoggingArgs;
+
+/// Global arguments shared across all CLI commands.
+///
+/// Chain ID defaults to Base Mainnet (8453). Can be set via `--chain-id` or `BASE_NETWORK` env.
+#[derive(Debug, Clone, clap::Args)]
+pub struct GlobalArgs {
+ /// Chain ID (8453 = Base Mainnet, 84532 = Base Sepolia).
+ #[arg(long = "chain-id", env = "BASE_NETWORK", default_value = "8453")]
+ pub chain_id: u64,
+
+ /// Logging configuration.
+ #[command(flatten)]
+ pub logging: LoggingArgs,
+}
diff --git a/crates/shared/cli-utils/src/lib.rs b/crates/shared/cli-utils/src/lib.rs
index 0f1115dd..b8eed9da 100644
--- a/crates/shared/cli-utils/src/lib.rs
+++ b/crates/shared/cli-utils/src/lib.rs
@@ -3,5 +3,13 @@
#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
#![cfg_attr(not(test), warn(unused_crate_dependencies))]
+mod args;
+mod logging;
mod version;
+
+pub use args::GlobalArgs;
+pub use logging::{LogFormat, LogRotation, LoggingArgs};
pub use version::Version;
+
+pub mod runtime;
+pub use runtime::{build_runtime, run_until_ctrl_c, run_until_ctrl_c_fallible};
diff --git a/crates/shared/cli-utils/src/logging.rs b/crates/shared/cli-utils/src/logging.rs
new file mode 100644
index 00000000..0ab096e1
--- /dev/null
+++ b/crates/shared/cli-utils/src/logging.rs
@@ -0,0 +1,136 @@
+//! Logging configuration for CLI applications.
+
+use std::path::PathBuf;
+
+use clap::{ArgAction, Parser, ValueEnum};
+
+/// Log output format.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
+pub enum LogFormat {
+ /// Full format with timestamp, level, target, and spans.
+ #[default]
+ Full,
+ /// Compact format with minimal metadata.
+ Compact,
+ /// JSON format for structured logging.
+ Json,
+}
+
+/// Log rotation strategy.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
+pub enum LogRotation {
+ /// Rotate every minute (for testing).
+ Minutely,
+ /// Rotate every hour.
+ #[default]
+ Hourly,
+ /// Rotate every day.
+ Daily,
+ /// Never rotate.
+ Never,
+}
+
+/// Logging configuration arguments.
+///
+/// Verbosity: `-v` (INFO), `-vv` (DEBUG), `-vvv` (TRACE). Default is WARN.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Parser)]
+pub struct LoggingArgs {
+ /// Increase logging verbosity (-v, -vv, -vvv).
+ #[arg(short = 'v', long = "verbose", action = ArgAction::Count, global = true)]
+ pub verbosity: u8,
+
+ /// Log output format (full, compact, json).
+ #[arg(long = "log-format", default_value = "full", global = true)]
+ pub format: LogFormat,
+
+ /// Path to write logs to a file.
+ #[arg(long = "log-file", global = true)]
+ pub log_file: Option,
+
+ /// Log file rotation (minutely, hourly, daily, never).
+ #[arg(long = "log-rotation", global = true)]
+ pub log_rotation: Option,
+}
+
+impl LoggingArgs {
+ /// Converts verbosity to a [`tracing::Level`].
+ #[inline]
+ pub const fn log_level(&self) -> tracing::Level {
+ match self.verbosity {
+ 0 => tracing::Level::WARN,
+ 1 => tracing::Level::INFO,
+ 2 => tracing::Level::DEBUG,
+ _ => tracing::Level::TRACE,
+ }
+ }
+
+ /// Converts verbosity to a [`tracing::level_filters::LevelFilter`].
+ #[inline]
+ pub const fn log_level_filter(&self) -> tracing::level_filters::LevelFilter {
+ match self.verbosity {
+ 0 => tracing::level_filters::LevelFilter::WARN,
+ 1 => tracing::level_filters::LevelFilter::INFO,
+ 2 => tracing::level_filters::LevelFilter::DEBUG,
+ _ => tracing::level_filters::LevelFilter::TRACE,
+ }
+ }
+
+ /// Returns `true` if file logging is enabled.
+ #[inline]
+ pub const fn has_file_logging(&self) -> bool {
+ self.log_file.is_some()
+ }
+
+ /// Returns the log rotation strategy, defaulting to [`LogRotation::Hourly`].
+ #[inline]
+ pub fn rotation(&self) -> LogRotation {
+ self.log_rotation.unwrap_or_default()
+ }
+
+ /// Returns `true` if the log format is JSON.
+ #[inline]
+ pub const fn is_json_format(&self) -> bool {
+ matches!(self.format, LogFormat::Json)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_verbosity_levels() {
+ assert_eq!(LoggingArgs::default().log_level(), tracing::Level::WARN);
+ assert_eq!(
+ LoggingArgs { verbosity: 1, ..Default::default() }.log_level(),
+ tracing::Level::INFO
+ );
+ assert_eq!(
+ LoggingArgs { verbosity: 2, ..Default::default() }.log_level(),
+ tracing::Level::DEBUG
+ );
+ assert_eq!(
+ LoggingArgs { verbosity: 3, ..Default::default() }.log_level(),
+ tracing::Level::TRACE
+ );
+ }
+
+ #[test]
+ fn test_format_and_rotation() {
+ let args = LoggingArgs::default();
+ assert_eq!(args.format, LogFormat::Full);
+ assert!(!args.is_json_format());
+ assert_eq!(args.rotation(), LogRotation::Hourly);
+
+ let args = LoggingArgs { format: LogFormat::Json, ..Default::default() };
+ assert!(args.is_json_format());
+ }
+
+ #[test]
+ fn test_file_logging() {
+ assert!(!LoggingArgs::default().has_file_logging());
+ let args =
+ LoggingArgs { log_file: Some(PathBuf::from("/tmp/test.log")), ..Default::default() };
+ assert!(args.has_file_logging());
+ }
+}
diff --git a/crates/shared/cli-utils/src/runtime.rs b/crates/shared/cli-utils/src/runtime.rs
new file mode 100644
index 00000000..ac2b61e4
--- /dev/null
+++ b/crates/shared/cli-utils/src/runtime.rs
@@ -0,0 +1,43 @@
+//! Tokio runtime utilities with graceful Ctrl+C shutdown handling.
+
+use std::future::Future;
+
+/// Builds a multi-threaded Tokio runtime with all features enabled.
+pub fn build_runtime() -> eyre::Result {
+ tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()
+ .map_err(|e| eyre::eyre!("Failed to build tokio runtime: {}", e))
+}
+
+/// Runs a future to completion, returning early on Ctrl+C.
+pub async fn run_until_ctrl_c(fut: F) -> eyre::Result<()>
+where
+ F: Future