#!/usr/bin/env python3 """ALIS (Medtelligent assisted-living EHR) REST API client for the `alis` skill. ALIS is a partner/App-Store integration API at https://api.alisonline.com. A tenant (e.g. Cascades of Tucson) lives at .alisonline.com but ALL API traffic goes to the shared api.alisonline.com host, scoped by the logged-in user's company + a communityId. Auth (verified live 2026-06-29): POST /user/tokens {"username":"@","password":"..."} -> {accessToken (JWT, ~1h), expiresIn, refreshToken} Send Authorization: Bearer on every call. Refresh: POST /user/tokens/refresh {accessToken, refreshToken}. CRITICAL: the username MUST be tenant-qualified (e.g. howard.enos@cascadestucson) or /user/tokens returns 400. The bare login name alone fails. Global API security is OR(Bearer | BasicAuth | VendorKey) - a user JWT alone authorizes the integration reads we use. Scope reality: this API is READ-ONLY for staff (only GET endpoints exist - no create/update/delete). Staff are CHANGED via the ALIS web-UI bulk import (see import_builder.py). This client's job is to READ current staff so new staff can be set up the same way (the job-role -> security-role reference map). Credentials load from the SOPS vault; env overrides exist for testing. Transport: prefers httpx if installed, else stdlib urllib (no hard dependency). """ from __future__ import annotations import json import os import subprocess import sys import urllib.error import urllib.parse import urllib.request from collections import Counter, defaultdict from pathlib import Path from typing import Any, Optional try: import httpx # type: ignore _HAS_HTTPX = True except ImportError: # pragma: no cover - depends on environment _HAS_HTTPX = False ERROR_BODY_MAX_CHARS = 500 ALIS_BASE_URL = os.environ.get("ALIS_BASE_URL", "https://api.alisonline.com") TIMEOUT_SECONDS = 60.0 CONNECT_TIMEOUT_SECONDS = 10.0 # Cascades of Tucson is the only tenant this credential sees today. DEFAULT_COMMUNITY_ID = int(os.environ.get("ALIS_COMMUNITY_ID", "622")) VAULT_ENTRY = "clients/cascades-tucson/alis-api-howard-user.sops.yaml" VAULT_USERNAME_FIELD = "credentials.username" VAULT_PASSWORD_FIELD = "credentials.password" SKILL_DIR = Path(__file__).resolve().parent.parent class ALISError(RuntimeError): """Raised for transport or API errors.""" # --- credential loading ------------------------------------------------------- def _resolve_claudetools_root() -> Path: derived_root = SKILL_DIR.parent.parent.parent # skills/alis -> repo root 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_get(field: str) -> str: root = _resolve_claudetools_root() vault_script = root / ".claude" / "scripts" / "vault.sh" if not vault_script.exists(): raise ALISError(f"vault wrapper not found at {vault_script}") 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 ALISError("'bash' not found on PATH; install Git Bash.") from exc except subprocess.TimeoutExpired as exc: raise ALISError("vault call timed out.") from exc if completed.returncode != 0: raise ALISError( f"vault read failed for {field} (exit {completed.returncode}): " f"{completed.stderr.strip()}" ) val = completed.stdout.strip() if not val: raise ALISError(f"vault returned empty value for {field}.") return val def load_credentials() -> tuple[str, str]: """Return (username, password). Env overrides ALIS_USERNAME/ALIS_PASSWORD, else the SOPS vault. Username must already be tenant-qualified.""" user = os.environ.get("ALIS_USERNAME") pw = os.environ.get("ALIS_PASSWORD") if not user: user = _vault_get(VAULT_USERNAME_FIELD) if not pw: pw = _vault_get(VAULT_PASSWORD_FIELD) return user, pw # --- client ------------------------------------------------------------------- class ALISClient: def __init__( self, username: Optional[str] = None, password: Optional[str] = None, api_base_url: str = ALIS_BASE_URL, community_id: int = DEFAULT_COMMUNITY_ID, timeout: float = TIMEOUT_SECONDS, connect_timeout: float = CONNECT_TIMEOUT_SECONDS, ): self.api_base_url = api_base_url.rstrip("/") self.community_id = community_id self._username = username self._password = password self._access_token: Optional[str] = None self._refresh_token: Optional[str] = None self.timeout = timeout self.connect_timeout = connect_timeout # -- auth ------------------------------------------------------------------ def _ensure_credentials(self) -> None: if not self._username or not self._password: self._username, self._password = load_credentials() @property def access_token(self) -> str: if not self._access_token: self.authenticate() return self._access_token # type: ignore[return-value] def authenticate(self) -> None: """Mint a fresh JWT via POST /user/tokens.""" self._ensure_credentials() body = {"username": self._username, "password": self._password} url = f"{self.api_base_url}/user/tokens" data = json.dumps(body).encode("utf-8") result = self._send("POST", url, data, with_auth=False) if not isinstance(result, dict) or "accessToken" not in result: raise ALISError(f"Unexpected token response: {str(result)[:200]}") self._access_token = result["accessToken"] self._refresh_token = result.get("refreshToken") # -- core transport -------------------------------------------------------- def _request(self, method: str, path: str, params: Optional[dict] = None, body: Optional[dict] = None) -> Any: url = f"{self.api_base_url}/{path.lstrip('/')}" if params: url = f"{url}?{urllib.parse.urlencode(params)}" data = json.dumps(body).encode("utf-8") if body is not None else None return self._send(method, url, data, with_auth=True) def _send(self, method: str, url: str, data: Optional[bytes], with_auth: bool) -> Any: headers = {"Content-Type": "application/json", "Accept": "application/json"} if with_auth: headers["Authorization"] = f"Bearer {self.access_token}" 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=headers) resp.raise_for_status() return resp.json() if resp.content else None except httpx.TimeoutException as exc: raise ALISError(f"ALIS request timed out: {exc}") from exc except httpx.HTTPStatusError as exc: detail = (exc.response.text or "")[:ERROR_BODY_MAX_CHARS] raise ALISError( f"ALIS HTTP {exc.response.status_code} [{method} {url}]: {detail}" ) from exc except httpx.HTTPError as exc: raise ALISError(f"ALIS request failed: {exc}") from exc req = urllib.request.Request(url, data=data, method=method, headers=headers) try: with urllib.request.urlopen(req, timeout=self.timeout) as resp: raw = resp.read() return json.loads(raw.decode("utf-8")) if raw else None except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace")[:ERROR_BODY_MAX_CHARS] raise ALISError(f"ALIS HTTP {exc.code} [{method} {url}]: {detail}") from exc except urllib.error.URLError as exc: raise ALISError(f"ALIS request failed: {exc}") from exc # ====================================================================== # READ METHODS (this API is read-only for staff) # ====================================================================== def list_communities(self) -> list[dict]: """Communities the logged-in user can see. VERIFIED LIVE.""" return self._request("GET", "/v1/integration/communities") or [] def list_staff(self, community_id: Optional[int] = None, status: Optional[str] = None, include_associated: bool = False) -> list[dict]: """Staff roster for a community. VERIFIED LIVE. communityId is REQUIRED - omitting it 403s 'Not authorized for facility 0'. Each record: staffId, firstName, lastName, staffRecordNumber, primaryEmail, mobilePhoneNumber, dateOfBirth, status, hireDate, dischargeDate, jobRole, securityRoles (list).""" params: dict = {"communityId": community_id or self.community_id} if status: params["status"] = status if include_associated: params["includeAssociatedStaff"] = "true" res = self._request("GET", "/v1/integration/staff", params=params) return res if isinstance(res, list) else (res or {}).get("data", []) or [] def get_staff(self, staff_id: int) -> dict: """One staff member's full record. VERIFIED LIVE.""" return self._request("GET", f"/v1/integration/staff/{staff_id}") or {} def get_staff_basic_info(self, staff_id: int) -> dict: """Staff basicInfo: address, license, jobRole, securityRoles, etc.""" return self._request( "GET", f"/v1/integration/staff/{staff_id}/basicInfo") or {} # -- derived reference model ----------------------------------------------- def build_role_map(self, community_id: Optional[int] = None) -> dict: """Derive the setup-reference from LIVE staff: the security-role and job-role vocabularies actually in use, and a job-role -> security-role(s) map learned from HIRED staff. This is how a new hire of a given job role should be configured to match existing staff.""" staff = self.list_staff(community_id=community_id) hired = [s for s in staff if s.get("status") == "Hired"] sr_all: Counter = Counter() jr_all: Counter = Counter() jr_to_sr: dict[str, Counter] = defaultdict(Counter) for s in staff: sr = s.get("securityRoles") or [] if isinstance(sr, str): sr = [sr] for x in sr: sr_all[x] += 1 jr = (s.get("jobRole") or "").strip() if jr: jr_all[jr] += 1 for s in hired: jr = (s.get("jobRole") or "").strip() if not jr: continue sr = s.get("securityRoles") or [] if isinstance(sr, str): sr = [sr] for x in sr: jr_to_sr[jr][x] += 1 return { "community": {"id": community_id or self.community_id}, "sourceStaffCount": len(staff), "hiredCount": len(hired), "securityRoleVocabulary": sorted(sr_all), "jobRoleVocabulary": sorted(jr_all), "jobRoleToSecurityRoles": { jr: [r for r, _ in c.most_common()] for jr, c in jr_to_sr.items() }, } # ====================================================================== # POWER TOOL # ====================================================================== def raw(self, method: str, path: str, params: Optional[dict] = None, body: Optional[dict] = None) -> Any: """Call any endpoint directly (read-only API - no staff writes exist).""" return self._request(method.upper(), path, params=params, body=body) def main() -> int: """Self-check: mint a token (live) and confirm community scope.""" try: client = ALISClient() client.authenticate() print("[OK] authenticated; transport =", "httpx" if _HAS_HTTPX else "urllib") comms = client.list_communities() print("[INFO] base =", client.api_base_url) print("[INFO] communities:", [(c.get("communityId"), c.get("communityName")) for c in comms]) return 0 except ALISError as exc: print(f"[ERROR] {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())