reth_ethereum_cli/
interface.rs

1//! CLI definition and entrypoint to executable
2
3use crate::{
4    app::{run_commands_with, CliApp},
5    chainspec::EthereumChainSpecParser,
6};
7use clap::{Parser, Subcommand};
8use reth_chainspec::{ChainSpec, EthChainSpec, Hardforks};
9use reth_cli::chainspec::ChainSpecParser;
10use reth_cli_commands::{
11    common::{CliComponentsBuilder, CliNodeTypes, HeaderMut},
12    config_cmd, db, download, dump_genesis, export_era, import, import_era, init_cmd, init_state,
13    launcher::FnLauncher,
14    node::{self, NoArgs},
15    p2p, prune, re_execute, stage,
16};
17use reth_cli_runner::CliRunner;
18use reth_db::DatabaseEnv;
19use reth_node_api::NodePrimitives;
20use reth_node_builder::{NodeBuilder, WithLaunchContext};
21use reth_node_core::{
22    args::{LogArgs, OtlpInitStatus, OtlpLogsStatus, TraceArgs},
23    version::version_metadata,
24};
25use reth_node_metrics::recorder::install_prometheus_recorder;
26use reth_rpc_server_types::{DefaultRpcModuleValidator, RpcModuleValidator};
27use reth_tracing::{FileWorkerGuard, Layers};
28use std::{ffi::OsString, fmt, future::Future, marker::PhantomData, sync::Arc};
29use tracing::{info, warn};
30
31/// The main reth cli interface.
32///
33/// This is the entrypoint to the executable.
34#[derive(Debug, Parser)]
35#[command(author, name = version_metadata().name_client.as_ref(), version = version_metadata().short_version.as_ref(), long_version = version_metadata().long_version.as_ref(), about = "Reth", long_about = None)]
36pub struct Cli<
37    C: ChainSpecParser = EthereumChainSpecParser,
38    Ext: clap::Args + fmt::Debug = NoArgs,
39    Rpc: RpcModuleValidator = DefaultRpcModuleValidator,
40    SubCmd: Subcommand + fmt::Debug = NoSubCmd,
41> {
42    /// The command to run
43    #[command(subcommand)]
44    pub command: Commands<C, Ext, SubCmd>,
45
46    /// The logging configuration for the CLI.
47    #[command(flatten)]
48    pub logs: LogArgs,
49
50    /// The tracing configuration for the CLI.
51    #[command(flatten)]
52    pub traces: TraceArgs,
53
54    /// Type marker for the RPC module validator
55    #[arg(skip)]
56    pub _phantom: PhantomData<Rpc>,
57}
58
59impl Cli {
60    /// Parsers only the default CLI arguments
61    pub fn parse_args() -> Self {
62        Self::parse()
63    }
64
65    /// Parsers only the default CLI arguments from the given iterator
66    pub fn try_parse_args_from<I, T>(itr: I) -> Result<Self, clap::error::Error>
67    where
68        I: IntoIterator<Item = T>,
69        T: Into<OsString> + Clone,
70    {
71        Self::try_parse_from(itr)
72    }
73}
74
75impl<
76        C: ChainSpecParser,
77        Ext: clap::Args + fmt::Debug,
78        Rpc: RpcModuleValidator,
79        SubCmd: crate::app::ExtendedCommand + Subcommand + fmt::Debug,
80    > Cli<C, Ext, Rpc, SubCmd>
81{
82    /// Configures the CLI and returns a [`CliApp`] instance.
83    ///
84    /// This method is used to prepare the CLI for execution by wrapping it in a
85    /// [`CliApp`] that can be further configured before running.
86    pub fn configure(self) -> CliApp<C, Ext, Rpc, SubCmd> {
87        CliApp::new(self)
88    }
89
90    /// Execute the configured cli command.
91    ///
92    /// This accepts a closure that is used to launch the node via the
93    /// [`NodeCommand`](node::NodeCommand).
94    ///
95    /// This command will be run on the [default tokio runtime](reth_cli_runner::tokio_runtime).
96    ///
97    ///
98    /// # Example
99    ///
100    /// ```no_run
101    /// use reth_ethereum_cli::interface::Cli;
102    /// use reth_node_ethereum::EthereumNode;
103    ///
104    /// Cli::parse_args()
105    ///     .run(async move |builder, _| {
106    ///         let handle = builder.launch_node(EthereumNode::default()).await?;
107    ///
108    ///         handle.wait_for_node_exit().await
109    ///     })
110    ///     .unwrap();
111    /// ```
112    ///
113    /// # Example
114    ///
115    /// Parse additional CLI arguments for the node command and use it to configure the node.
116    ///
117    /// ```no_run
118    /// use clap::Parser;
119    /// use reth_ethereum_cli::{chainspec::EthereumChainSpecParser, interface::Cli};
120    ///
121    /// #[derive(Debug, Parser)]
122    /// pub struct MyArgs {
123    ///     pub enable: bool,
124    /// }
125    ///
126    /// Cli::<EthereumChainSpecParser, MyArgs>::parse()
127    ///     .run(async move |builder, my_args: MyArgs|
128    ///         // launch the node
129    ///         Ok(()))
130    ///     .unwrap();
131    /// ````
132    pub fn run<L, Fut>(self, launcher: L) -> eyre::Result<()>
133    where
134        L: FnOnce(WithLaunchContext<NodeBuilder<Arc<DatabaseEnv>, C::ChainSpec>>, Ext) -> Fut,
135        Fut: Future<Output = eyre::Result<()>>,
136        C: ChainSpecParser<ChainSpec = ChainSpec>,
137    {
138        self.with_runner(CliRunner::try_default_runtime()?, launcher)
139    }
140
141    /// Execute the configured cli command with the provided [`CliComponentsBuilder`].
142    ///
143    /// This accepts a closure that is used to launch the node via the
144    /// [`NodeCommand`](node::NodeCommand).
145    ///
146    /// This command will be run on the [default tokio runtime](reth_cli_runner::tokio_runtime).
147    pub fn run_with_components<N>(
148        self,
149        components: impl CliComponentsBuilder<N>,
150        launcher: impl AsyncFnOnce(
151            WithLaunchContext<NodeBuilder<Arc<DatabaseEnv>, C::ChainSpec>>,
152            Ext,
153        ) -> eyre::Result<()>,
154    ) -> eyre::Result<()>
155    where
156        N: CliNodeTypes<Primitives: NodePrimitives<BlockHeader: HeaderMut>, ChainSpec: Hardforks>,
157        C: ChainSpecParser<ChainSpec = N::ChainSpec>,
158    {
159        self.with_runner_and_components(CliRunner::try_default_runtime()?, components, launcher)
160    }
161
162    /// Execute the configured cli command with the provided [`CliRunner`].
163    ///
164    ///
165    /// # Example
166    ///
167    /// ```no_run
168    /// use reth_cli_runner::CliRunner;
169    /// use reth_ethereum_cli::interface::Cli;
170    /// use reth_node_ethereum::EthereumNode;
171    ///
172    /// let runner = CliRunner::try_default_runtime().unwrap();
173    ///
174    /// Cli::parse_args()
175    ///     .with_runner(runner, |builder, _| async move {
176    ///         let handle = builder.launch_node(EthereumNode::default()).await?;
177    ///         handle.wait_for_node_exit().await
178    ///     })
179    ///     .unwrap();
180    /// ```
181    pub fn with_runner<L, Fut>(self, runner: CliRunner, launcher: L) -> eyre::Result<()>
182    where
183        L: FnOnce(WithLaunchContext<NodeBuilder<Arc<DatabaseEnv>, C::ChainSpec>>, Ext) -> Fut,
184        Fut: Future<Output = eyre::Result<()>>,
185        C: ChainSpecParser<ChainSpec = ChainSpec>,
186    {
187        let mut app = self.configure();
188        app.set_runner(runner);
189        app.run(FnLauncher::new::<C, Ext>(async move |builder, ext| launcher(builder, ext).await))
190    }
191
192    /// Execute the configured cli command with the provided [`CliRunner`] and
193    /// [`CliComponentsBuilder`].
194    pub fn with_runner_and_components<N>(
195        mut self,
196        runner: CliRunner,
197        components: impl CliComponentsBuilder<N>,
198        launcher: impl AsyncFnOnce(
199            WithLaunchContext<NodeBuilder<Arc<DatabaseEnv>, C::ChainSpec>>,
200            Ext,
201        ) -> eyre::Result<()>,
202    ) -> eyre::Result<()>
203    where
204        N: CliNodeTypes<Primitives: NodePrimitives<BlockHeader: HeaderMut>, ChainSpec: Hardforks>,
205        C: ChainSpecParser<ChainSpec = N::ChainSpec>,
206    {
207        // Add network name if available to the logs dir
208        if let Some(chain_spec) = self.command.chain_spec() {
209            self.logs.log_file_directory =
210                self.logs.log_file_directory.join(chain_spec.chain().to_string());
211        }
212        let _guard = self.init_tracing(&runner, Layers::new())?;
213
214        // Install the prometheus recorder to be sure to record all metrics
215        install_prometheus_recorder();
216
217        // Use the shared standalone function to avoid duplication
218        run_commands_with::<C, Ext, Rpc, N, SubCmd>(self, runner, components, launcher)
219    }
220
221    /// Initializes tracing with the configured options.
222    ///
223    /// If file logging is enabled, this function returns a guard that must be kept alive to ensure
224    /// that all logs are flushed to disk.
225    ///
226    /// If an OTLP endpoint is specified, it will export traces and logs to the configured
227    /// collector.
228    pub fn init_tracing(
229        &mut self,
230        runner: &CliRunner,
231        mut layers: Layers,
232    ) -> eyre::Result<Option<FileWorkerGuard>> {
233        let otlp_status = runner.block_on(self.traces.init_otlp_tracing(&mut layers))?;
234        let otlp_logs_status = runner.block_on(self.traces.init_otlp_logs(&mut layers))?;
235
236        let guard = self.logs.init_tracing_with_layers(layers)?;
237        info!(target: "reth::cli", "Initialized tracing, debug log directory: {}", self.logs.log_file_directory);
238
239        match otlp_status {
240            OtlpInitStatus::Started(endpoint) => {
241                info!(target: "reth::cli", "Started OTLP {:?} tracing export to {endpoint}", self.traces.protocol);
242            }
243            OtlpInitStatus::NoFeature => {
244                warn!(target: "reth::cli", "Provided OTLP tracing arguments do not have effect, compile with the `otlp` feature")
245            }
246            OtlpInitStatus::Disabled => {}
247        }
248
249        match otlp_logs_status {
250            OtlpLogsStatus::Started(endpoint) => {
251                info!(target: "reth::cli", "Started OTLP {:?} logs export to {endpoint}", self.traces.protocol);
252            }
253            OtlpLogsStatus::NoFeature => {
254                warn!(target: "reth::cli", "Provided OTLP logs arguments do not have effect, compile with the `otlp-logs` feature")
255            }
256            OtlpLogsStatus::Disabled => {}
257        }
258
259        Ok(guard)
260    }
261}
262
263/// Commands to be executed
264#[derive(Debug, Subcommand)]
265pub enum Commands<
266    C: ChainSpecParser,
267    Ext: clap::Args + fmt::Debug,
268    SubCmd: Subcommand + fmt::Debug = NoSubCmd,
269> {
270    /// Start the node
271    #[command(name = "node")]
272    Node(Box<node::NodeCommand<C, Ext>>),
273    /// Initialize the database from a genesis file.
274    #[command(name = "init")]
275    Init(init_cmd::InitCommand<C>),
276    /// Initialize the database from a state dump file.
277    #[command(name = "init-state")]
278    InitState(init_state::InitStateCommand<C>),
279    /// This syncs RLP encoded blocks from a file or files.
280    #[command(name = "import")]
281    Import(import::ImportCommand<C>),
282    /// This syncs ERA encoded blocks from a directory.
283    #[command(name = "import-era")]
284    ImportEra(import_era::ImportEraCommand<C>),
285    /// Exports block to era1 files in a specified directory.
286    #[command(name = "export-era")]
287    ExportEra(export_era::ExportEraCommand<C>),
288    /// Dumps genesis block JSON configuration to stdout.
289    DumpGenesis(dump_genesis::DumpGenesisCommand<C>),
290    /// Database debugging utilities
291    #[command(name = "db")]
292    Db(Box<db::Command<C>>),
293    /// Download public node snapshots
294    #[command(name = "download")]
295    Download(download::DownloadCommand<C>),
296    /// Manipulate individual stages.
297    #[command(name = "stage")]
298    Stage(stage::Command<C>),
299    /// P2P Debugging utilities
300    #[command(name = "p2p")]
301    P2P(Box<p2p::Command<C>>),
302    /// Generate Test Vectors
303    #[cfg(feature = "dev")]
304    #[command(name = "test-vectors")]
305    TestVectors(reth_cli_commands::test_vectors::Command),
306    /// Write config to stdout
307    #[command(name = "config")]
308    Config(config_cmd::Command),
309    /// Prune according to the configuration without any limits
310    #[command(name = "prune")]
311    Prune(prune::PruneCommand<C>),
312    /// Re-execute blocks in parallel to verify historical sync correctness.
313    #[command(name = "re-execute")]
314    ReExecute(re_execute::Command<C>),
315    /// Extension subcommands provided by consumers.
316    #[command(flatten)]
317    Ext(SubCmd),
318}
319
320/// A no-op subcommand type for when no extension subcommands are needed.
321///
322/// This is the default type parameter for `Commands` when consumers don't need
323/// to add custom subcommands.
324#[derive(Debug, Subcommand)]
325pub enum NoSubCmd {}
326
327impl crate::app::ExtendedCommand for NoSubCmd {
328    fn execute(self, _runner: CliRunner) -> eyre::Result<()> {
329        match self {}
330    }
331}
332
333impl<C: ChainSpecParser, Ext: clap::Args + fmt::Debug, SubCmd: Subcommand + fmt::Debug>
334    Commands<C, Ext, SubCmd>
335{
336    /// Returns the underlying chain being used for commands
337    pub fn chain_spec(&self) -> Option<&Arc<C::ChainSpec>> {
338        match self {
339            Self::Node(cmd) => cmd.chain_spec(),
340            Self::Init(cmd) => cmd.chain_spec(),
341            Self::InitState(cmd) => cmd.chain_spec(),
342            Self::Import(cmd) => cmd.chain_spec(),
343            Self::ExportEra(cmd) => cmd.chain_spec(),
344            Self::ImportEra(cmd) => cmd.chain_spec(),
345            Self::DumpGenesis(cmd) => cmd.chain_spec(),
346            Self::Db(cmd) => cmd.chain_spec(),
347            Self::Download(cmd) => cmd.chain_spec(),
348            Self::Stage(cmd) => cmd.chain_spec(),
349            Self::P2P(cmd) => cmd.chain_spec(),
350            #[cfg(feature = "dev")]
351            Self::TestVectors(_) => None,
352            Self::Config(_) => None,
353            Self::Prune(cmd) => cmd.chain_spec(),
354            Self::ReExecute(cmd) => cmd.chain_spec(),
355            Self::Ext(_) => None,
356        }
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::chainspec::SUPPORTED_CHAINS;
364    use clap::CommandFactory;
365    use reth_chainspec::SEPOLIA;
366    use reth_node_core::args::ColorMode;
367
368    #[test]
369    fn parse_color_mode() {
370        let reth = Cli::try_parse_args_from(["reth", "node", "--color", "always"]).unwrap();
371        assert_eq!(reth.logs.color, ColorMode::Always);
372    }
373
374    /// Tests that the help message is parsed correctly. This ensures that clap args are configured
375    /// correctly and no conflicts are introduced via attributes that would result in a panic at
376    /// runtime
377    #[test]
378    fn test_parse_help_all_subcommands() {
379        let reth = Cli::<EthereumChainSpecParser, NoArgs>::command();
380        for sub_command in reth.get_subcommands() {
381            let err = Cli::try_parse_args_from(["reth", sub_command.get_name(), "--help"])
382                .err()
383                .unwrap_or_else(|| {
384                    panic!("Failed to parse help message {}", sub_command.get_name())
385                });
386
387            // --help is treated as error, but
388            // > Not a true "error" as it means --help or similar was used. The help message will be sent to stdout.
389            assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
390        }
391    }
392
393    /// Tests that the log directory is parsed correctly when using the node command. It's
394    /// always tied to the specific chain's name.
395    #[test]
396    fn parse_logs_path_node() {
397        let mut reth = Cli::try_parse_args_from(["reth", "node"]).unwrap();
398        if let Some(chain_spec) = reth.command.chain_spec() {
399            reth.logs.log_file_directory =
400                reth.logs.log_file_directory.join(chain_spec.chain.to_string());
401        }
402        let log_dir = reth.logs.log_file_directory;
403        let end = format!("reth/logs/{}", SUPPORTED_CHAINS[0]);
404        assert!(log_dir.as_ref().ends_with(end), "{log_dir:?}");
405
406        let mut iter = SUPPORTED_CHAINS.iter();
407        iter.next();
408        for chain in iter {
409            let mut reth = Cli::try_parse_args_from(["reth", "node", "--chain", chain]).unwrap();
410            let chain =
411                reth.command.chain_spec().map(|c| c.chain.to_string()).unwrap_or(String::new());
412            reth.logs.log_file_directory = reth.logs.log_file_directory.join(chain.clone());
413            let log_dir = reth.logs.log_file_directory;
414            let end = format!("reth/logs/{chain}");
415            assert!(log_dir.as_ref().ends_with(end), "{log_dir:?}");
416        }
417    }
418
419    /// Tests that the log directory is parsed correctly when using the init command. It
420    /// uses the underlying environment in command to get the chain.
421    #[test]
422    fn parse_logs_path_init() {
423        let mut reth = Cli::try_parse_args_from(["reth", "init"]).unwrap();
424        if let Some(chain_spec) = reth.command.chain_spec() {
425            reth.logs.log_file_directory =
426                reth.logs.log_file_directory.join(chain_spec.chain.to_string());
427        }
428        let log_dir = reth.logs.log_file_directory;
429        let end = format!("reth/logs/{}", SUPPORTED_CHAINS[0]);
430        println!("{log_dir:?}");
431        assert!(log_dir.as_ref().ends_with(end), "{log_dir:?}");
432    }
433
434    /// Tests that the config command does not return any chain spec leading to empty chain id.
435    #[test]
436    fn parse_empty_logs_path() {
437        let mut reth = Cli::try_parse_args_from(["reth", "config"]).unwrap();
438        if let Some(chain_spec) = reth.command.chain_spec() {
439            reth.logs.log_file_directory =
440                reth.logs.log_file_directory.join(chain_spec.chain.to_string());
441        }
442        let log_dir = reth.logs.log_file_directory;
443        let end = "reth/logs".to_string();
444        println!("{log_dir:?}");
445        assert!(log_dir.as_ref().ends_with(end), "{log_dir:?}");
446    }
447
448    #[test]
449    fn parse_env_filter_directives() {
450        let temp_dir = tempfile::tempdir().unwrap();
451
452        unsafe { std::env::set_var("RUST_LOG", "info,evm=debug") };
453        let reth = Cli::try_parse_args_from([
454            "reth",
455            "init",
456            "--datadir",
457            temp_dir.path().to_str().unwrap(),
458            "--log.file.filter",
459            "debug,net=trace",
460        ])
461        .unwrap();
462        assert!(reth.run(async move |_, _| Ok(())).is_ok());
463    }
464
465    #[test]
466    fn test_rpc_module_validation() {
467        use reth_rpc_server_types::RethRpcModule;
468
469        // Test that standard modules are accepted
470        let cli =
471            Cli::try_parse_args_from(["reth", "node", "--http.api", "eth,admin,debug"]).unwrap();
472
473        if let Commands::Node(command) = &cli.command {
474            if let Some(http_api) = &command.rpc.http_api {
475                // Should contain the expected modules
476                let modules = http_api.to_selection();
477                assert!(modules.contains(&RethRpcModule::Eth));
478                assert!(modules.contains(&RethRpcModule::Admin));
479                assert!(modules.contains(&RethRpcModule::Debug));
480            } else {
481                panic!("Expected http.api to be set");
482            }
483        } else {
484            panic!("Expected Node command");
485        }
486
487        // Test that unknown modules are parsed as Other variant
488        let cli =
489            Cli::try_parse_args_from(["reth", "node", "--http.api", "eth,customrpc"]).unwrap();
490
491        if let Commands::Node(command) = &cli.command {
492            if let Some(http_api) = &command.rpc.http_api {
493                let modules = http_api.to_selection();
494                assert!(modules.contains(&RethRpcModule::Eth));
495                assert!(modules.contains(&RethRpcModule::Other("customrpc".to_string())));
496            } else {
497                panic!("Expected http.api to be set");
498            }
499        } else {
500            panic!("Expected Node command");
501        }
502    }
503
504    #[test]
505    fn test_rpc_module_unknown_rejected() {
506        use reth_cli_runner::CliRunner;
507
508        // Test that unknown module names are rejected during validation
509        let cli =
510            Cli::try_parse_args_from(["reth", "node", "--http.api", "unknownmodule"]).unwrap();
511
512        // When we try to run the CLI with validation, it should fail
513        let runner = CliRunner::try_default_runtime().unwrap();
514        let result = cli.with_runner(runner, |_, _| async { Ok(()) });
515
516        assert!(result.is_err());
517        let err = result.unwrap_err();
518        let err_msg = err.to_string();
519
520        // The error should mention it's an unknown module
521        assert!(
522            err_msg.contains("Unknown RPC module"),
523            "Error should mention unknown module: {}",
524            err_msg
525        );
526        assert!(
527            err_msg.contains("'unknownmodule'"),
528            "Error should mention the module name: {}",
529            err_msg
530        );
531    }
532
533    #[test]
534    fn parse_unwind_chain() {
535        let cli = Cli::try_parse_args_from([
536            "reth", "stage", "unwind", "--chain", "sepolia", "to-block", "100",
537        ])
538        .unwrap();
539        match cli.command {
540            Commands::Stage(cmd) => match cmd.command {
541                stage::Subcommands::Unwind(cmd) => {
542                    assert_eq!(cmd.chain_spec().unwrap().chain_id(), SEPOLIA.chain_id());
543                }
544                _ => panic!("Expected Unwind command"),
545            },
546            _ => panic!("Expected Stage command"),
547        };
548    }
549
550    #[test]
551    fn parse_empty_supported_chains() {
552        #[derive(Debug, Clone, Default)]
553        struct FileChainSpecParser;
554
555        impl ChainSpecParser for FileChainSpecParser {
556            type ChainSpec = ChainSpec;
557
558            const SUPPORTED_CHAINS: &'static [&'static str] = &[];
559
560            fn parse(s: &str) -> eyre::Result<Arc<Self::ChainSpec>> {
561                EthereumChainSpecParser::parse(s)
562            }
563        }
564
565        let cli = Cli::<FileChainSpecParser>::try_parse_from([
566            "reth", "stage", "unwind", "--chain", "sepolia", "to-block", "100",
567        ])
568        .unwrap();
569        match cli.command {
570            Commands::Stage(cmd) => match cmd.command {
571                stage::Subcommands::Unwind(cmd) => {
572                    assert_eq!(cmd.chain_spec().unwrap().chain_id(), SEPOLIA.chain_id());
573                }
574                _ => panic!("Expected Unwind command"),
575            },
576            _ => panic!("Expected Stage command"),
577        };
578    }
579
580    #[test]
581    fn test_extensible_subcommands() {
582        use crate::app::ExtendedCommand;
583        use reth_cli_runner::CliRunner;
584        use reth_rpc_server_types::DefaultRpcModuleValidator;
585        use std::sync::atomic::{AtomicBool, Ordering};
586
587        #[derive(Debug, Subcommand)]
588        enum CustomCommands {
589            /// A custom hello command
590            #[command(name = "hello")]
591            Hello {
592                /// Name to greet
593                #[arg(long)]
594                name: String,
595            },
596            /// Another custom command
597            #[command(name = "goodbye")]
598            Goodbye,
599        }
600
601        static EXECUTED: AtomicBool = AtomicBool::new(false);
602
603        impl ExtendedCommand for CustomCommands {
604            fn execute(self, _runner: CliRunner) -> eyre::Result<()> {
605                match self {
606                    Self::Hello { name } => {
607                        assert_eq!(name, "world");
608                        EXECUTED.store(true, Ordering::SeqCst);
609                        Ok(())
610                    }
611                    Self::Goodbye => Ok(()),
612                }
613            }
614        }
615
616        // Test parsing the custom "hello" command
617        let cli = Cli::<
618            EthereumChainSpecParser,
619            NoArgs,
620            DefaultRpcModuleValidator,
621            CustomCommands,
622        >::try_parse_from(["reth", "hello", "--name", "world"])
623        .unwrap();
624
625        match &cli.command {
626            Commands::Ext(CustomCommands::Hello { name }) => {
627                assert_eq!(name, "world");
628            }
629            _ => panic!("Expected Ext(Hello) command"),
630        }
631
632        // Test parsing the custom "goodbye" command
633        let cli = Cli::<
634            EthereumChainSpecParser,
635            NoArgs,
636            DefaultRpcModuleValidator,
637            CustomCommands,
638        >::try_parse_from(["reth", "goodbye"])
639        .unwrap();
640
641        match &cli.command {
642            Commands::Ext(CustomCommands::Goodbye) => {}
643            _ => panic!("Expected Ext(Goodbye) command"),
644        }
645
646        // Test that built-in commands still work alongside custom ones
647        let cli = Cli::<
648            EthereumChainSpecParser,
649            NoArgs,
650            DefaultRpcModuleValidator,
651            CustomCommands,
652        >::try_parse_from(["reth", "node"])
653        .unwrap();
654
655        match &cli.command {
656            Commands::Node(_) => {}
657            _ => panic!("Expected Node command"),
658        }
659
660        // Test executing the custom command
661        let cli = Cli::<
662            EthereumChainSpecParser,
663            NoArgs,
664            DefaultRpcModuleValidator,
665            CustomCommands,
666        >::try_parse_from(["reth", "hello", "--name", "world"])
667        .unwrap();
668
669        if let Commands::Ext(cmd) = cli.command {
670            let runner = CliRunner::try_default_runtime().unwrap();
671            cmd.execute(runner).unwrap();
672            assert!(EXECUTED.load(Ordering::SeqCst), "Custom command should have been executed");
673        }
674    }
675}