#!/usr/bin/env python3 """crm-cli - bounded, agent-first command line for the HubSpot CRM API. Reads HubSpot CRM records (contacts, deals) and, with explicit confirmation, updates deal stages over HTTPS using only the Python standard library. Covers records, search, and pipeline views. Design rules: - Read-only by default. Every state-changing command (moving a deal to a new stage) is a guarded mutation: it requires --dry-run to preview, then --yes to confirm. Mutation requires explicit confirmation. - Bounded reads: every listing and search 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 HUBSPOT_TOKEN set and makes no network calls. Environment: HUBSPOT_TOKEN HubSpot private app access token Exit codes: 0 success, 1 HubSpot 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("HUBSPOT_API_BASE", "https://api.hubapi.com/crm/v3") DEFAULT_LIMIT = 20 MAX_LIMIT = 100 REQUEST_TIMEOUT = 15 TEXT_TRUNCATE = 500 class CrmError(Exception): """Raised when the HubSpot API returns an error or transport fails.""" def get_token() -> str: token = os.environ.get("HUBSPOT_TOKEN", "") if not token: raise CrmError("HUBSPOT_TOKEN environment variable is not set") return token def api_request(method: str, path: str, token: str, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: data = json.dumps(body).encode("utf-8") if body is not None else None request = urllib.request.Request( f"{API_BASE}/{path.lstrip('/')}", data=data, headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, method=method, ) try: with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT) as response: raw = response.read().decode("utf-8") return json.loads(raw) if raw else {} except urllib.error.HTTPError as error: detail = "" try: detail = json.loads(error.read().decode("utf-8")).get("message", "") except (json.JSONDecodeError, UnicodeDecodeError): pass raise CrmError(f"HubSpot API HTTP {error.code}: {detail or error.reason}") from error except urllib.error.URLError as error: raise CrmError(f"HubSpot API unreachable: {error.reason}") from error except json.JSONDecodeError as error: raise CrmError(f"HubSpot API returned non-JSON: {error}") from error def truncate(text: str, limit: int = TEXT_TRUNCATE) -> str: if len(text) <= limit: return text return text[: limit - 1] + "…" def summarize_contact(contact: Dict[str, Any]) -> Dict[str, Any]: properties = contact.get("properties", {}) return { "id": contact.get("id", ""), "firstname": properties.get("firstname", ""), "lastname": properties.get("lastname", ""), "email": properties.get("email", ""), "company": properties.get("company", ""), "createdate": properties.get("createdate", ""), } def summarize_deal(deal: Dict[str, Any]) -> Dict[str, Any]: properties = deal.get("properties", {}) return { "id": deal.get("id", ""), "dealname": truncate(properties.get("dealname", "")), "amount": properties.get("amount", ""), "pipeline": properties.get("pipeline", ""), "dealstage": properties.get("dealstage", ""), "hs_lastmodifieddate": properties.get("hs_lastmodifieddate", ""), } def summarize_pipeline(pipeline: Dict[str, Any]) -> Dict[str, Any]: stages = [] for stage in pipeline.get("stages", []): stages.append({"id": stage.get("id", ""), "label": stage.get("label", ""), "displayOrder": stage.get("displayOrder")}) return {"id": pipeline.get("id", ""), "label": pipeline.get("label", ""), "stages": stages} # -------------------------------------------------------------------------- # Command implementations # -------------------------------------------------------------------------- def _contacts_common(args: argparse.Namespace, token: str, fields: Dict[str, str]) -> Dict[str, Any]: query = "&".join(f"{key}={urllib.parse.quote(value)}" for key, value in fields.items()) payload = api_request("GET", f"objects/contacts?{query}", token) contacts = [summarize_contact(c) for c in payload.get("results", [])] return {"ok": True, "contacts": contacts, "total": payload.get("total", len(contacts)), "paging": payload.get("paging", {})} def cmd_contacts_list(args: argparse.Namespace, token: str) -> Dict[str, Any]: return _contacts_common(args, token, {"limit": str(args.limit)}) def cmd_contacts_get(args: argparse.Namespace, token: str) -> Dict[str, Any]: payload = api_request("GET", f"objects/contacts/{args.contact_id}", token) return {"ok": True, "contact": summarize_contact(payload)} def cmd_contacts_search(args: argparse.Namespace, token: str) -> Dict[str, Any]: payload = api_request("POST", "objects/contacts/search", token, {"query": args.query, "limit": args.limit}) contacts = [summarize_contact(c) for c in payload.get("results", [])] return {"ok": True, "query": args.query, "contacts": contacts, "total": payload.get("total", len(contacts))} def cmd_deals_list(args: argparse.Namespace, token: str) -> Dict[str, Any]: fields = {"limit": str(args.limit)} if args.pipeline: fields["pipeline"] = args.pipeline if args.stage: fields["dealstage"] = args.stage query = "&".join(f"{key}={urllib.parse.quote(value)}" for key, value in fields.items()) payload = api_request("GET", f"objects/deals?{query}", token) deals = [summarize_deal(d) for d in payload.get("results", [])] return {"ok": True, "deals": deals, "total": payload.get("total", len(deals)), "paging": payload.get("paging", {})} def cmd_deals_update_stage(args: argparse.Namespace, token: str) -> Dict[str, Any]: if not args.dry_run and not args.yes: raise CrmError( "refusing to move a deal without confirmation: pass --dry-run to " "preview or --yes to confirm the mutation" ) body = {"properties": {"dealstage": args.stage}} if args.dry_run: return {"ok": True, "dry_run": True, "would_update": { "deal_id": args.deal_id, "dealstage": args.stage}} payload = api_request("PATCH", f"objects/deals/{args.deal_id}", token, body) return {"ok": True, "deal": summarize_deal(payload)} def cmd_pipelines_list(args: argparse.Namespace, token: str) -> Dict[str, Any]: payload = api_request("GET", "pipelines/deals", token) pipelines = [summarize_pipeline(p) for p in payload.get("results", [])] return {"ok": True, "pipelines": pipelines, "count": len(pipelines)} # -------------------------------------------------------------------------- # 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 "contact" in data and "dry_run" not in data: contact = data["contact"] name = f"{contact['firstname']} {contact['lastname']}".strip() print(f"{name or contact['email'] or contact['id']} <{contact['id']}> {contact['email']}") elif "contacts" in data: print(f"contacts ({data['total']}):") for contact in data["contacts"]: name = f"{contact['firstname']} {contact['lastname']}".strip() print(f" {name or contact['email'] or contact['id']} <{contact['id']}>") elif "deals" in data: print(f"deals ({data['total']}):") for deal in data["deals"]: print(f" {deal['dealname']} ${deal['amount']} stage={deal['dealstage']} <{deal['id']}>") elif "pipelines" in data: print(f"deals pipelines ({data['count']}):") for pipeline in data["pipelines"]: labels = ", ".join(f"{s['label']}({s['id']})" for s in pipeline["stages"]) print(f" {pipeline['label']} <{pipeline['id']}>: {labels}") elif data.get("dry_run"): print("DRY RUN (no change):") print(f" deal: {data['would_update']['deal_id']}") print(f" stage: {data['would_update']['dealstage']}") elif "deal" in data: deal = data["deal"] print(f"updated deal {deal['dealname']} -> stage {deal['dealstage']}") else: print(json.dumps(data, indent=2, sort_keys=True)) # -------------------------------------------------------------------------- # CLI # -------------------------------------------------------------------------- def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="crm-cli", description=( "Bounded, agent-first CLI for the HubSpot CRM API: contact records, " "contact search, deal pipeline views, and guarded deal stage " "updates. Read-only by default; stage changes require --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) contacts = sub.add_parser("contacts", help="contact records and search (read-only)") contact_sub = contacts.add_subparsers(dest="action", required=True) contact_list = contact_sub.add_parser("list", help="list contacts") contact_get = contact_sub.add_parser("get", help="get one contact") contact_get.add_argument("--id", dest="contact_id", required=True, help="contact ID") contact_search = contact_sub.add_parser("search", help="search contacts") contact_search.add_argument("--query", required=True, help="search query text") deals = sub.add_parser("deals", help="deal pipeline views and updates") deal_sub = deals.add_subparsers(dest="action", required=True) deal_list = deal_sub.add_parser("list", help="list deals in the pipeline view (read-only)") deal_list.add_argument("--pipeline", help="filter to a pipeline ID") deal_list.add_argument("--stage", help="filter to a dealstage ID") deal_update = deal_sub.add_parser("update-stage", help="move a deal to a stage (guarded mutation)") deal_update.add_argument("--id", dest="deal_id", required=True, help="deal ID") deal_update.add_argument("--stage", required=True, help="target dealstage ID") deal_update.add_argument("--dry-run", action="store_true", help="preview the change without applying") deal_update.add_argument("--yes", action="store_true", help="confirm the mutation and apply") pipelines = sub.add_parser("pipelines", help="list deal pipelines and stages (read-only)") pipelines.add_argument("action", nargs="?", default="list", choices=["list"]) 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: token = get_token() if args.command == "contacts": if args.action == "list": result = cmd_contacts_list(args, token) elif args.action == "get": result = cmd_contacts_get(args, token) else: result = cmd_contacts_search(args, token) elif args.command == "deals": if args.action == "list": result = cmd_deals_list(args, token) else: result = cmd_deals_update_stage(args, token) elif args.command == "pipelines": result = cmd_pipelines_list(args, token) else: # pragma: no cover - argparse prevents this parser.error(f"unknown command: {args.command}") return emit(result, args.json) except CrmError as error: if args.json: print(json.dumps({"ok": False, "error": str(error)}, indent=2, sort_keys=True)) else: print(f"crm-cli: {error}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main())