Files
magnus919_agent-skills/stripe/scripts/stripe-cli
T
Magnus HedemarkGitHubfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
3256a87bcb feat(skill): add collaboration & business-app tool layer (Slack, Notion, email, CRM, payments) (#269)
Adds five top-level operational tool skills, one per named tool:

- slack: messages, channels, threads, search, files, and webhook signature
  verification (HMAC-SHA256) via a bounded, stdlib-only slack-cli.
- notion: pages, database queries, search, and guarded page updates via
  notion-cli.
- email: transactional email via Twilio SendGrid (send, deliverability
  bounces/spam reports, Signed Event Webhook verification with a
  self-contained ECDSA P-256 verifier) via email-cli.
- crm: HubSpot CRM records, contact search, and deal pipeline views with
  guarded stage updates via crm-cli.
- stripe: read-only-first balance, payment, and subscription queries with
  a guarded period-end subscription cancellation via stripe-cli.

Each skill ships an executable script (--json output, --limit bounded reads,
--dry-run/--yes mutation gate), a human README with the five required
sections, a schema-v1 evals/evals.json with six output-quality cases, a dated
source index + operations reference, and a deterministic unittest suite run
by check-artifacts. All five are indexed in the top-level README and the
generated catalogs were regenerated. Eval coverage rises from 78/139 to
83/144.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-03 20:19:26 -04:00

294 lines
12 KiB
Python
Executable File

#!/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 api_request(method: str, path: str, api_key: str,
fields: Optional[Dict[str, str]] = 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 = urllib.parse.urlencode(fields).encode("utf-8")
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())