Files
magnus919_agent-skills/crm/scripts/crm-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

300 lines
12 KiB
Python
Executable File

#!/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())