diff --git a/.claude/skills/packetdial/SKILL.md b/.claude/skills/packetdial/SKILL.md index 5019ad59..5f135f91 100644 --- a/.claude/skills/packetdial/SKILL.md +++ b/.claude/skills/packetdial/SKILL.md @@ -84,6 +84,39 @@ bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py update-user acme 101 --body bash "$CLAUDETOOLS_ROOT/.claude/scripts/py.sh" ns.py delete-user acme 101 --confirm ``` +### Call queue edits (live-verified create→update→delete, 2026-06-22) + +```bash +# create — body MUST include synchronous + callqueue (the queue extension/id) +ns.py create-callqueue acme --body '{"synchronous":"yes","callqueue":"8000","description":"Support"}' --confirm +ns.py update-callqueue acme 8000 --body '{"description":"Support (2nd shift)","callqueue-dispatch-type":"Round-robin"}' --confirm +ns.py delete-callqueue acme 8000 --confirm +# agents on a queue +ns.py add-agent acme 8000 --body '{"callqueue-agent-id":"101@acme"}' --confirm +ns.py update-agent acme 8000 101@acme --body '{"callqueue-agent-dispatch-order-ordinal":1}' --confirm +ns.py remove-agent acme 8000 101@acme --confirm +``` + +### Time frame edits (live-verified create→update→delete, 2026-06-22) + +Timeframes are **body-discriminated** — same path, the server picks the operation from the +body. The id arg is the `timeframe-id` (from `ns.py timeframes `). + +```bash +# create: timeframe-type = always | days-of-week | specific-dates | holiday | custom +ns.py create-timeframe acme --body '{"synchronous":"yes","timeframe-name":"After Hours","timeframe-type":"always"}' --confirm +# update: the body carries the type-specific array OR a type-convert: +ns.py update-timeframe acme --body '{"update-only":"yes","timeframe-days-of-week-array":[...]}' --confirm +ns.py update-timeframe acme --body '{"type":"days-of-week"}' --confirm # convert type +# delete: no body = delete the whole timeframe; {"child_id":N} = delete one entry +ns.py delete-timeframe acme --confirm +``` + +**Gotcha:** a `days-of-week` (or other entry-bearing) timeframe rejects creation without its +entry array (`HTTP 400 "...should have at most 1 days of week entry"`) — create `always` first +and convert, or include the array in the create body. The **`arizonacomputerguru` domain is the +sanctioned test bed** (not in production use) for exercising these writes safely. + ## Raw escape hatch (any of the 239 v2 paths) The named commands cover the common surface; for anything else, hit the path @@ -105,8 +138,8 @@ The live OpenAPI spec is **NetSapiens API v2 v44.4.10** — **239 paths / 354 op | `users` (+ `/devices`, answerrules, voicemail, contacts under a user) | R/W, biggest surface | `users`, `user`, `devices`, `create/update/delete-user` | per-user devices live at `users/{u}/devices` | | `phonenumbers` (DIDs + dial-rule routing) | R/W | `dids`, `create-did` | | | `phones` (SIP devices, MAC provisioning) | R/W | `phones`, `create-phone` | | -| `callqueues` (ACD: agents, dispatch, live stats) | R/W + PATCH | `callqueues` (read) | writes via `raw` | -| `timeframes` (business-hours / holiday schedules) | R/W | `timeframes` (read) | writes via `raw` | +| `callqueues` (ACD: agents, dispatch, live stats) | R/W + PATCH | `callqueues` + `create/update/delete-callqueue`, `add/update/remove-agent` | full CRUD wrapped (verified) | +| `timeframes` (business-hours / holiday schedules) | R/W | `timeframes` + `create/update/delete-timeframe` | body-discriminated; CRUD wrapped (verified) | | `autoattendants` (IVR menus) | R/W | `autoattendants` (read) | | | `sites` (multi-site) | R/W | `sites` (read) | | | `contacts` (domain address book) | R/W | `contacts` (read) | | diff --git a/.claude/skills/packetdial/references/api.md b/.claude/skills/packetdial/references/api.md index a1969f1b..98857b50 100644 --- a/.claude/skills/packetdial/references/api.md +++ b/.claude/skills/packetdial/references/api.md @@ -86,3 +86,10 @@ exact request/response schema of any specific path. SKILL.md. Added live-verified read wrappers: `callqueues`, `timeframes`, `sites`, `contacts`, `autoattendants`, `billing`. Reseller territory = 3 domains today (arizonacomputerguru + two `*.91912.service`). + Added gated WRITE wrappers for call queues (`create/update/delete-callqueue`, + `add/update/remove-agent`) and time frames (`create/update/delete-timeframe`), each + live-verified create→update→delete on the `arizonacomputerguru` test domain (sanctioned + test bed, not in production use). Timeframe writes are body-discriminated (same path; the + server selects the op from the body) — wrappers pass `--body` verbatim. Note: entry-bearing + timeframe types (days-of-week etc.) reject create without their array — create `always` then + convert, or include the array. diff --git a/.claude/skills/packetdial/scripts/ns.py b/.claude/skills/packetdial/scripts/ns.py index f9cd3699..7c2faa11 100644 --- a/.claude/skills/packetdial/scripts/ns.py +++ b/.claude/skills/packetdial/scripts/ns.py @@ -116,6 +116,19 @@ def main(argv=None) -> int: 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") + # --- 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"]) @@ -190,6 +203,42 @@ def main(argv=None) -> int: _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)) + elif args.cmd == "raw": body = _parse_body(args.body) if args.method != "GET": diff --git a/.claude/skills/packetdial/scripts/ns_client.py b/.claude/skills/packetdial/scripts/ns_client.py index 6274e628..29081835 100644 --- a/.claude/skills/packetdial/scripts/ns_client.py +++ b/.claude/skills/packetdial/scripts/ns_client.py @@ -404,3 +404,51 @@ class NetSapiensClient: return self.request( "POST", f"domains/{urllib.parse.quote(domain)}/phonenumbers", json_body=body ) + + # --- call queue writes (live-verified on arizonacomputerguru, 2026-06-22) --- + # Create body requires {"synchronous":"yes","callqueue":""} (+ optional config). + def create_callqueue(self, domain: str, body: dict) -> Any: + return self.request( + "POST", f"domains/{urllib.parse.quote(domain)}/callqueues", json_body=body + ) + + def update_callqueue(self, domain: str, callqueue: str, body: dict) -> Any: + d, c = urllib.parse.quote(domain), urllib.parse.quote(callqueue) + return self.request("PUT", f"domains/{d}/callqueues/{c}", json_body=body) + + def delete_callqueue(self, domain: str, callqueue: str) -> Any: + d, c = urllib.parse.quote(domain), urllib.parse.quote(callqueue) + return self.request("DELETE", f"domains/{d}/callqueues/{c}") + + def add_callqueue_agent(self, domain: str, callqueue: str, body: dict) -> Any: + # body requires {"callqueue-agent-id": "@"}. + d, c = urllib.parse.quote(domain), urllib.parse.quote(callqueue) + return self.request("POST", f"domains/{d}/callqueues/{c}/agents", json_body=body) + + def update_callqueue_agent(self, domain: str, callqueue: str, agent_id: str, body: dict) -> Any: + d, c, a = (urllib.parse.quote(domain), urllib.parse.quote(callqueue), + urllib.parse.quote(agent_id)) + return self.request("PUT", f"domains/{d}/callqueues/{c}/agents/{a}", json_body=body) + + def remove_callqueue_agent(self, domain: str, callqueue: str, agent_id: str) -> Any: + d, c, a = (urllib.parse.quote(domain), urllib.parse.quote(callqueue), + urllib.parse.quote(agent_id)) + return self.request("DELETE", f"domains/{d}/callqueues/{c}/agents/{a}") + + # --- timeframe writes --- + # Same path, body-discriminated variants (NetSapiens routes PUT/DELETE by body): + # create: {"synchronous":"yes","timeframe-name":"X","timeframe-type":"days-of-week|specific-dates|holiday|custom|always"} + # update PUT body carries the type-specific array(s); pass --body verbatim. + # delete with no body = delete the whole timeframe; with {"child_id":N} = delete one entry. + def create_timeframe(self, domain: str, body: dict) -> Any: + return self.request( + "POST", f"domains/{urllib.parse.quote(domain)}/timeframes", json_body=body + ) + + def update_timeframe(self, domain: str, tf_id: str, body: dict) -> Any: + d, t = urllib.parse.quote(domain), urllib.parse.quote(tf_id) + return self.request("PUT", f"domains/{d}/timeframes/{t}", json_body=body) + + def delete_timeframe(self, domain: str, tf_id: str, body: Optional[dict] = None) -> Any: + d, t = urllib.parse.quote(domain), urllib.parse.quote(tf_id) + return self.request("DELETE", f"domains/{d}/timeframes/{t}", json_body=body)