mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
3256a87bcb
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>
294 lines
12 KiB
Python
Executable File
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())
|