agentswap 0.6.0

AgentSwap CLI — AI-agent-optimized DEX aggregator
// Clap argument definitions for the AgentSwap binary.
// Exports: Cli and Commands.
// Deps: clap derive macros.

use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(
    name = "agentswap",
    version,
    about = "AgentSwap CLI — AI-agent-optimized DEX aggregator"
)]
pub struct Cli {
    /// Output raw JSON instead of formatted tables
    #[arg(short, long, global = true)]
    pub json: bool,

    /// AgentSwap service URL
    #[arg(
        short,
        long,
        global = true,
        env = "AGENTSWAP_URL",
        default_value = "https://api.agentswap.co"
    )]
    pub url: String,

    /// API key for authenticated endpoints
    #[arg(short = 'k', long, global = true, env = "SR_API_KEY")]
    pub api_key: Option<String>,

    /// Local signer key file for trade/MCP signing
    #[arg(long, global = true, env = "AGENTSWAP_KEY_FILE")]
    pub key_file: Option<String>,

    /// Pay and retry when the service answers 402, which happens past the free allowance.
    /// Quotes, trades and intents need no payment while a request is served.
    #[arg(long, global = true, env = "AGENTSWAP_X402")]
    pub x402: bool,

    /// Pay through x402 even when an API key is configured and the request would be served
    #[arg(long, global = true)]
    pub prefer_x402: bool,

    /// Local key file used for x402 EIP-3009 signatures
    #[arg(long, global = true, env = "AGENTSWAP_X402_KEY_FILE")]
    pub x402_key_file: Option<String>,

    /// Chain ID the x402 payment is made on
    #[arg(long = "x402-chainid", global = true, env = "AGENTSWAP_X402_CHAINID", default_value_t = 8453)]
    pub x402_chain_id: u64,

    /// Maximum x402 payment amount as unsigned decimal digits in raw token units
    #[arg(long, global = true, env = "AGENTSWAP_X402_MAX_AMOUNT", default_value = "0")]
    pub x402_max_amount: String,

    /// x402 asset symbol or address selector
    #[arg(long, global = true, env = "AGENTSWAP_X402_ASSET", default_value = "USDC")]
    pub x402_asset: String,

    /// Allow trade execution; otherwise trade is forced to dry-run
    #[arg(long, global = true)]
    pub allow_trade: bool,

    /// Per-trade cap on amountIn as unsigned decimal digits in raw token units; refuses to sign/self-submit above it. Also bounds MCP trades.
    #[arg(long = "max-amount", global = true, env = "AGENTSWAP_TRADE_MAX_AMOUNT")]
    pub trade_max_amount: Option<String>,

    #[command(subcommand)]
    pub command: Commands,
}

#[derive(Subcommand)]
pub enum Commands {
    /// Get quotes for multiple token pairs at once
    BatchQuote {
        /// Chain ID such as 8453; known aliases such as base are also accepted.
        #[arg(short, long = "chainid")]
        chain_id: String,
        #[arg(required = true)]
        pairs: Vec<String>,
        /// Unsigned decimal amount in the input token's smallest unit.
        #[arg(short, long)]
        amount: String,
    },
    /// List supported chains and DEX info
    Chains,
    /// Show wallet call instructions for buying API quota
    BuyQuota {
        /// Chain ID such as 8453; known aliases such as base are also accepted.
        #[arg(short, long = "chainid")]
        chain_id: String,
        #[arg(short, long)]
        token: String,
        /// Unsigned decimal amount in the input token's smallest unit.
        #[arg(short, long)]
        amount: String,
    },
    /// Get a swap quote
    Quote {
        /// Chain ID such as 8453; known aliases such as base are also accepted.
        #[arg(short, long = "chainid")]
        chain_id: String,
        #[arg(short, long)]
        from: String,
        #[arg(short, long)]
        to: String,
        /// Unsigned decimal amount in the input token's smallest unit.
        #[arg(short, long)]
        amount: String,
        #[arg(short, long)]
        slippage: Option<u16>,
        #[arg(long)]
        verify: bool,
    },
    /// Check service health
    Health,
    /// Show API key status, quota, and usage
    KeyInfo,
    /// List supported tokens
    Tokens {
        /// Chain ID such as 8453; known aliases such as base are also accepted.
        #[arg(short, long = "chainid")]
        chain_id: Option<String>,
    },
    /// Inspect a specific pool
    Pools {
        /// Chain ID such as 8453; known aliases such as base are also accepted.
        #[arg(short, long = "chainid")]
        chain_id: String,
        #[arg(short, long)]
        address: String,
    },
    /// Register a new API key via wallet signature
    Register {
        #[arg(long)]
        address: String,
        #[arg(long)]
        private_key: Option<String>,
        #[arg(long)]
        key_file: Option<String>,
    },
    /// Show quote pricing + quota purchase contract mapping
    Pricing,
    /// Claim purchased API quote quota using an on-chain tx hash
    QuotaClaim {
        /// Chain ID such as 8453; known aliases such as base are also accepted.
        #[arg(short, long = "chainid")]
        chain_id: String,
        #[arg(long)]
        tx_hash: String,
    },
    /// Explain a saved quote route by hash
    RouteExplain {
        #[arg(long)]
        hash: String,
    },
    /// Run an MCP server over stdio
    Mcp,
    /// Place, list, or inspect V6 open intents
    Intent {
        #[command(subcommand)]
        command: IntentCommands,
    },
    /// Show the V6 policy and token budgets for an agent
    Policy {
        /// Chain ID such as 8453; known aliases such as base are also accepted.
        #[arg(short, long = "chainid")]
        chain_id: String,
        #[arg(long)]
        owner: String,
        #[arg(long)]
        agent: String,
        #[arg(long)]
        lookback_blocks: Option<u64>,
        #[arg(long = "token")]
        tokens: Vec<String>,
    },
    /// Quote and sign an agent order, optionally self-submitting it; dry-run requires a reachable RPC and deployed V6 proxy to verify policy and order hashes before signing
    Trade {
        /// Chain ID such as 8453; known aliases such as base are also accepted.
        #[arg(short, long = "chainid")]
        chain_id: String,
        #[arg(short, long)]
        from: String,
        #[arg(short, long)]
        to: String,
        /// Unsigned decimal amount in the input token's smallest unit.
        #[arg(short, long)]
        amount: String,
        #[arg(short, long)]
        slippage: Option<u16>,
        /// Optional unsigned decimal minimum output in raw token units.
        #[arg(long)]
        min_out: Option<String>,
        #[arg(long, default_value = "agent-order")]
        mode: String,
        #[arg(long, env = "AGENTSWAP_PROXY")]
        proxy: String,
        #[arg(long)]
        nonce: Option<String>,
        #[arg(long, default_value_t = 120)]
        deadline_secs: u64,
        #[arg(long)]
        dry_run: bool,
        #[arg(long)]
        self_submit: bool,
        #[arg(long)]
        key_file: Option<String>,
    },
}

#[derive(Subcommand)]
pub enum IntentCommands {
    /// Sign and announce an agent-placed open intent
    Place {
        /// Chain ID such as 8453; known aliases such as base are also accepted.
        #[arg(short, long = "chainid")]
        chain_id: String,
        #[arg(long)]
        proxy_owner: String,
        #[arg(short, long)]
        from: String,
        #[arg(short, long)]
        to: String,
        /// Unsigned decimal input amount in the token's smallest unit.
        #[arg(short, long)]
        amount: String,
        /// Unsigned decimal starting output in the token's smallest unit.
        #[arg(long)]
        start_out: String,
        /// Unsigned decimal ending output in the token's smallest unit.
        #[arg(long)]
        end_out: String,
        #[arg(long)]
        decay_secs: Option<u64>,
        #[arg(long)]
        duration_secs: Option<u64>,
        #[arg(long)]
        deadline_secs: Option<u64>,
        #[arg(long)]
        relay: bool,
        #[arg(long)]
        self_submit: bool,
        #[arg(long)]
        dry_run: bool,
    },
    /// List announced intents by owner or agent
    List {
        /// Chain ID such as 8453; known aliases such as base are also accepted.
        #[arg(short, long = "chainid")]
        chain_id: String,
        #[arg(long, conflicts_with = "agent", required_unless_present = "agent")]
        owner: Option<String>,
        #[arg(long, conflicts_with = "owner", required_unless_present = "owner")]
        agent: Option<String>,
        #[arg(long)]
        lookback_blocks: Option<u64>,
    },
    /// Inspect an announced intent by bytes32 id
    Status {
        /// Chain ID such as 8453; known aliases such as base are also accepted.
        #[arg(short, long = "chainid")]
        chain_id: String,
        #[arg(long)]
        id: String,
        #[arg(long)]
        lookback_blocks: Option<u64>,
    },
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn chainid_is_the_only_chain_selector_flag() {
        let cli = Cli::try_parse_from([
            "agentswap", "quote", "--chainid", "8453", "--from", "USDC", "--to", "WETH",
            "--amount", "1",
        ])
        .expect("--chainid should parse");
        let Commands::Quote { chain_id, .. } = cli.command else {
            panic!("expected quote command");
        };
        assert_eq!(chain_id, "8453");
        assert!(Cli::try_parse_from([
            "agentswap", "quote", "--chain", "base", "--from", "USDC", "--to", "WETH",
            "--amount", "1",
        ])
        .is_err());
    }

    #[test]
    fn no_subcommand_still_takes_the_old_chain_flag() {
        // One rename that is only half done would leave the old flag alive on a command nobody
        // exercises, so every subcommand that selects a chain is probed here.
        let cases: [&[&str]; 6] = [
            &["agentswap", "tokens", "--chain", "base"],
            &["agentswap", "pools", "--chain", "base", "--address", "0x1"],
            &["agentswap", "batch-quote", "--chain", "base", "--amount", "1", "USDC/WETH"],
            &["agentswap", "policy", "--chain", "base", "--owner", "0x1", "--agent", "0x2"],
            &["agentswap", "intent", "list", "--chain", "base", "--owner", "0x1"],
            &["agentswap", "quota-claim", "--chain", "base", "--tx-hash", "0x1"],
        ];
        for case in cases {
            assert!(
                Cli::try_parse_from(case).is_err(),
                "--chain still parses for {:?}",
                case[1]
            );
        }
    }

    #[test]
    fn the_x402_payment_chain_is_selected_by_chainid() {
        let cli = Cli::try_parse_from([
            "agentswap", "--x402-chainid", "42161", "tokens", "--chainid", "8453",
        ])
        .expect("--x402-chainid should parse");
        assert_eq!(cli.x402_chain_id, 42161);
        assert!(Cli::try_parse_from([
            "agentswap", "--x402-chain", "42161", "tokens", "--chainid", "8453",
        ])
        .is_err());
    }
}