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>
300 lines
12 KiB
Python
Executable File
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())
|