#!/usr/bin/env python3 """stripe-cli - bounded, agent-first command line for the Stripe API. Reads Stripe balance, payments, and subscriptions and, with explicit confirmation, performs guarded mutations (canceling a subscription) over HTTPS using only the Python standard library. The read surface is primary; every state-changing command requires --dry-run to preview, then --yes to confirm. Mutation requires explicit confirmation. Design rules: - Read-only first: balance, payment, and subscription queries run freely. - Guarded mutations: canceling a subscription requires --dry-run then --yes. - Bounded reads: every listing caps results with --limit and never pages past the requested cap. - --json emits machine-readable JSON; the default is human-readable text. - --help works with no STRIPE_API_KEY set and makes no network calls. Environment: STRIPE_API_KEY Stripe secret or restricted API key (sk_... / rk_...) Exit codes: 0 success, 1 Stripe API error or failed check, 2 usage error. """ import argparse import json import os import sys import urllib.error import urllib.parse import urllib.request from typing import Any, Dict, List, Optional API_BASE = os.environ.get("STRIPE_API_BASE", "https://api.stripe.com/v1") DEFAULT_LIMIT = 20 MAX_LIMIT = 100 REQUEST_TIMEOUT = 15 TEXT_TRUNCATE = 500 class StripeError(Exception): """Raised when the Stripe API returns an error or transport fails.""" def get_api_key() -> str: key = os.environ.get("STRIPE_API_KEY", "") if not key: raise StripeError("STRIPE_API_KEY environment variable is not set") return key def _encode_form_fields(fields: Dict[str, Any]) -> bytes: """Encode form fields, mapping booleans to Stripe's lowercase true/false.""" normalized = { key: "true" if value is True else "false" if value is False else value for key, value in fields.items() } return urllib.parse.urlencode(normalized).encode("utf-8") def api_request(method: str, path: str, api_key: str, fields: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: base = f"{API_BASE}/{path.lstrip('/')}" if fields and method == "GET": query = "&".join(f"{key}={urllib.parse.quote(str(value))}" for key, value in fields.items()) url = f"{base}?{query}" data = None elif fields: url = base data = _encode_form_fields(fields) else: url = base data = None request = urllib.request.Request( url, data=data, headers={"Authorization": f"Bearer {api_key}"}, method=method, ) try: with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response: return json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as error: detail = "" try: detail = json.loads(error.read().decode("utf-8")).get("error", {}).get("message", "") except (json.JSONDecodeError, UnicodeDecodeError): pass raise StripeError(f"Stripe API HTTP {error.code}: {detail or error.reason}") from error except urllib.error.URLError as error: raise StripeError(f"Stripe API unreachable: {error.reason}") from error except json.JSONDecodeError as error: raise StripeError(f"Stripe API returned non-JSON: {error}") from error def usd_cents_to_display(cents: Optional[int]) -> str: if cents is None: return "" return f"{cents / 100:.2f}" def summarize_balance(balance: Dict[str, Any]) -> Dict[str, Any]: available = [{"amount": usd_cents_to_display(b.get("amount")), "currency": b.get("currency", "").upper()} for b in balance.get("available", [])] pending = [{"amount": usd_cents_to_display(b.get("amount")), "currency": b.get("currency", "").upper()} for b in balance.get("pending", [])] return {"available": available, "pending": pending} def summarize_payment(payment: Dict[str, Any]) -> Dict[str, Any]: return { "id": payment.get("id", ""), "amount": usd_cents_to_display(payment.get("amount")), "currency": (payment.get("currency") or "").upper(), "status": payment.get("status", ""), "customer": payment.get("customer", ""), "created": payment.get("created"), } def summarize_subscription(subscription: Dict[str, Any]) -> Dict[str, Any]: items = [] for item in subscription.get("items", {}).get("data", []): price = item.get("price", {}) items.append({ "id": item.get("id", ""), "price_id": price.get("id", ""), "amount": usd_cents_to_display(price.get("unit_amount")), "currency": (price.get("currency") or "").upper(), "interval": (price.get("recurring") or {}).get("interval", ""), }) return { "id": subscription.get("id", ""), "status": subscription.get("status", ""), "customer": subscription.get("customer", ""), "current_period_end": subscription.get("current_period_end"), "cancel_at_period_end": subscription.get("cancel_at_period_end", False), "items": items, } # -------------------------------------------------------------------------- # Command implementations # -------------------------------------------------------------------------- def cmd_balance(args: argparse.Namespace, api_key: str) -> Dict[str, Any]: payload = api_request("GET", "balance", api_key) return {"ok": True, "balance": summarize_balance(payload)} def cmd_payments_list(args: argparse.Namespace, api_key: str) -> Dict[str, Any]: payload = api_request("GET", "payment_intents", api_key, {"limit": str(args.limit)}) payments = [summarize_payment(p) for p in payload.get("data", [])] return {"ok": True, "payments": payments, "has_more": payload.get("has_more", False)} def cmd_subscriptions_list(args: argparse.Namespace, api_key: str) -> Dict[str, Any]: payload = api_request("GET", "subscriptions", api_key, {"limit": str(args.limit)}) subscriptions = [summarize_subscription(s) for s in payload.get("data", [])] return {"ok": True, "subscriptions": subscriptions, "has_more": payload.get("has_more", False)} def cmd_subscriptions_get(args: argparse.Namespace, api_key: str) -> Dict[str, Any]: payload = api_request("GET", f"subscriptions/{args.subscription_id}", api_key) return {"ok": True, "subscription": summarize_subscription(payload)} def cmd_subscriptions_cancel(args: argparse.Namespace, api_key: str) -> Dict[str, Any]: if not args.dry_run and not args.yes: raise StripeError( "refusing to cancel a subscription without confirmation: pass " "--dry-run to preview or --yes to confirm the mutation" ) if args.dry_run: return {"ok": True, "dry_run": True, "would_cancel": {"subscription_id": args.subscription_id}} # Safer default: schedule cancellation at the period end (reversible by # setting cancel_at_period_end back to false) rather than cancelling # immediately. payload = api_request("POST", f"subscriptions/{args.subscription_id}", api_key, {"cancel_at_period_end": True}) if payload.get("cancel_at_period_end") is not True: raise StripeError( "Stripe did not confirm the cancellation (cancel_at_period_end is " f"{payload.get('cancel_at_period_end')!r}); no state change assumed" ) return {"ok": True, "subscription": summarize_subscription(payload)} # -------------------------------------------------------------------------- # Output helpers # -------------------------------------------------------------------------- def emit(data: Dict[str, Any], json_mode: bool) -> int: if json_mode: print(json.dumps(data, indent=2, sort_keys=True)) else: _emit_human(data) return 0 def _emit_human(data: Dict[str, Any]) -> None: if "balance" in data: for entry in data["balance"]["available"]: print(f"available: {entry['currency']} {entry['amount']}") for entry in data["balance"]["pending"]: print(f"pending: {entry['currency']} {entry['amount']}") elif "payments" in data: print(f"payment intents (has_more={data['has_more']}):") for payment in data["payments"]: print(f" {payment['id']} {payment['currency']} {payment['amount']} " f"status={payment['status']} customer={payment['customer']}") elif "subscriptions" in data: print(f"subscriptions (has_more={data['has_more']}):") for subscription in data["subscriptions"]: print(f" {subscription['id']} status={subscription['status']} " f"customer={subscription['customer']}") elif "subscription" in data and "dry_run" not in data: subscription = data["subscription"] print(f"subscription {subscription['id']} status={subscription['status']} " f"customer={subscription['customer']}") for item in subscription["items"]: print(f" item {item['id']}: {item['currency']} {item['amount']}/{item['interval']}") elif data.get("dry_run"): print("DRY RUN (nothing canceled):") print(f" subscription: {data['would_cancel']['subscription_id']}") else: print(json.dumps(data, indent=2, sort_keys=True)) # -------------------------------------------------------------------------- # CLI # -------------------------------------------------------------------------- def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="stripe-cli", description=( "Bounded, agent-first CLI for the Stripe API. Read-only-first " "surface: balance, payment intents, and subscriptions. Guarded " "mutation: canceling a subscription requires --dry-run then --yes." ), ) parser.add_argument("--json", action="store_true", help="emit machine-readable JSON output") parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, metavar="N", help=f"cap reads at N results (default {DEFAULT_LIMIT}, max {MAX_LIMIT})") sub = parser.add_subparsers(dest="command", required=True) balance = sub.add_parser("balance", help="read account balance (read-only)") balance.add_argument("action", nargs="?", default="show", choices=["show"]) payments = sub.add_parser("payments", help="read payment intents (read-only)") pay_sub = payments.add_subparsers(dest="action", required=True) pay_list = pay_sub.add_parser("list", help="list recent payment intents") subscriptions = sub.add_parser("subscriptions", help="read or cancel subscriptions") sub_sub = subscriptions.add_subparsers(dest="action", required=True) sub_list = sub_sub.add_parser("list", help="list subscriptions (read-only)") sub_get = sub_sub.add_parser("get", help="get one subscription (read-only)") sub_get.add_argument("--id", dest="subscription_id", required=True, help="subscription ID (sub_...)") sub_cancel = sub_sub.add_parser("cancel", help="cancel a subscription (guarded mutation)") sub_cancel.add_argument("--id", dest="subscription_id", required=True, help="subscription ID (sub_...)") sub_cancel.add_argument("--dry-run", action="store_true", help="preview the cancellation without applying") sub_cancel.add_argument("--yes", action="store_true", help="confirm the mutation and cancel") return parser def main(argv: Optional[List[str]] = None) -> int: parser = build_parser() args = parser.parse_args(argv) if args.limit < 1 or args.limit > MAX_LIMIT: parser.error(f"--limit must be between 1 and {MAX_LIMIT}") try: api_key = get_api_key() if args.command == "balance": result = cmd_balance(args, api_key) elif args.command == "payments": result = cmd_payments_list(args, api_key) elif args.command == "subscriptions": if args.action == "list": result = cmd_subscriptions_list(args, api_key) elif args.action == "get": result = cmd_subscriptions_get(args, api_key) else: result = cmd_subscriptions_cancel(args, api_key) else: # pragma: no cover - argparse prevents this parser.error(f"unknown command: {args.command}") return emit(result, args.json) except StripeError as error: if args.json: print(json.dumps({"ok": False, "error": str(error)}, indent=2, sort_keys=True)) else: print(f"stripe-cli: {error}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main())