diff --git a/src/aqua/cli/qr.py b/src/aqua/cli/qr.py index 80cfcb7..9b741a6 100644 --- a/src/aqua/cli/qr.py +++ b/src/aqua/cli/qr.py @@ -1,6 +1,6 @@ import click -from ..tools import qr_decode +from ..tools import qr_decode, qr_generate from .output import run_tool @@ -9,6 +9,38 @@ def qr(): """QR code utilities.""" +@qr.command("generate") +@click.argument("data") +@click.option( + "--output-dir", + type=click.Path(file_okay=False, dir_okay=True, path_type=str), + default=None, + help="Directory where the PNG should be written (default: ~/.aqua/qr).", +) +@click.option( + "--filename", + default=None, + help="Optional PNG filename. Defaults to qr_.png.", +) +@click.option( + "--terminal", + is_flag=True, + help="Also include a terminal-friendly block QR in the output.", +) +@click.pass_obj +def generate(ctx, data, output_dir, filename, terminal): + """Generate a QR code PNG for DATA.""" + run_tool( + ctx, + lambda: qr_generate( + data=data, + output_dir=output_dir, + filename=filename, + terminal=terminal, + ), + ) + + @qr.command("decode") @click.argument("image_path") @click.pass_obj diff --git a/src/aqua/features.py b/src/aqua/features.py index 9327a62..646338a 100644 --- a/src/aqua/features.py +++ b/src/aqua/features.py @@ -111,6 +111,7 @@ ("wapupay", "provision-account"): "wapupay_provision_account", # qr group (cli/qr.py) + ("qr", "generate"): "qr_generate", ("qr", "decode"): "qr_decode", # jan3 group (cli/jan3.py) — JAN3 account login + sessions + Lightning Address diff --git a/src/aqua/qr.py b/src/aqua/qr.py index 588aa88..6ee4738 100644 --- a/src/aqua/qr.py +++ b/src/aqua/qr.py @@ -60,6 +60,25 @@ def generate_qr(data: str, output_dir: str | Path, filename: str | None = None) return str(path.resolve()) +def render_qr_terminal(data: str) -> str: + """Render ``data`` as a terminal-friendly block QR string. + + This is intentionally text-only (no ANSI colours) so it works in logs, + JSON output, and terminals with minimal capabilities. Each QR module is + represented by two spaces so the square proportions remain scannable. + """ + if not isinstance(data, str) or not data: + raise ValueError("QR data must be a non-empty string") + + qr = qrcode.QRCode(border=1) + qr.add_data(data) + qr.make(fit=True) + lines = [] + for row in qr.get_matrix(): + lines.append("".join("██" if cell else " " for cell in row)) + return "\n".join(lines) + + def decode_qr(image_path: str) -> str: path = Path(image_path) if not path.is_file(): diff --git a/src/aqua/server.py b/src/aqua/server.py index 0433a17..6c5dd53 100644 --- a/src/aqua/server.py +++ b/src/aqua/server.py @@ -1254,6 +1254,35 @@ "required": ["email"], }, }, + "qr_generate": { + "description": ( + "Generate a PNG QR code for arbitrary content and return qr_code_path. " + "Optionally includes a terminal-friendly block QR string." + ), + "inputSchema": { + "type": "object", + "properties": { + "data": { + "type": "string", + "description": "Content to encode (address, invoice, URI, or other QR payload).", + }, + "output_dir": { + "type": "string", + "description": "Optional directory for the PNG; defaults to ~/.aqua/qr.", + }, + "filename": { + "type": "string", + "description": "Optional PNG filename; defaults to qr_.png.", + }, + "terminal": { + "type": "boolean", + "default": False, + "description": "Also return terminal_qr block art.", + }, + }, + "required": ["data"], + }, + }, "qr_decode": { "description": "Decode a QR code from an image file and return the raw string content. Supports Bitcoin addresses, Liquid addresses, Lightning invoices (BOLT11), and Lightning addresses.", "inputSchema": { diff --git a/src/aqua/tools.py b/src/aqua/tools.py index f748cd6..770f375 100644 --- a/src/aqua/tools.py +++ b/src/aqua/tools.py @@ -11,7 +11,7 @@ from .assets import MAINNET_ASSETS, TESTNET_ASSETS, resolve_asset_name, resolve_liquid_asset_id from .bitcoin import BitcoinWalletManager from .bolt11 import decode_bolt11_fields -from .qr import decode_qr, generate_qr +from .qr import decode_qr, generate_qr, render_qr_terminal from .wallet import WalletManager logger = logging.getLogger(__name__) @@ -1980,6 +1980,21 @@ def sideswap_swap_status(order_id: str) -> dict[str, Any]: return manager.status(order_id) +def qr_generate( + data: str, + output_dir: str | None = None, + filename: str | None = None, + terminal: bool = False, +) -> dict[str, Any]: + """Generate a QR PNG for arbitrary content, optionally with terminal art.""" + qr_dir = output_dir or str(get_manager().storage.qr_dir) + path = generate_qr(data, qr_dir, filename=filename) + result: dict[str, Any] = {"content": data, "qr_code_path": path} + if terminal: + result["terminal_qr"] = render_qr_terminal(data) + return result + + def qr_decode(image_path: str) -> dict[str, Any]: """Decode a QR code from an image file and return the raw string content.""" content = decode_qr(image_path) @@ -2301,5 +2316,6 @@ def jan3_purchase_ln_username( "jan3_rebind_wallet": jan3_rebind_wallet, "jan3_ln_check_username": jan3_ln_check_username, "jan3_purchase_ln_username": jan3_purchase_ln_username, + "qr_generate": qr_generate, "qr_decode": qr_decode, } diff --git a/tests/test_cli.py b/tests/test_cli.py index 115c128..e5af650 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -196,6 +196,39 @@ def test_serve_help_has_no_transport_option(self, runner): assert "Start the MCP server over stdio (`aqua serve` or `aqua-mcp`)." in result.output +# QR commands + + +class TestQrCommands: + def test_qr_generate_json_creates_png_that_decodes(self, runner): + from aqua.qr import decode_qr + + payload = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq" + + result = runner.invoke(cli, ["--format", "json", "qr", "generate", payload]) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["content"] == payload + path = Path(data["qr_code_path"]) + assert path.is_file() + assert decode_qr(str(path)) == payload + + def test_qr_generate_can_include_terminal_qr(self, runner): + payload = "liquid-address-example" + + result = runner.invoke( + cli, ["--format", "json", "qr", "generate", "--terminal", payload] + ) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["content"] == payload + assert "terminal_qr" in data + assert "██" in data["terminal_qr"] + assert len(data["terminal_qr"].splitlines()) > 5 + + # Wallet commands diff --git a/tests/test_tools.py b/tests/test_tools.py index 302ee5d..8f92523 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -1188,6 +1188,7 @@ def test_all_tools_registered(self): "lightning_send", "lightning_transaction_status", "lightning_decode", + "qr_generate", "qr_decode", "delete_wallet", "btc_import_descriptor",