#!/usr/bin/env python3 """CLI for the screenconnect skill - ConnectWise ScreenConnect (Control) API. Read-only subcommands run freely. State-changing subcommands (send-command, send-message, set-properties) refuse to run without --confirm. Usage: python sc.py status # auth check + instance info python sc.py sessions [--name NAME] [--json] python sc.py session # full session detail python sc.py send-command --session --command "..." --confirm python sc.py send-message --session --message "..." --confirm python sc.py set-properties --session --json '[null,"Site","Tag"]' --confirm python sc.py raw --method GetSessionsByName --body '{"sessionName":""}' [--get] """ from __future__ import annotations import argparse import json import os import subprocess import sys from sc_client import ( ScreenConnectClient, ScreenConnectError, SC_BASE_URL, CUSTOM_PROPERTIES, ) # --- errorlog (skill rule): log GENUINE failures only, never expected conditions --- _EXPECTED_ERROR_MARKERS = ( "web method does not exist", # method not unlocked on this instance yet "429", "too many requests", ) def _is_expected_error(msg: str) -> bool: m = (msg or "").lower() return any(marker in m for marker in _EXPECTED_ERROR_MARKERS) def _should_log_error(command: str, msg: str) -> bool: if os.environ.get("SC_SUPPRESS_ERRORLOG"): return False if command == "raw": return False return not _is_expected_error(msg) def _log_skill_error(skill, msg, context=""): """Soft-fail append to errorlog.md via the canonical helper (never throws).""" 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 def _emit(obj, as_json: bool) -> None: print(json.dumps(obj, indent=2, default=str)) def _print_sessions(data) -> None: items = data if isinstance(data, list) else [data] print(f"Sessions: {len(items)}") for s in items: if not isinstance(s, dict): print(f" {s}") continue cp = s.get("CustomProperties") or s.get("CustomPropertyValues") or [] cps = ",".join(str(x) for x in cp) if isinstance(cp, list) else str(cp) print( f" {s.get('SessionID','?')} type={s.get('SessionType','?')} " f"name={s.get('Name','') or '-'!r} host={s.get('Host','') or '-'} " f"guest={s.get('GuestInfoName', s.get('GuestMachineName',''))} [{cps}]" ) # --- gating --- def _gated(action_desc: str, confirm: bool) -> bool: if not confirm: print("[WARNING] Refusing state-changing action without --confirm.") print(f"[INFO] Would: {action_desc}") return False return True # --- handlers --- def cmd_status(client, args): # Auth check via the one verified method; report instance + custom-property map. sessions = client.get_sessions_by_name("") n = len(sessions) if isinstance(sessions, list) else 0 print(f"[OK] Authenticated to {SC_BASE_URL}") print(f" extension: {client.extension_guid}") print(f" custom properties: {CUSTOM_PROPERTIES}") print(f" GetSessionsByName('') returned {n} session(s)") return 0 def cmd_sessions(client, args): _print_sessions(client.get_sessions_by_name(args.name)) if not args.json else _emit( client.get_sessions_by_name(args.name), True ) return 0 def cmd_session(client, args): _emit(client.get_session_details(args.session_id), True) return 0 def cmd_build_installer(client, args): url = client.build_installer_url( platform=args.platform, name=args.name, company=args.company, site=args.site, tag=args.tag, ) if args.json: _emit({"installer_url": url, "platform": args.platform}, True) return 0 print(url) return 0 def cmd_send_command(client, args): if not _gated(f"run command on session {args.session} : {args.run_command!r}", args.confirm): return 3 _emit(client.send_command_to_session(args.session, args.run_command), args.json) return 0 def cmd_send_message(client, args): if not _gated(f"message session {args.session}", args.confirm): return 3 _emit(client.send_message_to_session(args.session, args.message), args.json) return 0 def cmd_set_properties(client, args): try: props = json.loads(args.json_props) except json.JSONDecodeError as exc: print(f"[ERROR] --json is not valid JSON: {exc}", file=sys.stderr) return 2 if not isinstance(props, list): print("[ERROR] --json must be a JSON array (CP1=Company, CP2=Site, CP3=Tag).", file=sys.stderr) return 2 if not _gated(f"set custom properties on session {args.session}", args.confirm): return 3 _emit(client.update_session_custom_properties(args.session, props), args.json) return 0 DESTRUCTIVE_RAW_PATTERNS = ("sendcommand", "updatesession", "create", "delete", "end", "remove", "transfer", "install", "uninstall") def cmd_raw(client, args): m = args.method.lower() if any(p in m for p in DESTRUCTIVE_RAW_PATTERNS) and not args.confirm: print(f"[WARNING] '{args.method}' looks state-changing; refusing without --confirm.", file=sys.stderr) return 3 body = None if args.body: try: body = json.loads(args.body) except json.JSONDecodeError as exc: print(f"[ERROR] --body is not valid JSON: {exc}", file=sys.stderr) return 2 result = client.raw(args.method, body, http_method="GET" if args.get else "POST") print(json.dumps(result, indent=2, default=str)) return 0 def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="sc.py", description="ConnectWise ScreenConnect API CLI (ACG).") common = argparse.ArgumentParser(add_help=False) common.add_argument("--json", action="store_true", help="Emit raw JSON.") sub = p.add_subparsers(dest="command", required=True) sub.add_parser("status", help="Auth check + instance info.", parents=[common]) sp = sub.add_parser("sessions", help="List sessions by Name (verified).", parents=[common]) sp.add_argument("--name", default="", help="Session Name filter (blank = unattended).") sp = sub.add_parser("session", help="Session detail.", parents=[common]) sp.add_argument("session_id") sp = sub.add_parser("build-installer", help="Build a parameterized Access installer URL.", parents=[common]) sp.add_argument("--platform", default="msi", help="msi | exe | pkg | deb | rpm | sh (default msi).") sp.add_argument("--name", help="Session name (defaults to machine name if omitted).") sp.add_argument("--company", help="CP1 = Company.") sp.add_argument("--site", help="CP2 = Site.") sp.add_argument("--tag", help="CP3 = Tag.") sp = sub.add_parser("send-command", help="Run a backstage command (gated).", parents=[common]) sp.add_argument("--session", required=True) sp.add_argument("--command", dest="run_command", required=True) sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("send-message", help="Send a chat message (gated).", parents=[common]) sp.add_argument("--session", required=True) sp.add_argument("--message", required=True) sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("set-properties", help="Set CP1/CP2/CP3 (gated).", parents=[common]) sp.add_argument("--session", required=True) sp.add_argument("--props-json", dest="json_props", required=True, help='JSON array, e.g. ["Company","Site","Tag"]') sp.add_argument("--confirm", action="store_true") sp = sub.add_parser("raw", help="Call any method directly (power use / probing).", parents=[common]) sp.add_argument("--method", required=True) sp.add_argument("--body", help="JSON body.") sp.add_argument("--get", action="store_true", help="Use GET instead of POST.") sp.add_argument("--confirm", action="store_true", help="Required for state-changing method names.") return p HANDLERS = { "status": cmd_status, "sessions": cmd_sessions, "session": cmd_session, "build-installer": cmd_build_installer, "send-command": cmd_send_command, "send-message": cmd_send_message, "set-properties": cmd_set_properties, "raw": cmd_raw, } def main(argv=None) -> int: args = build_parser().parse_args(argv) handler = HANDLERS[args.command] try: client = ScreenConnectClient() rc = handler(client, args) return rc if isinstance(rc, int) else 0 except ScreenConnectError as exc: print(f"[ERROR] {exc}", file=sys.stderr) cmd = getattr(args, "command", "?") if _should_log_error(cmd, str(exc)): _log_skill_error("screenconnect", f"{exc}", context=f"cmd={cmd}") return 1 except KeyboardInterrupt: return 130 if __name__ == "__main__": raise SystemExit(main())