Skip to main content

scope/cli/
mod.rs

1//! # CLI Module
2//!
3//! This module defines the command-line interface using `clap` with derive macros.
4//! It provides the main `Cli` struct and `Commands` enum that define all
5//! available commands and their arguments.
6//!
7//! ## Command Structure
8//!
9//! ```text
10//! scope [OPTIONS] <COMMAND>
11//!
12//! Commands:
13//!   address      Analyze a blockchain address
14//!   tx           Analyze a transaction
15//!   crawl        Crawl a token for analytics data
16//!   discover     Browse trending and boosted tokens (alias: disc)
17//!   monitor      Live token monitor with real-time TUI dashboard
18//!   market       Peg and order book health for stablecoin markets
19//!   token-health Token health suite (DEX + optional market; alias: health)
20//!   portfolio    Portfolio management commands
21//!   export       Export analysis data
22//!   interactive  Interactive mode with preserved context
23//!   report       Batch and combined report generation
24//!   setup        Configure settings and API keys
25//!   compliance   Compliance and risk analysis commands
26//!
27//! Options:
28//!   --config <PATH>   Path to configuration file
29//!   -v, --verbose...  Increase logging verbosity
30//!   --ai              Markdown output for agent parsing
31//!   --no-color        Disable colored output
32//!   -h, --help        Print help
33//!   -V, --version     Print version
34//! ```
35
36pub mod address;
37pub mod address_report;
38pub mod compliance;
39pub mod crawl;
40pub mod discover;
41pub mod export;
42pub mod interactive;
43pub mod market;
44pub mod monitor;
45pub mod portfolio;
46pub mod report;
47pub mod setup;
48pub mod token_health;
49pub mod tx;
50
51use clap::{Parser, Subcommand};
52use std::path::PathBuf;
53
54pub use address::AddressArgs;
55pub use crawl::CrawlArgs;
56pub use export::ExportArgs;
57pub use interactive::InteractiveArgs;
58pub use monitor::MonitorArgs;
59pub use portfolio::PortfolioArgs;
60pub use setup::SetupArgs;
61pub use tx::TxArgs;
62
63/// Blockchain Analysis CLI - A tool for blockchain data analysis.
64///
65/// Scope provides comprehensive blockchain analysis capabilities including
66/// address investigation, transaction decoding, portfolio tracking, and
67/// data export functionality.
68#[derive(Debug, Parser)]
69#[command(
70    name = "scope",
71    version,
72    about = "Scope Blockchain Analysis - A tool for blockchain data analysis",
73    long_about = "Scope Blockchain Analysis is a production-grade tool for \
74                  blockchain data analysis, portfolio tracking, and transaction investigation.\n\n\
75                  Use --help with any subcommand for detailed usage information."
76)]
77pub struct Cli {
78    /// Subcommand to execute.
79    #[command(subcommand)]
80    pub command: Commands,
81
82    /// Path to configuration file.
83    ///
84    /// Overrides the default location (~/.config/scope/config.yaml).
85    #[arg(long, global = true, value_name = "PATH")]
86    pub config: Option<PathBuf>,
87
88    /// Increase logging verbosity.
89    ///
90    /// Can be specified multiple times:
91    /// -v    = INFO level
92    /// -vv   = DEBUG level
93    /// -vvv  = TRACE level
94    #[arg(short, long, global = true, action = clap::ArgAction::Count)]
95    pub verbose: u8,
96
97    /// Disable colored output.
98    #[arg(long, global = true)]
99    pub no_color: bool,
100
101    /// Output markdown to console for agent parsing.
102    ///
103    /// Forces all commands to emit markdown-formatted output to stdout
104    /// instead of tables or JSON. Useful for LLM/agent consumption.
105    #[arg(long, global = true)]
106    pub ai: bool,
107}
108
109/// Available CLI subcommands.
110#[derive(Debug, Subcommand)]
111pub enum Commands {
112    /// Analyze a blockchain address.
113    ///
114    /// Retrieves balance, transaction history, and token holdings
115    /// for the specified address.
116    #[command(visible_alias = "addr")]
117    Address(AddressArgs),
118
119    /// Analyze a transaction.
120    ///
121    /// Decodes transaction data, traces execution, and displays
122    /// detailed information about the transaction.
123    #[command(visible_alias = "transaction")]
124    Tx(TxArgs),
125
126    /// Crawl a token for analytics data.
127    ///
128    /// Retrieves comprehensive token information including top holders,
129    /// volume statistics, price data, and liquidity. Displays results
130    /// with ASCII charts and can generate markdown reports.
131    #[command(visible_alias = "token")]
132    Crawl(CrawlArgs),
133
134    /// Portfolio management commands.
135    ///
136    /// Add, remove, and list watched addresses. View aggregated
137    /// portfolio balances across multiple chains.
138    #[command(visible_alias = "port")]
139    Portfolio(PortfolioArgs),
140
141    /// Export analysis data.
142    ///
143    /// Export transaction history, balances, or analysis results
144    /// to various formats (JSON, CSV).
145    Export(ExportArgs),
146
147    /// Interactive mode with preserved context.
148    ///
149    /// Launch a REPL where chain, format, and other settings persist
150    /// between commands for faster workflow.
151    #[command(visible_alias = "shell")]
152    Interactive(InteractiveArgs),
153
154    /// Live token monitor with real-time TUI dashboard.
155    ///
156    /// Launches a terminal UI with price/volume charts, buy/sell gauges,
157    /// transaction feed, and more. Shortcut for `scope interactive` + `monitor <token>`.
158    #[command(visible_alias = "mon")]
159    Monitor(MonitorArgs),
160
161    /// Configure Scope settings and API keys.
162    ///
163    /// Run the setup wizard to configure API keys and preferences,
164    /// or use --status to view current configuration.
165    #[command(visible_alias = "config")]
166    Setup(SetupArgs),
167
168    /// Compliance and risk analysis commands.
169    ///
170    /// Assess risk, trace taint, detect patterns, and generate
171    /// compliance reports for blockchain addresses.
172    #[command(subcommand)]
173    Compliance(compliance::ComplianceCommands),
174
175    /// Peg and order book health for stablecoin markets.
176    ///
177    /// Fetches level-2 depth from exchange APIs and reports
178    /// peg deviation, spread, depth, and configurable health checks.
179    #[command(subcommand)]
180    Market(market::MarketCommands),
181
182    /// Token health suite: DEX analytics + optional order book (crawl + market).
183    ///
184    /// Combines liquidity, volume, and holder data with optional market/peg
185    /// summary for stablecoins. Use --with-market for order book data.
186    #[command(visible_alias = "health")]
187    TokenHealth(token_health::TokenHealthArgs),
188
189    /// Batch and combined report generation.
190    #[command(subcommand)]
191    Report(report::ReportCommands),
192
193    /// Discover trending and boosted tokens from DexScreener.
194    ///
195    /// Browse featured token profiles, recently boosted tokens,
196    /// or top boosted tokens by activity.
197    #[command(visible_alias = "disc")]
198    Discover(discover::DiscoverArgs),
199}
200
201impl Cli {
202    /// Parses CLI arguments from the environment.
203    ///
204    /// This is a convenience wrapper around `clap::Parser::parse()`.
205    pub fn parse_args() -> Self {
206        Self::parse()
207    }
208
209    /// Returns the log level based on verbosity flag.
210    ///
211    /// Maps the `-v` count to tracing log levels:
212    /// - 0: WARN (default)
213    /// - 1: INFO
214    /// - 2: DEBUG
215    /// - 3+: TRACE
216    pub fn log_level(&self) -> tracing::Level {
217        match self.verbose {
218            0 => tracing::Level::WARN,
219            1 => tracing::Level::INFO,
220            2 => tracing::Level::DEBUG,
221            _ => tracing::Level::TRACE,
222        }
223    }
224}
225
226// ============================================================================
227// Unit Tests
228// ============================================================================
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use clap::Parser;
234
235    #[test]
236    fn test_cli_parse_address_command() {
237        let cli = Cli::try_parse_from([
238            "scope",
239            "address",
240            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
241        ])
242        .unwrap();
243
244        assert!(matches!(cli.command, Commands::Address(_)));
245        assert!(cli.config.is_none());
246        assert_eq!(cli.verbose, 0);
247    }
248
249    #[test]
250    fn test_cli_parse_address_alias() {
251        let cli = Cli::try_parse_from([
252            "scope",
253            "addr",
254            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
255        ])
256        .unwrap();
257
258        assert!(matches!(cli.command, Commands::Address(_)));
259    }
260
261    #[test]
262    fn test_cli_parse_tx_command() {
263        let cli = Cli::try_parse_from([
264            "scope",
265            "tx",
266            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
267        ])
268        .unwrap();
269
270        assert!(matches!(cli.command, Commands::Tx(_)));
271    }
272
273    #[test]
274    fn test_cli_parse_tx_alias() {
275        let cli = Cli::try_parse_from([
276            "scope",
277            "transaction",
278            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
279        ])
280        .unwrap();
281
282        assert!(matches!(cli.command, Commands::Tx(_)));
283    }
284
285    #[test]
286    fn test_cli_parse_portfolio_command() {
287        let cli = Cli::try_parse_from(["scope", "portfolio", "list"]).unwrap();
288
289        assert!(matches!(cli.command, Commands::Portfolio(_)));
290    }
291
292    #[test]
293    fn test_cli_parse_export_command() {
294        let cli = Cli::try_parse_from([
295            "scope",
296            "export",
297            "--address",
298            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
299            "--output",
300            "data.json",
301        ])
302        .unwrap();
303
304        assert!(matches!(cli.command, Commands::Export(_)));
305    }
306
307    #[test]
308    fn test_cli_parse_interactive_command() {
309        let cli = Cli::try_parse_from(["scope", "interactive"]).unwrap();
310
311        assert!(matches!(cli.command, Commands::Interactive(_)));
312    }
313
314    #[test]
315    fn test_cli_parse_interactive_alias() {
316        let cli = Cli::try_parse_from(["scope", "shell"]).unwrap();
317
318        assert!(matches!(cli.command, Commands::Interactive(_)));
319    }
320
321    #[test]
322    fn test_cli_parse_interactive_no_banner() {
323        let cli = Cli::try_parse_from(["scope", "interactive", "--no-banner"]).unwrap();
324
325        if let Commands::Interactive(args) = cli.command {
326            assert!(args.no_banner);
327        } else {
328            panic!("Expected Interactive command");
329        }
330    }
331
332    #[test]
333    fn test_cli_verbose_flag_counting() {
334        let cli = Cli::try_parse_from([
335            "scope",
336            "-vvv",
337            "address",
338            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
339        ])
340        .unwrap();
341
342        assert_eq!(cli.verbose, 3);
343    }
344
345    #[test]
346    fn test_cli_verbose_separate_flags() {
347        let cli = Cli::try_parse_from([
348            "scope",
349            "-v",
350            "-v",
351            "address",
352            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
353        ])
354        .unwrap();
355
356        assert_eq!(cli.verbose, 2);
357    }
358
359    #[test]
360    fn test_cli_global_config_option() {
361        let cli = Cli::try_parse_from([
362            "scope",
363            "--config",
364            "/custom/path.yaml",
365            "tx",
366            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
367        ])
368        .unwrap();
369
370        assert_eq!(cli.config, Some(PathBuf::from("/custom/path.yaml")));
371    }
372
373    #[test]
374    fn test_cli_config_long_flag() {
375        let cli = Cli::try_parse_from([
376            "scope",
377            "--config",
378            "/custom/config.yaml",
379            "address",
380            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
381        ])
382        .unwrap();
383
384        assert_eq!(cli.config, Some(PathBuf::from("/custom/config.yaml")));
385    }
386
387    #[test]
388    fn test_cli_no_color_flag() {
389        let cli = Cli::try_parse_from([
390            "scope",
391            "--no-color",
392            "address",
393            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
394        ])
395        .unwrap();
396
397        assert!(cli.no_color);
398    }
399
400    #[test]
401    fn test_cli_missing_required_args_fails() {
402        let result = Cli::try_parse_from(["scope", "address"]);
403        assert!(result.is_err());
404    }
405
406    #[test]
407    fn test_cli_invalid_subcommand_fails() {
408        let result = Cli::try_parse_from(["scope", "invalid"]);
409        assert!(result.is_err());
410    }
411
412    #[test]
413    fn test_cli_log_level_default() {
414        let cli = Cli::try_parse_from([
415            "scope",
416            "address",
417            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
418        ])
419        .unwrap();
420
421        assert_eq!(cli.log_level(), tracing::Level::WARN);
422    }
423
424    #[test]
425    fn test_cli_log_level_info() {
426        let cli = Cli::try_parse_from([
427            "scope",
428            "-v",
429            "address",
430            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
431        ])
432        .unwrap();
433
434        assert_eq!(cli.log_level(), tracing::Level::INFO);
435    }
436
437    #[test]
438    fn test_cli_log_level_debug() {
439        let cli = Cli::try_parse_from([
440            "scope",
441            "-vv",
442            "address",
443            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
444        ])
445        .unwrap();
446
447        assert_eq!(cli.log_level(), tracing::Level::DEBUG);
448    }
449
450    #[test]
451    fn test_cli_log_level_trace() {
452        let cli = Cli::try_parse_from([
453            "scope",
454            "-vvvv",
455            "address",
456            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
457        ])
458        .unwrap();
459
460        assert_eq!(cli.log_level(), tracing::Level::TRACE);
461    }
462
463    #[test]
464    fn test_cli_debug_impl() {
465        let cli = Cli::try_parse_from([
466            "scope",
467            "address",
468            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
469        ])
470        .unwrap();
471
472        let debug_str = format!("{:?}", cli);
473        assert!(debug_str.contains("Cli"));
474        assert!(debug_str.contains("Address"));
475    }
476
477    // ========================================================================
478    // Monitor command tests
479    // ========================================================================
480
481    #[test]
482    fn test_cli_parse_monitor_command() {
483        let cli = Cli::try_parse_from([
484            "scope",
485            "monitor",
486            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
487        ])
488        .unwrap();
489
490        assert!(matches!(cli.command, Commands::Monitor(_)));
491    }
492
493    #[test]
494    fn test_cli_parse_monitor_alias_mon() {
495        let cli = Cli::try_parse_from(["scope", "mon", "USDC"]).unwrap();
496
497        assert!(matches!(cli.command, Commands::Monitor(_)));
498        if let Commands::Monitor(args) = cli.command {
499            assert_eq!(args.token, "USDC");
500            assert_eq!(args.chain, "ethereum"); // default
501            assert!(args.layout.is_none());
502            assert!(args.refresh.is_none());
503            assert!(args.scale.is_none());
504            assert!(args.color_scheme.is_none());
505            assert!(args.export.is_none());
506        }
507    }
508
509    #[test]
510    fn test_cli_parse_monitor_with_chain() {
511        let cli = Cli::try_parse_from(["scope", "monitor", "USDC", "--chain", "solana"]).unwrap();
512
513        if let Commands::Monitor(args) = cli.command {
514            assert_eq!(args.token, "USDC");
515            assert_eq!(args.chain, "solana");
516        } else {
517            panic!("Expected Monitor command");
518        }
519    }
520
521    #[test]
522    fn test_cli_parse_monitor_chain_short_flag() {
523        let cli = Cli::try_parse_from(["scope", "monitor", "PEPE", "-c", "ethereum"]).unwrap();
524
525        if let Commands::Monitor(args) = cli.command {
526            assert_eq!(args.token, "PEPE");
527            assert_eq!(args.chain, "ethereum");
528        } else {
529            panic!("Expected Monitor command");
530        }
531    }
532
533    #[test]
534    fn test_cli_parse_monitor_with_layout() {
535        let cli =
536            Cli::try_parse_from(["scope", "monitor", "USDC", "--layout", "chart-focus"]).unwrap();
537
538        if let Commands::Monitor(args) = cli.command {
539            assert_eq!(args.layout, Some(monitor::LayoutPreset::ChartFocus));
540        } else {
541            panic!("Expected Monitor command");
542        }
543    }
544
545    #[test]
546    fn test_cli_parse_monitor_with_refresh() {
547        let cli = Cli::try_parse_from(["scope", "monitor", "USDC", "--refresh", "3"]).unwrap();
548
549        if let Commands::Monitor(args) = cli.command {
550            assert_eq!(args.refresh, Some(3));
551        } else {
552            panic!("Expected Monitor command");
553        }
554    }
555
556    #[test]
557    fn test_cli_parse_monitor_with_scale() {
558        let cli = Cli::try_parse_from(["scope", "monitor", "USDC", "--scale", "log"]).unwrap();
559
560        if let Commands::Monitor(args) = cli.command {
561            assert_eq!(args.scale, Some(monitor::ScaleMode::Log));
562        } else {
563            panic!("Expected Monitor command");
564        }
565    }
566
567    #[test]
568    fn test_cli_parse_monitor_with_color_scheme() {
569        let cli =
570            Cli::try_parse_from(["scope", "monitor", "USDC", "--color-scheme", "blue-orange"])
571                .unwrap();
572
573        if let Commands::Monitor(args) = cli.command {
574            assert_eq!(args.color_scheme, Some(monitor::ColorScheme::BlueOrange));
575        } else {
576            panic!("Expected Monitor command");
577        }
578    }
579
580    #[test]
581    fn test_cli_parse_monitor_with_export() {
582        let cli =
583            Cli::try_parse_from(["scope", "monitor", "USDC", "--export", "/tmp/out.csv"]).unwrap();
584
585        if let Commands::Monitor(args) = cli.command {
586            assert_eq!(args.export, Some(std::path::PathBuf::from("/tmp/out.csv")));
587        } else {
588            panic!("Expected Monitor command");
589        }
590    }
591
592    #[test]
593    fn test_cli_parse_monitor_short_flags() {
594        let cli = Cli::try_parse_from([
595            "scope", "mon", "USDC", "-c", "solana", "-l", "compact", "-r", "10", "-s", "log", "-e",
596            "data.csv",
597        ])
598        .unwrap();
599
600        if let Commands::Monitor(args) = cli.command {
601            assert_eq!(args.token, "USDC");
602            assert_eq!(args.chain, "solana");
603            assert_eq!(args.layout, Some(monitor::LayoutPreset::Compact));
604            assert_eq!(args.refresh, Some(10));
605            assert_eq!(args.scale, Some(monitor::ScaleMode::Log));
606            assert_eq!(args.export, Some(std::path::PathBuf::from("data.csv")));
607        } else {
608            panic!("Expected Monitor command");
609        }
610    }
611
612    #[test]
613    fn test_cli_parse_monitor_missing_token_fails() {
614        let result = Cli::try_parse_from(["scope", "monitor"]);
615        assert!(result.is_err());
616    }
617
618    #[test]
619    fn test_cli_parse_monitor_invalid_layout_fails() {
620        let result = Cli::try_parse_from(["scope", "monitor", "USDC", "--layout", "invalid"]);
621        assert!(result.is_err());
622    }
623
624    #[test]
625    fn test_cli_parse_monitor_invalid_scale_fails() {
626        let result = Cli::try_parse_from(["scope", "monitor", "USDC", "--scale", "quadratic"]);
627        assert!(result.is_err());
628    }
629
630    #[test]
631    fn test_cli_parse_market_summary() {
632        let cli = Cli::try_parse_from(["scope", "market", "summary"]).unwrap();
633        assert!(matches!(cli.command, Commands::Market(_)));
634    }
635
636    #[test]
637    fn test_cli_parse_market_summary_with_pair() {
638        let cli = Cli::try_parse_from(["scope", "market", "summary", "PUSD_USDT"]).unwrap();
639        if let Commands::Market(market::MarketCommands::Summary(args)) = cli.command {
640            assert_eq!(args.pair, "PUSD_USDT");
641        } else {
642            panic!("Expected Market Summary command");
643        }
644    }
645
646    #[test]
647    fn test_cli_parse_report_batch() {
648        let cli = Cli::try_parse_from([
649            "scope",
650            "report",
651            "batch",
652            "--addresses",
653            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
654            "--output",
655            "report.md",
656        ])
657        .unwrap();
658        assert!(matches!(cli.command, Commands::Report(_)));
659    }
660
661    #[test]
662    fn test_cli_parse_market_summary_with_thresholds() {
663        let cli = Cli::try_parse_from([
664            "scope",
665            "market",
666            "summary",
667            "--peg-range",
668            "0.002",
669            "--min-bid-ask-ratio",
670            "0.1",
671            "--max-bid-ask-ratio",
672            "10",
673        ])
674        .unwrap();
675        if let Commands::Market(market::MarketCommands::Summary(args)) = cli.command {
676            assert_eq!(args.peg_range, 0.002);
677            assert_eq!(args.min_bid_ask_ratio, 0.1);
678            assert_eq!(args.max_bid_ask_ratio, 10.0);
679        } else {
680            panic!("Expected Market Summary command");
681        }
682    }
683
684    #[test]
685    fn test_cli_parse_token_health() {
686        let cli = Cli::try_parse_from(["scope", "token-health", "USDC"]).unwrap();
687        if let Commands::TokenHealth(args) = cli.command {
688            assert_eq!(args.token, "USDC");
689            assert!(!args.with_market);
690        } else {
691            panic!("Expected TokenHealth command");
692        }
693    }
694
695    #[test]
696    fn test_cli_parse_token_health_alias() {
697        let cli = Cli::try_parse_from(["scope", "health", "USDC", "--with-market"]).unwrap();
698        if let Commands::TokenHealth(args) = cli.command {
699            assert_eq!(args.token, "USDC");
700            assert!(args.with_market);
701            assert!(matches!(
702                args.market_venue,
703                crate::market::MarketVenue::Binance
704            ));
705        } else {
706            panic!("Expected TokenHealth command");
707        }
708    }
709
710    #[test]
711    fn test_cli_parse_token_health_market_venue_biconomy() {
712        let cli = Cli::try_parse_from([
713            "scope",
714            "token-health",
715            "USDC",
716            "--with-market",
717            "--market-venue",
718            "biconomy",
719        ])
720        .unwrap();
721        if let Commands::TokenHealth(args) = cli.command {
722            assert!(matches!(
723                args.market_venue,
724                crate::market::MarketVenue::Biconomy
725            ));
726        } else {
727            panic!("Expected TokenHealth command");
728        }
729    }
730
731    #[test]
732    fn test_cli_parse_token_health_market_venue_eth() {
733        let cli = Cli::try_parse_from([
734            "scope",
735            "token-health",
736            "USDC",
737            "--with-market",
738            "--market-venue",
739            "eth",
740        ])
741        .unwrap();
742        if let Commands::TokenHealth(args) = cli.command {
743            assert!(matches!(
744                args.market_venue,
745                crate::market::MarketVenue::Ethereum
746            ));
747        } else {
748            panic!("Expected TokenHealth command");
749        }
750    }
751
752    #[test]
753    fn test_cli_parse_token_health_market_venue_solana() {
754        let cli = Cli::try_parse_from([
755            "scope",
756            "token-health",
757            "USDC",
758            "--with-market",
759            "--market-venue",
760            "solana",
761        ])
762        .unwrap();
763        if let Commands::TokenHealth(args) = cli.command {
764            assert!(matches!(
765                args.market_venue,
766                crate::market::MarketVenue::Solana
767            ));
768        } else {
769            panic!("Expected TokenHealth command");
770        }
771    }
772
773    #[test]
774    fn test_cli_parse_ai_flag() {
775        let cli = Cli::try_parse_from([
776            "scope",
777            "--ai",
778            "address",
779            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
780        ])
781        .unwrap();
782        assert!(cli.ai);
783    }
784}