290 lines
11 KiB
Python
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())
|