Files
claudetools/.claude/skills/alis/scripts/alis.py
Howard Enos 31f2bdb84f sync: auto-sync from HOWARD-HOME at 2026-06-29 16:55:22
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-06-29 16:55:22
2026-06-29 16:55:55 -07:00

290 lines
11 KiB
Python

#!/usr/bin/env python3
"""CLI for the `alis` skill.
ALIS (Medtelligent) staff are READ via the API and CHANGED via a web-UI bulk
import. This CLI does both halves:
- read: auth-test, communities, staff, roles, role-map (reference for setup)
- build: template, build-import, inspect (produce/check the import workbook)
The API is read-only for staff - there is no write subcommand because no staff
write endpoint exists. The end deliverable is an .xls you upload in ALIS.
Examples:
python alis.py auth-test
python alis.py communities
python alis.py staff [--status Hired] [--limit 20] [--json]
python alis.py roles # live security/job role vocab
python alis.py role-map [--refresh] # jobRole -> securityRole reference
python alis.py template --out new_staff.xls # blank, ready-to-fill template
python alis.py build-import --input hires.csv --out import.xls [--gen-passwords]
python alis.py inspect import.xls
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from alis_client import ALISClient, ALISError
import import_builder as ib
# --- errorlog (soft-fail, per CLAUDE.md) --------------------------------------
def _log_skill_error(skill: str, msg: str, context: str = "") -> None:
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
# 404/"not found" on a probe is expected; a real auth/transport failure is not.
_EXPECTED = ("http 404", "not found", "http 429", "too many requests")
def _should_log(msg: str) -> bool:
if os.environ.get("ALIS_SUPPRESS_ERRORLOG"):
return False
m = (msg or "").lower()
return not any(x in m for x in _EXPECTED)
def _emit(obj, as_json: bool) -> None:
print(json.dumps(obj, indent=2, default=str))
def _trunc(s, n):
s = "" if s is None else str(s)
return s if len(s) <= n else s[: n - 1] + ""
# --- read subcommands ---------------------------------------------------------
def cmd_auth_test(args) -> int:
c = ALISClient()
c.authenticate()
comms = c.list_communities()
print("[OK] authenticated to", c.api_base_url)
print("[INFO] communities:",
[(x.get("communityId"), x.get("communityName")) for x in comms])
return 0
def cmd_communities(args) -> int:
c = ALISClient()
comms = c.list_communities()
if args.json:
_emit(comms, True)
else:
for x in comms:
print(f" {x.get('communityId'):>6} {x.get('communityName')}")
return 0
def cmd_staff(args) -> int:
c = ALISClient()
staff = c.list_staff(community_id=args.community, status=args.status)
if args.json:
_emit(staff[: args.limit] if args.limit else staff, True)
return 0
print(f"[INFO] {len(staff)} staff"
+ (f" (status={args.status})" if args.status else ""))
shown = staff[: args.limit] if args.limit else staff
for s in shown:
name = f"{s.get('firstName','')} {s.get('lastName','')}".strip()
sr = s.get("securityRoles") or []
sr = ", ".join(sr) if isinstance(sr, list) else str(sr)
print(f" {s.get('staffId'):>7} {_trunc(name,26):26} "
f"{_trunc(s.get('status',''),11):11} "
f"{_trunc(s.get('jobRole') or '-',26):26} [{_trunc(sr,40)}]")
if args.limit and len(staff) > args.limit:
print(f" ... {len(staff)-args.limit} more (raise --limit or use --json)")
return 0
def cmd_roles(args) -> int:
c = ALISClient()
rm = c.build_role_map(community_id=args.community)
if args.json:
_emit(rm, True)
return 0
print(f"[INFO] from {rm['sourceStaffCount']} staff "
f"({rm['hiredCount']} Hired), community {rm['community']['id']}")
print("\n=== Security Roles in use ===")
for r in rm["securityRoleVocabulary"]:
print(f" {r}")
print("\n=== Job Roles in use ===")
for r in rm["jobRoleVocabulary"]:
print(f" {r}")
return 0
def cmd_role_map(args) -> int:
if args.refresh:
c = ALISClient()
rm = c.build_role_map(community_id=args.community)
path = Path(__file__).resolve().parent.parent / "references" / "role-map.json"
path.write_text(json.dumps(rm, indent=2), encoding="utf-8")
print(f"[OK] refreshed role-map.json from live ({rm['sourceStaffCount']} staff)")
else:
rm = ib.load_role_map()
if not rm:
print("[WARNING] no cached role-map.json; run with --refresh", file=sys.stderr)
return 1
combo = rm.get("jobRoleToSecurityRolesCombo") or {
k: ", ".join(v) for k, v in rm.get("jobRoleToSecurityRoles", {}).items()}
if args.json:
_emit(combo, True)
return 0
print("=== Job Role -> typical Security Roles (learned from current staff) ===")
for jr, sr in sorted(combo.items()):
print(f" {_trunc(jr,34):34} -> {sr}")
return 0
# --- build subcommands --------------------------------------------------------
def cmd_template(args) -> int:
out = ib.write_workbook([], args.out)
print(f"[OK] blank ALIS staff-import template -> {out}")
print("[INFO] columns:", " | ".join(ib.TEMPLATE_HEADERS))
return 0
def cmd_build_import(args) -> int:
rows_in = ib.read_input_rows(args.input)
if not rows_in:
print("[ERROR] no rows read from input", file=sys.stderr)
return 1
role_map = ib.load_role_map()
if args.refresh_roles:
try:
role_map = ALISClient().build_role_map(community_id=args.community)
except ALISError as exc:
print(f"[WARNING] live role refresh failed ({exc}); using cached map",
file=sys.stderr)
rows, report = ib.enrich_and_validate(
rows_in, role_map, default_status=args.default_status,
suggest_roles=not args.no_suggest)
sidecar = None
if args.gen_passwords:
if args.format == "update":
print("[WARNING] --gen-passwords ignored: the update format has no "
"Password column. Use --format create for new logins.",
file=sys.stderr)
else:
generated = ib.fill_passwords(rows)
sidecar = ib.write_password_sidecar(generated, args.out)
out = ib.write_workbook(rows, args.out, fmt=args.format)
# Report (never prints passwords)
warn_rows = [r for r in report if r["warnings"]]
print(f"[OK] wrote {len(rows)} staff -> {out} (format={args.format})")
print("[INFO] columns:", " | ".join(ib.HEADERS_BY_FORMAT[args.format]))
for r in report:
for n in r["notes"]:
print(f" [INFO] row {r['row']} ({r['name']}): {n}")
for r in warn_rows:
for w in r["warnings"]:
print(f" [WARNING] row {r['row']} ({r['name']}): {w}")
if sidecar:
print(f"[CRITICAL] generated passwords written PLAINTEXT to {sidecar}")
print(" Distribute to staff, then DELETE or vault it. Never commit it.")
print(f"\n[NEXT] Upload the .xls in ALIS: Staff -> Import. Dates use "
f"{ib.DATE_FORMAT_HINT}. Rows without an ALIS ID are CREATED; the update "
"format (with ALIS ID) edits existing staff. Test with ONE row first.")
return 0
def cmd_inspect(args) -> int:
data = ib.read_workbook(args.path)
if args.json:
_emit(data, True)
return 0
print(f"[INFO] format: {data['format']}")
for name, rows in data["sheets"].items():
print(f"=== {name} ({len(rows)} rows shown) ===")
for i, row in enumerate(rows):
trimmed = list(row)
while trimmed and trimmed[-1] in ("", None):
trimmed.pop()
print(f" row{i}: {trimmed}")
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="alis", description="ALIS staff read + import-builder")
p.add_argument("--json", action="store_true", help="emit raw JSON")
p.add_argument("--community", type=int, default=None,
help="communityId (default: vault/env, Cascades=622)")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("auth-test", help="mint a token and list communities")
sub.add_parser("communities", help="list communities in scope")
sp = sub.add_parser("staff", help="staff roster")
sp.add_argument("--status", help="filter: Applicant|Hired|Discharged|Rejected")
sp.add_argument("--limit", type=int, default=25)
sub.add_parser("roles", help="security + job role vocabulary (live)")
sp = sub.add_parser("role-map", help="job-role -> security-role reference")
sp.add_argument("--refresh", action="store_true", help="rebuild from live + cache")
sp = sub.add_parser("template", help="write a blank import template .xls")
sp.add_argument("--out", required=True)
sp = sub.add_parser("build-import", help="build an import .xls from a CSV/JSON")
sp.add_argument("--input", required=True, help="CSV/JSON of new staff")
sp.add_argument("--out", required=True, help="output .xls path")
sp.add_argument("--format", choices=["create", "update"], default="create",
help="create = new staff (has Password); update = edit "
"existing (leads with ALIS ID)")
sp.add_argument("--default-status", default="Hired")
sp.add_argument("--no-suggest", action="store_true",
help="do not infer Security Roles from Job Role")
sp.add_argument("--refresh-roles", action="store_true",
help="pull live role-map instead of cached")
sp.add_argument("--gen-passwords", action="store_true",
help="generate passwords for Login-enabled rows lacking one "
"(writes a plaintext sidecar CSV)")
sp = sub.add_parser("inspect", help="dump an existing import workbook")
sp.add_argument("path")
return p
_DISPATCH = {
"auth-test": cmd_auth_test, "communities": cmd_communities, "staff": cmd_staff,
"roles": cmd_roles, "role-map": cmd_role_map, "template": cmd_template,
"build-import": cmd_build_import, "inspect": cmd_inspect,
}
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
try:
return _DISPATCH[args.cmd](args)
except (ALISError, ib.ImportBuilderError) as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
if _should_log(str(exc)):
_log_skill_error("alis", str(exc), context=f"cmd={args.cmd}")
return 1
if __name__ == "__main__":
raise SystemExit(main())