#!/usr/bin/env python3 """CLI for the packetdial skill — NetSapiens SNAPsolution API v2. Read subcommands run freely. Write subcommands (create-*, update-*, delete-*, raw with a non-GET method) refuse to run unless --confirm is passed; without it they print what they WOULD do and exit non-zero. Output: --json emits raw JSON (default for most commands); a few commands render a readable table when --json is omitted. Read examples: python ns.py status python ns.py whoami python ns.py domains python ns.py domain python ns.py users python ns.py user python ns.py phones python ns.py dids python ns.py devices python ns.py cdrs --domain --start 2026-06-01 --end 2026-06-02 python ns.py resellers Write examples (all require --confirm): python ns.py create-domain --body '{"domain":"acme","description":"Acme Inc"}' --confirm python ns.py create-user --body '{"user":"101","name-first-name":"Jane"}' --confirm python ns.py delete-user --confirm Escape hatch (raw request against any of the 239 v2 paths): python ns.py raw GET domains/acme/users python ns.py raw POST domains/acme/users --body '{...}' --confirm """ from __future__ import annotations import argparse import json import os import subprocess import sys from ns_client import NetSapiensClient, PacketDialError def _log_skill_error(skill, msg, context=""): """Soft-fail: append a functional-error entry to errorlog.md (never throws).""" try: root = os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath( os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") ) h = os.path.join(root, ".claude", "scripts", "log-skill-error.sh") if not os.path.exists(h): return a = ["bash", h, skill, msg] if context: a += ["--context", context] subprocess.run(a, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10) except Exception: pass def _emit(obj) -> None: print(json.dumps(obj, indent=2, ensure_ascii=False, default=str)) def _parse_body(raw: str | None) -> dict | None: if raw is None: return None try: parsed = json.loads(raw) except json.JSONDecodeError as exc: raise SystemExit(f"--body is not valid JSON: {exc}") if not isinstance(parsed, dict): raise SystemExit("--body must be a JSON object") return parsed def _require_confirm(args, action: str, detail: str) -> None: if not getattr(args, "confirm", False): print(f"[DRY RUN] Would {action}: {detail}") print("Refusing to perform a write without --confirm. Re-run with --confirm.") raise SystemExit(2) def main(argv=None) -> int: p = argparse.ArgumentParser(prog="ns.py", description="PacketDial / NetSapiens v2 CLI") p.add_argument("--json", action="store_true", help="emit raw JSON (default for most)") sub = p.add_subparsers(dest="cmd", required=True) # --- read --- sub.add_parser("status", help="API version + authenticated key identity") sub.add_parser("whoami", help="details of the authenticated API key") sub.add_parser("domains", help="list all domains") sp = sub.add_parser("domain", help="one domain"); sp.add_argument("domain") sp = sub.add_parser("users", help="users in a domain"); sp.add_argument("domain") sp = sub.add_parser("user", help="one user"); sp.add_argument("domain"); sp.add_argument("user") sp = sub.add_parser("phones", help="phones (devices) in a domain"); sp.add_argument("domain") sp = sub.add_parser("dids", help="phone numbers in a domain"); sp.add_argument("domain") sp = sub.add_parser("devices", help="devices for a user"); sp.add_argument("domain"); sp.add_argument("user") sp = sub.add_parser("callqueues", help="ACD call queues in a domain"); sp.add_argument("domain") sp = sub.add_parser("timeframes", help="time-based routing schedules in a domain"); sp.add_argument("domain") sp = sub.add_parser("sites", help="multi-site definitions in a domain"); sp.add_argument("domain") sp = sub.add_parser("contacts", help="shared/domain contacts"); sp.add_argument("domain") sp = sub.add_parser("autoattendants", help="auto-attendants (IVR) in a domain"); sp.add_argument("domain") sp = sub.add_parser("billing", help="domain limits + current usage counts"); sp.add_argument("domain") sub.add_parser("resellers", help="list resellers") sp = sub.add_parser("cdrs", help="call detail records") sp.add_argument("--domain"); sp.add_argument("--start"); sp.add_argument("--end") sp.add_argument("--limit", type=int, default=100) # --- write (gated) --- sp = sub.add_parser("create-domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("create-user"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("update-user"); sp.add_argument("domain"); sp.add_argument("user"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("delete-user"); sp.add_argument("domain"); sp.add_argument("user"); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("create-phone"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("create-did"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") # call queue edits (gated) sp = sub.add_parser("create-callqueue"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("update-callqueue"); sp.add_argument("domain"); sp.add_argument("callqueue"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("delete-callqueue"); sp.add_argument("domain"); sp.add_argument("callqueue"); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("add-agent"); sp.add_argument("domain"); sp.add_argument("callqueue"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("update-agent"); sp.add_argument("domain"); sp.add_argument("callqueue"); sp.add_argument("agent_id"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("remove-agent"); sp.add_argument("domain"); sp.add_argument("callqueue"); sp.add_argument("agent_id"); sp.add_argument("--confirm", action="store_true") # timeframe edits (gated) — body-discriminated variants, pass --body verbatim sp = sub.add_parser("create-timeframe"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("update-timeframe"); sp.add_argument("domain"); sp.add_argument("tf_id"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("delete-timeframe"); sp.add_argument("domain"); sp.add_argument("tf_id"); sp.add_argument("--body"); sp.add_argument("--confirm", action="store_true") # more read wrappers sp = sub.add_parser("addresses", help="E911 addresses in a domain"); sp.add_argument("domain") sp = sub.add_parser("smsnumbers", help="SMS numbers in a domain"); sp.add_argument("domain") sp = sub.add_parser("blocked-numbers", help="blocked-number filters in a domain"); sp.add_argument("domain") sp = sub.add_parser("moh", help="music-on-hold entries in a domain"); sp.add_argument("domain") sp = sub.add_parser("dialrules", help="dial rules in a dial plan"); sp.add_argument("domain"); sp.add_argument("dialplan") sp = sub.add_parser("recording", help="get a recording by call id"); sp.add_argument("domain"); sp.add_argument("callid") sp = sub.add_parser("transcriptions", help="call transcriptions in a domain"); sp.add_argument("domain") # DID complete + device CRUD sp = sub.add_parser("update-did"); sp.add_argument("domain"); sp.add_argument("phonenumber"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("delete-did"); sp.add_argument("domain"); sp.add_argument("phonenumber"); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("create-device"); sp.add_argument("domain"); sp.add_argument("user"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("update-device"); sp.add_argument("domain"); sp.add_argument("user"); sp.add_argument("device"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("delete-device"); sp.add_argument("domain"); sp.add_argument("user"); sp.add_argument("device"); sp.add_argument("--confirm", action="store_true") # E911 address CRUD sp = sub.add_parser("create-address"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("update-address"); sp.add_argument("domain"); sp.add_argument("address_id"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("delete-address"); sp.add_argument("domain"); sp.add_argument("address_id"); sp.add_argument("--confirm", action="store_true") # contact CRUD sp = sub.add_parser("create-contact"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("update-contact"); sp.add_argument("domain"); sp.add_argument("contact_id"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("delete-contact"); sp.add_argument("domain"); sp.add_argument("contact_id"); sp.add_argument("--confirm", action="store_true") # sites + auto-attendants + sms + blocked numbers + moh (writes) sp = sub.add_parser("create-site"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("update-site"); sp.add_argument("domain"); sp.add_argument("site"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("create-autoattendant"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("create-smsnumber"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("update-smsnumber"); sp.add_argument("domain"); sp.add_argument("smsnumber"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("delete-smsnumber"); sp.add_argument("domain"); sp.add_argument("smsnumber"); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("block-numbers"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("unblock-numbers"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("create-moh"); sp.add_argument("domain"); sp.add_argument("--body", required=True); sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("delete-moh"); sp.add_argument("domain"); sp.add_argument("index"); sp.add_argument("--confirm", action="store_true") # onboard a new client domain (wizard -> 3-call flow): domain + optional E911 address sp = sub.add_parser("onboard-domain", help="create a domain (+ optional E911 address) from one JSON body") sp.add_argument("--body"); sp.add_argument("--body-file"); sp.add_argument("--confirm", action="store_true") # --- raw escape hatch --- sp = sub.add_parser("raw", help="raw request against any v2 path") sp.add_argument("method", choices=["GET", "POST", "PUT", "PATCH", "DELETE"]) sp.add_argument("path", help="relative path, e.g. domains/acme/users") sp.add_argument("--body"); sp.add_argument("--confirm", action="store_true") args = p.parse_args(argv) client = NetSapiensClient() try: if args.cmd == "status": _emit({"version": client.version(), "apiKey": client.whoami()}) elif args.cmd == "whoami": _emit(client.whoami()) elif args.cmd == "domains": _emit(client.domains()) elif args.cmd == "domain": _emit(client.domain(args.domain)) elif args.cmd == "users": _emit(client.users(args.domain)) elif args.cmd == "user": _emit(client.user(args.domain, args.user)) elif args.cmd == "phones": _emit(client.phones(args.domain)) elif args.cmd == "dids": _emit(client.phonenumbers(args.domain)) elif args.cmd == "devices": _emit(client.devices(args.domain, args.user)) elif args.cmd == "callqueues": _emit(client.callqueues(args.domain)) elif args.cmd == "timeframes": _emit(client.timeframes(args.domain)) elif args.cmd == "sites": _emit(client.sites(args.domain)) elif args.cmd == "contacts": _emit(client.contacts(args.domain)) elif args.cmd == "autoattendants": _emit(client.autoattendants(args.domain)) elif args.cmd == "billing": _emit(client.billing(args.domain)) elif args.cmd == "resellers": _emit(client.resellers()) elif args.cmd == "cdrs": filters = {"limit": args.limit} if args.start: filters["start-date"] = args.start if args.end: filters["end-date"] = args.end _emit(client.cdrs(domain=args.domain, **filters)) elif args.cmd == "create-domain": body = _parse_body(args.body) _require_confirm(args, "create domain", json.dumps(body)) _emit(client.create_domain(body)) elif args.cmd == "create-user": body = _parse_body(args.body) _require_confirm(args, "create user", f"{args.domain}: {json.dumps(body)}") _emit(client.create_user(args.domain, body)) elif args.cmd == "update-user": body = _parse_body(args.body) _require_confirm(args, "update user", f"{args.domain}/{args.user}: {json.dumps(body)}") _emit(client.update_user(args.domain, args.user, body)) elif args.cmd == "delete-user": _require_confirm(args, "DELETE user", f"{args.domain}/{args.user}") _emit(client.delete_user(args.domain, args.user)) elif args.cmd == "create-phone": body = _parse_body(args.body) _require_confirm(args, "create phone", f"{args.domain}: {json.dumps(body)}") _emit(client.create_phone(args.domain, body)) elif args.cmd == "create-did": body = _parse_body(args.body) _require_confirm(args, "create phone number", f"{args.domain}: {json.dumps(body)}") _emit(client.create_phonenumber(args.domain, body)) elif args.cmd == "create-callqueue": body = _parse_body(args.body) _require_confirm(args, "create call queue", f"{args.domain}: {json.dumps(body)}") _emit(client.create_callqueue(args.domain, body)) elif args.cmd == "update-callqueue": body = _parse_body(args.body) _require_confirm(args, "update call queue", f"{args.domain}/{args.callqueue}: {json.dumps(body)}") _emit(client.update_callqueue(args.domain, args.callqueue, body)) elif args.cmd == "delete-callqueue": _require_confirm(args, "DELETE call queue", f"{args.domain}/{args.callqueue}") _emit(client.delete_callqueue(args.domain, args.callqueue)) elif args.cmd == "add-agent": body = _parse_body(args.body) _require_confirm(args, "add queue agent", f"{args.domain}/{args.callqueue}: {json.dumps(body)}") _emit(client.add_callqueue_agent(args.domain, args.callqueue, body)) elif args.cmd == "update-agent": body = _parse_body(args.body) _require_confirm(args, "update queue agent", f"{args.domain}/{args.callqueue}/{args.agent_id}: {json.dumps(body)}") _emit(client.update_callqueue_agent(args.domain, args.callqueue, args.agent_id, body)) elif args.cmd == "remove-agent": _require_confirm(args, "remove queue agent", f"{args.domain}/{args.callqueue}/{args.agent_id}") _emit(client.remove_callqueue_agent(args.domain, args.callqueue, args.agent_id)) elif args.cmd == "create-timeframe": body = _parse_body(args.body) _require_confirm(args, "create timeframe", f"{args.domain}: {json.dumps(body)}") _emit(client.create_timeframe(args.domain, body)) elif args.cmd == "update-timeframe": body = _parse_body(args.body) _require_confirm(args, "update timeframe", f"{args.domain}/{args.tf_id}: {json.dumps(body)}") _emit(client.update_timeframe(args.domain, args.tf_id, body)) elif args.cmd == "delete-timeframe": body = _parse_body(args.body) if args.body else None _require_confirm(args, "DELETE timeframe", f"{args.domain}/{args.tf_id}" + (f" {json.dumps(body)}" if body else "")) _emit(client.delete_timeframe(args.domain, args.tf_id, body)) # --- additional reads --- elif args.cmd == "addresses": _emit(client.addresses(args.domain)) elif args.cmd == "smsnumbers": _emit(client.smsnumbers(args.domain)) elif args.cmd == "blocked-numbers": _emit(client.blocked_numbers(args.domain)) elif args.cmd == "moh": _emit(client.moh(args.domain)) elif args.cmd == "dialrules": _emit(client.dialrules(args.domain, args.dialplan)) elif args.cmd == "recording": _emit(client.recording(args.domain, args.callid)) elif args.cmd == "transcriptions": _emit(client.transcriptions(args.domain)) # --- DID complete + device CRUD --- elif args.cmd == "update-did": body = _parse_body(args.body) _require_confirm(args, "update DID", f"{args.domain}/{args.phonenumber}: {json.dumps(body)}") _emit(client.update_phonenumber(args.domain, args.phonenumber, body)) elif args.cmd == "delete-did": _require_confirm(args, "DELETE DID", f"{args.domain}/{args.phonenumber}") _emit(client.delete_phonenumber(args.domain, args.phonenumber)) elif args.cmd == "create-device": body = _parse_body(args.body) _require_confirm(args, "create device", f"{args.domain}/{args.user}: {json.dumps(body)}") _emit(client.create_device(args.domain, args.user, body)) elif args.cmd == "update-device": body = _parse_body(args.body) _require_confirm(args, "update device", f"{args.domain}/{args.user}/{args.device}: {json.dumps(body)}") _emit(client.update_device(args.domain, args.user, args.device, body)) elif args.cmd == "delete-device": _require_confirm(args, "DELETE device", f"{args.domain}/{args.user}/{args.device}") _emit(client.delete_device(args.domain, args.user, args.device)) # --- E911 address CRUD --- elif args.cmd == "create-address": body = _parse_body(args.body) _require_confirm(args, "create E911 address", f"{args.domain}: {json.dumps(body)}") _emit(client.create_address(args.domain, body)) elif args.cmd == "update-address": body = _parse_body(args.body) _require_confirm(args, "update E911 address", f"{args.domain}/{args.address_id}: {json.dumps(body)}") _emit(client.update_address(args.domain, args.address_id, body)) elif args.cmd == "delete-address": _require_confirm(args, "DELETE E911 address", f"{args.domain}/{args.address_id}") _emit(client.delete_address(args.domain, args.address_id)) # --- contact CRUD --- elif args.cmd == "create-contact": body = _parse_body(args.body) _require_confirm(args, "create contact", f"{args.domain}: {json.dumps(body)}") _emit(client.create_contact(args.domain, body)) elif args.cmd == "update-contact": body = _parse_body(args.body) _require_confirm(args, "update contact", f"{args.domain}/{args.contact_id}: {json.dumps(body)}") _emit(client.update_contact(args.domain, args.contact_id, body)) elif args.cmd == "delete-contact": _require_confirm(args, "DELETE contact", f"{args.domain}/{args.contact_id}") _emit(client.delete_contact(args.domain, args.contact_id)) # --- sites / auto-attendants / sms / blocked numbers / moh --- elif args.cmd == "create-site": body = _parse_body(args.body) _require_confirm(args, "create site", f"{args.domain}: {json.dumps(body)}") _emit(client.create_site(args.domain, body)) elif args.cmd == "update-site": body = _parse_body(args.body) _require_confirm(args, "update site", f"{args.domain}/{args.site}: {json.dumps(body)}") _emit(client.update_site(args.domain, args.site, body)) elif args.cmd == "create-autoattendant": body = _parse_body(args.body) _require_confirm(args, "create auto-attendant", f"{args.domain}: {json.dumps(body)}") _emit(client.create_autoattendant(args.domain, body)) elif args.cmd == "create-smsnumber": body = _parse_body(args.body) _require_confirm(args, "create SMS number", f"{args.domain}: {json.dumps(body)}") _emit(client.create_smsnumber(args.domain, body)) elif args.cmd == "update-smsnumber": body = _parse_body(args.body) _require_confirm(args, "update SMS number", f"{args.domain}/{args.smsnumber}: {json.dumps(body)}") _emit(client.update_smsnumber(args.domain, args.smsnumber, body)) elif args.cmd == "delete-smsnumber": _require_confirm(args, "DELETE SMS number", f"{args.domain}/{args.smsnumber}") _emit(client.delete_smsnumber(args.domain, args.smsnumber)) elif args.cmd == "block-numbers": body = _parse_body(args.body) _require_confirm(args, "block numbers", f"{args.domain}: {json.dumps(body)}") _emit(client.block_numbers(args.domain, body)) elif args.cmd == "unblock-numbers": body = _parse_body(args.body) _require_confirm(args, "unblock numbers", f"{args.domain}: {json.dumps(body)}") _emit(client.unblock_numbers(args.domain, body)) elif args.cmd == "create-moh": body = _parse_body(args.body) _require_confirm(args, "create MOH (TTS)", f"{args.domain}: {json.dumps(body)}") _emit(client.create_moh(args.domain, body)) elif args.cmd == "delete-moh": _require_confirm(args, "DELETE MOH", f"{args.domain}/{args.index}") _emit(client.delete_moh(args.domain, args.index)) elif args.cmd == "onboard-domain": if args.body_file: with open(args.body_file, encoding="utf-8") as fh: body = json.load(fh) else: body = _parse_body(args.body) if not isinstance(body, dict) or not body.get("domain"): print("[ERROR] onboard-domain needs a JSON body with 'domain'", file=sys.stderr); sys.exit(2) has_em = "emergency" in body _require_confirm(args, "ONBOARD domain", f"{body['domain']} (reseller {body.get('reseller','?')})" + (" + E911 address" if has_em else " (no E911 block)")) _emit(client.onboard_domain(body)) elif args.cmd == "raw": body = _parse_body(args.body) if args.method != "GET": _require_confirm(args, f"{args.method} {args.path}", json.dumps(body)) _emit(client.request(args.method, args.path, json_body=body)) else: p.error(f"unknown command {args.cmd}") except PacketDialError as exc: print(f"[ERROR] {exc}", file=sys.stderr) _log_skill_error("packetdial", f"{exc}", context=f"cmd={getattr(args, 'cmd', '?')}") return 1 return 0 if __name__ == "__main__": raise SystemExit(main())