sync: auto-sync from HOWARD-HOME at 2026-06-02 15:03:53
Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-06-02 15:03:53
This commit is contained in:
127
.claude/skills/packetdial/SKILL.md
Normal file
127
.claude/skills/packetdial/SKILL.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
---
|
||||||
|
name: packetdial
|
||||||
|
description: >-
|
||||||
|
Manage the Arizona Computer Guru (ACG) PacketDial / OITVOIP hosted-VoIP
|
||||||
|
platform via the NetSapiens SNAPsolution API v2 (pbx.packetdial.com,
|
||||||
|
v44.4). List and inspect domains, users, devices/phones, DIDs (phone
|
||||||
|
numbers), resellers, and pull CDRs (call detail records). Provision new
|
||||||
|
customer domains, users, SIP devices, and phone numbers (all writes gated
|
||||||
|
behind --confirm). Read-only by default. Invoke for: "packetdial",
|
||||||
|
"oitvoip", "oit voip", "netsapiens", "voip portal", "pbx portal", "voip
|
||||||
|
domain", "voip user", "voip extension", "provision phone", "add did",
|
||||||
|
"phone number on voip", "call detail records", "cdr", "voip.packetdial",
|
||||||
|
"pbx.packetdial". NOTE: voip.packetdial.com is the customer-facing portal
|
||||||
|
(the fax/UC dashboard, e.g. Cascades account 28598) and has no API — the
|
||||||
|
programmable surface is pbx.packetdial.com. This skill talks to the LIVE
|
||||||
|
production reseller PBX; treat writes conservatively.
|
||||||
|
---
|
||||||
|
|
||||||
|
# PacketDial / NetSapiens (OITVOIP) Skill
|
||||||
|
|
||||||
|
Standalone CLI client for the NetSapiens SNAPsolution **API v2** that backs
|
||||||
|
ACG's hosted-VoIP offering through OITVOIP / PacketDial. Read-only by default;
|
||||||
|
every write (create / update / delete) is gated behind `--confirm`.
|
||||||
|
|
||||||
|
## The two hostnames (important)
|
||||||
|
|
||||||
|
| Host | What it is | API? |
|
||||||
|
|---|---|---|
|
||||||
|
| `voip.packetdial.com` | Customer-facing white-label portal / UC & fax dashboard (e.g. Cascades fax account **28598**). Login-gated UI. | **No** |
|
||||||
|
| `pbx.packetdial.com` | Reseller PBX platform — NetSapiens v44.4. | **Yes** — this skill targets it |
|
||||||
|
|
||||||
|
- API base: `https://pbx.packetdial.com/ns-api/v2`
|
||||||
|
- Token endpoint: `https://pbx.packetdial.com/ns-api/v2/tokens`
|
||||||
|
- Live OpenAPI spec: `https://pbx.packetdial.com/ns-api/webroot/openapi/openapi.json`
|
||||||
|
- Live Swagger UI: `https://pbx.packetdial.com/ns-api/openapi`
|
||||||
|
- Vendor docs: https://docs.ns-api.com/ (login) and https://voipdocs.io/oitvoip-access-platform-apis
|
||||||
|
|
||||||
|
## Credentials — ONE-TIME SETUP (not yet provisioned)
|
||||||
|
|
||||||
|
As of this skill's creation **no API key exists yet** — the vault entry
|
||||||
|
`msp-tools/oitvoip.sops.yaml` is empty/absent, so every command will fail with a
|
||||||
|
clear "No credentials found" error until you do this once:
|
||||||
|
|
||||||
|
1. Log into `pbx.packetdial.com` -> **Admin > API Keys** and create a
|
||||||
|
reseller-scoped key (prefix `nsr_`). If self-service key creation is not
|
||||||
|
available, reply to **Darwin Escaro (OITVOIP)** for reseller OAuth client
|
||||||
|
credentials.
|
||||||
|
2. Store it in the SOPS vault. Preferred (static bearer key):
|
||||||
|
```
|
||||||
|
# msp-tools/oitvoip.sops.yaml
|
||||||
|
credentials:
|
||||||
|
api_key: nsr_xxxxxxxxxxxxxxxx
|
||||||
|
```
|
||||||
|
Or, for OAuth2 password-grant credentials:
|
||||||
|
```
|
||||||
|
credentials:
|
||||||
|
client_id: <client id>
|
||||||
|
client_secret: <client secret>
|
||||||
|
username: <portal user@domain>
|
||||||
|
password: <portal password>
|
||||||
|
```
|
||||||
|
3. That's it — the client auto-detects which shape is present.
|
||||||
|
|
||||||
|
The client never hardcodes secrets. Resolution order: `PACKETDIAL_API_KEY` env
|
||||||
|
-> `PACKETDIAL_CLIENT_ID`+friends env -> vault `credentials.api_key` -> vault
|
||||||
|
OAuth fields. Env overrides exist for quick testing without touching the vault.
|
||||||
|
|
||||||
|
## Running the CLI
|
||||||
|
|
||||||
|
This machine's Python launcher is `py` (per identity.json); `python` / `python3`
|
||||||
|
also work. Run from the scripts dir so the two modules resolve.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/claudetools/.claude/skills/packetdial/scripts
|
||||||
|
|
||||||
|
py ns.py status # API version + authenticated key identity
|
||||||
|
py ns.py domains # list all domains
|
||||||
|
py ns.py domain <domain> # one domain's config
|
||||||
|
py ns.py users <domain> # users / extensions in a domain
|
||||||
|
py ns.py user <domain> <user>
|
||||||
|
py ns.py phones <domain> # SIP devices registered in a domain
|
||||||
|
py ns.py dids <domain> # phone numbers (DIDs) on a domain
|
||||||
|
py ns.py devices <domain> <user>
|
||||||
|
py ns.py cdrs --domain <domain> --start 2026-06-01 --end 2026-06-02
|
||||||
|
py ns.py resellers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writes (gated)
|
||||||
|
|
||||||
|
Every mutating command prints a `[DRY RUN]` line and exits non-zero unless you
|
||||||
|
pass `--confirm`. Bodies are raw JSON matching the NetSapiens v2 schema.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
py ns.py create-domain --body '{"domain":"acme","description":"Acme Inc"}' --confirm
|
||||||
|
py ns.py create-user acme --body '{"user":"101","name-first-name":"Jane"}' --confirm
|
||||||
|
py ns.py create-phone acme --body '{...}' --confirm
|
||||||
|
py ns.py create-did acme --body '{"phonenumber":"15205551234"}' --confirm
|
||||||
|
py ns.py update-user acme 101 --body '{"name-last-name":"Doe"}' --confirm
|
||||||
|
py ns.py delete-user acme 101 --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Raw escape hatch (any of the 239 v2 paths)
|
||||||
|
|
||||||
|
The named commands cover the common surface; for anything else, hit the path
|
||||||
|
directly. Non-GET methods still require `--confirm`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
py ns.py raw GET domains/acme/users/101/answerrules
|
||||||
|
py ns.py raw POST domains/acme/users --body '{...}' --confirm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Standard provisioning flow (new customer)
|
||||||
|
|
||||||
|
1. `create-domain` -> dial plan auto-generates
|
||||||
|
2. `create-user` per extension
|
||||||
|
3. `create-phone` per SIP device (MAC-provisioned)
|
||||||
|
4. `create-did` to attach DIDs and route them to users
|
||||||
|
5. Log the work back to the Syncro ticket
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- This is the LIVE production reseller PBX. A bad `create-domain` or
|
||||||
|
`delete-user` affects real customers — confirm the target domain first with a
|
||||||
|
read command before any write.
|
||||||
|
- CDR queries can be large; always pass `--start`/`--end` and a `--limit`.
|
||||||
|
- Reference detail (auth shapes, full endpoint inventory) lives in
|
||||||
|
`references/api.md`.
|
||||||
82
.claude/skills/packetdial/references/api.md
Normal file
82
.claude/skills/packetdial/references/api.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# NetSapiens SNAPsolution API v2 — Reference (PacketDial / OITVOIP)
|
||||||
|
|
||||||
|
Source of truth: the live OpenAPI spec at
|
||||||
|
`https://pbx.packetdial.com/ns-api/webroot/openapi/openapi.json`
|
||||||
|
(title "NetSapiens API v2", version 44.4.10 as of 2026-06-02).
|
||||||
|
|
||||||
|
## Hosts
|
||||||
|
|
||||||
|
- **API host:** `pbx.packetdial.com` — `https://pbx.packetdial.com/ns-api/v2`
|
||||||
|
- **Customer portal (no API):** `voip.packetdial.com` — login-gated UI; the
|
||||||
|
Cascades fax/UC dashboard lives here (account 28598). Hitting `/ns-api/*` on
|
||||||
|
this host returns `errors/not_found`.
|
||||||
|
|
||||||
|
## Authentication (`bearerAuth`)
|
||||||
|
|
||||||
|
All calls send `Authorization: Bearer <token>`. Two ways to get the token:
|
||||||
|
|
||||||
|
1. **Static API key** — created in `pbx.packetdial.com` Admin > API Keys.
|
||||||
|
Prefix encodes scope: `nsr_` = reseller, `nss_` = system, `nsd_` = domain.
|
||||||
|
Use the key string directly as the bearer token (no exchange step).
|
||||||
|
2. **OAuth2 password grant** — `POST /ns-api/v2/tokens` form-encoded:
|
||||||
|
`grant_type=password&client_id=..&client_secret=..&username=..&password=..`
|
||||||
|
Response includes `access_token` (JWT) + `expires_in`. The client caches it
|
||||||
|
and refreshes ~60s before expiry.
|
||||||
|
|
||||||
|
## Endpoint inventory (239 paths, grouped)
|
||||||
|
|
||||||
|
Top-level resource groups and the methods present:
|
||||||
|
|
||||||
|
| Resource | Methods | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `/version` | GET | platform version |
|
||||||
|
| `/apikeys`, `/apikeys/{id}`, `/apikeys/~` | GET POST PUT DELETE | `~` = current key |
|
||||||
|
| `/tokens`, `/jwt` | POST (+ GET/DELETE on jwt) | auth / token mgmt |
|
||||||
|
| `/resellers`, `/resellers/{reseller}` | GET POST PUT DELETE | |
|
||||||
|
| `/domains`, `/domains/{domain}` | GET POST PUT PATCH DELETE | `/domains/count` for totals |
|
||||||
|
| `/domains/{domain}/users`, `/users/{user}` | GET POST PUT DELETE | extensions |
|
||||||
|
| `/domains/{domain}/users/{user}/devices` | GET POST PUT DELETE | per-user SIP devices |
|
||||||
|
| `/domains/{domain}/users/{user}/answerrules` | GET POST | call routing |
|
||||||
|
| `/domains/{domain}/users/{user}/cdrs` | GET | per-user CDRs |
|
||||||
|
| `/domains/{domain}/phones` | GET POST PUT DELETE | domain device list |
|
||||||
|
| `/domains/{domain}/phonenumbers`, `/{phonenumber}` | GET POST PUT DELETE | DIDs |
|
||||||
|
| `/domains/{domain}/addresses`, `/addresses/endpoints` | GET POST PUT DELETE | emergency/E911 addrs |
|
||||||
|
| `/domains/{domain}/cdrs`, `/cdrs` | GET | call detail records (`/count` variants) |
|
||||||
|
| `/phonenumbers` | GET | global DID search |
|
||||||
|
| `/smsnumbers` | GET | |
|
||||||
|
| `/subscriptions` | GET POST PUT DELETE | event/webhook subs |
|
||||||
|
| `/connections`, `/routes`, `/routecon` | GET POST PUT DELETE | SIP trunks / routing |
|
||||||
|
| `/configurations`, `/config-definitions`, `/templates` | GET POST PUT DELETE | |
|
||||||
|
| `/certificates`, `/images` | GET POST PUT DELETE | |
|
||||||
|
| `/meetings`, `/video`, `/firebase` | GET POST | UC / video |
|
||||||
|
| `/sipflow` | GET | SIP trace |
|
||||||
|
| `/holidays`, `/cdrs` | GET | |
|
||||||
|
| `/backup`, `/restore` | POST / GET PUT | platform backup |
|
||||||
|
|
||||||
|
Many resources also expose `/count` (totals) and `#1/#2/#3` openapi-dedup
|
||||||
|
variants (same path, different query-param shapes). Use the Swagger UI for the
|
||||||
|
exact request/response schema of any specific path.
|
||||||
|
|
||||||
|
## Common query params
|
||||||
|
|
||||||
|
- List endpoints accept `limit`, `offset`, and resource-specific filters.
|
||||||
|
- CDR endpoints accept `start-date` / `end-date` (and `domain`, `user` scoping
|
||||||
|
via the nested path). Always bound CDR queries — they can be large.
|
||||||
|
|
||||||
|
## Provisioning flow (new customer domain)
|
||||||
|
|
||||||
|
1. `POST /domains` — creates the domain; dial plan auto-generates.
|
||||||
|
2. `POST /domains/{domain}/users` — one per extension.
|
||||||
|
3. `POST /domains/{domain}/users/{user}/devices` or
|
||||||
|
`POST /domains/{domain}/phones` — SIP devices (MAC provisioning).
|
||||||
|
4. `POST /domains/{domain}/phonenumbers` — attach DIDs, route to users.
|
||||||
|
5. Configure `answerrules` as needed.
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
- 2026-04-20: API researched (session log `session-logs/2026-04-20-session.md`,
|
||||||
|
"OITVOIP / NetSapiens API Research"). Platform identified, auth shapes
|
||||||
|
documented. **No API key created yet** — provisioning the key + storing it in
|
||||||
|
`msp-tools/oitvoip.sops.yaml` was the open TODO.
|
||||||
|
- 2026-06-02: `packetdial` skill created wrapping the v2 API (read-by-default,
|
||||||
|
gated writes). Confirmed `voip.` is portal-only and `pbx.` is the API host.
|
||||||
169
.claude/skills/packetdial/scripts/ns.py
Normal file
169
.claude/skills/packetdial/scripts/ns.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
#!/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 <domain>
|
||||||
|
python ns.py users <domain>
|
||||||
|
python ns.py user <domain> <user>
|
||||||
|
python ns.py phones <domain>
|
||||||
|
python ns.py dids <domain>
|
||||||
|
python ns.py devices <domain> <user>
|
||||||
|
python ns.py cdrs --domain <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 <domain> --body '{"user":"101","name-first-name":"Jane"}' --confirm
|
||||||
|
python ns.py delete-user <domain> <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 sys
|
||||||
|
|
||||||
|
from ns_client import NetSapiensClient, PacketDialError
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
|
||||||
|
# --- 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 == "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 == "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)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
381
.claude/skills/packetdial/scripts/ns_client.py
Normal file
381
.claude/skills/packetdial/scripts/ns_client.py
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Client for the packetdial skill — NetSapiens SNAPsolution API v2.
|
||||||
|
|
||||||
|
Talks to the live PacketDial / OITVOIP reseller PBX at pbx.packetdial.com
|
||||||
|
(NetSapiens v44.4). voip.packetdial.com is the customer-facing white-label
|
||||||
|
portal and is NOT the API host — the programmable surface lives on pbx.
|
||||||
|
|
||||||
|
Auth: Bearer token. Two credential shapes are supported, tried in this order:
|
||||||
|
1. Static API key (`nsr_` reseller-scoped or `nss_` system) used directly as
|
||||||
|
the bearer token. Preferred for machine-to-machine.
|
||||||
|
2. OAuth2 password grant: client_id + client_secret + username + password
|
||||||
|
POSTed to /tokens, which returns a short-lived access_token (JWT).
|
||||||
|
|
||||||
|
Credentials are NEVER hardcoded. They are loaded at runtime from the SOPS vault
|
||||||
|
entry `msp-tools/oitvoip.sops.yaml`, or from environment overrides. As of the
|
||||||
|
skill's creation that vault entry does not yet exist — see SKILL.md for the
|
||||||
|
one-time provisioning steps.
|
||||||
|
|
||||||
|
Transport: prefers httpx if installed, else falls back to stdlib urllib so the
|
||||||
|
skill works on a bare Python install.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
import httpx # type: ignore
|
||||||
|
|
||||||
|
_HAS_HTTPX = True
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
_HAS_HTTPX = False
|
||||||
|
|
||||||
|
SKILL_DIR = Path(__file__).resolve().parent.parent # .../.claude/skills/packetdial
|
||||||
|
ERROR_BODY_MAX_CHARS = 1500
|
||||||
|
DEFAULT_TIMEOUT = 60.0
|
||||||
|
DEFAULT_CONNECT_TIMEOUT = 15.0
|
||||||
|
|
||||||
|
# pbx host is the API host; voip host is the customer portal (no API).
|
||||||
|
API_BASE_URL = os.environ.get(
|
||||||
|
"PACKETDIAL_API_BASE_URL", "https://pbx.packetdial.com/ns-api/v2"
|
||||||
|
)
|
||||||
|
TOKEN_URL = os.environ.get(
|
||||||
|
"PACKETDIAL_TOKEN_URL", "https://pbx.packetdial.com/ns-api/v2/tokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
VAULT_ENTRY = "msp-tools/oitvoip.sops.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
class PacketDialError(Exception):
|
||||||
|
"""Any failure talking to the NetSapiens API or loading credentials."""
|
||||||
|
|
||||||
|
|
||||||
|
# --- repo-root + credential loading -------------------------------------------
|
||||||
|
def _resolve_claudetools_root() -> Path:
|
||||||
|
"""Resolve the ClaudeTools repo root: env var, then identity.json, then derived.
|
||||||
|
|
||||||
|
Final fallback is derived from this file's location so it works on the
|
||||||
|
Mac/Linux fleet, not only the Windows default.
|
||||||
|
"""
|
||||||
|
# SKILL_DIR = .../.claude/skills/packetdial ; root is three levels up.
|
||||||
|
derived_root = SKILL_DIR.parent.parent.parent
|
||||||
|
|
||||||
|
env_root = os.environ.get("CLAUDETOOLS_ROOT")
|
||||||
|
if env_root:
|
||||||
|
return Path(env_root)
|
||||||
|
|
||||||
|
identity_path = derived_root / ".claude" / "identity.json"
|
||||||
|
if identity_path.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(identity_path.read_text(encoding="utf-8"))
|
||||||
|
root = data.get("claudetools_root")
|
||||||
|
if root:
|
||||||
|
return Path(root)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return derived_root
|
||||||
|
|
||||||
|
|
||||||
|
def _vault_field(field: str) -> Optional[str]:
|
||||||
|
"""Read a single field from the oitvoip vault entry. Returns None if absent.
|
||||||
|
|
||||||
|
Soft failure: a missing field (vault exits non-zero) returns None so the
|
||||||
|
caller can try the next credential shape. A missing vault wrapper or bash
|
||||||
|
raises, since that is an environment problem the user must fix.
|
||||||
|
"""
|
||||||
|
root = _resolve_claudetools_root()
|
||||||
|
vault_script = root / ".claude" / "scripts" / "vault.sh"
|
||||||
|
if not vault_script.exists():
|
||||||
|
raise PacketDialError(
|
||||||
|
f"vault wrapper not found at {vault_script}; set PACKETDIAL_API_KEY "
|
||||||
|
"or PACKETDIAL_* OAuth env vars instead."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
["bash", str(vault_script), "get-field", VAULT_ENTRY, field],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise PacketDialError(
|
||||||
|
"'bash' not found on PATH. Install Git Bash or set PACKETDIAL_API_KEY."
|
||||||
|
) from exc
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise PacketDialError("vault call timed out.") from exc
|
||||||
|
|
||||||
|
if completed.returncode != 0:
|
||||||
|
return None
|
||||||
|
value = completed.stdout.strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def load_credentials() -> dict:
|
||||||
|
"""Resolve NetSapiens credentials. Returns a dict describing the auth mode.
|
||||||
|
|
||||||
|
Returns either:
|
||||||
|
{"mode": "apikey", "api_key": "nsr_..."}
|
||||||
|
{"mode": "oauth", "client_id": ..., "client_secret": ...,
|
||||||
|
"username": ..., "password": ...}
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. PACKETDIAL_API_KEY env -> apikey mode
|
||||||
|
2. PACKETDIAL_CLIENT_ID + ... env -> oauth mode
|
||||||
|
3. vault credentials.api_key -> apikey mode
|
||||||
|
4. vault credentials.{client_id,client_secret,username,password} -> oauth
|
||||||
|
|
||||||
|
Raises PacketDialError with provisioning guidance if nothing resolves.
|
||||||
|
"""
|
||||||
|
env_key = os.environ.get("PACKETDIAL_API_KEY")
|
||||||
|
if env_key:
|
||||||
|
return {"mode": "apikey", "api_key": env_key.strip()}
|
||||||
|
|
||||||
|
env_client = os.environ.get("PACKETDIAL_CLIENT_ID")
|
||||||
|
if env_client:
|
||||||
|
return {
|
||||||
|
"mode": "oauth",
|
||||||
|
"client_id": env_client.strip(),
|
||||||
|
"client_secret": (os.environ.get("PACKETDIAL_CLIENT_SECRET") or "").strip(),
|
||||||
|
"username": (os.environ.get("PACKETDIAL_USERNAME") or "").strip(),
|
||||||
|
"password": (os.environ.get("PACKETDIAL_PASSWORD") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
api_key = _vault_field("credentials.api_key")
|
||||||
|
if api_key:
|
||||||
|
return {"mode": "apikey", "api_key": api_key}
|
||||||
|
|
||||||
|
client_id = _vault_field("credentials.client_id")
|
||||||
|
if client_id:
|
||||||
|
return {
|
||||||
|
"mode": "oauth",
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": _vault_field("credentials.client_secret") or "",
|
||||||
|
"username": _vault_field("credentials.username") or "",
|
||||||
|
"password": _vault_field("credentials.password") or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
raise PacketDialError(
|
||||||
|
"No PacketDial / NetSapiens credentials found.\n"
|
||||||
|
f" Expected vault entry: {VAULT_ENTRY} with either:\n"
|
||||||
|
" credentials.api_key (nsr_ reseller bearer key) -- preferred\n"
|
||||||
|
" or OAuth password-grant fields:\n"
|
||||||
|
" credentials.client_id\n"
|
||||||
|
" credentials.client_secret\n"
|
||||||
|
" credentials.username\n"
|
||||||
|
" credentials.password\n"
|
||||||
|
" Provision a key in pbx.packetdial.com -> Admin > API Keys, then store it.\n"
|
||||||
|
" See .claude/skills/packetdial/SKILL.md for the full setup steps."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- client -------------------------------------------------------------------
|
||||||
|
class NetSapiensClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_base_url: str = API_BASE_URL,
|
||||||
|
token_url: str = TOKEN_URL,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
||||||
|
):
|
||||||
|
self.api_base_url = api_base_url.rstrip("/")
|
||||||
|
self.token_url = token_url
|
||||||
|
self.timeout = timeout
|
||||||
|
self.connect_timeout = connect_timeout
|
||||||
|
self._creds: Optional[dict] = None
|
||||||
|
self._bearer: Optional[str] = None
|
||||||
|
self._bearer_expiry: float = 0.0
|
||||||
|
|
||||||
|
# -- auth ------------------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def creds(self) -> dict:
|
||||||
|
if self._creds is None:
|
||||||
|
self._creds = load_credentials()
|
||||||
|
return self._creds
|
||||||
|
|
||||||
|
def _bearer_token(self) -> str:
|
||||||
|
"""Return a valid bearer token, fetching/refreshing an OAuth one if needed."""
|
||||||
|
creds = self.creds
|
||||||
|
if creds["mode"] == "apikey":
|
||||||
|
return creds["api_key"]
|
||||||
|
|
||||||
|
# oauth: reuse cached token until ~60s before expiry
|
||||||
|
if self._bearer and time.monotonic() < self._bearer_expiry - 60:
|
||||||
|
return self._bearer
|
||||||
|
|
||||||
|
form = {
|
||||||
|
"grant_type": "password",
|
||||||
|
"client_id": creds["client_id"],
|
||||||
|
"client_secret": creds["client_secret"],
|
||||||
|
"username": creds["username"],
|
||||||
|
"password": creds["password"],
|
||||||
|
}
|
||||||
|
body = self._http(
|
||||||
|
"POST",
|
||||||
|
self.token_url,
|
||||||
|
data=urllib.parse.urlencode(form).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
auth_header=None,
|
||||||
|
)
|
||||||
|
if not isinstance(body, dict) or "access_token" not in body:
|
||||||
|
raise PacketDialError(f"OAuth token response missing access_token: {body}")
|
||||||
|
self._bearer = body["access_token"]
|
||||||
|
expires_in = float(body.get("expires_in", 3600))
|
||||||
|
self._bearer_expiry = time.monotonic() + expires_in
|
||||||
|
return self._bearer
|
||||||
|
|
||||||
|
# -- core transport --------------------------------------------------------
|
||||||
|
def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
params: Optional[dict] = None,
|
||||||
|
json_body: Optional[dict] = None,
|
||||||
|
) -> Any:
|
||||||
|
"""One REST call against the API base. `path` is relative (e.g. 'domains')."""
|
||||||
|
url = f"{self.api_base_url}/{path.lstrip('/')}"
|
||||||
|
if params:
|
||||||
|
url = f"{url}?{urllib.parse.urlencode(params, doseq=True)}"
|
||||||
|
data = json.dumps(json_body).encode("utf-8") if json_body is not None else None
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
return self._http(
|
||||||
|
method, url, data=data, headers=headers,
|
||||||
|
auth_header=f"Bearer {self._bearer_token()}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _http(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
data: Optional[bytes] = None,
|
||||||
|
headers: Optional[dict] = None,
|
||||||
|
auth_header: Optional[str] = "__use_bearer__",
|
||||||
|
) -> Any:
|
||||||
|
hdrs = dict(headers or {})
|
||||||
|
if auth_header and auth_header != "__use_bearer__":
|
||||||
|
hdrs["Authorization"] = auth_header
|
||||||
|
|
||||||
|
if _HAS_HTTPX:
|
||||||
|
try:
|
||||||
|
timeout = httpx.Timeout(self.timeout, connect=self.connect_timeout)
|
||||||
|
with httpx.Client(timeout=timeout) as client:
|
||||||
|
resp = client.request(method, url, content=data, headers=hdrs)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return self._parse(resp.content)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise PacketDialError(f"request timed out: {exc}") from exc
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
detail = (exc.response.text or "")[:ERROR_BODY_MAX_CHARS]
|
||||||
|
raise PacketDialError(
|
||||||
|
f"HTTP {exc.response.status_code} {method} {url}: {detail}"
|
||||||
|
) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise PacketDialError(f"request failed: {exc}") from exc
|
||||||
|
|
||||||
|
# stdlib fallback
|
||||||
|
req = urllib.request.Request(url, data=data, method=method, headers=hdrs)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||||
|
return self._parse(resp.read())
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")[:ERROR_BODY_MAX_CHARS]
|
||||||
|
raise PacketDialError(f"HTTP {exc.code} {method} {url}: {detail}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise PacketDialError(f"request failed: {exc}") from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse(raw: bytes) -> Any:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(raw.decode("utf-8"))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
return raw.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# READ METHODS (safe — always live)
|
||||||
|
# ======================================================================
|
||||||
|
def version(self) -> Any:
|
||||||
|
return self.request("GET", "version")
|
||||||
|
|
||||||
|
def whoami(self) -> Any:
|
||||||
|
"""Details of the currently authenticated API key."""
|
||||||
|
return self.request("GET", "apikeys/~")
|
||||||
|
|
||||||
|
def domains(self, limit: int = 1000) -> Any:
|
||||||
|
return self.request("GET", "domains", params={"limit": limit})
|
||||||
|
|
||||||
|
def domain(self, domain: str) -> Any:
|
||||||
|
return self.request("GET", f"domains/{urllib.parse.quote(domain)}")
|
||||||
|
|
||||||
|
def users(self, domain: str) -> Any:
|
||||||
|
return self.request("GET", f"domains/{urllib.parse.quote(domain)}/users")
|
||||||
|
|
||||||
|
def user(self, domain: str, user: str) -> Any:
|
||||||
|
d, u = urllib.parse.quote(domain), urllib.parse.quote(user)
|
||||||
|
return self.request("GET", f"domains/{d}/users/{u}")
|
||||||
|
|
||||||
|
def phones(self, domain: str) -> Any:
|
||||||
|
return self.request("GET", f"domains/{urllib.parse.quote(domain)}/phones")
|
||||||
|
|
||||||
|
def devices(self, domain: str, user: str) -> Any:
|
||||||
|
d, u = urllib.parse.quote(domain), urllib.parse.quote(user)
|
||||||
|
return self.request("GET", f"domains/{d}/users/{u}/devices")
|
||||||
|
|
||||||
|
def phonenumbers(self, domain: str) -> Any:
|
||||||
|
return self.request("GET", f"domains/{urllib.parse.quote(domain)}/phonenumbers")
|
||||||
|
|
||||||
|
def resellers(self) -> Any:
|
||||||
|
return self.request("GET", "resellers")
|
||||||
|
|
||||||
|
def cdrs(self, domain: Optional[str] = None, **filters) -> Any:
|
||||||
|
if domain:
|
||||||
|
return self.request(
|
||||||
|
"GET", f"domains/{urllib.parse.quote(domain)}/cdrs", params=filters or None
|
||||||
|
)
|
||||||
|
return self.request("GET", "cdrs", params=filters or None)
|
||||||
|
|
||||||
|
def apikeys(self) -> Any:
|
||||||
|
return self.request("GET", "apikeys/~")
|
||||||
|
|
||||||
|
def subscriptions(self) -> Any:
|
||||||
|
return self.request("GET", "subscriptions")
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# WRITE METHODS (gated — the CLI requires --confirm before calling these)
|
||||||
|
# ======================================================================
|
||||||
|
def create_domain(self, body: dict) -> Any:
|
||||||
|
return self.request("POST", "domains", json_body=body)
|
||||||
|
|
||||||
|
def create_user(self, domain: str, body: dict) -> Any:
|
||||||
|
return self.request(
|
||||||
|
"POST", f"domains/{urllib.parse.quote(domain)}/users", json_body=body
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_user(self, domain: str, user: str, body: dict) -> Any:
|
||||||
|
d, u = urllib.parse.quote(domain), urllib.parse.quote(user)
|
||||||
|
return self.request("PUT", f"domains/{d}/users/{u}", json_body=body)
|
||||||
|
|
||||||
|
def delete_user(self, domain: str, user: str) -> Any:
|
||||||
|
d, u = urllib.parse.quote(domain), urllib.parse.quote(user)
|
||||||
|
return self.request("DELETE", f"domains/{d}/users/{u}")
|
||||||
|
|
||||||
|
def create_phone(self, domain: str, body: dict) -> Any:
|
||||||
|
return self.request(
|
||||||
|
"POST", f"domains/{urllib.parse.quote(domain)}/phones", json_body=body
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_phonenumber(self, domain: str, body: dict) -> Any:
|
||||||
|
return self.request(
|
||||||
|
"POST", f"domains/{urllib.parse.quote(domain)}/phonenumbers", json_body=body
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user