← Commit history

Upload 1 file(s)

John Lauer ·e50653a926 ·4mo ago ·parent e10ab45
1 file changed +1011
aps.pyadded+1011
@@ -0,0 +1,1011 @@+"""Autodesk Platform Services (APS) cloud-file search for the Fusion bridge.++Gives the fusion bridge its OWN fast, server-indexed cloud search (the+Data Management API) instead of the slow folder-by-folder walk that+fusion_walk_cloud_tree / fusion_search_cloud_files do. Works even when+Fusion is closed — this is pure HTTPS against APS.++Auth: the bridge runs its OWN 3-legged PKCE OAuth (public client, no client+secret). Only the APS Client ID is stored. We can't reuse Fusion's in-session+token — Autodesk DPAPI-encrypts that store with app entropy on purpose — so the+bridge owns its own login. One-time: register a Desktop/PKCE app at+aps.autodesk.com (Data Management API enabled, callback below), put the Client+ID in config, then fusion_aps_signin once.++Verbs (CLI fusion_aps_*  ->  bridge command aps_*):+  fusion_aps_status                 - configured? signed in? token live?+  fusion_aps_set_client_id {clientId}+  fusion_aps_signin                 - opens browser; bg listener stores the token+  fusion_aps_search {query, hubId?, projectId?, limit?}+  fusion_aps_get {path}             - raw authenticated GET (dev/verify helper)++No third-party deps — stdlib only (the bridge must run on stock Python).+"""++import base64+import hashlib+import json+import os+import secrets+import threading+import time+import urllib.parse+import urllib.request+import urllib.error+import webbrowser+from concurrent.futures import ThreadPoolExecutor+from http.server import BaseHTTPRequestHandler, HTTPServer+from pathlib import Path++_usage_lock = threading.Lock()++# ── Constants ────────────────────────────────────────────────────────────────+APS_BASE = "https://developer.api.autodesk.com"+AUTH_URL = f"{APS_BASE}/authentication/v2/authorize"+TOKEN_URL = f"{APS_BASE}/authentication/v2/token"++# Fixed callback — MUST match the registered APS app exactly. 8910 is AD-native+# APS's port; the bridge uses its own so the two never collide.+# Fixed callback ports — tried in order at sign-in; the FIRST free one is bound.+# All of these (× localhost AND 127.0.0.1) are registered on the APS app, so a+# port collision on any user's machine just falls through to the next — no failed+# sign-ins. 8917 avoids AD's own APS on 8910; all verified free + outside Windows'+# reserved/excluded ranges (5357, 27339, 50000-50059) on a real box.+REDIRECT_PORTS = [8917, 8918, 8920]+REDIRECT_PATH = "/fusion-aps/callback"+_active_redirect_uri = None  # set to the bound port's URI when a sign-in starts+++def _redirect_uri(port: int) -> str:+    return f"http://localhost:{port}{REDIRECT_PATH}"+++def all_registered_callbacks() -> list:+    """Every callback URL that must be registered on the APS app (localhost + 127.0.0.1)."""+    out = []+    for p in REDIRECT_PORTS:+        out.append(f"http://localhost:{p}{REDIRECT_PATH}")+        out.append(f"http://127.0.0.1:{p}{REDIRECT_PATH}")+    return out+# data:read is enough for search/browse/recent/file_info/versions/download.+# data:write + data:create are needed for create_folder/upload. Including them in+# the scope means a user who signed in under the old read-only scope must run+# fusion_aps_signin once more before the write verbs work (read verbs keep working).+SCOPES = "data:read data:write data:create"++APS_DIR = Path(os.path.expanduser("~")) / ".adom" / "fusion-aps"+CONFIG_FILE = APS_DIR / "config.json"+TOKENS_FILE = APS_DIR / "tokens.json"+USAGE_FILE = APS_DIR / "usage.json"++# ── NEVER-CHARGE GUARANTEE (hard rule) ───────────────────────────────────────+# A user must NEVER be charged money — a card on file is only ever for Autodesk's+# identity verification. Two independent guarantees enforce this:+#   1. ALLOWLIST — the bridge calls ONLY Autodesk Data Management endpoints, which+#      are in APS's always-FREE API set. The paid APIs (Model Derivative, Design+#      Automation, Reality Capture, etc.) are NEVER called. Any path outside the+#      allowlist is refused before the request goes out.+#   2. METER + HARD CAP — even free APIs have monthly quotas; we count calls per+#      UTC month and FAIL CLOSED at a conservative cap, so a runaway loop can never+#      push past the free tier into billable overage.+# Data Management (/project/v1/, /data/v1/) AND OSS storage (/oss/v2/) are all in+# APS's always-FREE set — transferring your OWN Fusion Team hub files (download/+# upload) is included with the subscription, never billed. The paid APIs+# (Model Derivative, Automation, Reality Capture) are never in this list.+FREE_PATH_PREFIXES = ("/project/v1/", "/data/v1/", "/oss/v2/")+MONTHLY_CALL_CAP = 50000  # conservative; well under the DM free quota; fail-closed++_signin_lock = threading.Lock()+_signin_state = None  # {"verifier","state","server","thread","done","error","startedAt"}+++# ── Config + token storage ───────────────────────────────────────────────────+def _read_json(path: Path) -> dict:+    try:+        return json.loads(path.read_text(encoding="utf-8"))+    except Exception:+        return {}+++def _write_json(path: Path, data: dict):+    APS_DIR.mkdir(parents=True, exist_ok=True)+    path.write_text(json.dumps(data, indent=2), encoding="utf-8")+++def get_client_id() -> str:+    return _read_json(CONFIG_FILE).get("client_id", "") or os.environ.get("FUSION_APS_CLIENT_ID", "")+++def set_client_id(client_id: str):+    cfg = _read_json(CONFIG_FILE)+    cfg["client_id"] = client_id.strip()+    _write_json(CONFIG_FILE, cfg)+++def _load_tokens() -> dict:+    return _read_json(TOKENS_FILE)+++def _save_tokens(tok: dict):+    _write_json(TOKENS_FILE, tok)+++# ── Usage meter (per UTC month; fail-closed cap) ─────────────────────────────+def _usage_month() -> str:+    return time.strftime("%Y-%m", time.gmtime())+++def _read_usage() -> dict:+    u = _read_json(USAGE_FILE)+    if u.get("month") != _usage_month():+        u = {"month": _usage_month(), "calls": 0}  # auto-reset each month+    return u+++def _bump_usage(n: int = 1) -> dict:+    with _usage_lock:  # parallel search calls share this+        u = _read_usage()+        u["calls"] = int(u.get("calls", 0)) + n+        _write_json(USAGE_FILE, u)+        return u+++def _cap_remaining() -> int:+    return max(0, MONTHLY_CALL_CAP - int(_read_usage().get("calls", 0)))+++# ── PKCE helpers ─────────────────────────────────────────────────────────────+def _pkce_pair():+    verifier = base64.urlsafe_b64encode(secrets.token_bytes(48)).rstrip(b"=").decode()+    challenge = base64.urlsafe_b64encode(+        hashlib.sha256(verifier.encode()).digest()+    ).rstrip(b"=").decode()+    return verifier, challenge+++# ── HTTP ─────────────────────────────────────────────────────────────────────+def _http(method: str, url: str, headers=None, data=None, timeout=30):+    """Return (status, json_or_text). Never raises on HTTP error — returns the body."""+    if isinstance(data, dict):+        data = urllib.parse.urlencode(data).encode()+    req = urllib.request.Request(url, data=data, headers=headers or {}, method=method)+    try:+        with urllib.request.urlopen(req, timeout=timeout) as resp:+            body = resp.read().decode("utf-8", "replace")+            return resp.status, _maybe_json(body)+    except urllib.error.HTTPError as e:+        body = e.read().decode("utf-8", "replace")+        return e.code, _maybe_json(body)+    except Exception as e:+        return 0, {"error": str(e)}+++def _maybe_json(text: str):+    try:+        return json.loads(text)+    except Exception:+        return text+++# ── Token lifecycle ──────────────────────────────────────────────────────────+def _exchange_code(code: str, verifier: str) -> dict:+    status, body = _http("POST", TOKEN_URL,+        headers={"Content-Type": "application/x-www-form-urlencoded"},+        data={+            "grant_type": "authorization_code",+            "code": code,+            "client_id": get_client_id(),+            "redirect_uri": _active_redirect_uri,  # must match the authorize request+            "code_verifier": verifier,+        })+    if status == 200 and isinstance(body, dict) and body.get("access_token"):+        body["obtained_at"] = int(time.time())+        body["expires_at"] = int(time.time()) + int(body.get("expires_in", 3600)) - 60+        _save_tokens(body)+        return {"ok": True}+    return {"ok": False, "status": status, "body": body}+++def _refresh() -> bool:+    tok = _load_tokens()+    rt = tok.get("refresh_token")+    if not rt:+        return False+    # IMPORTANT: do NOT send scope on refresh. A refresh may only narrow scope, not+    # broaden it — so passing the (now wider) SCOPES would make Autodesk reject any+    # token originally granted read-only. Omitting scope preserves whatever was+    # granted, so existing read sessions keep refreshing; write features just need a+    # fresh fusion_aps_signin to consent to the wider scope.+    status, body = _http("POST", TOKEN_URL,+        headers={"Content-Type": "application/x-www-form-urlencoded"},+        data={+            "grant_type": "refresh_token",+            "refresh_token": rt,+            "client_id": get_client_id(),+        })+    if status == 200 and isinstance(body, dict) and body.get("access_token"):+        body["obtained_at"] = int(time.time())+        body["expires_at"] = int(time.time()) + int(body.get("expires_in", 3600)) - 60+        if "refresh_token" not in body:+            body["refresh_token"] = rt+        _save_tokens(body)+        return True+    return False+++def _valid_access_token() -> str | None:+    tok = _load_tokens()+    if not tok.get("access_token"):+        return None+    if int(tok.get("expires_at", 0)) <= int(time.time()):+        if not _refresh():+            return None+        tok = _load_tokens()+    return tok.get("access_token")+++def _aps_get(path: str, query: dict = None):+    """Authenticated GET against APS. `path` is absolute-from-host or a full URL.++    Enforces the never-charge guarantee: free-API allowlist + monthly hard cap.+    """+    at = _valid_access_token()+    if not at:+        return None, {"error": "not_signed_in"}++    # Guarantee 1: only ever hit free Data Management endpoints.+    only_path = path if not path.startswith("http") else urllib.parse.urlparse(path).path+    if not any(only_path.startswith(p) for p in FREE_PATH_PREFIXES):+        return 0, {"error": "blocked_non_free_endpoint", "path": only_path,+                   "_hint": "Refused — the bridge only calls free Data Management endpoints "+                            "(/project/v1/, /data/v1/) so you are never charged."}++    # Guarantee 2: fail closed at the monthly cap (never blow past the free tier).+    if _cap_remaining() <= 0:+        return 0, {"error": "usage_cap_reached", "cap": MONTHLY_CALL_CAP,+                   "_hint": f"Monthly APS call cap ({MONTHLY_CALL_CAP}) reached — refusing further "+                            "calls so you are never charged. Resets at the start of next UTC month."}++    url = path if path.startswith("http") else f"{APS_BASE}{path}"+    if query:+        url += ("&" if "?" in url else "?") + urllib.parse.urlencode(query)+    _bump_usage(1)+    return _http("GET", url, headers={"Authorization": f"Bearer {at}"})+++# ── Sign-in (PKCE, browser + background callback listener) ────────────────────+class _CallbackHandler(BaseHTTPRequestHandler):+    def do_GET(self):+        parsed = urllib.parse.urlparse(self.path)+        if parsed.path != REDIRECT_PATH:+            self.send_response(404); self.end_headers(); return+        qs = urllib.parse.parse_qs(parsed.query)+        code = (qs.get("code") or [None])[0]+        state = (qs.get("state") or [None])[0]+        ok = False+        with _signin_lock:+            st = _signin_state+        if code and st and state == st["state"]:+            res = _exchange_code(code, st["verifier"])+            ok = res.get("ok", False)+            with _signin_lock:+                st["done"] = True+                st["error"] = None if ok else res+        html = (b"<html><body style='font-family:sans-serif;background:#111;color:#eee;"+                b"text-align:center;padding-top:80px'><h2>Fusion APS: "+                + (b"signed in &check;" if ok else b"sign-in failed")+                + b"</h2><p>You can close this tab and return to Adom.</p></body></html>")+        self.send_response(200)+        self.send_header("Content-Type", "text/html")+        self.end_headers()+        self.wfile.write(html)++    def log_message(self, *a):+        pass+++def start_signin() -> dict:+    global _signin_state, _active_redirect_uri+    client_id = get_client_id()+    if not client_id:+        return {"success": False, "error": "No APS Client ID configured.",+                "errorCode": "aps_not_configured",+                "_hint": "Call fusion_aps_set_client_id {\"clientId\":\"...\"} first "+                         "(register a Desktop/PKCE app at https://aps.autodesk.com with "+                         "Data Management API + callbacks " + ", ".join(all_registered_callbacks()) + ")."}++    verifier, challenge = _pkce_pair()+    state = secrets.token_urlsafe(16)+    # Bind the FIRST free callback port from the registered set.+    server = None+    bound_port = None+    for port in REDIRECT_PORTS:+        try:+            server = HTTPServer(("127.0.0.1", port), _CallbackHandler)+            bound_port = port+            break+        except OSError:+            continue+    if server is None:+        return {"success": False,+                "error": f"All callback ports busy on this machine: {REDIRECT_PORTS}",+                "_hint": "Close whatever is holding these ports, or a sign-in is already in "+                         "progress. These are only bound for ~3 min during sign-in."}+    _active_redirect_uri = _redirect_uri(bound_port)++    with _signin_lock:+        _signin_state = {"verifier": verifier, "state": state, "server": server,+                         "done": False, "error": None, "startedAt": time.time()}++    def _serve():+        # Handle requests until the exchange completes or ~3 min elapses.+        server.timeout = 1+        deadline = time.time() + 180+        while time.time() < deadline:+            with _signin_lock:+                done = _signin_state and _signin_state["done"]+            if done:+                break+            server.handle_request()+        try:+            server.server_close()+        except Exception:+            pass++    threading.Thread(target=_serve, daemon=True).start()++    params = {+        "response_type": "code",+        "client_id": client_id,+        "redirect_uri": _active_redirect_uri,+        "scope": SCOPES,+        "code_challenge": challenge,+        "code_challenge_method": "S256",+        "state": state,+        # No prompt=login — reuse the user's existing Autodesk browser session so+        # sign-in is one-click (or zero-click if already consented), not a forced+        # re-login every time.+    }+    auth_url = AUTH_URL + "?" + urllib.parse.urlencode(params)+    try:+        webbrowser.open(auth_url)+    except Exception:+        try:+            os.startfile(auth_url)  # noqa: Windows+        except Exception:+            pass++    return {+        "success": True,+        "output": "Opened the Autodesk sign-in page in your browser. Approve access, "+                  "then call fusion_aps_status to confirm.",+        "authUrl": auth_url,+        "redirectUri": _active_redirect_uri,+        "_hint": "A background listener on port "+                 f"{bound_port} will capture the token automatically once you approve. "+                 "Poll fusion_aps_status until signedIn:true (up to ~3 min).",+    }+++# ── Verb handlers ────────────────────────────────────────────────────────────+def handle_status(args: dict) -> dict:+    # NOTE: AD's relay only forwards output/data/success/error/_hint to the+    # cloud caller — every structured field MUST live under `data`.+    client_id = get_client_id()+    tok = _load_tokens()+    has_at = bool(tok.get("access_token"))+    live = has_at and int(tok.get("expires_at", 0)) > int(time.time())+    with _signin_lock:+        pending = bool(_signin_state and not _signin_state["done"]+                       and time.time() - _signin_state["startedAt"] < 180)+    usage = _read_usage()+    data = {+        "configured": bool(client_id),+        "signedIn": bool(has_at),+        "tokenLive": bool(live),+        "tokenExpiresAt": tok.get("expires_at"),+        "scope": tok.get("scope", SCOPES),+        "signinPending": pending,+        "callbackUrls": all_registered_callbacks(),+        # Never-charge meter — the AI-driven setup watches this.+        "usageMonth": usage.get("month"),+        "callsThisMonth": int(usage.get("calls", 0)),+        "monthlyCap": MONTHLY_CALL_CAP,+        "capRemaining": _cap_remaining(),+        "freeEndpointsOnly": list(FREE_PATH_PREFIXES),+        "neverCharge": True,+    }+    if not client_id:+        hint = ("Not configured. Register a Desktop/PKCE app at https://aps.autodesk.com "+                "(enable Data Management API, set callbacks " + ", ".join(all_registered_callbacks())+                + "), then fusion_aps_set_client_id {\"clientId\":\"...\"} and fusion_aps_signin.")+        summary = "APS not configured"+    elif not has_at:+        hint = "Configured but not signed in. Call fusion_aps_signin (opens browser)."+        summary = "configured, not signed in"+    elif not live:+        hint = "Token expired and refresh failed. Call fusion_aps_signin again."+        summary = "signed in, token expired"+    else:+        hint = "Ready. Use fusion_aps_search {\"query\":\"...\"}."+        summary = "ready"+    return {"success": True, "output": summary, "data": data, "_hint": hint}+++def handle_set_client_id(args: dict) -> dict:+    cid = (args.get("clientId") or args.get("client_id") or "").strip()+    if not cid:+        return {"success": False, "error": "Missing clientId."}+    set_client_id(cid)+    return {"success": True,+            "output": f"Saved APS Client ID ({cid[:6]}...). Now call fusion_aps_signin.",+            "data": {"configured": True}}+++def handle_signin(args: dict) -> dict:+    res = start_signin()+    # Move custom fields under data so the relay forwards them.+    if res.get("success"):+        res["data"] = {"authUrl": res.pop("authUrl", None),+                       "redirectUri": res.pop("redirectUri", None)}+    return res+++def handle_get(args: dict) -> dict:+    """Raw authenticated GET — dev/verify helper for nailing the APS grammar."""+    path = args.get("path")+    if not path:+        return {"success": False, "error": "Missing path."}+    status, body = _aps_get(path, args.get("query"))+    return {"success": 200 <= (status or 0) < 300,+            "output": f"HTTP {status}",+            "data": {"httpStatus": status, "body": body}}+++def _list_hubs():+    status, body = _aps_get("/project/v1/hubs")+    if status != 200 or not isinstance(body, dict):+        return [], (status, body)+    return body.get("data", []), None+++def _list_projects(hub_id):+    status, body = _aps_get(f"/project/v1/hubs/{hub_id}/projects")+    if status != 200 or not isinstance(body, dict):+        return []+    return body.get("data", [])+++def _top_folders(hub_id, project_id):+    status, body = _aps_get(f"/project/v1/hubs/{hub_id}/projects/{project_id}/topFolders")+    if status != 200 or not isinstance(body, dict):+        return []+    return body.get("data", [])+++def _folder_search(project_id, folder_id, query, limit):+    """Recursive name search within a folder.++    APS's `:search` endpoint is the server-side recursive INDEX (returns all+    descendant items in a few paged calls — vastly faster than the add-in walk).+    Its server-side `filter[attributes.displayName]` matching proved unreliable+    (returned 0 for known files), so we page through the index and filter+    displayName CLIENT-SIDE (case-insensitive substring) — robust + still fast.+    """+    ql = query.lower()+    matches, seen = [], set()++    def _take(it):+        iid = it.get("id")+        if iid in seen:+            return False+        name = (it.get("attributes", {}) or {}).get("displayName", "") or ""+        if ql in name.lower():+            seen.add(iid)+            matches.append(it)+        return len(matches) >= limit++    # Pass 1 — the folder's DIRECT contents. :search only recurses subfolders, so+    # items sitting in the top folder itself (e.g. a design straight in "Main")+    # would otherwise be missed.+    status, body = _aps_get(f"/data/v1/projects/{project_id}/folders/{folder_id}/contents",+                            {"page[limit]": 200})+    if status == 200 and isinstance(body, dict):+        for it in body.get("data", []):+            if it.get("type") == "items" and _take(it):+                return matches, None++    # Pass 2 — the recursive server index (fast, free), paged. Filter client-side+    # (the server-side displayName filter is unreliable — returns 0 for known files).+    url, next_q = f"/data/v1/projects/{project_id}/folders/{folder_id}/search", {"page[limit]": 200}+    for _ in range(25):+        status, body = _aps_get(url, next_q)+        if status != 200 or not isinstance(body, dict):+            return matches, (None if matches else (status, body))+        for it in body.get("data", []):+            if _take(it):+                return matches, None+        nxt = (body.get("links", {}) or {}).get("next", {})+        nxt_href = nxt.get("href") if isinstance(nxt, dict) else nxt+        if not nxt_href:+            break+        url, next_q = nxt_href, None+    return matches, None+++def handle_search(args: dict) -> dict:+    query = (args.get("query") or "").strip()+    if not query:+        return {"success": False, "error": "Missing query."}+    if not _valid_access_token():+        return {"success": False, "error": "Not signed in to APS.",+                "errorCode": "aps_not_signed_in",+                "_hint": "Call fusion_aps_signin first, then retry."}+    limit = int(args.get("limit", 50))+    hub_filter = args.get("hubId")+    project_filter = args.get("projectId")++    hubs, err = _list_hubs()+    if err:+        return {"success": False, "error": "APS hubs request failed.",+                "data": {"httpStatus": err[0], "body": err[1]},+                "_hint": "Token may lack data:read scope or the app isn't Data-Management-enabled."}++    # Build the (hub, project, folder) task list, then search projects IN PARALLEL.+    scanned = {"hubs": 0, "projects": 0, "folders": 0}+    tasks = []+    for hub in hubs:+        hub_id = hub.get("id")+        if hub_filter and hub_id != hub_filter:+            continue+        scanned["hubs"] += 1+        projects = _list_projects(hub_id)+        if project_filter:+            projects = [p for p in projects if p.get("id") == project_filter] or \+                       [{"id": project_filter, "attributes": {"name": project_filter}}]+        for proj in projects:+            pid = proj.get("id")+            pname = (proj.get("attributes", {}) or {}).get("name", "")+            scanned["projects"] += 1+            for folder in _top_folders(hub_id, pid):+                scanned["folders"] += 1+                tasks.append((hub_id, pid, pname, folder.get("id")))++    def _scan(task):+        hub_id, pid, pname, fid = task+        hits, _ = _folder_search(pid, fid, query, limit)+        out = []+        for h in hits:+            a = h.get("attributes", {}) or {}+            out.append({"name": a.get("displayName"), "type": h.get("type"), "id": h.get("id"),+                        "hubId": hub_id, "projectId": pid, "projectName": pname,+                        "lastModified": a.get("lastModifiedTime")})+        return out++    results, seen = [], set()+    with ThreadPoolExecutor(max_workers=12) as pool:+        for out in pool.map(_scan, tasks):+            for r in out:+                if r["id"] in seen:+                    continue+                seen.add(r["id"])+                results.append(r)++    results.sort(key=lambda r: (r.get("lastModified") or ""), reverse=True)+    return {+        "success": True,+        "output": f"{len(results)} match(es) for '{query}'",+        "data": {+            "query": query,+            "count": len(results),+            "results": results[:limit],+            "scanned": scanned,+        },+        "_hint": f"Found {len(results)} match(es) via the APS server index across "+                 f"{scanned['projects']} project(s), newest first. Pass projectId to scope to "+                 "one project (faster). Use fusion_aps_open to open a hit in Fusion.",+    }+++def find_one(query: str) -> dict | None:+    """Best single match for a query (newest) — used by fusion_aps_open."""+    res = handle_search({"query": query, "limit": 5})+    rs = res.get("data", {}).get("results", [])+    return rs[0] if rs else None+++def handle_browse(args: dict) -> dict:+    """Browse the cloud without launching Fusion: hubs+projects → top folders → contents."""+    if not _valid_access_token():+        return {"success": False, "error": "Not signed in to APS.", "errorCode": "aps_not_signed_in",+                "_hint": "Call fusion_aps_signin first."}+    hub_id = args.get("hubId")+    project_id = args.get("projectId")+    folder_id = args.get("folderId")++    if project_id and folder_id:+        s, b = _aps_get(f"/data/v1/projects/{project_id}/folders/{folder_id}/contents", {"page[limit]": 200})+        items = [{"name": (x.get("attributes", {}) or {}).get("displayName") or (x.get("attributes", {}) or {}).get("name"),+                  "type": x.get("type"), "id": x.get("id")} for x in (b.get("data", []) if isinstance(b, dict) else [])]+        return {"success": s == 200, "output": f"{len(items)} item(s)",+                "data": {"level": "folder", "projectId": project_id, "folderId": folder_id, "items": items}}++    if project_id:+        if not hub_id:+            hubs, _ = _list_hubs()+            hub_id = hubs[0].get("id") if hubs else None+        folders = _top_folders(hub_id, project_id)+        items = [{"name": (f.get("attributes", {}) or {}).get("name"), "type": "folder", "id": f.get("id")}+                 for f in folders]+        return {"success": True, "output": f"{len(items)} top folder(s)",+                "data": {"level": "project", "hubId": hub_id, "projectId": project_id, "items": items}}++    hubs, err = _list_hubs()+    if err:+        return {"success": False, "error": "APS hubs request failed.", "data": {"body": err[1]}}+    out = []+    for h in hubs:+        hid = h.get("id")+        if hub_id and hid != hub_id:+            continue+        projs = _list_projects(hid)+        out.append({"hub": (h.get("attributes", {}) or {}).get("name"), "hubId": hid,+                    "projects": [{"name": (p.get("attributes", {}) or {}).get("name"), "id": p.get("id")} for p in projs]})+    nprej = sum(len(x["projects"]) for x in out)+    return {"success": True, "output": f"{len(out)} hub(s), {nprej} project(s)",+            "data": {"level": "hubs", "hubs": out}}+++def handle_recent(args: dict) -> dict:+    """Most-recently-modified files across the team hub (or one project). Reuses the+    parallel index scan with a match-all filter, then sorts by lastModifiedTime."""+    if not _valid_access_token():+        return {"success": False, "error": "Not signed in to APS.", "errorCode": "aps_not_signed_in",+                "_hint": "Call fusion_aps_signin first."}+    limit = int(args.get("limit", 25))+    hub_filter = args.get("hubId")+    project_filter = args.get("projectId")+    hubs, err = _list_hubs()+    if err:+        return {"success": False, "error": "APS hubs request failed.", "data": {"body": err[1]}}+    tasks = []+    for hub in hubs:+        hid = hub.get("id")+        if hub_filter and hid != hub_filter:+            continue+        projects = _list_projects(hid)+        if project_filter:+            projects = [p for p in projects if p.get("id") == project_filter] or \+                       [{"id": project_filter, "attributes": {"name": project_filter}}]+        for proj in projects:+            pid = proj.get("id")+            pname = (proj.get("attributes", {}) or {}).get("name", "")+            for f in _top_folders(hid, pid):+                tasks.append((pid, pname, f.get("id")))++    def _scan(t):+        pid, pname, fid = t+        hits, _ = _folder_search(pid, fid, "", 200)  # "" matches everything+        out = []+        for h in hits:+            a = h.get("attributes", {}) or {}+            out.append({"name": a.get("displayName"), "type": h.get("type"), "id": h.get("id"),+                        "projectId": pid, "projectName": pname, "lastModified": a.get("lastModifiedTime")})+        return out++    items, seen = [], set()+    with ThreadPoolExecutor(max_workers=12) as pool:+        for out in pool.map(_scan, tasks):+            for r in out:+                if r["id"] in seen:+                    continue+                seen.add(r["id"])+                items.append(r)+    items = [i for i in items if i.get("lastModified")]+    items.sort(key=lambda r: r["lastModified"], reverse=True)+    items = items[:limit]+    return {"success": True, "output": f"{len(items)} recent file(s)",+            "data": {"count": len(items), "results": items},+            "_hint": "Most-recently-modified designs across the team hub, newest first."}+++def handle_file_info(args: dict) -> dict:+    """Version history + metadata for a file: who last changed it, when, how many versions."""+    if not _valid_access_token():+        return {"success": False, "error": "Not signed in to APS.", "errorCode": "aps_not_signed_in",+                "_hint": "Call fusion_aps_signin first."}+    project_id = args.get("projectId")+    urn = args.get("urn")+    name = args.get("name")+    query = (args.get("query") or "").strip()+    if query and not (project_id and urn):+        m = find_one(query)+        if not m:+            return {"success": False, "error": f"No cloud file matching '{query}'."}+        project_id, urn, name = m.get("projectId"), m.get("id"), m.get("name")+    if not (project_id and urn):+        return {"success": False, "error": "Provide query, or projectId + urn."}++    # Derive the item (lineage) urn from a version urn (vf.<base>?version=N).+    raw = urn.split("?")[0]+    item_urn = urn+    if "fs.file:vf." in raw:+        item_urn = "urn:adsk.wipprod:dm.lineage:" + raw.split("fs.file:vf.")[-1]+    enc = urllib.parse.quote(item_urn, safe="")+    s, b = _aps_get(f"/data/v1/projects/{project_id}/items/{enc}/versions")+    if s != 200 or not isinstance(b, dict):+        return {"success": False, "error": "Could not fetch versions.",+                "data": {"httpStatus": s, "body": b, "itemUrn": item_urn}}+    versions = []+    for v in b.get("data", []):+        a = v.get("attributes", {}) or {}+        versions.append({+            "versionNumber": a.get("versionNumber"),+            "lastModifiedTime": a.get("lastModifiedTime"),+            "lastModifiedUserName": a.get("lastModifiedUserName"),+            "createTime": a.get("createTime"),+            "createUserName": a.get("createUserName"),+            "storageSize": a.get("storageSize"),+        })+    versions.sort(key=lambda x: (x.get("versionNumber") or 0), reverse=True)+    tip = versions[0] if versions else {}+    return {+        "success": True,+        "output": f"{name or item_urn}: {len(versions)} version(s), last by {tip.get('lastModifiedUserName')}",+        "data": {"name": name, "projectId": project_id, "itemUrn": item_urn,+                 "versionCount": len(versions), "tip": tip, "versions": versions[:20]},+        "_hint": "Version history newest-first; tip = current version (who/when/size).",+    }+++# ── Free Data Management writes + file transfer ──────────────────────────────+# Everything below stays within the user's OWN Fusion Team hub. Data Management+# writes (folders/items/versions) and OSS storage transfer (download/upload) are+# in APS's always-FREE set, so the never-charge guarantee still holds (the gating+# in _aps_send mirrors _aps_get). Writes need data:write/data:create scope, so a+# user signed in under the old read-only scope must fusion_aps_signin once more.+JSONAPI = "application/vnd.api+json"+++def _aps_send(method: str, path: str, json_body=None, content_type=JSONAPI, timeout=120):+    """Authenticated POST/PATCH/PUT with the SAME never-charge gating as _aps_get."""+    at = _valid_access_token()+    if not at:+        return None, {"error": "not_signed_in"}+    only_path = path if not path.startswith("http") else urllib.parse.urlparse(path).path+    if not any(only_path.startswith(p) for p in FREE_PATH_PREFIXES):+        return 0, {"error": "blocked_non_free_endpoint", "path": only_path,+                   "_hint": "Refused — only free Data Management / OSS storage endpoints are "+                            "allowed, so you are never charged."}+    if _cap_remaining() <= 0:+        return 0, {"error": "usage_cap_reached", "cap": MONTHLY_CALL_CAP}+    url = path if path.startswith("http") else f"{APS_BASE}{path}"+    headers = {"Authorization": f"Bearer {at}"}+    data = None+    if json_body is not None:+        data = json.dumps(json_body).encode()+        headers["Content-Type"] = content_type+    _bump_usage(1)+    return _http(method, url, headers=headers, data=data, timeout=timeout)+++def _http_bytes(method: str, url: str, data=None, headers=None, timeout=300):+    """Raw binary HTTP (presigned S3 up/download) — _http decodes text, which would+    corrupt binary, so transfers use this. Returns (status, bytes)."""+    req = urllib.request.Request(url, data=data, headers=headers or {}, method=method)+    try:+        with urllib.request.urlopen(req, timeout=timeout) as resp:+            return resp.status, resp.read()+    except urllib.error.HTTPError as e:+        return e.code, e.read()+    except Exception as e:+        return 0, str(e).encode()+++def _lineage_urn(urn: str) -> str:+    raw = urn.split("?")[0]+    if "fs.file:vf." in raw:+        return "urn:adsk.wipprod:dm.lineage:" + raw.split("fs.file:vf.")[-1]+    return urn+++def _tip_version_urn(project_id: str, urn: str):+    """Given a version OR lineage urn, return a concrete version urn (tip if lineage)."""+    if "?version=" in urn or "fs.file:vf." in urn.split("?")[0]:+        return urn, None+    enc = urllib.parse.quote(_lineage_urn(urn), safe="")+    s, b = _aps_get(f"/data/v1/projects/{project_id}/items/{enc}")+    if s != 200 or not isinstance(b, dict):+        return None, (s, b)+    tip = (((b.get("data") or {}).get("relationships") or {}).get("tip") or {}).get("data") or {}+    return tip.get("id"), None+++def _storage_of_version(project_id: str, version_urn: str):+    enc = urllib.parse.quote(version_urn, safe="")+    s, b = _aps_get(f"/data/v1/projects/{project_id}/versions/{enc}")+    if s != 200 or not isinstance(b, dict):+        return None, None, (s, b)+    d = b.get("data") or {}+    storage = (((d.get("relationships") or {}).get("storage") or {}).get("data") or {}).get("id")+    name = (d.get("attributes") or {}).get("name")+    return storage, name, None+++def _parse_oss(storage_urn: str):+    # urn:adsk.objects:os.object:<bucketKey>/<objectKey>+    tail = storage_urn.split("os.object:", 1)[-1]+    bucket, _, obj = tail.partition("/")+    return bucket, obj+++def handle_versions(args: dict) -> dict:+    """Full version history of a file (every version, uncapped): who/when/size per version."""+    info = handle_file_info(args)+    if not info.get("success"):+        return info+    d = info["data"]+    project_id, item_urn = d["projectId"], d["itemUrn"]+    enc = urllib.parse.quote(item_urn, safe="")+    s, b = _aps_get(f"/data/v1/projects/{project_id}/items/{enc}/versions", {"page[limit]": 200})+    versions = []+    for v in (b.get("data", []) if isinstance(b, dict) else []):+        a = v.get("attributes", {}) or {}+        versions.append({"versionNumber": a.get("versionNumber"), "id": v.get("id"),+                         "lastModifiedTime": a.get("lastModifiedTime"),+                         "lastModifiedUserName": a.get("lastModifiedUserName"),+                         "createTime": a.get("createTime"), "createUserName": a.get("createUserName"),+                         "storageSize": a.get("storageSize")})+    versions.sort(key=lambda x: (x.get("versionNumber") or 0), reverse=True)+    return {"success": True, "output": f"{d.get('name') or item_urn}: {len(versions)} version(s)",+            "data": {"name": d.get("name"), "projectId": project_id, "itemUrn": item_urn,+                     "versionCount": len(versions), "versions": versions},+            "_hint": "Complete version history, newest-first."}+++def handle_download(args: dict) -> dict:+    """Download a cloud file to the local machine (no Fusion needed).+    args: query | (projectId + urn); optional saveDir, fileName."""+    if not _valid_access_token():+        return {"success": False, "error": "Not signed in to APS.", "errorCode": "aps_not_signed_in",+                "_hint": "Call fusion_aps_signin first."}+    project_id, urn, name = args.get("projectId"), args.get("urn"), args.get("name")+    query = (args.get("query") or "").strip()+    if query and not (project_id and urn):+        m = find_one(query)+        if not m:+            return {"success": False, "error": f"No cloud file matching '{query}'."}+        project_id, urn, name = m.get("projectId"), m.get("id"), m.get("name")+    if not (project_id and urn):+        return {"success": False, "error": "Provide query, or projectId + urn."}++    version_urn, err = _tip_version_urn(project_id, urn)+    if not version_urn:+        return {"success": False, "error": "Could not resolve a version.", "data": {"detail": err}}+    storage_urn, vname, err = _storage_of_version(project_id, version_urn)+    if not storage_urn:+        return {"success": False, "error": "Could not find storage for this version.",+                "data": {"detail": err}}+    bucket, obj = _parse_oss(storage_urn)+    s, b = _aps_get(f"/oss/v2/buckets/{bucket}/objects/{urllib.parse.quote(obj, safe='')}/signeds3download")+    if s != 200 or not isinstance(b, dict) or not b.get("url"):+        return {"success": False, "error": "Could not get a download URL.",+                "data": {"httpStatus": s, "body": b}}++    save_dir = args.get("saveDir") or str(Path(os.path.expanduser("~")) / "Downloads")+    fname = args.get("fileName") or vname or name or "download.bin"+    Path(save_dir).mkdir(parents=True, exist_ok=True)+    out_path = str(Path(save_dir) / fname)+    st, data = _http_bytes("GET", b["url"])+    if st != 200:+        return {"success": False, "error": f"Download failed (HTTP {st}).",+                "data": {"bytes": len(data)}}+    with open(out_path, "wb") as fh:+        fh.write(data)+    return {"success": True, "output": f"Downloaded {fname} ({len(data):,} bytes) to {out_path}",+            "data": {"path": out_path, "bytes": len(data), "name": fname, "version": version_urn},+            "_hint": "Saved to the local machine; opening in Fusion is a separate step (fusion_aps_open)."}+++def handle_create_folder(args: dict) -> dict:+    """Create a subfolder in a project. args: projectId, parentFolderId, name."""+    if not _valid_access_token():+        return {"success": False, "error": "Not signed in to APS.", "errorCode": "aps_not_signed_in",+                "_hint": "Call fusion_aps_signin first."}+    project_id = args.get("projectId")+    parent = args.get("parentFolderId") or args.get("folderId")+    fname = (args.get("name") or "").strip()+    if not (project_id and parent and fname):+        return {"success": False, "error": "Provide projectId, parentFolderId, name."}+    body = {"jsonapi": {"version": "1.0"},+            "data": {"type": "folders",+                     "attributes": {"name": fname,+                                    "extension": {"type": "folders:autodesk.core:Folder", "version": "1.0"}},+                     "relationships": {"parent": {"data": {"type": "folders", "id": parent}}}}}+    s, b = _aps_send("POST", f"/data/v1/projects/{project_id}/folders", body)+    if s not in (200, 201) or not isinstance(b, dict):+        return {"success": False, "error": "Create folder failed.",+                "data": {"httpStatus": s, "body": b},+                "_hint": "HTTP 403 = sign-in lacks data:write/data:create (fusion_aps_signin again). "+                         "409 = a folder with that name already exists here."}+    fid = (b.get("data") or {}).get("id")+    return {"success": True, "output": f"Created folder '{fname}'",+            "data": {"folderId": fid, "name": fname, "projectId": project_id}}+++def handle_upload(args: dict) -> dict:+    """Upload a local file as a NEW cloud file. args: projectId, folderId, localPath; opt fileName."""+    if not _valid_access_token():+        return {"success": False, "error": "Not signed in to APS.", "errorCode": "aps_not_signed_in",+                "_hint": "Call fusion_aps_signin first."}+    project_id, folder_id, local = args.get("projectId"), args.get("folderId"), args.get("localPath")+    if not (project_id and folder_id and local):+        return {"success": False, "error": "Provide projectId, folderId, localPath."}+    if not os.path.isfile(local):+        return {"success": False, "error": f"Local file not found: {local}"}+    fname = args.get("fileName") or os.path.basename(local)+    data = open(local, "rb").read()++    # 1. Reserve a storage object in the target folder.+    sbody = {"jsonapi": {"version": "1.0"},+             "data": {"type": "objects", "attributes": {"name": fname},+                      "relationships": {"target": {"data": {"type": "folders", "id": folder_id}}}}}+    s, b = _aps_send("POST", f"/data/v1/projects/{project_id}/storage", sbody)+    if s not in (200, 201) or not isinstance(b, dict):+        return {"success": False, "error": "Storage reservation failed.",+                "data": {"httpStatus": s, "body": b},+                "_hint": "HTTP 403 = sign-in lacks data:write/data:create — fusion_aps_signin again."}+    storage_urn = (b.get("data") or {}).get("id")+    bucket, obj = _parse_oss(storage_urn)+    objenc = urllib.parse.quote(obj, safe="")++    # 2. Get a signed S3 upload URL, 3. PUT the bytes, 4. complete the upload.+    s, b = _aps_get(f"/oss/v2/buckets/{bucket}/objects/{objenc}/signeds3upload")+    if s != 200 or not isinstance(b, dict) or not b.get("urls"):+        return {"success": False, "error": "Could not get an upload URL.", "data": {"httpStatus": s, "body": b}}+    upload_key, put_url = b.get("uploadKey"), b["urls"][0]+    st, _ = _http_bytes("PUT", put_url, data=data, headers={"Content-Type": "application/octet-stream"})+    if st not in (200, 201):+        return {"success": False, "error": f"S3 upload PUT failed (HTTP {st})."}+    s, b = _aps_send("POST", f"/oss/v2/buckets/{bucket}/objects/{objenc}/signeds3upload",+                     {"uploadKey": upload_key}, content_type="application/json")+    if s not in (200, 201, 202):+        return {"success": False, "error": "Upload completion failed.", "data": {"httpStatus": s, "body": b}}++    # 5. Create the item (new file, version 1) pointing at the uploaded object.+    ibody = {"jsonapi": {"version": "1.0"},+             "data": {"type": "items",+                      "attributes": {"displayName": fname,+                                     "extension": {"type": "items:autodesk.core:File", "version": "1.0"}},+                      "relationships": {"tip": {"data": {"type": "versions", "id": "1"}},+                                        "parent": {"data": {"type": "folders", "id": folder_id}}}},+             "included": [{"type": "versions", "id": "1",+                           "attributes": {"name": fname,+                                          "extension": {"type": "versions:autodesk.core:File", "version": "1.0"}},+                           "relationships": {"storage": {"data": {"type": "objects", "id": storage_urn}}}}]}+    s, b = _aps_send("POST", f"/data/v1/projects/{project_id}/items", ibody)+    if s not in (200, 201) or not isinstance(b, dict):+        return {"success": False,+                "error": "Create item failed (this verb uploads NEW files; the name may already exist).",+                "data": {"httpStatus": s, "body": b}}+    item_id = (b.get("data") or {}).get("id")+    return {"success": True, "output": f"Uploaded '{fname}' ({len(data):,} bytes) as a new cloud file.",+            "data": {"itemUrn": item_id, "name": fname, "bytes": len(data),+                     "projectId": project_id, "folderId": folder_id}}+++# Command-name -> handler (invoked as fusion_aps_* from the CLI).+HANDLERS = {+    "aps_status": handle_status,+    "aps_set_client_id": handle_set_client_id,+    "aps_signin": handle_signin,+    "aps_search": handle_search,+    "aps_browse": handle_browse,+    "aps_recent": handle_recent,+    "aps_file_info": handle_file_info,+    "aps_versions": handle_versions,+    "aps_download": handle_download,+    "aps_create_folder": handle_create_folder,+    "aps_upload": handle_upload,+    "aps_get": handle_get,+}