diff --git a/kaleido_cli/commands/channel.py b/kaleido_cli/commands/channel.py index a0f4645..03d0d23 100644 --- a/kaleido_cli/commands/channel.py +++ b/kaleido_cli/commands/channel.py @@ -36,6 +36,7 @@ print_panel, print_success, ) +from kaleido_cli.utils.prompts import resolve_required_text channel_app = typer.Typer( no_args_is_help=True, @@ -691,14 +692,17 @@ async def _channel_order_create(params) -> None: ), ) def channel_order_get( - order_id: Annotated[str, typer.Argument(help="LSP order ID.")], + order_id: Annotated[str | None, typer.Argument(help="LSP order ID.")] = None, access_token: Annotated[ - str, - typer.Option("--access-token", help="Optional access token returned for the order."), - ] = "", + str | None, + typer.Option("--access-token", help="Access token returned for the order."), + ] = None, ) -> None: """Get the status and details of an LSP channel order.""" - asyncio.run(_channel_order_get(order_id, access_token)) + resolved_order_id = resolve_required_text(order_id, "LSP order ID", "ORDER_ID argument") + resolved_access_token = resolve_required_text(access_token, "Access token", "--access-token") + + asyncio.run(_channel_order_get(resolved_order_id, resolved_access_token)) async def _channel_order_get(order_id: str, access_token: str) -> None: diff --git a/kaleido_cli/commands/market.py b/kaleido_cli/commands/market.py index 5a7a1d9..28ca95f 100644 --- a/kaleido_cli/commands/market.py +++ b/kaleido_cli/commands/market.py @@ -23,6 +23,11 @@ resolve_quote_layers, resolve_trading_pair, ) +from kaleido_cli.utils.prompts import ( + display_amount_to_raw, + resolve_amount_pair, + resolve_pair, +) market_app = typer.Typer( no_args_is_help=True, @@ -95,10 +100,10 @@ async def _market_pairs() -> None: "quote", epilog=( "[bold]Examples[/bold]\n\n" - " Quote: send 100 000 msat via Lightning, receive USDT via RGB Lightning:\n" - " [cyan]kaleido market quote BTC/USDT --from-amount 100000[/cyan]\n\n" + " Quote: send BTC via Lightning, receive USDT via RGB Lightning:\n" + " [cyan]kaleido market quote BTC/USDT --from-amount 0.001[/cyan]\n\n" " Quote with explicit layers:\n" - " [cyan]kaleido market quote BTC/USDT --from-amount 100000 --from-layer BTC_LN --to-layer RGB_LN[/cyan]\n\n" + " [cyan]kaleido market quote BTC/USDT --from-amount 0.001 --from-layer BTC_LN --to-layer RGB_LN[/cyan]\n\n" " Quote how much BTC is needed to receive 500 USDT:\n" " [cyan]kaleido market quote BTC/USDT --to-amount 500 --from-layer BTC_LN --to-layer RGB_LN[/cyan]\n\n" "[bold]Available layers[/bold]: [green]BTC_LN[/green] [green]RGB_LN[/green] [green]BTC_ONCHAIN[/green]\n" @@ -113,17 +118,17 @@ def market_quote( ), ] = None, from_amount: Annotated[ - int | None, + str | None, typer.Option( "--from-amount", - help="Amount to send (raw units of the base asset). Provide this OR --to-amount.", + help="Amount to send in display units. Provide this OR --to-amount.", ), ] = None, to_amount: Annotated[ - int | None, + str | None, typer.Option( "--to-amount", - help="Amount to receive (raw units of the quote asset). Provide this OR --from-amount.", + help="Amount to receive in display units. Provide this OR --from-amount.", ), ] = None, from_layer: Annotated[ @@ -142,28 +147,14 @@ def market_quote( ] = None, ) -> None: """Get a swap quote for a trading pair.""" - resolved_pair: str - if pair is not None: - resolved_pair = pair - elif is_interactive(): - resolved_pair = typer.prompt("Trading pair (e.g. BTC/USDT)") - else: - print_error("PAIR argument is required in non-interactive mode.") - raise typer.Exit(1) - - if from_amount is None and to_amount is None: - if is_interactive(): - choice = typer.prompt("Quote by [S]end amount or [R]eceive amount?", default="S") - if choice.strip().upper().startswith("R"): - to_amount = typer.prompt("Amount to receive (raw units)", type=int) - else: - from_amount = typer.prompt("Amount to send (raw units)", type=int) - else: - print_error("Provide --from-amount or --to-amount in non-interactive mode.") - raise typer.Exit(1) - elif from_amount is not None and to_amount is not None: - print_error("Provide exactly one of --from-amount or --to-amount.") - raise typer.Exit(1) + resolved_pair = resolve_pair(pair) + resolved_from_amount, resolved_to_amount = resolve_amount_pair( + from_amount, + to_amount, + prompt_prefix="Quote", + default_choice="R", + pair=resolved_pair, + ) resolved_from_layer, resolved_to_layer = resolve_quote_layers( resolved_pair, from_layer, to_layer @@ -171,8 +162,8 @@ def market_quote( asyncio.run( _market_quote( resolved_pair, - from_amount, - to_amount, + resolved_from_amount, + resolved_to_amount, resolved_from_layer, resolved_to_layer, ) @@ -181,8 +172,8 @@ def market_quote( async def _market_quote( pair: str, - from_amount: int | None, - to_amount: int | None, + from_amount: str | None, + to_amount: str | None, from_layer: str, to_layer: str, ) -> None: @@ -205,17 +196,37 @@ async def _market_quote( except ValueError as exc: print_error(str(exc)) raise typer.Exit(1) + resolved_from_amount = ( + display_amount_to_raw( + from_amount, + precision=from_asset.precision, + asset_label=from_asset.ticker, + option_name="--from-amount", + ) + if from_amount is not None + else None + ) + resolved_to_amount = ( + display_amount_to_raw( + to_amount, + precision=to_asset.precision, + asset_label=to_asset.ticker, + option_name="--to-amount", + ) + if to_amount is not None + else None + ) body = PairQuoteRequest( from_asset=SwapLegInput( asset_id=from_asset_id, layer=Layer(from_layer), - amount=from_amount, + amount=resolved_from_amount, ), to_asset=SwapLegInput( asset_id=to_asset_id, layer=Layer(to_layer), - amount=to_amount, + amount=resolved_to_amount, ), ) quote = await client.maker.get_quote(body) diff --git a/kaleido_cli/commands/node.py b/kaleido_cli/commands/node.py index 76f4bf8..a7a76c8 100644 --- a/kaleido_cli/commands/node.py +++ b/kaleido_cli/commands/node.py @@ -51,6 +51,7 @@ "[bold]After starting:[/bold]\n" " [cyan]kaleido node init[/cyan] — initialize node wallet (once)\n" " [cyan]kaleido node unlock[/cyan] — unlock wallet after restart\n" + " [cyan]kaleido node shutdown[/cyan] — gracefully shut down the node process\n" " [cyan]kaleido node info[/cyan] — check node reachability and details\n" ), ) @@ -418,6 +419,25 @@ def node_stop( raise typer.Exit(rc) +@node_app.command( + "shutdown", + epilog=" [cyan]kaleido node shutdown[/cyan] Gracefully shut down the configured node process.", +) +def node_shutdown() -> None: + """Gracefully shut down the configured node.""" + asyncio.run(_node_shutdown()) + + +async def _node_shutdown() -> None: + try: + client = get_client(require_node=True) + await client.rln.shutdown() + print_success("Node shutdown initiated.") + except Exception as e: + print_error(f"Error: {e}") + raise typer.Exit(1) + + @node_app.command( "down", epilog=" [cyan]kaleido node down [/cyan] Stop and remove containers + networks.", diff --git a/kaleido_cli/commands/swap.py b/kaleido_cli/commands/swap.py index 34fbc32..16408b4 100644 --- a/kaleido_cli/commands/swap.py +++ b/kaleido_cli/commands/swap.py @@ -27,7 +27,6 @@ SwapStatusRequest, SwapStatusResponse, TradingPairsResponse, - parse_raw_amount, ) from kaleido_sdk.rln import ( GetSwapRequest, @@ -56,6 +55,12 @@ resolve_quote_layers, resolve_trading_pair, ) +from kaleido_cli.utils.prompts import ( + display_amount_to_raw, + resolve_amount_pair, + resolve_pair, + resolve_required_text, +) from kaleido_cli.utils.swaps import ( decode_swapstring, validate_swapstring_against_quote, @@ -88,70 +93,6 @@ swap_app.add_typer(node_app, name="node") -def _resolve_pair(pair: str | None) -> str: - if pair is not None: - return pair - if is_interactive(): - return typer.prompt("Trading pair (e.g. BTC/USDT)") - print_error("PAIR argument is required in non-interactive mode.") - raise typer.Exit(1) - - -def _resolve_amount_pair( - from_amount: str | None, - to_amount: str | None, - *, - prompt_prefix: str, - default_choice: str, - pair: str, -) -> tuple[str | None, str | None]: - base_ticker, _, quote_ticker = pair.partition("/") - send_label = base_ticker or "base asset" - receive_label = quote_ticker or "quote asset" - if from_amount is None and to_amount is None: - if is_interactive(): - choice = typer.prompt( - f"{prompt_prefix} by [S]end amount or [R]eceive amount?", - default=default_choice, - ) - if choice.strip().upper().startswith("R"): - return None, typer.prompt(f"Amount to receive ({receive_label}, display units)") - return typer.prompt(f"Amount to send ({send_label}, display units)"), None - print_error("Provide --from-amount or --to-amount in non-interactive mode.") - raise typer.Exit(1) - if from_amount is not None and to_amount is not None: - print_error("Provide exactly one of --from-amount or --to-amount.") - raise typer.Exit(1) - return from_amount, to_amount - - -def _display_amount_to_raw( - value: str, - *, - precision: int | None, - asset_label: str, - option_name: str, -) -> int: - normalized = value.strip() - if not normalized: - print_error(f"{option_name} cannot be empty.") - raise typer.Exit(1) - try: - return parse_raw_amount(normalized, precision or 0) - except ValueError as exc: - print_error(f"{option_name} for {asset_label}: {exc}") - raise typer.Exit(1) - - -def _resolve_required_text(value: str | None, prompt: str, option_name: str) -> str: - if value is not None: - return value - if is_interactive(): - return typer.prompt(prompt) - print_error(f"{option_name} is required in non-interactive mode.") - raise typer.Exit(1) - - def _resolve_accept_reject(accept: bool, reject: bool, prompt: str) -> bool: if is_interactive() and not accept and not reject: return typer.confirm(prompt, default=False) @@ -207,7 +148,7 @@ async def _fetch_quote( raise typer.Exit(1) resolved_from_amount = ( - _display_amount_to_raw( + display_amount_to_raw( from_amount, precision=from_asset.precision, asset_label=from_asset.ticker, @@ -217,7 +158,7 @@ async def _fetch_quote( else None ) resolved_to_amount = ( - _display_amount_to_raw( + display_amount_to_raw( to_amount, precision=to_asset.precision, asset_label=to_asset.ticker, @@ -309,17 +250,17 @@ def order_create( ] = None, ) -> None: """Create a maker swap order from a live quote.""" - resolved_pair = _resolve_pair(pair) - resolved_from_amount, resolved_to_amount = _resolve_amount_pair( + resolved_pair = resolve_pair(pair) + resolved_from_amount, resolved_to_amount = resolve_amount_pair( from_amount, to_amount, prompt_prefix="Order", default_choice="R", pair=resolved_pair ) resolved_from_layer, resolved_to_layer = resolve_quote_layers( resolved_pair, from_layer, to_layer ) - resolved_receiver_address = _resolve_required_text( + resolved_receiver_address = resolve_required_text( receiver_address, "Receiver address / invoice", "--receiver-address" ) - resolved_receiver_format = _resolve_required_text( + resolved_receiver_format = resolve_required_text( receiver_format, "Receiver format (e.g. BOLT11, RGB_INVOICE, BTC_ADDRESS)", "--receiver-format", @@ -400,7 +341,7 @@ def order_decide( ] = "", ) -> None: """Submit a rate decision for a pending maker swap order.""" - resolved_order_id = _resolve_required_text(order_id, "Swap order ID", "ORDER_ID argument") + resolved_order_id = resolve_required_text(order_id, "Swap order ID", "ORDER_ID argument") accept_new_rate = _resolve_accept_reject(accept, reject, "Accept the new quoted rate?") asyncio.run(_order_decide(resolved_order_id, accept_new_rate, access_token)) @@ -559,8 +500,8 @@ def atomic_init( ] = False, ) -> None: """Initialize an atomic swap against the maker server using a live quote.""" - resolved_pair = _resolve_pair(pair) - resolved_from_amount, resolved_to_amount = _resolve_amount_pair( + resolved_pair = resolve_pair(pair) + resolved_from_amount, resolved_to_amount = resolve_amount_pair( from_amount, to_amount, prompt_prefix="Atomic swap", @@ -660,9 +601,9 @@ def atomic_execute( ] = False, ) -> None: """Execute an atomic swap against the maker server.""" - resolved_swapstring = _resolve_required_text(swapstring, "Swap string", "--swapstring") - resolved_taker_pubkey = _resolve_required_text(taker_pubkey, "Taker pubkey", "--taker-pubkey") - resolved_payment_hash = _resolve_required_text(payment_hash, "Payment hash", "--payment-hash") + resolved_swapstring = resolve_required_text(swapstring, "Swap string", "--swapstring") + resolved_taker_pubkey = resolve_required_text(taker_pubkey, "Taker pubkey", "--taker-pubkey") + resolved_payment_hash = resolve_required_text(payment_hash, "Payment hash", "--payment-hash") if is_interactive() and not auto_whitelist: auto_whitelist = typer.confirm( "Auto-whitelist on the local taker node before executing?", @@ -808,8 +749,8 @@ def atomic_run( ] = False, ) -> None: """Run an atomic swap end-to-end using the local node as taker.""" - resolved_pair = _resolve_pair(pair) - resolved_from_amount, resolved_to_amount = _resolve_amount_pair( + resolved_pair = resolve_pair(pair) + resolved_from_amount, resolved_to_amount = resolve_amount_pair( from_amount, to_amount, prompt_prefix="Atomic swap", @@ -1001,7 +942,7 @@ def node_whitelist( ] = None, ) -> None: """Whitelist a swap on the local taker node via /taker.""" - resolved_swapstring = _resolve_required_text(swapstring, "Swap string", "--swapstring") + resolved_swapstring = resolve_required_text(swapstring, "Swap string", "--swapstring") asyncio.run(_node_whitelist(resolved_swapstring)) @@ -1040,8 +981,8 @@ def node_execute( ] = None, ) -> None: """Execute a low-level local node swap via maker-execute.""" - resolved_swapstring = _resolve_required_text(swapstring, "Swap string", "--swapstring") - resolved_payment_secret = _resolve_required_text( + resolved_swapstring = resolve_required_text(swapstring, "Swap string", "--swapstring") + resolved_payment_secret = resolve_required_text( payment_secret, "Payment secret", "--payment-secret" ) asyncio.run(_node_execute(resolved_swapstring, resolved_payment_secret, taker_pubkey)) @@ -1089,7 +1030,7 @@ def node_status( maker: Annotated[bool, typer.Option("--maker", help="Look up the maker-side swap.")] = False, ) -> None: """Check a local node swap by payment hash.""" - resolved_payment_hash = _resolve_required_text( + resolved_payment_hash = resolve_required_text( payment_hash, "Payment hash", "PAYMENT_HASH argument" ) if not taker and not maker: diff --git a/kaleido_cli/commands/wallet.py b/kaleido_cli/commands/wallet.py index 1822e30..5a8b843 100644 --- a/kaleido_cli/commands/wallet.py +++ b/kaleido_cli/commands/wallet.py @@ -506,22 +506,3 @@ async def _wallet_estimate_fee(blocks: int) -> None: except Exception as e: print_error(f"Error: {e}") raise typer.Exit(1) - - -@wallet_app.command( - "shutdown", - epilog=" [cyan]kaleido wallet shutdown[/cyan] Gracefully shut down the node process.", -) -def wallet_shutdown() -> None: - """Gracefully shut down the node.""" - asyncio.run(_wallet_shutdown()) - - -async def _wallet_shutdown() -> None: - try: - client = get_client(require_node=True) - await client.rln.shutdown() - print_success("Node shutdown initiated.") - except Exception as e: - print_error(f"Error: {e}") - raise typer.Exit(1) diff --git a/kaleido_cli/utils/prompts.py b/kaleido_cli/utils/prompts.py new file mode 100644 index 0000000..1a7361b --- /dev/null +++ b/kaleido_cli/utils/prompts.py @@ -0,0 +1,72 @@ +"""Shared interactive prompt helpers for CLI commands.""" + +from __future__ import annotations + +import typer +from kaleido_sdk import parse_raw_amount + +from kaleido_cli.output import is_interactive, print_error + + +def resolve_required_text(value: str | None, prompt: str, option_name: str) -> str: + if value is not None: + return value + if is_interactive(): + return typer.prompt(prompt) + print_error(f"{option_name} is required in non-interactive mode.") + raise typer.Exit(1) + + +def resolve_pair(pair: str | None) -> str: + if pair is not None: + return pair + if is_interactive(): + return typer.prompt("Trading pair (e.g. BTC/USDT)") + print_error("PAIR argument is required in non-interactive mode.") + raise typer.Exit(1) + + +def resolve_amount_pair( + from_amount: str | None, + to_amount: str | None, + *, + prompt_prefix: str, + default_choice: str, + pair: str, +) -> tuple[str | None, str | None]: + base_ticker, _, quote_ticker = pair.partition("/") + send_label = base_ticker or "base asset" + receive_label = quote_ticker or "quote asset" + if from_amount is None and to_amount is None: + if is_interactive(): + choice = typer.prompt( + f"{prompt_prefix} by [S]end amount or [R]eceive amount?", + default=default_choice, + ) + if choice.strip().upper().startswith("R"): + return None, typer.prompt(f"Amount to receive ({receive_label}, display units)") + return typer.prompt(f"Amount to send ({send_label}, display units)"), None + print_error("Provide --from-amount or --to-amount in non-interactive mode.") + raise typer.Exit(1) + if from_amount is not None and to_amount is not None: + print_error("Provide exactly one of --from-amount or --to-amount.") + raise typer.Exit(1) + return from_amount, to_amount + + +def display_amount_to_raw( + value: str, + *, + precision: int | None, + asset_label: str, + option_name: str, +) -> int: + normalized = value.strip() + if not normalized: + print_error(f"{option_name} cannot be empty.") + raise typer.Exit(1) + try: + return parse_raw_amount(normalized, precision or 0) + except ValueError as exc: + print_error(f"{option_name} for {asset_label}: {exc}") + raise typer.Exit(1)