#!/usr/bin/env python3 """MSP360 Managed Backup Service (MBS) CLI. Read the fleet backup state and manage users / companies / licenses via the MBS provider API. Reads run freely; every WRITE requires --confirm (a dry-run preview is printed without it). See SKILL.md for the trigger list and examples. Reads: status | monitoring | companies | users | computers | licenses | billing Writes: create-company | delete-company | create-user | delete-user grant-license | release-license | revoke-license | raw """ from __future__ import annotations import argparse import datetime as _dt import json import subprocess import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from msp360_client import Msp360Client, Msp360Error, STATUS_TEXT, BAD_STATUS # noqa: E402 import backup_common as bc # noqa: E402 (msp360_client put .claude/scripts on sys.path) # --- helpers ------------------------------------------------------------------ def _root() -> Path: # honor CLAUDETOOLS_ROOT / identity.json, same as the vault/HTTP plumbing. return bc.resolve_root() def _as_list(data, what: str) -> list: """A read endpoint promised a JSON array; a non-list 2xx body is anomalous.""" if isinstance(data, list): return data raise Msp360Error(f"expected a list from {what}, got {type(data).__name__}: {str(data)[:160]}") def log_error(brief: str, context: str = "") -> None: """Best-effort fleet error log; never breaks the caller (per CLAUDE.md).""" try: script = _root() / ".claude" / "scripts" / "log-skill-error.sh" if not script.exists(): return cmd = ["bash", str(script), "msp360", brief] if context: cmd += ["--context", context] subprocess.run(cmd, capture_output=True, text=True, timeout=20) except Exception: pass def out(obj, as_json: bool) -> None: if as_json: print(json.dumps(obj, indent=2, default=str)) def human(n) -> str: try: n = float(n) except (TypeError, ValueError): return str(n) for unit in ("B", "KB", "MB", "GB", "TB"): if abs(n) < 1024: return f"{n:.1f}{unit}" n /= 1024 return f"{n:.1f}PB" def _days_since(ts: str): """Whole days since an ISO timestamp. None if empty OR unparseable (callers must distinguish 'never ran' from 'could not parse' via the raw value themselves).""" if not ts: return None try: dt = _dt.datetime.fromisoformat(ts.strip().replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=_dt.timezone.utc) return (_dt.datetime.now(_dt.timezone.utc) - dt).days except Exception: return None def require_confirm(args, action: str) -> bool: """Return True to proceed; if --confirm absent, print the dry-run and return False.""" if getattr(args, "confirm", False): return True print(f"[DRY-RUN] Would: {action}") print("[DRY-RUN] Re-run with --confirm to execute. Nothing was changed.") return False # --- read commands ------------------------------------------------------------ def cmd_status(c: Msp360Client, args) -> int: comps = c.companies() users = c.users() mon = c.monitoring() if args.json: out({"companies": len(comps), "users": len(users), "plans": len(mon)}, True) return 0 bad = sum(1 for m in mon if m.get("Status") in BAD_STATUS) print(f"[OK] MSP360 MBS reachable at {c.base}") print(f"[INFO] companies={len(comps)} users={len(users)} backup plans={len(mon)} " f"plans needing attention={bad}") return 0 def cmd_monitoring(c: Msp360Client, args) -> int: mon = _as_list(c.monitoring(), "monitoring") rows = [] for m in mon: if args.company and args.company.lower() not in (m.get("CompanyName") or "").lower(): continue status = m.get("Status") last_raw = m.get("LastStart") or "" never = not last_raw age = _days_since(last_raw) # --failed = triage: anything that is NOT a clean completed run and is not # in-progress. Includes never-run, Unknown(5) and null status (dead plans). if args.failed: clean_success = status in (0, 1) and not never if clean_success or status == 4: # 4 = Running continue # --stale = last run older than N days, OR never ran. An unparseable-but-present # timestamp is NOT claimed stale (avoid a false "not backing up"). if args.stale is not None: if never: pass elif age is not None and age >= args.stale: pass else: continue rows.append((m, age)) if args.json: out([r[0] for r in rows], True) return 0 if not rows: print("[INFO] no plans match the filter.") return 0 print(f"{'COMPANY':<26} {'COMPUTER':<20} {'PLAN':<26} {'STATUS':<12} {'LAST START':<19} DATA") for m, age in sorted(rows, key=lambda r: ((r[0].get('CompanyName') or '').lower(), r[0].get('ComputerName') or '')): st = STATUS_TEXT.get(m.get("Status"), str(m.get("Status"))) last = m.get("LastStart") or "never" agestr = f" ({age}d)" if age is not None and age >= 30 else "" print(f"{(m.get('CompanyName') or '')[:25]:<26} {(m.get('ComputerName') or '')[:19]:<20} " f"{(m.get('PlanName') or '')[:25]:<26} {st:<12} {last[:19]:<19}{agestr} " f"{human(m.get('DataCopied', 0))}") print(f"\n[INFO] {len(rows)} plan(s). Filters: " f"company={args.company or '-'} failed={args.failed} stale>={args.stale}") return 0 def cmd_companies(c: Msp360Client, args) -> int: comps = _as_list(c.companies(), "companies") if args.json: out(comps, True) return 0 for co in sorted(comps, key=lambda x: (x.get("Name") or "").lower()): lim = co.get("StorageLimit") limstr = "unlimited" if lim in (-1, None) else human(lim) print(f" {(co.get('Name') or ''):<34} id={co.get('Id')} storage_limit={limstr}") print(f"\n[INFO] {len(comps)} companies") return 0 def cmd_users(c: Msp360Client, args) -> int: users = _as_list(c.users(), "users") if args.company: users = [u for u in users if args.company.lower() in (u.get("Company") or "").lower()] if args.json: out(users, True) return 0 for u in sorted(users, key=lambda x: (x.get("Company") or "", x.get("Email") or "")): en = "enabled" if u.get("Enabled") else "DISABLED" print(f" {(u.get('Email') or ''):<34} {(u.get('Company') or ''):<26} " f"{en:<9} used={human(u.get('SpaceUsed', 0))} id={u.get('ID')}") print(f"\n[INFO] {len(users)} users" + (f" in '{args.company}'" if args.company else "")) return 0 def cmd_computers(c: Msp360Client, args) -> int: try: comps = _as_list(c.computers(), "computers") except Msp360Error as exc: msg = str(exc).lower() if "remote management" in msg or "not enabled" in msg or "not available" in msg: print("[INFO] /api/Computers is gated behind the Remote Management API feature, " "which is not enabled on this MSP360 account. Use `users` + `monitoring` " "for endpoint/backup data instead.") return 0 raise if args.json: out(comps, True) return 0 for m in comps: print(f" {(m.get('Name') or m.get('ComputerName') or ''):<24} " f"user={m.get('User') or m.get('UserEmail') or ''} hid={m.get('Hid') or m.get('ID')}") print(f"\n[INFO] {len(comps) if isinstance(comps, list) else 0} computers") return 0 def cmd_licenses(c: Msp360Client, args) -> int: lic = _as_list(c.licenses(available=True if args.available else None), "licenses") if args.json: out(lic, True) return 0 taken = sum(1 for x in lic if x.get("IsTaken")) for x in lic: state = "TAKEN" if x.get("IsTaken") else "free" num = x.get("Number") num = "" if num is None else num print(f" #{str(num):<5} {(x.get('LicenseType') or ''):<22} {state:<6} " f"exp={(x.get('DateExpired') or '')[:10]} user={x.get('User') or '-'}") print(f"\n[INFO] {len(lic)} licenses ({taken} taken, {len(lic) - taken} free)") return 0 def cmd_billing(c: Msp360Client, args) -> int: out(c.billing(), True) # billing has no table form; always JSON return 0 # --- write commands ----------------------------------------------------------- def cmd_create_company(c: Msp360Client, args) -> int: if not require_confirm(args, f"create company '{args.name}'"): return 0 res = c.create_company(args.name) print(f"[OK] created company '{args.name}'") out(res, True) return 0 def cmd_delete_company(c: Msp360Client, args) -> int: print(f"[WARNING] Deleting company id={args.id} removes the company record.") if not require_confirm(args, f"DELETE company id={args.id}"): return 0 res = c.delete_company(args.id) print(f"[OK] deleted company id={args.id}") out(res, True) return 0 def cmd_create_user(c: Msp360Client, args) -> int: payload = { "Email": args.email, "FirstName": args.first or "", "LastName": args.last or "", "Company": args.company, "Enabled": True, "NotificationEmails": [args.notify] if args.notify else [], } if args.password: payload["Password"] = args.password if not require_confirm(args, f"create user '{args.email}' in company '{args.company}'"): preview = dict(payload) if "Password" in preview: preview["Password"] = "***redacted***" # never echo a secret to the transcript out(preview, True) return 0 res = c.create_user(payload) print(f"[OK] created user '{args.email}'") out(res, True) return 0 def cmd_delete_user(c: Msp360Client, args) -> int: scope = "metadata only (backup data KEPT)" if args.keep_data else "metadata AND all backup data" print(f"[WARNING] Deleting user id={args.id}: {scope}.") if not require_confirm(args, f"DELETE user id={args.id} ({scope})"): return 0 res = c.delete_user(args.id, keep_data=args.keep_data) print(f"[OK] deleted user id={args.id}") out(res, True) return 0 def cmd_grant_license(c: Msp360Client, args) -> int: payload = {"UserID": args.user_id} if args.license_id: payload["LicenseID"] = args.license_id if not require_confirm(args, f"grant license {args.license_id or '(auto)'} to user {args.user_id}"): out(payload, True) return 0 res = c.grant_license(payload) print("[OK] license granted") out(res, True) return 0 def cmd_release_license(c: Msp360Client, args) -> int: payload = {"UserID": args.user_id} if args.license_id: payload["LicenseID"] = args.license_id if not require_confirm(args, f"release license from user {args.user_id}"): return 0 res = c.release_license(payload) print("[OK] license released") out(res, True) return 0 def cmd_revoke_license(c: Msp360Client, args) -> int: payload = {"UserID": args.user_id} if args.license_id: payload["LicenseID"] = args.license_id if not require_confirm(args, f"revoke license from user {args.user_id}"): return 0 res = c.revoke_license(payload) print("[OK] license revoked") out(res, True) return 0 def cmd_raw(c: Msp360Client, args) -> int: method = args.method.upper() body = json.loads(args.data) if args.data else None # Git-Bash rewrites a leading-slash arg into a Windows path (e.g. # '/api/Companies' -> 'C:/Program Files/Git/api/Companies'). Recover the /api/... tail. path = args.path if "/api/" in path and not path.startswith("/api"): path = path[path.index("/api/"):] elif not path.startswith("/"): path = "/" + path if method != "GET" and not require_confirm(args, f"{method} {path} {args.data or ''}"): return 0 res = c.request(method, path, body=body) out(res, True) return 0 # --- argparse ----------------------------------------------------------------- def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="msp360", description="MSP360 Managed Backup Service CLI") p.add_argument("--base", help="override API base URL") sub = p.add_subparsers(dest="command", required=True) def add_json(sp): sp.add_argument("--json", action="store_true", help="machine-readable JSON output") return sp add_json(sub.add_parser("status", help="reachability + fleet counts")).set_defaults(func=cmd_status) sp = sub.add_parser("monitoring", help="fleet backup status (the primary read)") sp.add_argument("--company", help="filter by company name substring") sp.add_argument("--failed", action="store_true", help="only plans not in a clean state") sp.add_argument("--stale", type=int, metavar="DAYS", help="only plans whose last run is >= DAYS old (or never)") add_json(sp) sp.set_defaults(func=cmd_monitoring) add_json(sub.add_parser("companies", help="list companies")).set_defaults(func=cmd_companies) sp = sub.add_parser("users", help="list backup users") sp.add_argument("--company", help="filter by company name substring") add_json(sp) sp.set_defaults(func=cmd_users) add_json(sub.add_parser("computers", help="list managed endpoints")).set_defaults(func=cmd_computers) sp = sub.add_parser("licenses", help="list licenses") sp.add_argument("--available", action="store_true", help="only free/available licenses") add_json(sp) sp.set_defaults(func=cmd_licenses) sub.add_parser("billing", help="billing info (JSON)").set_defaults(func=cmd_billing) # writes sp = sub.add_parser("create-company", help="create a company [--confirm]") sp.add_argument("--name", required=True) sp.add_argument("--confirm", action="store_true") sp.set_defaults(func=cmd_create_company) sp = sub.add_parser("delete-company", help="delete a company by id [--confirm]") sp.add_argument("--id", required=True) sp.add_argument("--confirm", action="store_true") sp.set_defaults(func=cmd_delete_company) sp = sub.add_parser("create-user", help="create a backup user [--confirm]") sp.add_argument("--email", required=True) sp.add_argument("--company", required=True) sp.add_argument("--first") sp.add_argument("--last") sp.add_argument("--password") sp.add_argument("--notify", help="notification email") sp.add_argument("--confirm", action="store_true") sp.set_defaults(func=cmd_create_user) sp = sub.add_parser("delete-user", help="delete a user by id [--confirm]") sp.add_argument("--id", required=True) sp.add_argument("--keep-data", action="store_true", help="keep backup data (metadata only)") sp.add_argument("--confirm", action="store_true") sp.set_defaults(func=cmd_delete_user) # API requires LicenseID for Release/Revoke (HTTP 400 without it); Grant can # auto-pick a license, so it stays optional there. for name, fn, verb, lic_required in (("grant-license", cmd_grant_license, "grant", False), ("release-license", cmd_release_license, "release", True), ("revoke-license", cmd_revoke_license, "revoke", True)): sp = sub.add_parser(name, help=f"{verb} a license [--confirm]") sp.add_argument("--user-id", required=True) sp.add_argument("--license-id", required=lic_required) sp.add_argument("--confirm", action="store_true") sp.set_defaults(func=fn) sp = sub.add_parser("raw", help="raw API call (writes need --confirm)") sp.add_argument("method") sp.add_argument("path", help="e.g. /api/Users/{id}") sp.add_argument("--data", help="JSON request body") sp.add_argument("--confirm", action="store_true") sp.set_defaults(func=cmd_raw) return p def main(argv=None) -> int: args = build_parser().parse_args(argv) client = Msp360Client(base=args.base) try: return args.func(client, args) except bc.BackupError as exc: # includes Msp360Error + vault/HTTP transport errors print(f"[ERROR] {exc}", file=sys.stderr) log_error(str(exc)[:200], context=f"command={args.command}") return 1 except Exception as exc: # noqa: BLE001 print(f"[ERROR] unexpected: {exc}", file=sys.stderr) log_error(f"unexpected: {exc}"[:200], context=f"command={args.command}") return 2 if __name__ == "__main__": raise SystemExit(main())