Skip to main content

degenbot_cli/
argv.rs

1//! The clap v4 argv tree and the argv -> [`Command`] mapping (ADR-051 D2).
2//!
3//! This is the ONE place argv is spelled. The mapping below translates clap
4//! matches into the constructor-parsed values of `degenbot-cli-core` and never
5//! re-encodes semantics: every arm, flag, prompt and exit code lives there.
6//!
7//! # Where clap's shape differs from the retired click tree
8//!
9//! - **`exchange` (ADR-051 D5, 34 click verbs -> one data-driven command).**
10//!   Python declared one verb per `(chain, DEX)` pair (`base_aerodrome_v2`,
11//!   `ethereum_uniswap_v3`, …). cli-core models that as
12//!   `exchange activate|deactivate --chain <slug|id> --name <dex>`, resolving
13//!   the pair against [`RETIRED_EXCHANGES`](degenbot_cli_core::RETIRED_EXCHANGES).
14//! - **`aave activate|deactivate`.** Python spelled the market as a verb
15//!   (`aave activate ethereum_aave_v3`); cli-core takes `chain_id` and resolves
16//!   the deployment from [`AAVE_DEPLOYMENTS`](degenbot_cli_core::AAVE_DEPLOYMENTS),
17//!   so the tree is `aave activate [--chain-id <id>]` with Python's Ethereum
18//!   default (1) when no chain layer supplied a value.
19//! - **`aave position show <ADDRESS>`.** The click shape is kept, except that
20//!   its chain selector IS the global `--chain-id` (ADR-051 D8) rather than a
21//!   second, position-local spelling of the same thing; cli-core's arm takes
22//!   the resolved session chain id.
23//! - **`--socket`.** cli-core resolves the operator socket through the cascade
24//!   `--socket` > `DEGENBOT_OPERATOR_SOCKET` > `~/.config/degenbot/operator.sock`
25//!   ([`resolve_socket`](degenbot_cli_core::resolve_socket)); click declared
26//!   `--socket` required. The flag is therefore optional here - cli-core owns
27//!   the cascade.
28//! - **`database reset`.** Python marks it `hidden=True`. The command is
29//!   present and visible here (cli-core carries the arm; hiding it would make
30//!   the Rust tree the only place the arm is unreachable).
31//! - **Flag help text** is the Python help text, so the two surfaces read the
32//!   same. The per-flag click `envvar=` fallbacks (`DEGENBOT_CHUNK_SIZE`, …) are
33//!   NOT re-added: cli-core models these inputs as argv only, and the only env
34//!   vocabulary the console owns is the driver-domain set of ADR-051 D8.
35
36use std::io::Write as _;
37
38use clap::{ArgAction, Args, CommandFactory as _, Parser, Subcommand, ValueEnum};
39use degenbot_cli_core::{
40    AaveCommand, CliContext, CliError, Command, DatabaseCommand, ExchangeCommand, FleetCommand,
41    PathCommand, PathDirection, PoolCommand, PoolFamily, PosturePatchEntry, DEFAULT_CHUNK_SIZE,
42    DEFAULT_TO_BLOCK, DEFAULT_VERIFY_ALL_INTERVAL,
43};
44use degenbot_config::{EnvVars, ProcessEnv, DEFAULT_CHAIN_ID_ENV};
45
46use crate::VERSION_LINE;
47
48/// The degenbot console.
49#[derive(Debug, Parser)]
50#[command(
51    name = "degenbot",
52    about = "Perform cli.",
53    version = VERSION_LINE,
54    arg_required_else_help = true,
55    disable_help_subcommand = true
56)]
57pub struct Cli {
58    /// The command to run.
59    #[command(subcommand)]
60    pub command: Option<Commands>,
61
62    /// Path to the SQLite database (`--database` > `DEGENBOT_DB_PATH` >
63    /// `~/.config/degenbot/degenbot.db`).
64    #[arg(long, global = true, value_name = "PATH")]
65    pub database: Option<String>,
66
67    /// Session chain id (`--chain-id` > `DEGENBOT_DEFAULT_CHAIN_ID`).
68    #[arg(long, global = true, value_name = "CHAIN_ID")]
69    pub chain_id: Option<String>,
70
71    /// HTTP RPC endpoint (`--node-http` > `DEGENBOT_RPC_HTTP_CHAINID_<id>`).
72    #[arg(long, global = true, value_name = "URI")]
73    pub node_http: Option<String>,
74
75    /// WebSocket RPC endpoint (`--node-ws` > `DEGENBOT_RPC_WS_CHAINID_<id>`).
76    #[arg(long, global = true, value_name = "URI")]
77    pub node_ws: Option<String>,
78}
79
80/// The command groups.
81#[derive(Debug, Subcommand)]
82pub enum Commands {
83    /// Database commands.
84    Database {
85        /// The database command.
86        #[command(subcommand)]
87        command: DatabaseSub,
88    },
89    /// Exchange commands.
90    Exchange {
91        /// The exchange command.
92        #[command(subcommand)]
93        command: ExchangeSub,
94    },
95    /// Pool commands.
96    Pool {
97        /// The pool command.
98        #[command(subcommand)]
99        command: PoolSub,
100    },
101    /// Aave commands.
102    Aave {
103        /// The aave command.
104        #[command(subcommand)]
105        command: AaveSub,
106    },
107    /// Steer the live worker fleet over the operator command channel.
108    Fleet {
109        /// The fleet command.
110        #[command(subcommand)]
111        command: FleetSub,
112    },
113    /// Steer a live bot over the operator command channel.
114    Path {
115        /// The path command.
116        #[command(subcommand)]
117        command: PathSub,
118    },
119}
120
121/// The `database` command group.
122#[derive(Debug, Subcommand)]
123pub enum DatabaseSub {
124    /// Back up the database.
125    Backup,
126    /// Remove and recreate the database.
127    Reset {
128        /// Skip confirmation prompt.
129        #[arg(long)]
130        force: bool,
131    },
132    /// Upgrade the database to the latest schema (RETIRED: the database
133    /// upgrades itself at open; `database heal` is the explicit repair).
134    Upgrade {
135        /// Skip confirmation prompt.
136        #[arg(long)]
137        force: bool,
138    },
139    /// Compact the database.
140    Compact,
141    /// Flip an Alembic-stamped DB into Rust schema ownership (ADR-010).
142    Cutover {
143        /// Report the schema state + what cutover would do; write nothing.
144        #[arg(long)]
145        dry_run: bool,
146        /// Skip the confirmation prompt and run the cutover.
147        #[arg(long)]
148        force: bool,
149    },
150    /// Rebuild an Alembic-stamped DB into Rust ownership via dump-and-restore
151    /// (ADR-011).
152    Heal {
153        /// Report the schema state + what heal would do; write nothing.
154        #[arg(long)]
155        dry_run: bool,
156        /// Skip the confirmation prompt and run the heal.
157        #[arg(long)]
158        force: bool,
159    },
160    /// Inspect the database schema state (read-only; never writes).
161    Inspect,
162}
163
164/// The `exchange` command group.
165#[derive(Debug, Subcommand)]
166pub enum ExchangeSub {
167    /// Activate the exchange. Liquidity pools for all activated exchanges are
168    /// included when running "pool update".
169    Activate {
170        /// The chain selector: a chain slug (`base`, `ethereum`) or a numeric
171        /// chain id.
172        #[arg(long, value_name = "CHAIN")]
173        chain: String,
174        /// The DEX name slug stored in the database (`aerodrome_v2`,
175        /// `uniswap_v3`, …).
176        #[arg(long, value_name = "NAME")]
177        name: String,
178    },
179    /// Deactivate the exchange. Liquidity pools for all deactivated exchanges
180    /// are not included when running "pool update".
181    Deactivate {
182        /// The chain selector: a chain slug (`base`, `ethereum`) or a numeric
183        /// chain id.
184        #[arg(long, value_name = "CHAIN")]
185        chain: String,
186        /// The DEX name slug stored in the database.
187        #[arg(long, value_name = "NAME")]
188        name: String,
189    },
190}
191
192/// The `pool` command group.
193#[derive(Debug, Subcommand)]
194pub enum PoolSub {
195    /// Update liquidity pool information for activated exchanges.
196    Update {
197        /// The maximum number of blocks to process before committing changes to
198        /// the database.
199        #[arg(long = "chunk", value_name = "BLOCKS", default_value_t = DEFAULT_CHUNK_SIZE)]
200        chunk_size: u64,
201        /// The last block in the update range. Must be a valid block
202        /// identifier: 'earliest', 'finalized', 'safe', 'latest', 'pending'.
203        /// An identifier can be given with an optional offset, e.g. 'latest:-64'
204        /// stops 64 blocks before the chain tip, 'safe:128' stops 128 blocks
205        /// after the last 'safe' block.
206        #[arg(long = "to-block", value_name = "BLOCK", default_value = DEFAULT_TO_BLOCK)]
207        to_block: String,
208        /// The pre-commit verification gates.
209        #[command(flatten)]
210        verify: VerifyFlags,
211        /// Block interval for the `--verify-all` full-verification gate. A chunk
212        /// that crosses or lands-on a multiple of this interval triggers a
213        /// pre-commit market-wide verify. Ignored unless `--verify-all` is set.
214        #[arg(
215            long = "verify-all-interval",
216            value_name = "BLOCKS",
217            default_value_t = DEFAULT_VERIFY_ALL_INTERVAL
218        )]
219        verify_all_interval: u64,
220    },
221    /// Verify a pool's committed liquidity map against on-chain truth.
222    Verify {
223        /// The HTTP RPC endpoint to read on-chain truth from.
224        #[arg(long = "rpc-url", value_name = "URL", required = true)]
225        rpc_url: String,
226        /// The chain id the pool lives on. The Rust field is named
227        /// `pool_chain_id` so its clap arg id cannot collide with the global
228        /// `--chain-id` driver flag; the argv spelling stays `--chain`.
229        #[arg(long = "chain", value_name = "CHAIN_ID", required = true)]
230        pool_chain_id: i64,
231        /// The block number to read on-chain truth at.
232        #[arg(long = "block", value_name = "BLOCK", required = true)]
233        block_number: u64,
234        #[arg(
235            long = "pool",
236            value_name = "POOL",
237            required = true,
238            help = "The pool to verify. V3: the pool contract address. V4: the PoolId (pool_hash, bytes32 hex 0x…)."
239        )]
240        pool: String,
241        #[arg(
242            long = "family",
243            value_enum,
244            required = true,
245            help = "The pool family (selects ticks()/tickBitmap() vs PoolManager extsload)."
246        )]
247        family: FamilyArg,
248        #[arg(
249            long = "pool-manager",
250            value_name = "ADDRESS",
251            help = "(V4 only) The deployed V4 PoolManager singleton address (the V4 exchange's factory). Required for --family v4."
252        )]
253        pool_manager: Option<String>,
254    },
255}
256
257/// The `--verify-chunk` / `--verify-all` gate pairs shared by both updaters.
258#[derive(Debug, Clone, Args)]
259#[expect(
260    clippy::struct_excessive_bools,
261    reason = "the two on/off gate pairs are argv vocabulary, not state: each pair is two mutually-overriding flags"
262)]
263pub struct VerifyFlags {
264    /// Run the pre-commit per-chunk on-chain-truth gate before each chunk's
265    /// persist commits. A divergence rolls back the chunk + does NOT advance
266    /// `last_update_block`.
267    #[arg(long = "verify-chunk", action = ArgAction::SetTrue, overrides_with = "no_verify_chunk")]
268    pub verify_chunk: bool,
269    /// Disable the pre-commit per-chunk on-chain-truth gate.
270    #[arg(long = "no-verify-chunk", action = ArgAction::SetTrue, overrides_with = "verify_chunk")]
271    pub no_verify_chunk: bool,
272    /// Run a pre-commit FULL verification at the block boundary set by
273    /// `--verify-all-interval` AND when the run completes its last block. Off
274    /// by default (operator opt-in).
275    #[arg(long = "verify-all", action = ArgAction::SetTrue, overrides_with = "no_verify_all")]
276    pub verify_all: bool,
277    /// Disable the market-wide `--verify-all` gate.
278    #[arg(long = "no-verify-all", action = ArgAction::SetTrue, overrides_with = "verify_all")]
279    pub no_verify_all: bool,
280}
281
282impl VerifyFlags {
283    /// The per-chunk gate value: default ON, `--no-verify-chunk` turns it off.
284    #[must_use]
285    pub const fn chunk_gate(&self) -> bool {
286        self.verify_chunk || !self.no_verify_chunk
287    }
288
289    /// The market-wide gate value: default OFF, `--verify-all` turns it on.
290    #[must_use]
291    pub const fn all_gate(&self) -> bool {
292        self.verify_all && !self.no_verify_all
293    }
294}
295
296/// The pool family selector.
297#[derive(Debug, Clone, Copy, ValueEnum)]
298pub enum FamilyArg {
299    /// V3 (`ticks()`/`tickBitmap()`).
300    V3,
301    /// V4 (`PoolManager` `extsload`).
302    V4,
303}
304
305/// The `aave` command group.
306#[derive(Debug, Subcommand)]
307pub enum AaveSub {
308    /// Activate an Aave market. Positions for activated markets are included
309    /// when running `degenbot aave position update`.
310    Activate,
311    /// Deactivate an Aave market. Positions for deactivated markets are not
312    /// included when running `degenbot aave position update`.
313    Deactivate {
314        /// Market name to flip (default: Aave Ethereum Market).
315        #[arg(
316            long = "name",
317            value_name = "MARKET",
318            default_value = "Aave Ethereum Market"
319        )]
320        market_name: String,
321    },
322    /// Update positions for active Aave markets.
323    Update {
324        /// The maximum number of blocks to process before committing changes to
325        /// the database.
326        #[arg(long = "chunk", value_name = "BLOCKS", default_value_t = DEFAULT_CHUNK_SIZE)]
327        chunk_size: u64,
328        /// The last block in the update range. Must be a valid block
329        /// identifier: 'earliest', 'finalized', 'safe', 'latest', 'pending'.
330        /// An identifier can be given with an optional offset, e.g. 'latest:-64'
331        /// stops 64 blocks before the chain tip, 'safe:128' stops 128 blocks
332        /// after the last 'safe' block.
333        #[arg(long = "to-block", value_name = "BLOCK", default_value = DEFAULT_TO_BLOCK)]
334        to_block: String,
335        /// The pre-commit verification gates.
336        #[command(flatten)]
337        verify: VerifyFlags,
338        /// Block interval for the `--verify-all` full-verification gate. A chunk
339        /// that crosses or lands-on a multiple of this interval triggers a
340        /// pre-commit market-wide verify. Ignored unless `--verify-all` is set.
341        #[arg(
342            long = "verify-all-interval",
343            value_name = "BLOCKS",
344            default_value_t = DEFAULT_VERIFY_ALL_INTERVAL
345        )]
346        verify_all_interval: u64,
347        /// Stop processing after the first chunk.
348        #[arg(long = "one-chunk", action = ArgAction::SetTrue)]
349        stop_after_one_chunk: bool,
350        /// Preview changes without committing to the database.
351        #[arg(long = "dry-run", action = ArgAction::SetTrue)]
352        dry_run: bool,
353        /// Create a database backup after the completion verification runs
354        /// (once per market, at the end of the run).
355        #[arg(long = "backup", action = ArgAction::SetTrue, overrides_with = "no_backup")]
356        backup: bool,
357        /// Disable the completion backup.
358        #[arg(long = "no-backup", action = ArgAction::SetTrue, overrides_with = "backup")]
359        no_backup: bool,
360    },
361    /// Position commands.
362    Position {
363        /// The position command.
364        #[command(subcommand)]
365        command: AavePositionSub,
366    },
367}
368
369/// The `aave position` command group.
370#[derive(Debug, Subcommand)]
371pub enum AavePositionSub {
372    /// Display current Aave positions for a user.
373    Show {
374        /// The user address.
375        #[arg(value_name = "ADDRESS")]
376        address: String,
377        /// Market name to query (default: Aave Ethereum Market).
378        #[arg(
379            long = "market",
380            value_name = "MARKET",
381            default_value = "Aave Ethereum Market"
382        )]
383        market: String,
384    },
385}
386
387/// The `fleet` command group.
388#[derive(Debug, Subcommand)]
389pub enum FleetSub {
390    /// Inspect or re-tune the live cordon posture thresholds.
391    Posture {
392        /// The posture command.
393        #[command(subcommand)]
394        command: FleetPostureSub,
395    },
396}
397
398/// The `fleet posture` command group.
399#[derive(Debug, Subcommand)]
400pub enum FleetPostureSub {
401    /// Show the LIVE cordon posture: thresholds + Nominal|Cordoned.
402    Show {
403        #[arg(
404            long,
405            value_name = "PATH",
406            help = "Unix domain socket path of the running bot's OperatorServer."
407        )]
408        socket: Option<String>,
409    },
410    /// Re-tune the LIVE cordon thresholds (a partial patch).
411    Set {
412        #[arg(
413            long,
414            value_name = "PATH",
415            help = "Unix domain socket path of the running bot's OperatorServer."
416        )]
417        socket: Option<String>,
418        /// Throttle events within the enter window that cordon the fleet.
419        #[arg(long = "cordon-enter-events", value_name = "COUNT")]
420        cordon_enter_events: Option<u64>,
421        /// Throttled-time duty percent over the duty window that cordons the
422        /// fleet.
423        #[arg(long = "cordon-duty-percent", value_name = "PERCENT")]
424        cordon_duty_percent: Option<f64>,
425        /// Rolling window (ms) for the throttle-event burst enter trigger.
426        #[arg(long = "cordon-enter-window-ms", value_name = "MS")]
427        cordon_enter_window_ms: Option<u64>,
428        /// Trailing window (ms) over which throttled-time duty is evaluated.
429        #[arg(long = "cordon-duty-window-ms", value_name = "MS")]
430        cordon_duty_window_ms: Option<u64>,
431        /// Clean-window hysteresis (ms) required before cordon exits.
432        #[arg(long = "cordon-exit-clean-ms", value_name = "MS")]
433        cordon_exit_clean_ms: Option<u64>,
434        #[arg(
435            long = "cordon-sim-intake-floor",
436            value_name = "COUNT|null",
437            help = "SimDriver new-lease cap while cordoned. The literal null restores half the slot cap."
438        )]
439        cordon_sim_intake_floor: Option<String>,
440    },
441}
442
443/// The `path` command group.
444#[derive(Debug, Subcommand)]
445pub enum PathSub {
446    /// Add ONE specific path to the live bot mid-run.
447    Add {
448        #[arg(
449            long,
450            value_name = "PATH",
451            help = "Unix domain socket path of the running bot's OperatorServer."
452        )]
453        socket: Option<String>,
454        /// A path hop as FAMILY:ADDRESS, or V4:ADDRESS:HASH (pool id). Repeat
455        /// for each hop, in path order.
456        #[arg(long = "hop", value_name = "HOP", required = true)]
457        hops: Vec<String>,
458        /// Applied to every hop when set: 'zfo' (zero-for-one, True for each
459        /// hop) or 'ozf' (one-for-zero, False for each hop). Omit to let the bot
460        /// auto-resolve directions.
461        #[arg(long = "direction", value_enum)]
462        direction: Option<DirectionArg>,
463    },
464    /// Trigger a bounded on-demand discovery sweep on the live bot.
465    Discover {
466        #[arg(
467            long,
468            value_name = "PATH",
469            help = "Unix domain socket path of the running bot's OperatorServer."
470        )]
471        socket: Option<String>,
472        /// Maximum number of paths to process in this discovery sweep.
473        #[arg(long = "bound", value_name = "COUNT")]
474        bound: Option<u64>,
475    },
476}
477
478/// The `--direction` choice on `path add`.
479#[derive(Debug, Clone, Copy, ValueEnum)]
480pub enum DirectionArg {
481    /// Zero-for-one.
482    Zfo,
483    /// One-for-zero.
484    Ozf,
485}
486
487/// The [`CliContext`] the argv overrides describe (ADR-051 D8).
488#[must_use]
489pub fn context<'a>(cli: &Cli, env: &'a dyn EnvVars) -> CliContext<'a> {
490    let mut ctx = CliContext::new(env);
491    if let Some(database) = &cli.database {
492        ctx = ctx.with_database(database.clone());
493    }
494    if let Some(chain_id) = &cli.chain_id {
495        ctx = ctx.with_chain_id(chain_id.clone());
496    }
497    if let Some(node_http) = &cli.node_http {
498        ctx = ctx.with_node_http(node_http.clone());
499    }
500    if let Some(node_ws) = &cli.node_ws {
501        ctx = ctx.with_node_ws(node_ws.clone());
502    }
503    ctx
504}
505
506/// Map argv into a [`Command`], reading the driver-domain env through the real
507/// process environment.
508///
509/// # Errors
510///
511/// [`CliError`] when an arm needs a driver-domain value no layer supplied, or a
512/// malformed `--chain-id` / `cordon_sim_intake_floor`.
513pub fn resolve(cli: &Cli) -> Result<Command, CliError> {
514    resolve_with_env(cli, &ProcessEnv)
515}
516
517/// Map argv into a [`Command`] over an injectable env seam (tests never mutate
518/// the process environment).
519///
520/// # Errors
521///
522/// As [`resolve`].
523///
524/// # Panics
525///
526/// Never: a missing subcommand is a typed [`CliError::InvalidArgument`].
527pub fn resolve_with_env(cli: &Cli, env: &dyn EnvVars) -> Result<Command, CliError> {
528    let ctx = context(cli, env);
529    let Some(command) = &cli.command else {
530        return Err(CliError::InvalidArgument(
531            "a subcommand is required".to_string(),
532        ));
533    };
534    match command {
535        Commands::Database { command } => Ok(Command::Database(database(command))),
536        Commands::Exchange { command } => Ok(Command::Exchange(exchange(command))),
537        Commands::Pool { command } => Ok(Command::Pool(pool(command))),
538        Commands::Aave { command } => Ok(Command::Aave(aave(command, cli, &ctx)?)),
539        Commands::Fleet { command } => Ok(Command::Fleet(fleet(command)?)),
540        Commands::Path { command } => Ok(Command::Path(path(command))),
541    }
542}
543
544/// Print clap's own usage block for the no-subcommand-but-args case.
545pub fn write_missing_subcommand_error() {
546    let mut command = Cli::command();
547    let usage = command.render_usage().to_string();
548    let _ = writeln!(
549        std::io::stderr().lock(),
550        "error: a subcommand is required\n\n{usage}"
551    );
552}
553
554fn database(command: &DatabaseSub) -> DatabaseCommand {
555    match command {
556        DatabaseSub::Backup => DatabaseCommand::Backup,
557        DatabaseSub::Reset { force } => DatabaseCommand::Reset { force: *force },
558        DatabaseSub::Upgrade { force } => DatabaseCommand::Upgrade { force: *force },
559        DatabaseSub::Compact => DatabaseCommand::Compact,
560        DatabaseSub::Cutover { dry_run, force } => DatabaseCommand::Cutover {
561            dry_run: *dry_run,
562            force: *force,
563        },
564        DatabaseSub::Heal { dry_run, force } => DatabaseCommand::Heal {
565            dry_run: *dry_run,
566            force: *force,
567        },
568        DatabaseSub::Inspect => DatabaseCommand::Inspect,
569    }
570}
571
572fn exchange(command: &ExchangeSub) -> ExchangeCommand {
573    match command {
574        ExchangeSub::Activate { chain, name } => ExchangeCommand::Activate {
575            chain: chain.clone(),
576            name: name.clone(),
577        },
578        ExchangeSub::Deactivate { chain, name } => ExchangeCommand::Deactivate {
579            chain: chain.clone(),
580            name: name.clone(),
581        },
582    }
583}
584
585fn pool(command: &PoolSub) -> PoolCommand {
586    match command {
587        PoolSub::Update {
588            chunk_size,
589            to_block,
590            verify,
591            verify_all_interval,
592        } => PoolCommand::Update {
593            chunk_size: *chunk_size,
594            to_block: to_block.clone(),
595            verify_chunk: verify.chunk_gate(),
596            verify_all: verify.all_gate(),
597            verify_all_interval: *verify_all_interval,
598        },
599        PoolSub::Verify {
600            rpc_url,
601            pool_chain_id,
602            block_number,
603            pool,
604            family,
605            pool_manager,
606        } => PoolCommand::Verify {
607            rpc_url: rpc_url.clone(),
608            chain_id: *pool_chain_id,
609            block_number: *block_number,
610            pool: pool.clone(),
611            family: match family {
612                FamilyArg::V3 => PoolFamily::V3,
613                FamilyArg::V4 => PoolFamily::V4,
614            },
615            pool_manager: pool_manager.clone(),
616        },
617    }
618}
619
620fn aave(command: &AaveSub, cli: &Cli, ctx: &CliContext<'_>) -> Result<AaveCommand, CliError> {
621    match command {
622        AaveSub::Activate => Ok(AaveCommand::Activate {
623            chain_id: chain_or_default(cli, ctx, 1)?,
624        }),
625        AaveSub::Deactivate { market_name } => Ok(AaveCommand::Deactivate {
626            chain_id: chain_or_default(cli, ctx, 1)?,
627            market_name: market_name.clone(),
628        }),
629        AaveSub::Update {
630            chunk_size,
631            to_block,
632            verify,
633            verify_all_interval,
634            stop_after_one_chunk,
635            dry_run,
636            backup,
637            no_backup,
638        } => Ok(AaveCommand::Update {
639            chunk_size: *chunk_size,
640            to_block: to_block.clone(),
641            verify_chunk: verify.chunk_gate(),
642            verify_all: verify.all_gate(),
643            verify_all_interval: *verify_all_interval,
644            stop_after_one_chunk: *stop_after_one_chunk,
645            dry_run: *dry_run,
646            enable_backup: *backup && !*no_backup,
647        }),
648        AaveSub::Position { command } => match command {
649            AavePositionSub::Show { address, market } => Ok(AaveCommand::PositionShow {
650                address: address.clone(),
651                market: market.clone(),
652                chain_id: chain_or_default(cli, ctx, 1)?,
653            }),
654        },
655    }
656}
657
658fn fleet(command: &FleetSub) -> Result<FleetCommand, CliError> {
659    match command {
660        FleetSub::Posture { command } => match command {
661            FleetPostureSub::Show { socket } => Ok(FleetCommand::PostureShow {
662                socket: socket.clone(),
663            }),
664            FleetPostureSub::Set {
665                socket,
666                cordon_enter_events,
667                cordon_duty_percent,
668                cordon_enter_window_ms,
669                cordon_duty_window_ms,
670                cordon_exit_clean_ms,
671                cordon_sim_intake_floor,
672            } => {
673                let mut patch = Vec::new();
674                if let Some(value) = cordon_enter_events {
675                    patch.push(PosturePatchEntry::int("cordon_enter_events", *value));
676                }
677                if let Some(value) = cordon_duty_percent {
678                    patch.push(PosturePatchEntry::float("cordon_duty_percent", *value));
679                }
680                if let Some(value) = cordon_enter_window_ms {
681                    patch.push(PosturePatchEntry::int("cordon_enter_window_ms", *value));
682                }
683                if let Some(value) = cordon_duty_window_ms {
684                    patch.push(PosturePatchEntry::int("cordon_duty_window_ms", *value));
685                }
686                if let Some(value) = cordon_exit_clean_ms {
687                    patch.push(PosturePatchEntry::int("cordon_exit_clean_ms", *value));
688                }
689                if let Some(value) = cordon_sim_intake_floor {
690                    patch.push(PosturePatchEntry::new(
691                        "cordon_sim_intake_floor",
692                        degenbot_cli_core::parse_sim_intake_floor(value)?,
693                    ));
694                }
695                Ok(FleetCommand::PostureSet {
696                    socket: socket.clone(),
697                    patch,
698                })
699            }
700        },
701    }
702}
703
704fn path(command: &PathSub) -> PathCommand {
705    match command {
706        PathSub::Add {
707            socket,
708            hops,
709            direction,
710        } => PathCommand::Add {
711            socket: socket.clone(),
712            hops: hops.clone(),
713            direction: direction.map(|direction| match direction {
714                DirectionArg::Zfo => PathDirection::Zfo,
715                DirectionArg::Ozf => PathDirection::Ozf,
716            }),
717        },
718        PathSub::Discover { socket, bound } => PathCommand::Discover {
719            socket: socket.clone(),
720            bound: *bound,
721        },
722    }
723}
724
725/// The session chain id for the aave arms, which carry Python's Ethereum
726/// default when NO chain layer (`--chain-id` or `DEGENBOT_DEFAULT_CHAIN_ID`)
727/// supplied a value. A layer that IS present and malformed stays an error.
728fn chain_or_default(cli: &Cli, ctx: &CliContext<'_>, default: u64) -> Result<u64, CliError> {
729    let cli_layer = cli
730        .chain_id
731        .as_deref()
732        .is_some_and(|value| !value.is_empty());
733    let env_layer = ctx
734        .env()
735        .get(DEFAULT_CHAIN_ID_ENV)
736        .is_some_and(|value| !value.is_empty());
737    if cli_layer || env_layer {
738        return ctx
739            .chain_id()
740            .map(|resolved| resolved.value)
741            .map_err(CliError::from);
742    }
743    Ok(default)
744}