← Microsoft DataverseCONTENT HISTORY

Update to Microsoft Dataverse

Snapshot Oct 24, 2026 · 13:01 UTC · version 1.16.0

WHAT CHANGED

Package or technical metadata updated

Package or technical metadata updated

Observed in package metadata. These changes alone do not establish a new customer-facing feature.

Save Microsoft Dataverse and check future changes in Following.

Up to 3 plugins free · no card required.

All fields and original values.

These are saved file metadata. Open a changed file to read its text edits.

Package file

Before

Sep 30, 2026 · 23:13 UTC

6e40c3dfa58513854e5ec8e5db8da85ddf561dc41b2ccbdfa149b44c9f4cfa28

After

Oct 24, 2026 · 13:01 UTC

5677ada5e3f5f6af333915498383bfeb0c7afbd40bcd1d0b4915a11b606411e5

Package file

Before

Sep 30, 2026 · 23:13 UTC

533

After

Oct 24, 2026 · 13:01 UTC

544

Package file

Before

Sep 30, 2026 · 23:13 UTC

{"sha256":"5eca0f76b4da51091c0bf7aa16b43d1bf05ccf82df8fcc5ed4d35fe1967bcc79","size":5127}

After

Oct 24, 2026 · 13:01 UTC

Not present

Package file

Before

Sep 30, 2026 · 23:13 UTC

{"sha256":"5eca0f76b4da51091c0bf7aa16b43d1bf05ccf82df8fcc5ed4d35fe1967bcc79","size":5127}

After

Oct 24, 2026 · 13:01 UTC

Not present

Compare saved observations

Download comparison JSON

Changed files

Choose a file to see its text changes. Binary or large files show metadata only.

.claude-plugin/plugin.json →

.codex-plugin/assets/composer-icon.svg →

.codex-plugin/assets/logo.svg →

.codex-plugin/plugin.json →

.cursor-plugin/plugin.json →

.github/plugin/plugin.json →

assets/dataverse-logo.svg →

gemini-extension.json →

plugin.json →

scripts/__pycache__/auth.cpython-314.pyc →

scripts/__pycache__/enable-mcp-client.cpython-314.pyc →

scripts/__pycache__/mcp_remote.cpython-314.pyc →

scripts/__pycache__/test_auth.cpython-314.pyc →

scripts/auth.py →

scripts/enable-mcp-client.py →

skills/dv-admin/SKILL.md →

skills/dv-admin/references/erp-batch.md →

skills/dv-admin/references/orgdb-settings.md →

skills/dv-admin/references/recycle-bin.md →

skills/dv-admin/references/settings-overrides.md →

skills/dv-connect/SKILL.md →

skills/dv-connect/references/erp-detection.md →

skills/dv-connect/references/headless-hosts.md →

skills/dv-connect/references/helper-scripts.md →

skills/dv-connect/references/mcp-configuration.md →

skills/dv-connect/references/tools-setup.md →

skills/dv-data/SKILL.md →

skills/dv-data/references/erp-writes.md →

skills/dv-data/references/multi-table-fk-import.md →

skills/dv-data/references/sample-data-generation.md →

skills/dv-metadata/SKILL.md →

skills/dv-metadata/references/alternate-keys.md →

skills/dv-metadata/references/forms-and-views.md →

skills/dv-metadata/references/metadata-propagation.md →

skills/dv-overview/SKILL.md →

skills/dv-overview/references/erp-target.md →

skills/dv-overview/references/windows-scripting.md →

skills/dv-query/SKILL.md →

skills/dv-query/references/erp-reads.md →

skills/dv-query/references/jupyter-setup.md →

skills/dv-query/references/querybuilder.md →

skills/dv-query/references/web-api-advanced.md →

skills/dv-security/SKILL.md →

skills/dv-solution/SKILL.md →

skills/erp-xpp/SKILL.md →

skills/erp-xpp/references/authoring.md →

skills/erp-xpp/references/commands.md →

skills/erp-xpp/references/validation.md →

skills/erp-xpp/references/workflows.md →

templates/CLAUDE.md →

templates/deploy.yml →

scripts/auth.py

− removed · + added

--- before
+++ after
@@ -1,1156 +1,1274 @@
-"""
-auth.py — Acquire Dataverse tokens via Azure Identity.
-
-Auth chain (silent tiers first; interactive only when every silent tier is
-unavailable, so a working path wins without stranding the user -- issue #108):
-  1. Service principal (CLIENT_ID + CLIENT_SECRET in .env) -- non-interactive,
-     terminal (CI fails fast; no interactive fallback when SP is configured).
-  2. Shared Dataverse CLI token cache -- silent, no prompt, populated by
-     `dataverse auth create` (see dv-connect Step 2). Uses the same MSAL v3
-     cache the `@microsoft/dataverse` stdio MCP proxy reads. Probed at build
-     time against both the tenant-specific and `organizations` authority so an
-     authority mismatch falls through instead of hard-failing.
-  3. Azure CLI (`az login`) -- silent, scoped to TENANT_ID; skipped when az is
-     absent or not logged in.
-  4. Interactive (host-gated, last): workspace-cache device-code when
-     DATAVERSE_TOKEN_CACHE_DIR is set (ephemeral hosts); else a system-browser
-     InteractiveBrowserCredential on a desktop host (beats the CA device-code
-     block, and uses the browser not the MSAL broker so it sidesteps the macOS
-     broker bug); else DeviceCodeCredential on a headless host. Each persists an
-     AuthenticationRecord so later processes refresh silently.
-
-The shared cache uses the Dataverse CLI app registration
-(``0c412cc3-0dd6-449b-987f-05b053db9457``) so every Dataverse-skills tool
-authenticates as the same OAuth client and AAD treats it as one sign-in.
-
-Token caching layout (path 2):
-  Windows: %LocalAppData%\\Microsoft\\DataverseCli\\tokencache_msalv3.dat (DPAPI)
-  macOS:   Keychain service ``dataverse_cli_service`` / account ``dataverse_cli_account``
-  Linux:   libsecret schema ``com.microsoft.dataversecli`` (desktop), or a plaintext
-           ``tokencache_msalv3.dat`` under ``$XDG_DATA_HOME/Microsoft/DataverseCli``
-           on headless hosts with no keyring (matches the CLI's WithLinuxUnprotectedFile)
-
-Functions:
-  load_env()            — loads .env into os.environ
-  get_client(skill)     — returns a DataverseClient with plugin attribution
-  get_token(scope=None) — returns a raw access token string
-  get_plugin_headers(skill, token) — returns headers dict for raw Web API calls
-
-Usage:
-    # PREFERRED — SDK with plugin attribution:
-    from auth import get_client
-    client = get_client("dv-data")
-
-    # Raw Web API only (forms, views, $ref, $apply):
-    from auth import get_token, get_plugin_headers
-    headers = get_plugin_headers("dv-metadata", get_token())
-
-Reads from .env in the repo root (parent of scripts/) or current working directory:
-    DATAVERSE_URL      — required
-    TENANT_ID          — required
-    CLIENT_ID          — optional, enables service principal auth
-    CLIENT_SECRET      — optional, enables service principal auth
-    DATAVERSE_TOKEN_CACHE_DIR — optional; on ephemeral hosts (ChatGPT web / Codex
-                         sandbox) where $HOME is wiped between turns, set this to a
-                         workspace-relative dir (e.g. .dataverse) so the device-code
-                         token cache persists and refreshes silently within a session
-"""
-
-import os
-import re
-import sys
-import time
-from pathlib import Path
-
-# Dataverse CLI app registration. Must match McpOAuth.Config.ClientId in
-# DataverseCli/Auth/AuthClientConfig.cs so that tokens minted by
-# `dataverse auth create` and the @microsoft/dataverse stdio MCP proxy can
-# be silently reused by Python scripts (no second device-code prompt).
-_DATAVERSE_CLI_CLIENT_ID = "0c412cc3-0dd6-449b-987f-05b053db9457"
-
-# Legacy AuthenticationRecord path for the device-code fallback (path 3).
-# Kept for backward compatibility with workspaces that authenticated via the
-# previous auth.py before the shared-cache change.
-_AUTH_RECORD_PATH = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / ".IdentityService" / "dataverse_cli_auth_record.json"
-
-# Cross-workspace config so returning users skip the full dv-connect dance.
-_USER_CONFIG_DIR = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / ".dataverse-skills"
-_USER_CONFIG_PATH = _USER_CONFIG_DIR / "config.json"
-
-
-def _load_user_config():
-    """Load DATAVERSE_URL and TENANT_ID from user-level config when .env is absent."""
-    if not _USER_CONFIG_PATH.exists():
-        return
-    try:
-        import json
-        cfg = json.loads(_USER_CONFIG_PATH.read_text(encoding="utf-8"))
-        loaded = []
-        for key in ("DATAVERSE_URL", "TENANT_ID"):
-            val = cfg.get(key)
-            if val and not os.environ.get(key):
-                os.environ.setdefault(key, val)
-                loaded.append(key)
-        if loaded:
-            print(
-                f"NOTE: loaded {', '.join(loaded)} from {_USER_CONFIG_PATH} "
-                f"(no .env found). Target: {cfg.get('DATAVERSE_URL', '?')}",
-                flush=True,
-            )
-    except Exception:
-        pass
-
-
-def _save_user_config():
-    """Persist URL and tenant to user-level config for cross-workspace reuse."""
-    import json
-    url = os.environ.get("DATAVERSE_URL")
-    tenant = os.environ.get("TENANT_ID")
-    if not url or not tenant:
-        return
-    try:
-        _USER_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
-        try:
-            os.chmod(_USER_CONFIG_DIR, 0o700)
-        except Exception:
-            pass
-        _USER_CONFIG_PATH.write_text(
-            json.dumps({"DATAVERSE_URL": url, "TENANT_ID": tenant}, indent=2),
-            encoding="utf-8",
-        )
-    except Exception:
-        pass
-
-
-def load_env():
-    """Load key=value pairs from .env into os.environ (does not overwrite existing vars).
-
-    Searches for .env in two locations (first match wins):
-      1. The repo root (parent of the directory containing this script)
-      2. The current working directory
-    This ensures ``cd scripts && python auth.py`` works the same as
-    ``python scripts/auth.py`` from the repo root.
-
-    Falls back to user-level config (~/.dataverse-skills/config.json) when .env
-    is absent, so returning users on ephemeral-workspace hosts (ChatGPT desktop
-    app, Codex) skip the full dv-connect setup.
-    """
-    script_dir = Path(__file__).resolve().parent
-    candidates = [script_dir.parent / ".env", Path(".env")]
-    env_path = next((p for p in candidates if p.exists()), None)
-    if env_path is not None:
-        for line in env_path.read_text().splitlines():
-            line = line.strip()
-            if line and not line.startswith("#") and "=" in line:
-                key, _, value = line.partition("=")
-                os.environ.setdefault(key.strip(), value.strip())
-    if not os.environ.get("DATAVERSE_URL") or not os.environ.get("TENANT_ID"):
-        _load_user_config()
-
-
-_credential = None
-
-
-def _dataverse_scope():
-    """Return the ``{DATAVERSE_URL}/.default`` OAuth scope, or None if unset."""
-    url = os.environ.get("DATAVERSE_URL", "").rstrip("/")
-    if not url:
-        return None
-    return f"{url}/.default"
-
-
-def _shared_cache_persistences():
-    """Return the ordered list of MSAL cache persistences to try for the shared
-    DataverseCLI cache (platform-specific). Never raises -- returns [] on any issue.
-    On Linux this is libsecret-first (desktop) then the plaintext MSAL v3 file
-    (headless), matching the CLI's WithLinuxUnprotectedFile fallback.
-    """
-    persistences = []
-    try:
-        if sys.platform == "win32":
-            from msal_extensions import FilePersistenceWithDataProtection
-            cache_path = (
-                Path(os.environ.get("LOCALAPPDATA", str(Path.home())))
-                / "Microsoft" / "DataverseCli" / "tokencache_msalv3.dat"
-            )
-            if cache_path.exists():
-                persistences.append(FilePersistenceWithDataProtection(str(cache_path)))
-        elif sys.platform == "darwin":
-            from msal_extensions import KeychainPersistence
-            # Fallback file path is required by msal-extensions but unused on
-            # macOS -- the Keychain service/account match DataverseCLI's
-            # PacAuthApplicationFactory constants exactly.
-            fallback = str(Path.home() / ".dataverse_cli_msal_cache")
-            persistences.append(
-                KeychainPersistence(fallback, "dataverse_cli_service", "dataverse_cli_account")
-            )
-        else:
-            fallback = str(Path.home() / ".dataverse_cli_msal_cache")
-            try:
-                from msal_extensions import LibsecretPersistence
-                persistences.append(
-                    LibsecretPersistence(
-                        fallback,
-                        schema_name="com.microsoft.dataversecli",
-                        attributes={"Version": "1", "ProductGroup": "DataverseCli"},
-                    )
-                )
-            except Exception:
-                pass  # libsecret backend unavailable on this host -- skip it.
-            from msal_extensions import FilePersistence
-            xdg_data = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
-            plaintext_path = Path(xdg_data) / "Microsoft" / "DataverseCli" / "tokencache_msalv3.dat"
-            if plaintext_path.exists():
-                persistences.append(FilePersistence(str(plaintext_path)))
-    except Exception:
-        return []
-    return persistences
-
-
-def _build_shared_msal_cache():
-    """Open the DataverseCLI MSAL cache for silent reuse, probing it at build time.
-
-    Returns ``(msal.PublicClientApplication, list[account])`` only when a silent
-    token actually comes back -- probed against BOTH the tenant-specific and the
-    ``organizations`` authority, because ``dataverse auth create`` may have written
-    the cache under a different authority than TENANT_ID (issue #108, defect 2).
-    Returns ``None`` on any miss so the caller falls through to the next tier --
-    the shared-cache path can never break auth.
-    """
-    try:
-        import msal
-        from msal_extensions import PersistedTokenCache
-    except ImportError:
-        return None
-
-    tenant_id = os.environ.get("TENANT_ID")
-    if not tenant_id:
-        return None
-
-    scope = _dataverse_scope()
-    authorities = [
-        f"https://login.microsoftonline.com/{tenant_id}",
-        "https://login.microsoftonline.com/organizations",
-    ]
-    for persistence in _shared_cache_persistences():
-        for authority in authorities:
-            try:
-                app = msal.PublicClientApplication(
-                    client_id=_DATAVERSE_CLI_CLIENT_ID,
-                    authority=authority,
-                    token_cache=PersistedTokenCache(persistence),
-                )
-                accounts = app.get_accounts()
-                if not accounts:
-                    continue
-                if scope is None:
-                    # No DATAVERSE_URL to probe with -- trust account presence.
-                    return app, accounts
-                probe = app.acquire_token_silent([scope], account=accounts[0])
-                if probe and "access_token" in probe:
-                    return app, accounts  # this authority actually yields a token
-            except Exception:
-                continue  # never let the shared-cache path break auth
-    return None
-
-
-class _MsalSharedCacheCredential:
-    """TokenCredential adapter over an msal PublicClientApplication.
-
-    Implements just enough of the azure-core TokenCredential protocol
-    (`get_token(*scopes, **kwargs)` returning AccessToken) to satisfy
-    DataverseClient and direct urllib callers.
-    """
-
-    def __init__(self, app, accounts):
-        self._app = app
-        self._accounts = accounts
-
-    def get_token(self, *scopes, **kwargs):
-        from azure.core.credentials import AccessToken
-        from azure.identity import CredentialUnavailableError
-        # Single-account is the common case. If the shared cache happens to
-        # contain multiple accounts, the first one wins -- deterministic and
-        # matches what `dataverse auth select` would surface as active.
-        # Forward a CAE / Conditional-Access claims challenge when azure-core
-        # passes one -- CA-hardened tenants are this plugin's target scenario.
-        # A None claims_challenge is a harmless no-op.
-        result = self._app.acquire_token_silent(
-            list(scopes), account=self._accounts[0],
-            claims_challenge=kwargs.get("claims"),
-        )
-        if not result or "access_token" not in result:
-            # Fall through, do NOT hard-fail: a cached account can exist while
-            # silent acquisition fails (authority mismatch, expired refresh
-            # token). Raising CredentialUnavailableError lets the chain try the
-            # next tier instead of stranding the user. (Issue #108, defect 1.)
-            raise CredentialUnavailableError(
-                "Shared DataverseCLI cache present but silent token acquisition "
-                "failed (often an authority mismatch or expired refresh token)."
-            )
-        expires_on = int(time.time()) + int(result.get("expires_in", 3600))
-        return AccessToken(result["access_token"], expires_on)
-
-    def close(self):  # pragma: no cover — parity with azure-identity credentials
-        pass
-
-
-# Default workspace cache dir used on headless/ephemeral hosts when the user has not
-# set DATAVERSE_TOKEN_CACHE_DIR (see _workspace_token_cache_path).
-_DEFAULT_WORKSPACE_CACHE_DIRNAME = ".dataverse"
-
-# Explicit opt-out values for DATAVERSE_TOKEN_CACHE_DIR: keep the OS default cache
-# (do not write a plaintext refresh token into the workspace).
-_CACHE_DIR_OPT_OUT = frozenset({"off", "0", "false", "no", "none", "disable", "disabled"})
-
-
-def _should_use_workspace_cache():
-    """Pure decision (no I/O): whether the persisted workspace token cache is used,
-    and via which source. This is the SINGLE source of truth so
-    _workspace_token_cache_path (which builds the path) and _run_diagnose (which
-    reports the tier) can never drift. Returns:
-      "explicit" -- DATAVERSE_TOKEN_CACHE_DIR is set to a usable path.
-      "default"  -- unset on a HEADLESS, non-CI host -> auto-default to <workspace>/.dataverse.
-      None       -- keep the OS default cache (desktop, CI, or an explicit opt-out).
-    """
-    cache_dir = os.environ.get("DATAVERSE_TOKEN_CACHE_DIR")
-    if cache_dir is not None and cache_dir.strip().lower() in _CACHE_DIR_OPT_OUT:
-        return None  # explicit opt-out -- keep the per-process OS default cache
-    if cache_dir:
-        return "explicit"
-    # No explicit setting: auto-default ONLY where the OS cache will not survive --
-    # a headless, non-CI host. Desktop hosts keep their secure default cache; CI
-    # never reaches an interactive tier (service principal is terminal earlier).
-    if _host_has_browser() or _is_ci():
-        return None
-    return "default"
-
-
-def _workspace_token_cache_path():
-    """Return the MSAL v3 cache file path used to persist the device-code refresh
-    token across separate Python processes, or None to keep the OS default cache.
-    The use/skip decision is _should_use_workspace_cache(); this function only turns
-    a positive decision into a concrete, git-ignored, owner-only file path.
-
-    Resolution (see _should_use_workspace_cache):
-      - DATAVERSE_TOKEN_CACHE_DIR set to a path -> use it (a relative dir is anchored
-        to the workspace root, matching how load_env finds .env).
-      - opt-out value (off/false/0/no/none) -> None.
-      - unset on a HEADLESS, non-CI host (ChatGPT web / Codex sandbox / SSH / container)
-        -> DEFAULT to ``<workspace>/.dataverse`` (the OS cache lives under $HOME, which
-        the sandbox wipes between Python processes, so device code would re-prompt every
-        process; the persisted workspace cache makes sign-in once per conversation).
-      - unset on a desktop host -> None (keep the secure OS cache: Windows DPAPI /
-        macOS Keychain / Linux desktop keyring -- no behavior change).
-
-    Security: on non-Windows hosts (Linux, and macOS over SSH) the cache holds a
-    PLAINTEXT refresh token (no keyring; Windows uses DPAPI). The directory is created
-    owner-only (0700 on POSIX) with a self-contained `.gitignore` (`*`) so the token is
-    excluded from version control, and an ephemeral sandbox is torn down after the
-    session. To keep the OS default cache instead, set DATAVERSE_TOKEN_CACHE_DIR=off.
-    """
-    decision = _should_use_workspace_cache()
-    if decision is None:
-        return None
-    cache_dir = (
-        os.environ.get("DATAVERSE_TOKEN_CACHE_DIR")
-        if decision == "explicit"
-        else _DEFAULT_WORKSPACE_CACHE_DIRNAME
-    )
-    try:
-        path = Path(cache_dir)
-        if not path.is_absolute():
-            # Anchor a relative dir to the workspace root (this file's parent dir's
-            # parent -- the deployed <project>/scripts/auth.py layout), NOT the
-            # current working directory, so the cache location is stable no matter
-            # where the process is launched from (matches how load_env finds .env).
-            path = Path(__file__).resolve().parent.parent / path
-        path.mkdir(parents=True, exist_ok=True)
-        # Defense in depth: exclude the token cache from git even if the repo-root
-        # .gitignore does not cover this dir.
-        gitignore = path / ".gitignore"
-        if not gitignore.exists():
-            gitignore.write_text("*\n", encoding="utf-8")
-        try:
-            os.chmod(path, 0o700)  # owner-only on POSIX; benign on Windows
-        except Exception:
-            pass
-        return path / "tokencache_msalv3.dat"
-    except Exception:
-        return None
-
-
-class _MsalDeviceCodeCredential:
-    """TokenCredential over an msal app that persists at an explicit cache path.
-
-    Silent-refresh from the cache when possible; device-code sign-in on a cache miss.
-    Used only when DATAVERSE_TOKEN_CACHE_DIR is set (opt-in, ephemeral hosts) so the
-    refresh token lives in the persisted workspace cache and is reused across turns.
-    Implements the azure-core TokenCredential protocol (get_token).
-    """
-
-    def __init__(self, app):
-        self._app = app
-
-    def get_token(self, *scopes, **kwargs):
-        from azure.core.credentials import AccessToken
-        scope_list = list(scopes)
-        claims = kwargs.get("claims")  # CAE / Conditional-Access challenge, if any
-        result = None
-        accounts = self._app.get_accounts()
-        if accounts:
-            result = self._app.acquire_token_silent(
-                scope_list, account=accounts[0], claims_challenge=claims,
-            )
-        if not result or "access_token" not in result:
-            flow = self._app.initiate_device_flow(scopes=scope_list)
-            if "user_code" not in flow:
-                raise RuntimeError(
-                    "Failed to start device-code flow: "
-                    f"{flow.get('error_description', flow)}"
-                )
-            print(
-                f"\nTo sign in, visit {flow['verification_uri']} and enter code: "
-                f"{flow['user_code']}",
-                flush=True,
-            )
-            _expiry_min = max(1, int(flow.get("expires_in", 900)) // 60)
-            print(
-                f"(Waiting for you to complete the login in your browser -- "
-                f"this code expires in ~{_expiry_min} min...)\n",
-                flush=True,
-            )
-            result = self._app.acquire_token_by_device_flow(
-                flow, claims_challenge=claims,
-            )  # blocks until complete
-        if not result or "access_token" not in result:
-            detail = result.get("error_description", result) if result else "no response"
-            raise RuntimeError(f"Device-code authentication failed: {detail}")
-        expires_on = int(time.time()) + int(result.get("expires_in", 3600))
-        return AccessToken(result["access_token"], expires_on)
-
-    def close(self):  # pragma: no cover
-        pass
-
-
-def _is_ci():
-    """True on CI / build agents (no interactive user), even on win32/darwin --
-    GitHub Actions, ADO agents, containerized Windows. Treats only genuinely-
-    truthy values as set (avoids the CI="false" trap).
-    """
-    def _flag(name):
-        return os.environ.get(name, "").strip().lower() not in ("", "0", "false", "no")
-
-    return _flag("CI") or _flag("GITHUB_ACTIONS") or _flag("TF_BUILD") or _flag("BUILD_BUILDID")
-
-
-def _host_has_browser():
-    """True if an interactive system browser is likely available.
-
-    CI / build agents are headless even when sys.platform is win32/darwin --
-    launching a browser there crashes with no user session, so they route to the
-    device-code tier instead. After the CI gate: a Windows console or RDP session
-    has a browser, but a Windows service / headless container has an empty or
-    "Services" SESSIONNAME; macOS (non-CI) always has a session browser; Linux
-    needs a display server (DISPLAY / WAYLAND_DISPLAY) -- headless Linux (SSH /
-    container / ChatGPT web) has neither and uses device-code.
-    """
-    if _is_ci():
-        return False
-    if sys.platform == "win32":
-        session = os.environ.get("SESSIONNAME", "").strip().lower()
-        return session not in ("", "services")
-    if sys.platform == "darwin":
-        # An SSH / remote macOS session has no local browser -- use device-code.
-        if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"):
-            return False
-        return True
-    return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
-
-
-class _SilentChain:
-    """Try each silent tier in order; skip any that is unavailable OR errors, so a
-    convenience tier (e.g. az CLI logged into another tenant) can never strand the
-    fall-through to the interactive tier. Raises CredentialUnavailableError only
-    when every tier is exhausted.
-    """
-
-    def __init__(self, tiers):
-        self._tiers = tiers  # list[(name, credential)]
-        self.last_reasons = []  # why each tier was skipped -- surfaced before a prompt
-
-    def get_token(self, *scopes, **kwargs):
-        from azure.identity import CredentialUnavailableError
-        reasons = []
-        for tier_name, cred in self._tiers:
-            try:
-                return cred.get_token(*scopes, **kwargs)
-            except CredentialUnavailableError:
-                reasons.append(f"{tier_name}: unavailable")
-            except Exception as e:  # noqa: BLE001 -- a silent tier must never strand the chain
-                reasons.append(f"{tier_name}: {type(e).__name__}")
-        self.last_reasons = reasons
-        raise CredentialUnavailableError(
-            "no silent credential available (" + "; ".join(reasons) + ")"
-        )
-
-    def close(self):  # pragma: no cover
-        pass
-
-
-class _FallbackCredential:
-    """Silent tiers first; if all are unavailable, build and use the single
-    host-appropriate interactive tier ON DEMAND -- so an interactive prompt
-    happens ONLY when no silent path works. (Issue #108: never strand, never
-    prompt when a silent tier would do.)
-    """
-
-    def __init__(self, silent, interactive_builder):
-        self._silent = silent
-        self._interactive_builder = interactive_builder
-        self._interactive = None
-
-    def get_token(self, *scopes, **kwargs):
-        from azure.identity import CredentialUnavailableError
-        if self._silent is not None:
-            try:
-                return self._silent.get_token(*scopes, **kwargs)
-            except CredentialUnavailableError as e:
-                # Surface WHY every silent tier was skipped so a sudden interactive
-                # prompt is explicable ("it just prompted me and I don't know why").
-                reasons = getattr(self._silent, "last_reasons", None)
-                detail = "; ".join(reasons) if reasons else str(e)
-                print(
-                    f"No silent credential available ({detail}); "
-                    f"falling back to interactive sign-in.",
-                    flush=True,
-                )
-        if self._interactive is None:
-            self._interactive = self._interactive_builder()
-        return self._interactive.get_token(*scopes, **kwargs)
-
-    def close(self):  # pragma: no cover
-        pass
-
-
-def _build_workspace_device_code_credential(cache_path, tenant_id):
-    """Build the opt-in workspace-cache device-code credential, or None if msal
-    is unavailable. The MSAL cache (incl. refresh token) lives at cache_path in
-    the persisted workspace, so device code is once per conversation, not per turn.
-    """
-    try:
-        import msal
-        from msal_extensions import PersistedTokenCache
-        if sys.platform == "win32":
-            from msal_extensions import FilePersistenceWithDataProtection
-            persistence = FilePersistenceWithDataProtection(str(cache_path))
-        else:
-            from msal_extensions import FilePersistence
-            persistence = FilePersistence(str(cache_path))
-        app = msal.PublicClientApplication(
-            client_id=_DATAVERSE_CLI_CLIENT_ID,
-            authority=f"https://login.microsoftonline.com/{tenant_id}",
-            token_cache=PersistedTokenCache(persistence),
-        )
-        return _MsalDeviceCodeCredential(app)
-    except ImportError:
-        return None
-
-
-def _build_interactive_tier(tenant_id):
-    """Build ``(credential, kind)`` for the single interactive tier this host uses.
-
-    kind is one of ``workspace-device-code`` / ``interactive-browser`` /
-    ``device-code``. Selection:
-      - workspace cache available (DATAVERSE_TOKEN_CACHE_DIR set, or auto-defaulted on
-        a headless non-CI host) -> workspace-cache device-code (self-persisting).
-      - desktop host with a browser -> InteractiveBrowserCredential (system browser).
-      - headless host with no buildable workspace cache -> DeviceCodeCredential (legacy).
-    The azure-identity interactive tiers persist an AuthenticationRecord on first
-    login so a later process refreshes silently. Called ONLY after every silent
-    tier is exhausted, so the eager first-login prompt here is never premature.
-    """
-    # CI / unattended host: no interactive tier can succeed (no browser; a device
-    # code just blocks ~15 min then expires into a log nobody reads). Service
-    # principal is a terminal tier BEFORE we reach here, so being here under CI
-    # means no SP is configured -- fail fast with an actionable message instead of
-    # a guaranteed hang.
-    if _is_ci():
-        raise RuntimeError(
-            "Unattended/CI host detected with no working silent credential "
-            "(service principal, shared cache, or az login). Interactive sign-in "
-            "cannot succeed here -- set CLIENT_ID + CLIENT_SECRET for a service "
-            "principal, or run `python scripts/auth.py --diagnose` to see which "
-            "tier failed."
-        )
-    workspace_cache = _workspace_token_cache_path()
-    if workspace_cache is not None:
-        cred = _build_workspace_device_code_credential(workspace_cache, tenant_id)
-        if cred is not None:
-            return cred, "workspace-device-code"
-
-    from azure.identity import (
-        AuthenticationRecord,
-        DeviceCodeCredential,
-        InteractiveBrowserCredential,
-        TokenCachePersistenceOptions,
-    )
-
-    cache_opts = TokenCachePersistenceOptions(
-        name="dataverse_cli", allow_unencrypted_storage=True
-    )
-    record = None
-    if _AUTH_RECORD_PATH.exists():
-        try:
-            record = AuthenticationRecord.deserialize(
-                _AUTH_RECORD_PATH.read_text(encoding="utf-8")
-            )
-        except Exception:
-            record = None  # corrupt/stale record -- re-authenticate
-
-    if _host_has_browser():
-        cred = InteractiveBrowserCredential(
-            tenant_id=tenant_id,
-            client_id=_DATAVERSE_CLI_CLIENT_ID,
-            cache_persistence_options=cache_opts,
-            authentication_record=record,
-        )
-        kind = "interactive-browser"
-    else:
-        def _prompt_callback(verification_uri, user_code, _expires_on):
-            print(f"\nTo sign in, visit {verification_uri} and enter code: {user_code}", flush=True)
-            print("(Waiting for you to complete the login in your browser...)\n", flush=True)
-
-        cred = DeviceCodeCredential(
-            tenant_id=tenant_id,
-            client_id=_DATAVERSE_CLI_CLIENT_ID,
-            prompt_callback=_prompt_callback,
-            cache_persistence_options=cache_opts,
-            authentication_record=record,
-        )
-        kind = "device-code"
-
-    # First login: capture + persist the AuthenticationRecord so later processes
-    # refresh silently. authenticate() performs the one interactive flow; because
-    # we are here only after the silent tiers failed, it is never premature.
-    if record is None:
-        try:
-            scope = _dataverse_scope()
-            new_record = cred.authenticate(scopes=[scope] if scope else None)
-            _AUTH_RECORD_PATH.parent.mkdir(parents=True, exist_ok=True)
-            _AUTH_RECORD_PATH.write_text(new_record.serialize(), encoding="utf-8")
-        except KeyboardInterrupt:
-            raise  # user cancelled sign-in -- honor it, don't fall through to a 2nd prompt
-        except Exception as e:  # noqa: BLE001 -- non-fatal: get_token still works, may reprompt
-            print(
-                f"NOTE: first interactive sign-in did not persist a record "
-                f"({type(e).__name__}); a later token request may reprompt.",
-                flush=True,
-            )
-
-    return cred, kind
-
-
-def _get_credential():
-    """
-    Return a TokenCredential, creating one on first call.
-
-    The credential is cached for the lifetime of the process. Resolution
-    order: service principal (terminal) -> silent chain (shared DataverseCLI
-    cache, then az CLI) -> single host-gated interactive tier, used only when
-    every silent tier is unavailable (issue #108).
-    """
-    global _credential
-    if _credential is not None:
-        return _credential
-
-    load_env()
-
-    tenant_id = os.environ.get("TENANT_ID")
-    dataverse_url = os.environ.get("DATAVERSE_URL", "").rstrip("/")
-    client_id = os.environ.get("CLIENT_ID")
-    client_secret = os.environ.get("CLIENT_SECRET")
-
-    if not tenant_id or not dataverse_url:
-        missing = [k for k, v in [("TENANT_ID", tenant_id), ("DATAVERSE_URL", dataverse_url)] if not v]
-        print(f"ERROR: .env is missing required values: {', '.join(missing)}", flush=True)
-        print("  Run the init sequence (/dataverse:init) to create .env.", flush=True)
-        sys.exit(1)
-
-    try:
-        from azure.identity import AzureCliCredential, ClientSecretCredential
-    except ImportError:
-        print("ERROR: azure-identity not installed. Run: pip install --upgrade azure-identity", flush=True)
-        sys.exit(1)
-
-    # Warn if only one of CLIENT_ID / CLIENT_SECRET is set
-    if bool(client_id) != bool(client_secret):
-        print("WARNING: Only one of CLIENT_ID / CLIENT_SECRET is set. Both are required for", flush=True)
-        print("  service principal auth. Falling back to shared cache / az / interactive.", flush=True)
-
-    # Tier 1: Service principal. Terminal -- CI must fail fast, never fall to an
-    # interactive prompt that would hang an unattended run.
-    if client_id and client_secret:
-        _credential = ClientSecretCredential(
-            tenant_id=tenant_id,
-            client_id=client_id,
-            client_secret=client_secret,
-        )
-        return _credential
-
-    # No service principal -> silent tiers, then a single host-gated interactive
-    # tier used ONLY if every silent tier is unavailable (issue #108: never
-    # strand, and never prompt when a silent path works).
-    silent_tiers = []
-
-    # Tier 2: Shared DataverseCLI MSAL cache (populated by `dataverse auth
-    # create`; same client ID as the @microsoft/dataverse MCP proxy). Probed at
-    # build time so it is only selected when it actually yields a token.
-    shared = _build_shared_msal_cache()
-    if shared is not None:
-        app, accounts = shared
-        silent_tiers.append(("shared-cache", _MsalSharedCacheCredential(app, accounts)))
-
-    # Tier 3: Azure CLI (`az login`). Scoped to TENANT_ID; AzureCliCredential
-    # raises CredentialUnavailableError when az is absent / not logged in, so it
-    # is free when unused and a high-value silent win when present.
-    silent_tiers.append(("azure-cli", AzureCliCredential(tenant_id=tenant_id)))
-
-    silent = _SilentChain(silent_tiers)
-
-    def _interactive_builder():
-        cred, _kind = _build_interactive_tier(tenant_id)
-        return cred
-
-    _credential = _FallbackCredential(silent, _interactive_builder)
-    return _credential
-
-
-_auth_record_saved = False
-
-
-def get_token(scope=None):
-    """
-    Acquire a raw access token string for the Dataverse environment.
-
-    Resolution order is set by ``_get_credential()``: service principal, then
-    the silent chain (shared DataverseCLI cache, then az CLI), then a single
-    host-gated interactive tier used only if every silent tier is unavailable.
-    The interactive tier persists an AuthenticationRecord on first login so
-    subsequent processes refresh silently.
-
-    :param scope: OAuth2 scope. Defaults to "{DATAVERSE_URL}/.default".
-    :returns: Access token string suitable for a Bearer Authorization header.
-    """
-    load_env()
-    dataverse_url = os.environ.get("DATAVERSE_URL", "").rstrip("/")
-    if not scope:
-        scope = f"{dataverse_url}/.default"
-
-    credential = _get_credential()
-
-    try:
-        token = credential.get_token(scope)
-    except Exception as e:
-        print(f"ERROR: Failed to acquire access token: {e}", flush=True)
-        print("  Check your network connection, credentials, and .env configuration.", flush=True)
-        print("  Tip: run `python scripts/auth.py --diagnose` to see which credential", flush=True)
-        print("  tiers are available, or `dataverse auth create --environment "
-              f"{dataverse_url}` to populate the shared token cache.", flush=True)
-        sys.exit(1)
-
-    return token.token
-
-
-_ALLOWED_SKILLS = frozenset({
-    "dv-overview", "dv-connect", "dv-data", "dv-query",
-    "dv-metadata", "dv-solution", "dv-admin", "dv-security",
-    "unknown",
-})
-_ALLOWED_AGENTS = frozenset({
-    "claude-code", "copilot", "cursor", "codex", "unknown",
-})
-# Strict format: key=value pairs, semicolon-separated. No spaces, no PII.
-_CONTEXT_RE = re.compile(
-    r"^[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+(;[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+)*$"
-)
-
-
-def _plugin_version():
-    """Return the plugin version for telemetry attribution.
-
-    Sourced from DATAVERSE_PLUGIN_VERSION, which dv-connect writes into .env from
-    the live plugin manifest on every connect (Step 0 / Step 3). The deployed
-    layout copies this file to <project>/scripts/auth.py -- away from the plugin
-    manifest -- so a manifest path relative to __file__ is unreliable here; the
-    env var, refreshed at connect, is the source of truth.
-    """
-    return os.environ.get("DATAVERSE_PLUGIN_VERSION", "unknown")
-
-
-def _current_agent():
-    agent = os.environ.get("DATAVERSE_PLUGIN_AGENT", "unknown")
-    if agent not in _ALLOWED_AGENTS:
-        raise ValueError(f"Unknown agent '{agent}'; allowed: {_ALLOWED_AGENTS}")
-    return agent
-
-
-def _validate_skill(skill):
-    if skill not in _ALLOWED_SKILLS:
-        raise ValueError(f"Unknown skill '{skill}'; allowed: {_ALLOWED_SKILLS}")
-    return skill
-
-
-def _build_operation_context(skill):
-    """Build and validate the operation_context string.
-
-    Returns an OperationContext object for the SDK.  The string is validated
-    both here (via allowlists) and inside OperationContext.__post_init__
-    (via regex + control-char check).
-
-    SECURITY: Only closed-schema values from _ALLOWED_SKILLS and
-    _ALLOWED_AGENTS are used.  Never pass user-provided or free-form
-    strings into operation_context — it is written to HTTP headers and
-    server-side telemetry logs.
-    """
-    ctx_str = f"app=dataverse-skills/{_plugin_version()};skill={skill};agent={_current_agent()}"
-    if not _CONTEXT_RE.match(ctx_str):
-        raise ValueError(
-            f"operation_context failed format validation: {ctx_str!r}. "
-            "Must be semicolon-separated key=value pairs with no spaces or special characters."
-        )
-    from PowerPlatform.Dataverse.core.config import OperationContext
-    return OperationContext(user_agent_context=ctx_str)
-
-
-def get_client(skill, **kwargs):
-    """Return a DataverseClient with plugin attribution baked in.
-
-    The operation_context is appended to the User-Agent header as a
-    parenthesized comment for server-side traffic attribution.
-
-    IMPORTANT: Do not modify the operation_context — it uses a closed
-    schema (app/skill/agent) for safe server-side attribution.  Never
-    include secrets, PII, or free-form text.
-
-    :param skill: Skill name (e.g. "dv-data", "dv-query").
-    :param kwargs: Extra keyword arguments forwarded to DataverseClient.
-    :returns: Configured DataverseClient instance.
-    """
-    load_env()
-    _validate_skill(skill)
-    from PowerPlatform.Dataverse.client import DataverseClient
-    return DataverseClient(
-        base_url=os.environ["DATAVERSE_URL"],
-        credential=_get_credential(),
-        context=_build_operation_context(skill),
-        **kwargs,
-    )
-
-
-def get_plugin_headers(skill, token=None):
-    """Return HTTP headers for raw Web API calls, with plugin attribution.
-
-    Use this for operations the SDK does not support (forms, views, $apply,
-    N:N $expand, unbound actions).
-
-    IMPORTANT: Do not modify the User-Agent context — it uses a closed
-    schema (app/skill/agent) for safe server-side attribution.  Never
-    include secrets, PII, or free-form text.
-
-    :param skill: Skill name (e.g. "dv-metadata").
-    :param token: Optional bearer token (from get_token()).
-    :returns: Headers dict with User-Agent and optional Authorization.
-    """
-    _validate_skill(skill)
-    ctx_str = f"app=dataverse-skills/{_plugin_version()};skill={skill};agent={_current_agent()}"
-    if not _CONTEXT_RE.match(ctx_str):
-        raise ValueError(
-            f"operation_context failed format validation: {ctx_str!r}."
-        )
-    headers = {"User-Agent": f"Python-urllib ({ctx_str})"}
-    if token:
-        headers["Authorization"] = f"Bearer {token}"
-    return headers
-
-
-def _run_diagnose():
-    """Print a per-tier credential availability matrix WITHOUT triggering any
-    interactive prompt. Turns an auth hang / bare exit-1 into a self-service
-    report of which tiers are available and which one would serve the next call.
-    (Issue #108: give stranded users a diagnosis, not a dead end.)
-    """
-    load_env()
-    tenant_id = os.environ.get("TENANT_ID")
-    url = os.environ.get("DATAVERSE_URL", "").rstrip("/")
-    scope = _dataverse_scope()
-    client_id = os.environ.get("CLIENT_ID")
-    client_secret = os.environ.get("CLIENT_SECRET")
-
-    print(f"Dataverse auth diagnosis for {url or '<DATAVERSE_URL unset>'}")
-    print(
-        f"  tenant={tenant_id or '<TENANT_ID unset>'}  host={sys.platform}  "
-        f"browser={_host_has_browser()}  "
-        f"workspace-cache={'on' if os.environ.get('DATAVERSE_TOKEN_CACHE_DIR') else 'off'}"
-    )
-    print("")
-
-    rows = []  # (tier, status, detail)
-
-    if client_id and client_secret:
-        rows.append(("service-principal", "CONFIGURED", "terminal -- used exclusively, no interactive fallback"))
-    else:
-        rows.append(("service-principal", "not set", "set CLIENT_ID + CLIENT_SECRET for unattended auth"))
-
-    try:
-        shared = _build_shared_msal_cache()
-        if shared is not None:
-            rows.append(("shared-cache", "AVAILABLE", "`dataverse auth create` cache yields a silent token"))
-        else:
-            rows.append(("shared-cache", "unavailable", "no cache / no account / silent miss -- run `dataverse auth create`"))
-    except Exception as e:  # noqa: BLE001
-        rows.append(("shared-cache", "error", type(e).__name__))
-
-    try:
-        from azure.identity import AzureCliCredential, CredentialUnavailableError
-        try:
-            cred = AzureCliCredential(tenant_id=tenant_id)
-            if scope:
-                cred.get_token(scope)
-                rows.append(("azure-cli", "AVAILABLE", "`az login` yields a silent token for this tenant"))
-            else:
-                rows.append(("azure-cli", "unknown", "DATAVERSE_URL unset -- cannot probe"))
-        except CredentialUnavailableError:
-            rows.append(("azure-cli", "unavailable", "az not installed or not logged in -- run `az login`"))
-        except Exception as e:  # noqa: BLE001
-            rows.append(("azure-cli", "skipped", type(e).__name__))
-    except ImportError:
-        rows.append(("azure-cli", "n/a", "azure-identity not installed"))
-
-    if _should_use_workspace_cache() is not None:
-        kind = "workspace-device-code"
-    elif _host_has_browser():
-        kind = "interactive-browser"
-    else:
-        kind = "device-code"
-    rows.append(("interactive", kind, "used only if every silent tier above is unavailable"))
-
-    width = max(len(t) for t, _, _ in rows)
-    for tier, status, detail in rows:
-        print(f"  {tier.ljust(width)}  {status.ljust(12)}  {detail}")
-
-    silent_ok = any(s in ("CONFIGURED", "AVAILABLE") for _, s, _ in rows[:3])
-    print("")
-    if silent_ok:
-        print("Result: a silent tier is available -- normal calls will not prompt.")
-    else:
-        print(f"Result: no silent tier -- the next call uses the '{kind}' interactive tier.")
-
-
-def _run_ping():
-    """Lightweight reachability check: token + one HTTP call, no SDK import.
-
-    Uses only stdlib (urllib) and the credential chain (which needs msal or
-    azure-identity -- at least one is typically installed globally even before
-    pip runs in a fresh workspace). Writes .env and copies auth.py to scripts/
-    if missing, so a successful ping makes the workspace ready for SDK use
-    after pip install.
-    """
-    import shutil
-    from urllib.error import HTTPError, URLError
-    from urllib.request import Request, urlopen
-
-    load_env()
-    url = os.environ.get("DATAVERSE_URL", "").rstrip("/")
-    tenant_id = os.environ.get("TENANT_ID")
-
-    if not url or not tenant_id:
-        print("PING FAILED: no DATAVERSE_URL or TENANT_ID (no .env, no user-level config).", flush=True)
-        sys.exit(1)
-
-    # Acquire a token via the credential chain (shared CLI cache, az, interactive).
-    # _get_credential needs azure-identity or msal; if neither is installed, fail cleanly.
-    try:
-        token = get_token()
-    except SystemExit:
-        print("PING FAILED: could not acquire a token (azure-identity or msal may not be installed).", flush=True)
-        sys.exit(1)
-
-    # One raw HTTP call to verify the org is reachable.
-    try:
-        req = Request(f"{url}/api/data/v9.2/WhoAmI")
-        req.add_header("Authorization", f"Bearer {token}")
-        req.add_header("Accept", "application/json")
-        resp = urlopen(req, timeout=20)
-        resp.read()
-    except HTTPError as e:
-        print(f"PING FAILED: {url} returned HTTP {e.code}.", flush=True)
-        sys.exit(2)
-    except (URLError, OSError) as e:
-        reason = getattr(e, "reason", e)
-        print(f"PING FAILED: {url} unreachable ({reason}).", flush=True)
-        sys.exit(2)
-
-    # Workspace bootstrap: write .env and copy auth.py if missing.
-    workspace = Path.cwd()
-    env_path = workspace / ".env"
-    if not env_path.exists():
-        env_path.write_text(f"DATAVERSE_URL={url}\nTENANT_ID={tenant_id}\n", encoding="utf-8")
-    dest = workspace / "scripts" / "auth.py"
-    if not dest.exists():
-        dest.parent.mkdir(parents=True, exist_ok=True)
-        shutil.copy2(Path(__file__).resolve(), dest)
-    gitignore = workspace / ".gitignore"
-    existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
-    for entry in (".env", ".dataverse/"):
-        if entry not in existing:
-            with open(gitignore, "a", encoding="utf-8") as f:
-                f.write(f"\n{entry}\n")
-
-    _save_user_config()
-    print(f"REACHABLE: {url}", flush=True)
-
-
-def _run_bootstrap(url):
-    """One-command workspace setup from a Dataverse org URL.
-
-    Probes the URL for tenant ID via WWW-Authenticate, writes .env, copies
-    auth.py to scripts/, and saves to user-level config. Uses only stdlib
-    (urllib) so it works before pip install.
-    """
-    import shutil
-    from urllib.error import HTTPError, URLError
-    from urllib.request import Request, urlopen
-
-    url = url.rstrip("/")
-    if not url.startswith("https://"):
-        print(f"ERROR: URL must start with https:// (got: {url})", flush=True)
-        sys.exit(1)
-
-    print(f"Probing {url} for tenant ID...", flush=True)
-    www_auth = ""
-    try:
-        req = Request(f"{url}/api/data/v9.2/", method="GET")
-        req.add_header("Accept", "application/json")
-        urlopen(req, timeout=20)
-        print("ERROR: Unexpected 200 from unauthenticated probe.", flush=True)
-        sys.exit(1)
-    except HTTPError as e:
-        www_auth = e.headers.get("WWW-Authenticate", "")
-    except URLError as e:
-        print(f"ERROR: Cannot reach {url}: {e.reason}", flush=True)
-        sys.exit(1)
-
-    match = re.search(r"login\.microsoftonline\.com/([^/,\s]+)", www_auth)
-    if not match:
-        print(f"ERROR: Could not discover tenant from {url}", flush=True)
-        print(f"  WWW-Authenticate: {www_auth[:200]}", flush=True)
-        sys.exit(1)
-    tenant_id = match.group(1)
-
-    workspace = Path.cwd()
-    env_path = workspace / ".env"
-    env_path.write_text(
-        f"DATAVERSE_URL={url}\nTENANT_ID={tenant_id}\n", encoding="utf-8",
-    )
-
-    scripts_dir = workspace / "scripts"
-    dest = scripts_dir / "auth.py"
-    if not dest.exists():
-        scripts_dir.mkdir(parents=True, exist_ok=True)
-        shutil.copy2(Path(__file__).resolve(), dest)
-
-    gitignore = workspace / ".gitignore"
-    existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
-    missing = [e for e in (".env", ".dataverse/") if e not in existing]
-    if missing:
-        with open(gitignore, "a", encoding="utf-8") as f:
-            f.write("\n" + "\n".join(missing) + "\n")
-
-    os.environ["DATAVERSE_URL"] = url
-    os.environ["TENANT_ID"] = tenant_id
-    _save_user_config()
-
-    print(f"BOOTSTRAPPED: {url} (tenant {tenant_id})", flush=True)
-    print(f"  .env: {env_path}", flush=True)
-    print(f"  user config: {_USER_CONFIG_PATH}", flush=True)
-    print("  Next: pip install deps (if needed), then: python scripts/auth.py --check", flush=True)
-
-
-if __name__ == "__main__":
-    import argparse
-
-    parser = argparse.ArgumentParser(
-        description="Acquire a Dataverse token, or verify the environment is reachable."
-    )
-    parser.add_argument(
-        "--check",
-        action="store_true",
-        help="Make a REAL data-plane call and report reachability instead of printing a token. "
-        "A token can be minted while the org domain is blocked (restricted-egress hosts), so "
-        "this is the only proof of an actual connection. Exit 0 = reachable, 2 = not reachable.",
-    )
-    parser.add_argument(
-        "--ping",
-        action="store_true",
-        help="Lightweight reachability check using only stdlib + msal (no SDK import). "
-        "Loads user-level config or .env, acquires a token via the credential chain, makes "
-        "one raw HTTP call to verify the org is reachable. Exit 0 = reachable + prints the "
-        "org URL, 1 = no config or no token, 2 = token OK but org unreachable. Also writes "
-        ".env and copies auth.py to scripts/ if missing.",
-    )
-    parser.add_argument(
-        "--diagnose",
-        action="store_true",
-        help="Print which credential tiers (service principal / shared cache / az CLI / "
-        "interactive) are available, WITHOUT prompting. Use when auth hangs or fails to see "
-        "why and which tier would serve the next call.",
-    )
-    parser.add_argument(
-        "--bootstrap", metavar="URL",
-        help="One-shot setup: probe URL for tenant, write .env, copy auth.py to scripts/, "
-        "save to user-level config. Does not authenticate -- run --check after pip install.",
-    )
-    args = parser.parse_args()
-
-    if args.diagnose:
-        _run_diagnose()
-        sys.exit(0)
-
-    if args.bootstrap:
-        _run_bootstrap(args.bootstrap)
-        sys.exit(0)
-
-    if args.ping:
-        _run_ping()
-        sys.exit(0)
-
-    if not args.check:
-        # Default (unchanged) behavior: print a bearer token.
-        print(get_token())
-        sys.exit(0)
-
-    # Reachability gate. A green token is NOT a connection: auth traffic goes to
-    # login.microsoftonline.com (often reachable) while the org's data plane
-    # (*.dynamics.com) may be blocked by a sandbox egress allowlist. Only a real
-    # call proves it. Bound the wait so a blocked domain fails fast instead of hanging.
-    import socket
-
-    socket.setdefaulttimeout(30)
-    load_env()
-    url = os.environ.get("DATAVERSE_URL", "").rstrip("/")
-    try:
-        client = get_client("dv-connect")
-        tables = client.tables.list(select=["LogicalName"])
-        print(f"REACHABLE: {url} -- {len(tables)} non-private tables")
-        _save_user_config()
-        sys.exit(0)
-    except Exception as e:
-        print(f"NOT REACHABLE: {url} -- {type(e).__name__}: {e}", flush=True)
-        print(
-            "If this is a connection/timeout error, the org domain is blocked by the host "
-            "network egress allowlist -- auth is fine; the data plane is unreachable. Do NOT "
-            "report a table count or query result: nothing was retrieved.",
-            flush=True,
-        )
-        sys.exit(2)
+"""
+auth.py — Acquire Dataverse tokens via Azure Identity.
+
+Auth chain (silent tiers first; interactive only when every silent tier is
+unavailable, so a working path wins without stranding the user -- issue #108):
+  1. Service principal (CLIENT_ID + CLIENT_SECRET in .env) -- non-interactive,
+     terminal (CI fails fast; no interactive fallback when SP is configured).
+  2. Certificate (CLIENT_ID + CLIENT_CERTIFICATE_PATH in .env) -- non-interactive,
+     terminal (CI fails fast; no interactive fallback when configured).
+  3. Shared Dataverse CLI token cache -- silent, no prompt, populated by
+     `dataverse auth create` (see dv-connect Step 2). Uses the same MSAL v3
+     cache the `@microsoft/dataverse` stdio MCP proxy reads. Probed at build
+     time against both the tenant-specific and `organizations` authority so an
+     authority mismatch falls through instead of hard-failing.
+  4. Azure CLI (`az login`) -- silent, scoped to TENANT_ID; skipped when az is
+     absent or not logged in.
+  5. Interactive (host-gated, last): workspace-cache device-code when
+     DATAVERSE_TOKEN_CACHE_DIR is set (ephemeral hosts); else a system-browser
+     InteractiveBrowserCredential on a desktop host (beats the CA device-code
+     block, and uses the browser not the MSAL broker so it sidesteps the macOS
+     broker bug); else DeviceCodeCredential on a headless host. Each persists an
+     AuthenticationRecord so later processes refresh silently.
+
+The shared cache uses the Dataverse CLI app registration
+(``0c412cc3-0dd6-449b-987f-05b053db9457``) so every Dataverse-skills tool
+authenticates as the same OAuth client and AAD treats it as one sign-in.
+
+Token caching layout (path 2):
+  Windows: %LocalAppData%\\Microsoft\\DataverseCli\\tokencache_msalv3.dat (DPAPI)
+  macOS:   Keychain service ``dataverse_cli_service`` / account ``dataverse_cli_account``
+  Linux:   libsecret schema ``com.microsoft.dataversecli`` (desktop), or a plaintext
+           ``tokencache_msalv3.dat`` under ``$XDG_DATA_HOME/Microsoft/DataverseCli``
+           on headless hosts with no keyring (matches the CLI's WithLinuxUnprotectedFile)
+
+Functions:
+  load_env()            — loads .env into os.environ
+  get_client(skill)     — returns a DataverseClient with plugin attribution
+  get_token(scope=None) — returns a raw access token string
+  get_plugin_headers(skill, token) — returns headers dict for raw Web API calls
+
+Usage:
+    # PREFERRED — SDK with plugin attribution:
+    from auth import get_client
+    client = get_client("dv-data")
+
+    # Raw Web API only (forms, views, $ref, $apply):
+    from auth import get_token, get_plugin_headers
+    headers = get_plugin_headers("dv-metadata", get_token())
+
+Reads from .env in the repo root (parent of scripts/) or current working directory:
+    DATAVERSE_URL      — required
+    TENANT_ID          — required
+    CLIENT_ID          — optional, enables service principal auth
+    CLIENT_SECRET      — optional, enables service principal auth
+    CLIENT_CERTIFICATE_PATH — optional, enables certificate auth (.pfx or .pem)
+    CLIENT_CERTIFICATE_PASSWORD — optional, decrypts a password-protected certificate
+    CLIENT_SEND_CERTIFICATE_CHAIN — optional, set to 1 for SNI authentication
+    DATAVERSE_TOKEN_CACHE_DIR — optional; on ephemeral hosts (ChatGPT web / Codex
+                         sandbox) where $HOME is wiped between turns, set this to a
+                         workspace-relative dir (e.g. .dataverse) so the device-code
+                         token cache persists and refreshes silently within a session
+"""
+
+import os
+import re
+import sys
+import time
+from pathlib import Path
+
+# Dataverse CLI app registration. Must match McpOAuth.Config.ClientId in
+# DataverseCli/Auth/AuthClientConfig.cs so that tokens minted by
+# `dataverse auth create` and the @microsoft/dataverse stdio MCP proxy can
+# be silently reused by Python scripts (no second device-code prompt).
+_DATAVERSE_CLI_CLIENT_ID = "0c412cc3-0dd6-449b-987f-05b053db9457"
+
+# Legacy AuthenticationRecord path for the device-code fallback (path 3).
+# Kept for backward compatibility with workspaces that authenticated via the
+# previous auth.py before the shared-cache change.
+_AUTH_RECORD_PATH = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / ".IdentityService" / "dataverse_cli_auth_record.json"
+
+# Cross-workspace config so returning users skip the full dv-connect dance.
+_USER_CONFIG_DIR = Path(os.environ.get("LOCALAPPDATA") or Path.home()) / ".dataverse-skills"
+_USER_CONFIG_PATH = _USER_CONFIG_DIR / "config.json"
+
+
+def _load_user_config():
+    """Load DATAVERSE_URL and TENANT_ID from user-level config when .env is absent."""
+    if not _USER_CONFIG_PATH.exists():
+        return
+    try:
+        import json
+        cfg = json.loads(_USER_CONFIG_PATH.read_text(encoding="utf-8"))
+        loaded = []
+        for key in ("DATAVERSE_URL", "TENANT_ID"):
+            val = cfg.get(key)
+            if val and not os.environ.get(key):
+                os.environ.setdefault(key, val)
+                loaded.append(key)
+        if loaded:
+            print(
+                f"NOTE: loaded {', '.join(loaded)} from {_USER_CONFIG_PATH} "
+                f"(no .env found). Target: {cfg.get('DATAVERSE_URL', '?')}",
+                flush=True,
+            )
+    except Exception:
+        pass
+
+
+def _save_user_config():
+    """Persist URL and tenant to user-level config for cross-workspace reuse."""
+    import json
+    url = os.environ.get("DATAVERSE_URL")
+    tenant = os.environ.get("TENANT_ID")
+    if not url or not tenant:
+        return
+    try:
+        _USER_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
+        try:
+            os.chmod(_USER_CONFIG_DIR, 0o700)
+        except Exception:
+            pass
+        _USER_CONFIG_PATH.write_text(
+            json.dumps({"DATAVERSE_URL": url, "TENANT_ID": tenant}, indent=2),
+            encoding="utf-8",
+        )
+    except Exception:
+        pass
+
+
+def load_env():
+    """Load key=value pairs from .env into os.environ (does not overwrite existing vars).
+
+    Searches for .env in two locations (first match wins):
+      1. The repo root (parent of the directory containing this script)
+      2. The current working directory
+    This ensures ``cd scripts && python auth.py`` works the same as
+    ``python scripts/auth.py`` from the repo root.
+
+    Falls back to user-level config (~/.dataverse-skills/config.json) when .env
+    is absent, so returning users on ephemeral-workspace hosts (ChatGPT desktop
+    app, Codex) skip the full dv-connect setup.
+    """
+    script_dir = Path(__file__).resolve().parent
+    candidates = [script_dir.parent / ".env", Path(".env")]
+    env_path = next((p for p in candidates if p.exists()), None)
+    if env_path is not None:
+        for line in env_path.read_text().splitlines():
+            line = line.strip()
+            if line and not line.startswith("#") and "=" in line:
+                key, _, value = line.partition("=")
+                os.environ.setdefault(key.strip(), value.strip())
+    if not os.environ.get("DATAVERSE_URL") or not os.environ.get("TENANT_ID"):
+        _load_user_config()
+
+
+_credential = None
+
+
+def _dataverse_scope():
+    """Return the ``{DATAVERSE_URL}/.default`` OAuth scope, or None if unset."""
+    url = os.environ.get("DATAVERSE_URL", "").rstrip("/")
+    if not url:
+        return None
+    return f"{url}/.default"
+
+
+def _shared_cache_persistences():
+    """Return the ordered list of MSAL cache persistences to try for the shared
+    DataverseCLI cache (platform-specific). Never raises -- returns [] on any issue.
+    On Linux this is libsecret-first (desktop) then the plaintext MSAL v3 file
+    (headless), matching the CLI's WithLinuxUnprotectedFile fallback.
+    """
+    persistences = []
+    try:
+        if sys.platform == "win32":
+            from msal_extensions import FilePersistenceWithDataProtection
+            cache_path = (
+                Path(os.environ.get("LOCALAPPDATA", str(Path.home())))
+                / "Microsoft" / "DataverseCli" / "tokencache_msalv3.dat"
+            )
+            if cache_path.exists():
+                persistences.append(FilePersistenceWithDataProtection(str(cache_path)))
+        elif sys.platform == "darwin":
+            from msal_extensions import KeychainPersistence
+            # Fallback file path is required by msal-extensions but unused on
+            # macOS -- the Keychain service/account match DataverseCLI's
+            # PacAuthApplicationFactory constants exactly.
+            fallback = str(Path.home() / ".dataverse_cli_msal_cache")
+            persistences.append(
+                KeychainPersistence(fallback, "dataverse_cli_service", "dataverse_cli_account")
+            )
+        else:
+            fallback = str(Path.home() / ".dataverse_cli_msal_cache")
+            try:
+                from msal_extensions import LibsecretPersistence
+                persistences.append(
+                    LibsecretPersistence(
+                        fallback,
+                        schema_name="com.microsoft.dataversecli",
+                        attributes={"Version": "1", "ProductGroup": "DataverseCli"},
+                    )
+                )
+            except Exception:
+                pass  # libsecret backend unavailable on this host -- skip it.
+            from msal_extensions import FilePersistence
+            xdg_data = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
+            plaintext_path = Path(xdg_data) / "Microsoft" / "DataverseCli" / "tokencache_msalv3.dat"
+            if plaintext_path.exists():
+                persistences.append(FilePersistence(str(plaintext_path)))
+    except Exception:
+        return []
+    return persistences
+
+
+def _build_shared_msal_cache():
+    """Open the DataverseCLI MSAL cache for silent reuse, probing it at build time.
+
+    Returns ``(msal.PublicClientApplication, list[account])`` only when a silent
+    token actually comes back -- probed against BOTH the tenant-specific and the
+    ``organizations`` authority, because ``dataverse auth create`` may have written
+    the cache under a different authority than TENANT_ID (issue #108, defect 2).
+    Returns ``None`` on any miss so the caller falls through to the next tier --
+    the shared-cache path can never break auth.
+    """
+    try:
+        import msal
+        from msal_extensions import PersistedTokenCache
+    except ImportError:
+        return None
+
+    tenant_id = os.environ.get("TENANT_ID")
+    if not tenant_id:
+        return None
+
+    scope = _dataverse_scope()
+    authorities = [
+        f"https://login.microsoftonline.com/{tenant_id}",
+        "https://login.microsoftonline.com/organizations",
+    ]
+    for persistence in _shared_cache_persistences():
+        for authority in authorities:
+            try:
+                app = msal.PublicClientApplication(
+                    client_id=_DATAVERSE_CLI_CLIENT_ID,
+                    authority=authority,
+                    token_cache=PersistedTokenCache(persistence),
+                )
+                accounts = app.get_accounts()
+                if not accounts:
+                    continue
+                if scope is None:
+                    # No DATAVERSE_URL to probe with -- trust account presence.
+                    return app, accounts
+                probe = app.acquire_token_silent([scope], account=accounts[0])
+                if probe and "access_token" in probe:
+                    return app, accounts  # this authority actually yields a token
+            except Exception:
+                continue  # never let the shared-cache path break auth
+    return None
+
+
+class _MsalSharedCacheCredential:
+    """TokenCredential adapter over an msal PublicClientApplication.
+
+    Implements just enough of the azure-core TokenCredential protocol
+    (`get_token(*scopes, **kwargs)` returning AccessToken) to satisfy
+    DataverseClient and direct urllib callers.
+    """
+
+    def __init__(self, app, accounts):
+        self._app = app
+        self._accounts = accounts
+
+    def get_token(self, *scopes, **kwargs):
+        from azure.core.credentials import AccessToken
+        from azure.identity import CredentialUnavailableError
+        # Single-account is the common case. If the shared cache happens to
+        # contain multiple accounts, the first one wins -- deterministic and
+        # matches what `dataverse auth select` would surface as active.
+        # Forward a CAE / Conditional-Access claims challenge when azure-core
+        # passes one -- CA-hardened tenants are this plugin's target scenario.
+        # A None claims_challenge is a harmless no-op.
+        result = self._app.acquire_token_silent(
+            list(scopes), account=self._accounts[0],
+            claims_challenge=kwargs.get("claims"),
+        )
+        if not result or "access_token" not in result:
+            # Fall through, do NOT hard-fail: a cached account can exist while
+            # silent acquisition fails (authority mismatch, expired refresh
+            # token). Raising CredentialUnavailableError lets the chain try the
+            # next tier instead of stranding the user. (Issue #108, defect 1.)
+            raise CredentialUnavailableError(
+                "Shared DataverseCLI cache present but silent token acquisition "
+                "failed (often an authority mismatch or expired refresh token)."
+            )
+        expires_on = int(time.time()) + int(result.get("expires_in", 3600))
+        return AccessToken(result["access_token"], expires_on)
+
+    def close(self):  # pragma: no cover — parity with azure-identity credentials
+        pass
+
+
+# Default workspace cache dir used on headless/ephemeral hosts when the user has not
+# set DATAVERSE_TOKEN_CACHE_DIR (see _workspace_token_cache_path).
+_DEFAULT_WORKSPACE_CACHE_DIRNAME = ".dataverse"
+
+# Explicit opt-out values for DATAVERSE_TOKEN_CACHE_DIR: keep the OS default cache
+# (do not write a plaintext refresh token into the workspace).
+_CACHE_DIR_OPT_OUT = frozenset({"off", "0", "false", "no", "none", "disable", "disabled"})
+
+
+def _should_use_workspace_cache():
+    """Pure decision (no I/O): whether the persisted workspace token cache is used,
+    and via which source. This is the SINGLE source of truth so
+    _workspace_token_cache_path (which builds the path) and _run_diagnose (which
+    reports the tier) can never drift. Returns:
+      "explicit" -- DATAVERSE_TOKEN_CACHE_DIR is set to a usable path.
+      "default"  -- unset on a HEADLESS, non-CI host -> auto-default to <workspace>/.dataverse.
+      None       -- keep the OS default cache (desktop, CI, or an explicit opt-out).
+    """
+    cache_dir = os.environ.get("DATAVERSE_TOKEN_CACHE_DIR")
+    if cache_dir is not None and cache_dir.strip().lower() in _CACHE_DIR_OPT_OUT:
+        return None  # explicit opt-out -- keep the per-process OS default cache
+    if cache_dir:
+        return "explicit"
+    # No explicit setting: auto-default ONLY where the OS cache will not survive --
+    # a headless, non-CI host. Desktop hosts keep their secure default cache; CI
+    # never reaches an interactive tier (service principal is terminal earlier).
+    if _host_has_browser() or _is_ci():
+        return None
+    return "default"
+
+
+def _workspace_token_cache_path():
+    """Return the MSAL v3 cache file path used to persist the device-code refresh
+    token across separate Python processes, or None to keep the OS default cache.
+    The use/skip decision is _should_use_workspace_cache(); this function only turns
+    a positive decision into a concrete, git-ignored, owner-only file path.
+
+    Resolution (see _should_use_workspace_cache):
+      - DATAVERSE_TOKEN_CACHE_DIR set to a path -> use it (a relative dir is anchored
+        to the workspace root, matching how load_env finds .env).
+      - opt-out value (off/false/0/no/none) -> None.
+      - unset on a HEADLESS, non-CI host (ChatGPT web / Codex sandbox / SSH / container)
+        -> DEFAULT to ``<workspace>/.dataverse`` (the OS cache lives under $HOME, which
+        the sandbox wipes between Python processes, so device code would re-prompt every
+        process; the persisted workspace cache makes sign-in once per conversation).
+      - unset on a desktop host -> None (keep the secure OS cache: Windows DPAPI /
+        macOS Keychain / Linux desktop keyring -- no behavior change).
+
+    Security: on non-Windows hosts (Linux, and macOS over SSH) the cache holds a
+    PLAINTEXT refresh token (no keyring; Windows uses DPAPI). The directory is created
+    owner-only (0700 on POSIX) with a self-contained `.gitignore` (`*`) so the token is
+    excluded from version control, and an ephemeral sandbox is torn down after the
+    session. To keep the OS default cache instead, set DATAVERSE_TOKEN_CACHE_DIR=off.
+    """
+    decision = _should_use_workspace_cache()
+    if decision is None:
+        return None
+    cache_dir = (
+        os.environ.get("DATAVERSE_TOKEN_CACHE_DIR")
+        if decision == "explicit"
+        else _DEFAULT_WORKSPACE_CACHE_DIRNAME
+    )
+    try:
+        path = Path(cache_dir)
+        if not path.is_absolute():
+            # Anchor a relative dir to the workspace root (this file's parent dir's
+            # parent -- the deployed <project>/scripts/auth.py layout), NOT the
+            # current working directory, so the cache location is stable no matter
+            # where the process is launched from (matches how load_env finds .env).
+            path = Path(__file__).resolve().parent.parent / path
+        path.mkdir(parents=True, exist_ok=True)
+        # Defense in depth: exclude the token cache from git even if the repo-root
+        # .gitignore does not cover this dir.
+        gitignore = path / ".gitignore"
+        if not gitignore.exists():
+            gitignore.write_text("*\n", encoding="utf-8")
+        try:
+            os.chmod(path, 0o700)  # owner-only on POSIX; benign on Windows
+        except Exception:
+            pass
+        return path / "tokencache_msalv3.dat"
+    except Exception:
+        return None
+
+
+class _MsalDeviceCodeCredential:
+    """TokenCredential over an msal app that persists at an explicit cache path.
+
+    Silent-refresh from the cache when possible; device-code sign-in on a cache miss.
+    Used only when DATAVERSE_TOKEN_CACHE_DIR is set (opt-in, ephemeral hosts) so the
+    refresh token lives in the persisted workspace cache and is reused across turns.
+    Implements the azure-core TokenCredential protocol (get_token).
+    """
+
+    def __init__(self, app):
+        self._app = app
+
+    def get_token(self, *scopes, **kwargs):
+        from azure.core.credentials import AccessToken
+        scope_list = list(scopes)
+        claims = kwargs.get("claims")  # CAE / Conditional-Access challenge, if any
+        result = None
+        accounts = self._app.get_accounts()
+        if accounts:
+            result = self._app.acquire_token_silent(
+                scope_list, account=accounts[0], claims_challenge=claims,
+            )
+        if not result or "access_token" not in result:
+            flow = self._app.initiate_device_flow(scopes=scope_list)
+            if "user_code" not in flow:
+                raise RuntimeError(
+                    "Failed to start device-code flow: "
+                    f"{flow.get('error_description', flow)}"
+                )
+            print(
+                f"\nTo sign in, visit {flow['verification_uri']} and enter code: "
+                f"{flow['user_code']}",
+                flush=True,
+            )
+            _expiry_min = max(1, int(flow.get("expires_in", 900)) // 60)
+            print(
+                f"(Waiting for you to complete the login in your browser -- "
+                f"this code expires in ~{_expiry_min} min...)\n",
+                flush=True,
+            )
+            result = self._app.acquire_token_by_device_flow(
+                flow, claims_challenge=claims,
+            )  # blocks until complete
+        if not result or "access_token" not in result:
+            detail = result.get("error_description", result) if result else "no response"
+            raise RuntimeError(f"Device-code authentication failed: {detail}")
+        expires_on = int(time.time()) + int(result.get("expires_in", 3600))
+        return AccessToken(result["access_token"], expires_on)
+
+    def close(self):  # pragma: no cover
+        pass
+
+
+def _is_ci():
+    """True on CI / build agents (no interactive user), even on win32/darwin --
+    GitHub Actions, ADO agents, containerized Windows. Treats only genuinely-
+    truthy values as set (avoids the CI="false" trap).
+    """
+    def _flag(name):
+        return os.environ.get(name, "").strip().lower() not in ("", "0", "false", "no")
+
+    return _flag("CI") or _flag("GITHUB_ACTIONS") or _flag("TF_BUILD") or _flag("BUILD_BUILDID")
+
+
+def _host_has_browser():
+    """True if an interactive system browser is likely available.
+
+    CI / build agents are headless even when sys.platform is win32/darwin --
+    launching a browser there crashes with no user session, so they route to the
+    device-code tier instead. After the CI gate: a Windows console or RDP session
+    has a browser, but a Windows service / headless container has an empty or
+    "Services" SESSIONNAME; macOS (non-CI) always has a session browser; Linux
+    needs a display server (DISPLAY / WAYLAND_DISPLAY) -- headless Linux (SSH /
+    container / ChatGPT web) has neither and uses device-code.
+    """
+    if _is_ci():
+        return False
+    if sys.platform == "win32":
+        session = os.environ.get("SESSIONNAME", "").strip().lower()
+        return session not in ("", "services")
+    if sys.platform == "darwin":
+        # An SSH / remote macOS session has no local browser -- use device-code.
+        if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"):
+            return False
+        return True
+    return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
+
+
+class _SilentChain:
+    """Try each silent tier in order; skip any that is unavailable OR errors, so a
+    convenience tier (e.g. az CLI logged into another tenant) can never strand the
+    fall-through to the interactive tier. Raises CredentialUnavailableError only
+    when every tier is exhausted.
+    """
+
+    def __init__(self, tiers):
+        self._tiers = tiers  # list[(name, credential)]
+        self.last_reasons = []  # why each tier was skipped -- surfaced before a prompt
+
+    def get_token(self, *scopes, **kwargs):
+        from azure.identity import CredentialUnavailableError
+        reasons = []
+        for tier_name, cred in self._tiers:
+            try:
+                return cred.get_token(*scopes, **kwargs)
+            except CredentialUnavailableError:
+                reasons.append(f"{tier_name}: unavailable")
+            except Exception as e:  # noqa: BLE001 -- a silent tier must never strand the chain
+                reasons.append(f"{tier_name}: {type(e).__name__}")
+        self.last_reasons = reasons
+        raise CredentialUnavailableError(
+            "no silent credential available (" + "; ".join(reasons) + ")"
+        )
+
+    def close(self):  # pragma: no cover
+        pass
+
+
+class _FallbackCredential:
+    """Silent tiers first; if all are unavailable, build and use the single
+    host-appropriate interactive tier ON DEMAND -- so an interactive prompt
+    happens ONLY when no silent path works. (Issue #108: never strand, never
+    prompt when a silent tier would do.)
+    """
+
+    def __init__(self, silent, interactive_builder):
+        self._silent = silent
+        self._interactive_builder = interactive_builder
+        self._interactive = None
+
+    def get_token(self, *scopes, **kwargs):
+        from azure.identity import CredentialUnavailableError
+        if self._silent is not None:
+            try:
+                return self._silent.get_token(*scopes, **kwargs)
+            except CredentialUnavailableError as e:
+                # Surface WHY every silent tier was skipped so a sudden interactive
+                # prompt is explicable ("it just prompted me and I don't know why").
+                reasons = getattr(self._silent, "last_reasons", None)
+                detail = "; ".join(reasons) if reasons else str(e)
+                print(
+                    f"No silent credential available ({detail}); "
+                    f"falling back to interactive sign-in.",
+                    flush=True,
+                )
+        if self._interactive is None:
+            self._interactive = self._interactive_builder()
+        return self._interactive.get_token(*scopes, **kwargs)
+
+    def close(self):  # pragma: no cover
+        pass
+
+
+def _build_workspace_device_code_credential(cache_path, tenant_id):
+    """Build the opt-in workspace-cache device-code credential, or None if msal
+    is unavailable. The MSAL cache (incl. refresh token) lives at cache_path in
+    the persisted workspace, so device code is once per conversation, not per turn.
+    """
+    try:
+        import msal
+        from msal_extensions import PersistedTokenCache
+        if sys.platform == "win32":
+            from msal_extensions import FilePersistenceWithDataProtection
+            persistence = FilePersistenceWithDataProtection(str(cache_path))
+        else:
+            from msal_extensions import FilePersistence
+            persistence = FilePersistence(str(cache_path))
+        app = msal.PublicClientApplication(
+            client_id=_DATAVERSE_CLI_CLIENT_ID,
+            authority=f"https://login.microsoftonline.com/{tenant_id}",
+            token_cache=PersistedTokenCache(persistence),
+        )
+        return _MsalDeviceCodeCredential(app)
+    except ImportError:
+        return None
+
+
+def _build_interactive_tier(tenant_id):
+    """Build ``(credential, kind)`` for the single interactive tier this host uses.
+
+    kind is one of ``workspace-device-code`` / ``interactive-browser`` /
+    ``device-code``. Selection:
+      - workspace cache available (DATAVERSE_TOKEN_CACHE_DIR set, or auto-defaulted on
+        a headless non-CI host) -> workspace-cache device-code (self-persisting).
+      - desktop host with a browser -> InteractiveBrowserCredential (system browser).
+      - headless host with no buildable workspace cache -> DeviceCodeCredential (legacy).
+    The azure-identity interactive tiers persist an AuthenticationRecord on first
+    login so a later process refreshes silently. Called ONLY after every silent
+    tier is exhausted, so the eager first-login prompt here is never premature.
+    """
+    # CI / unattended host: no interactive tier can succeed (no browser; a device
+    # code just blocks ~15 min then expires into a log nobody reads). Service
+    # principal is a terminal tier BEFORE we reach here, so being here under CI
+    # means no SP is configured -- fail fast with an actionable message instead of
+    # a guaranteed hang.
+    if _is_ci():
+        raise RuntimeError(
+            "Unattended/CI host detected with no working silent credential "
+            "(service principal, shared cache, or az login). Interactive sign-in "
+            "cannot succeed here -- set CLIENT_ID + CLIENT_SECRET for a service "
+            "principal, or run `python scripts/auth.py --diagnose` to see which "
+            "tier failed."
+        )
+    workspace_cache = _workspace_token_cache_path()
+    if workspace_cache is not None:
+        cred = _build_workspace_device_code_credential(workspace_cache, tenant_id)
+        if cred is not None:
+            return cred, "workspace-device-code"
+
+    from azure.identity import (
+        AuthenticationRecord,
+        DeviceCodeCredential,
+        InteractiveBrowserCredential,
+        TokenCachePersistenceOptions,
+    )
+
+    cache_opts = TokenCachePersistenceOptions(
+        name="dataverse_cli", allow_unencrypted_storage=True
+    )
+    record = None
+    if _AUTH_RECORD_PATH.exists():
+        try:
+            record = AuthenticationRecord.deserialize(
+                _AUTH_RECORD_PATH.read_text(encoding="utf-8")
+            )
+        except Exception:
+            record = None  # corrupt/stale record -- re-authenticate
+
+    if _host_has_browser():
+        cred = InteractiveBrowserCredential(
+            tenant_id=tenant_id,
+            client_id=_DATAVERSE_CLI_CLIENT_ID,
+            cache_persistence_options=cache_opts,
+            authentication_record=record,
+        )
+        kind = "interactive-browser"
+    else:
+        def _prompt_callback(verification_uri, user_code, _expires_on):
+            print(f"\nTo sign in, visit {verification_uri} and enter code: {user_code}", flush=True)
+            print("(Waiting for you to complete the login in your browser...)\n", flush=True)
+
+        cred = DeviceCodeCredential(
+            tenant_id=tenant_id,
+            client_id=_DATAVERSE_CLI_CLIENT_ID,
+            prompt_callback=_prompt_callback,
+            cache_persistence_options=cache_opts,
+            authentication_record=record,
+        )
+        kind = "device-code"
+
+    # First login: capture + persist the AuthenticationRecord so later processes
+    # refresh silently. authenticate() performs the one interactive flow; because
+    # we are here only after the silent tiers failed, it is never premature.
+    if record is None:
+        try:
+            scope = _dataverse_scope()
+            new_record = cred.authenticate(scopes=[scope] if scope else None)
+            _AUTH_RECORD_PATH.parent.mkdir(parents=True, exist_ok=True)
+            _AUTH_RECORD_PATH.write_text(new_record.serialize(), encoding="utf-8")
+        except KeyboardInterrupt:
+            raise  # user cancelled sign-in -- honor it, don't fall through to a 2nd prompt
+        except Exception as e:  # noqa: BLE001 -- non-fatal: get_token still works, may reprompt
+            print(
+                f"NOTE: first interactive sign-in did not persist a record "
+                f"({type(e).__name__}); a later token request may reprompt.",
+                flush=True,
+            )
+
+    return cred, kind
+
+
+def _get_credential():
+    """
+    Return a TokenCredential, creating one on first call.
+
+    The credential is cached for the lifetime of the process. Resolution
+    order: service principal or certificate (terminal) -> silent chain (shared
+    DataverseCLI cache, then az CLI) -> single host-gated interactive tier,
+    used only when every silent tier is unavailable (issue #108).
+    """
+    global _credential
+    if _credential is not None:
+        return _credential
+
+    load_env()
+
+    tenant_id = os.environ.get("TENANT_ID")
+    dataverse_url = os.environ.get("DATAVERSE_URL", "").rstrip("/")
+    client_id = os.environ.get("CLIENT_ID")
+    client_secret = os.environ.get("CLIENT_SECRET")
+    certificate_path = os.environ.get("CLIENT_CERTIFICATE_PATH")
+    certificate_password = os.environ.get("CLIENT_CERTIFICATE_PASSWORD")
+
+    if not tenant_id or not dataverse_url:
+        missing = [k for k, v in [("TENANT_ID", tenant_id), ("DATAVERSE_URL", dataverse_url)] if not v]
+        print(f"ERROR: .env is missing required values: {', '.join(missing)}", flush=True)
+        print("  Run the init sequence (/dataverse:init) to create .env.", flush=True)
+        sys.exit(1)
+
+    try:
+        from azure.identity import (
+            AzureCliCredential,
+            CertificateCredential,
+            ClientSecretCredential,
+        )
+    except ImportError:
+        print("ERROR: azure-identity not installed. Run: pip install --upgrade azure-identity", flush=True)
+        sys.exit(1)
+
+    # Tier 1: Service principal. Terminal -- CI must fail fast, never fall to an
+    # interactive prompt that would hang an unattended run.
+    if client_id and client_secret:
+        _credential = ClientSecretCredential(
+            tenant_id=tenant_id,
+            client_id=client_id,
+            client_secret=client_secret,
+        )
+        return _credential
+
+    # Tier 2: Certificate-based service principal. Terminal for the same reason.
+    if client_id and certificate_path:
+        path = Path(certificate_path)
+        if not path.exists():
+            print(
+                f"ERROR: CLIENT_CERTIFICATE_PATH does not exist: {certificate_path}",
+                flush=True,
+            )
+            sys.exit(1)
+        if not path.is_file() or not os.access(path, os.R_OK):
+            print(
+                f"ERROR: CLIENT_CERTIFICATE_PATH is not a readable file: {certificate_path}",
+                flush=True,
+            )
+            sys.exit(1)
+
+        try:
+            _credential = CertificateCredential(
+                tenant_id=tenant_id,
+                client_id=client_id,
+                certificate_path=str(path),
+                password=certificate_password,
+                send_certificate_chain=(
+                    os.environ.get("CLIENT_SEND_CERTIFICATE_CHAIN") == "1"
+                ),
+            )
+        except ValueError as exc:
+            print(
+                "ERROR: Could not load CLIENT_CERTIFICATE_PATH. Verify the "
+                "certificate format and CLIENT_CERTIFICATE_PASSWORD.",
+                flush=True,
+            )
+            print(f"  {exc}", flush=True)
+            sys.exit(1)
+        return _credential
+
+    if client_id and not client_secret and not certificate_path:
+        print(
+            "WARNING: CLIENT_ID is set without CLIENT_SECRET or "
+            "CLIENT_CERTIFICATE_PATH.",
+            flush=True,
+        )
+        print(
+            "  Falling back to shared cache / device code flow.",
+            flush=True,
+        )
+
+    if not client_id and (client_secret or certificate_path):
+        configured = (
+            "CLIENT_SECRET" if client_secret else "CLIENT_CERTIFICATE_PATH"
+        )
+        print(
+            f"WARNING: {configured} is set without CLIENT_ID.",
+            flush=True,
+        )
+        print(
+            "  Falling back to shared cache / device code flow.",
+            flush=True,
+        )
+
+    # No service principal -> silent tiers, then a single host-gated interactive
+    # tier used ONLY if every silent tier is unavailable (issue #108: never
+    # strand, and never prompt when a silent path works).
+    silent_tiers = []
+
+    # Tier 3: Shared DataverseCLI MSAL cache (populated by `dataverse auth
+    # create`; same client ID as the @microsoft/dataverse MCP proxy). Probed at
+    # build time so it is only selected when it actually yields a token.
+    shared = _build_shared_msal_cache()
+    if shared is not None:
+        app, accounts = shared
+        silent_tiers.append(("shared-cache", _MsalSharedCacheCredential(app, accounts)))
+
+    # Tier 4: Azure CLI (`az login`). Scoped to TENANT_ID; AzureCliCredential
+    # raises CredentialUnavailableError when az is absent / not logged in, so it
+    # is free when unused and a high-value silent win when present.
+    silent_tiers.append(("azure-cli", AzureCliCredential(tenant_id=tenant_id)))
+
+    silent = _SilentChain(silent_tiers)
+
+    def _interactive_builder():
+        cred, _kind = _build_interactive_tier(tenant_id)
+        return cred
+
+    _credential = _FallbackCredential(silent, _interactive_builder)
+    return _credential
+
+
+_auth_record_saved = False
+
+
+def get_token(scope=None):
+    """
+    Acquire a raw access token string for the Dataverse environment.
+
+    Resolution order is set by ``_get_credential()``: service principal, then
+    the silent chain (shared DataverseCLI cache, then az CLI), then a single
+    host-gated interactive tier used only if every silent tier is unavailable.
+    The interactive tier persists an AuthenticationRecord on first login so
+    subsequent processes refresh silently.
+
+    :param scope: OAuth2 scope. Defaults to "{DATAVERSE_URL}/.default".
+    :returns: Access token string suitable for a Bearer Authorization header.
+    """
+    load_env()
+    dataverse_url = os.environ.get("DATAVERSE_URL", "").rstrip("/")
+    if not scope:
+        scope = f"{dataverse_url}/.default"
+
+    credential = _get_credential()
+
+    try:
+        token = credential.get_token(scope)
+    except Exception as e:
+        print(f"ERROR: Failed to acquire access token: {e}", flush=True)
+        print("  Check your network connection, credentials, and .env configuration.", flush=True)
+        print("  Tip: run `python scripts/auth.py --diagnose` to see which credential", flush=True)
+        print("  tiers are available, or `dataverse auth create --environment "
+              f"{dataverse_url}` to populate the shared token cache.", flush=True)
+        sys.exit(1)
+
+    return token.token
+
+
+_ALLOWED_SKILLS = frozenset({
+    "dv-overview", "dv-connect", "dv-data", "dv-query",
+    "dv-metadata", "dv-solution", "dv-admin", "dv-security", "erp-xpp",
+    "unknown",
+})
+_ALLOWED_AGENTS = frozenset({
+    "claude-code", "copilot", "cursor", "codex", "gemini-cli",
+    "antigravity-cli", "unknown",
+})
+# Strict format: key=value pairs, semicolon-separated. No spaces, no PII.
+_CONTEXT_RE = re.compile(
+    r"^[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+(;[a-zA-Z0-9_-]+=[a-zA-Z0-9_./-]+)*$"
+)
+
+
+_PLUGIN_VERSION_CACHE = None
+
+
+def _plugin_version():
+    """Return the plugin version for telemetry attribution.
+
+    Primary source is DATAVERSE_PLUGIN_VERSION, which dv-connect writes into .env
+    from the live plugin manifest on marketplace / plugin installs. npx and the
+    JetBrains registry install skill folders only (no manifest), so the env var is
+    often "unknown" there; in that case resolve the version from the canonical
+    manifest (local if reachable, else fetched once and cached).
+
+    Fail-open: a value that would break the closed-schema context string is
+    coerced to "unknown", so attribution can never block the operation.
+    """
+    env_version = os.environ.get("DATAVERSE_PLUGIN_VERSION", "").strip()
+    if env_version and env_version != "unknown":
+        return env_version if re.fullmatch(r"[a-zA-Z0-9_./-]+", env_version) else "unknown"
+    ver = _resolve_manifest_version()
+    return ver if re.fullmatch(r"[a-zA-Z0-9_./-]+", ver or "") else "unknown"
+
+
+def _resolve_manifest_version():
+    """Resolve the plugin version from the canonical manifest, cached per process.
+
+    Tries a local manifest (present on repo-clone / plugin installs), then fetches
+    it from GitHub (npx / registry installs ship no manifest). Network and parse
+    failures degrade to "unknown" -- this never raises.
+    """
+    global _PLUGIN_VERSION_CACHE
+    if _PLUGIN_VERSION_CACHE is not None:
+        return _PLUGIN_VERSION_CACHE
+    import json
+
+    version = "unknown"
+    for manifest in (".claude-plugin/plugin.json",
+                     ".github/plugins/dataverse/.claude-plugin/plugin.json"):
+        try:
+            with open(manifest, encoding="utf-8") as fh:
+                version = json.load(fh).get("version") or version
+            break
+        except (OSError, ValueError):
+            continue
+    if version == "unknown":
+        try:
+            import urllib.request
+            url = ("https://raw.githubusercontent.com/microsoft/Dataverse-skills/"
+                   "main/.github/plugins/dataverse/.claude-plugin/plugin.json")
+            with urllib.request.urlopen(url, timeout=5) as resp:
+                version = json.load(resp).get("version") or "unknown"
+        except Exception:
+            version = "unknown"
+    _PLUGIN_VERSION_CACHE = version
+    return version
+
+
+def _current_agent():
+    """Return the attribution agent, fail-open.
+
+    An unrecognized host string (LLM host misdetection: 'github-copilot' vs
+    'copilot', 'claude', 'windsurf', ...) is coerced to "unknown" rather than
+    raised -- telemetry attribution must never gate the user's data operation.
+    Strict validation stays on the .env WRITE path (dv-connect guidance).
+    """
+    agent = os.environ.get("DATAVERSE_PLUGIN_AGENT", "unknown")
+    return agent if agent in _ALLOWED_AGENTS else "unknown"
+
+
+def _validate_skill(skill):
+    if skill not in _ALLOWED_SKILLS:
+        raise ValueError(f"Unknown skill '{skill}'; allowed: {_ALLOWED_SKILLS}")
+    return skill
+
+
+def _operation_context_str(skill):
+    """Assemble the closed-schema attribution string, fail-open.
+
+    agent/version are already coerced to allow-listed / charset-safe values, so
+    the result satisfies _CONTEXT_RE. The final check is a defensive net: on any
+    mismatch it degrades to a minimal safe context instead of raising, so a bad
+    attribution value can never block the call. `skill` is a plugin-authored
+    literal validated strictly by the public entry points.
+    """
+    ctx_str = f"app=dataverse-skills/{_plugin_version()};skill={skill};agent={_current_agent()}"
+    if not _CONTEXT_RE.match(ctx_str):
+        safe_skill = skill if skill in _ALLOWED_SKILLS else "unknown"
+        ctx_str = f"app=dataverse-skills/unknown;skill={safe_skill};agent=unknown"
+    return ctx_str
+
+
+def _build_operation_context(skill):
+    """Build the operation_context for the SDK.
+
+    SECURITY: Only closed-schema values from _ALLOWED_SKILLS and _ALLOWED_AGENTS
+    reach the string; agent/version are coerced (never passed through raw), so
+    nothing un-allow-listed is written to HTTP headers or server-side telemetry.
+    """
+    ctx_str = _operation_context_str(skill)
+    from PowerPlatform.Dataverse.core.config import OperationContext
+    return OperationContext(user_agent_context=ctx_str)
+
+
+def get_client(skill, **kwargs):
+    """Return a DataverseClient with plugin attribution baked in.
+
+    The operation_context is appended to the User-Agent header as a
+    parenthesized comment for server-side traffic attribution.
+
+    IMPORTANT: Do not modify the operation_context — it uses a closed
+    schema (app/skill/agent) for safe server-side attribution.  Never
+    include secrets, PII, or free-form text.
+
+    :param skill: Skill name (e.g. "dv-data", "dv-query").
+    :param kwargs: Extra keyword arguments forwarded to DataverseClient.
+    :returns: Configured DataverseClient instance.
+    """
+    load_env()
+    _validate_skill(skill)
+    from PowerPlatform.Dataverse.client import DataverseClient
+    return DataverseClient(
+        base_url=os.environ["DATAVERSE_URL"],
+        credential=_get_credential(),
+        context=_build_operation_context(skill),
+        **kwargs,
+    )
+
+
+def get_plugin_headers(skill, token=None):
+    """Return HTTP headers for raw Web API calls, with plugin attribution.
+
+    Use this for operations the SDK does not support (forms, views, $apply,
+    N:N $expand, unbound actions).
+
+    IMPORTANT: Do not modify the User-Agent context — it uses a closed
+    schema (app/skill/agent) for safe server-side attribution.  Never
+    include secrets, PII, or free-form text.
+
+    :param skill: Skill name (e.g. "dv-metadata").
+    :param token: Optional bearer token (from get_token()).
+    :returns: Headers dict with User-Agent and optional Authorization.
+    """
+    _validate_skill(skill)
+    ctx_str = _operation_context_str(skill)
+    headers = {"User-Agent": f"Python-urllib ({ctx_str})"}
+    if token:
+        headers["Authorization"] = f"Bearer {token}"
+    return headers
+
+
+def _run_diagnose():
+    """Print a per-tier credential availability matrix WITHOUT triggering any
+    interactive prompt. Turns an auth hang / bare exit-1 into a self-service
+    report of which tiers are available and which one would serve the next call.
+    (Issue #108: give stranded users a diagnosis, not a dead end.)
+    """
+    load_env()
+    tenant_id = os.environ.get("TENANT_ID")
+    url = os.environ.get("DATAVERSE_URL", "").rstrip("/")
+    scope = _dataverse_scope()
+    client_id = os.environ.get("CLIENT_ID")
+    client_secret = os.environ.get("CLIENT_SECRET")
+
+    print(f"Dataverse auth diagnosis for {url or '<DATAVERSE_URL unset>'}")
+    print(
+        f"  tenant={tenant_id or '<TENANT_ID unset>'}  host={sys.platform}  "
+        f"browser={_host_has_browser()}  "
+        f"workspace-cache={'on' if os.environ.get('DATAVERSE_TOKEN_CACHE_DIR') else 'off'}"
+    )
+    print("")
+
+    rows = []  # (tier, status, detail)
+
+    if client_id and client_secret:
+        rows.append(("service-principal", "CONFIGURED", "terminal -- used exclusively, no interactive fallback"))
+    else:
+        rows.append(("service-principal", "not set", "set CLIENT_ID + CLIENT_SECRET for unattended auth"))
+
+    try:
+        shared = _build_shared_msal_cache()
+        if shared is not None:
+            rows.append(("shared-cache", "AVAILABLE", "`dataverse auth create` cache yields a silent token"))
+        else:
+            rows.append(("shared-cache", "unavailable", "no cache / no account / silent miss -- run `dataverse auth create`"))
+    except Exception as e:  # noqa: BLE001
+        rows.append(("shared-cache", "error", type(e).__name__))
+
+    try:
+        from azure.identity import AzureCliCredential, CredentialUnavailableError
+        try:
+            cred = AzureCliCredential(tenant_id=tenant_id)
+            if scope:
+                cred.get_token(scope)
+                rows.append(("azure-cli", "AVAILABLE", "`az login` yields a silent token for this tenant"))
+            else:
+                rows.append(("azure-cli", "unknown", "DATAVERSE_URL unset -- cannot probe"))
+        except CredentialUnavailableError:
+            rows.append(("azure-cli", "unavailable", "az not installed or not logged in -- run `az login`"))
+        except Exception as e:  # noqa: BLE001
+            rows.append(("azure-cli", "skipped", type(e).__name__))
+    except ImportError:
+        rows.append(("azure-cli", "n/a", "azure-identity not installed"))
+
+    if _should_use_workspace_cache() is not None:
+        kind = "workspace-device-code"
+    elif _host_has_browser():
+        kind = "interactive-browser"
+    else:
+        kind = "device-code"
+    rows.append(("interactive", kind, "used only if every silent tier above is unavailable"))
+
+    width = max(len(t) for t, _, _ in rows)
+    for tier, status, detail in rows:
+        print(f"  {tier.ljust(width)}  {status.ljust(12)}  {detail}")
+
+    silent_ok = any(s in ("CONFIGURED", "AVAILABLE") for _, s, _ in rows[:3])
+    print("")
+    if silent_ok:
+        print("Result: a silent tier is available -- normal calls will not prompt.")
+    else:
+        print(f"Result: no silent tier -- the next call uses the '{kind}' interactive tier.")
+
+
+def _run_ping():
+    """Lightweight reachability check: token + one HTTP call, no SDK import.
+
+    Uses only stdlib (urllib) and the credential chain (which needs msal or
+    azure-identity -- at least one is typically installed globally even before
+    pip runs in a fresh workspace). Writes .env and copies auth.py to scripts/
+    if missing, so a successful ping makes the workspace ready for SDK use
+    after pip install.
+    """
+    import shutil
+    from urllib.error import HTTPError, URLError
+    from urllib.request import Request, urlopen
+
+    load_env()
+    url = os.environ.get("DATAVERSE_URL", "").rstrip("/")
+    tenant_id = os.environ.get("TENANT_ID")
+
+    if not url or not tenant_id:
+        print("PING FAILED: no DATAVERSE_URL or TENANT_ID (no .env, no user-level config).", flush=True)
+        sys.exit(1)
+
+    # Acquire a token via the credential chain (shared CLI cache, az, interactive).
+    # _get_credential needs azure-identity or msal; if neither is installed, fail cleanly.
+    try:
+        token = get_token()
+    except SystemExit:
+        print("PING FAILED: could not acquire a token (azure-identity or msal may not be installed).", flush=True)
+        sys.exit(1)
+
+    # One raw HTTP call to verify the org is reachable.
+    try:
+        req = Request(f"{url}/api/data/v9.2/WhoAmI")
+        req.add_header("Authorization", f"Bearer {token}")
+        req.add_header("Accept", "application/json")
+        resp = urlopen(req, timeout=20)
+        resp.read()
+    except HTTPError as e:
+        print(f"PING FAILED: {url} returned HTTP {e.code}.", flush=True)
+        sys.exit(2)
+    except (URLError, OSError) as e:
+        reason = getattr(e, "reason", e)
+        print(f"PING FAILED: {url} unreachable ({reason}).", flush=True)
+        sys.exit(2)
+
+    # Workspace bootstrap: write .env and copy auth.py if missing.
+    workspace = Path.cwd()
+    env_path = workspace / ".env"
+    if not env_path.exists():
+        env_path.write_text(f"DATAVERSE_URL={url}\nTENANT_ID={tenant_id}\n", encoding="utf-8")
+    dest = workspace / "scripts" / "auth.py"
+    if not dest.exists():
+        dest.parent.mkdir(parents=True, exist_ok=True)
+        shutil.copy2(Path(__file__).resolve(), dest)
+    gitignore = workspace / ".gitignore"
+    existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
+    for entry in (".env", ".dataverse/"):
+        if entry not in existing:
+            with open(gitignore, "a", encoding="utf-8") as f:
+                f.write(f"\n{entry}\n")
+
+    _save_user_config()
+    print(f"REACHABLE: {url}", flush=True)
+
+
+def _run_bootstrap(url):
+    """One-command workspace setup from a Dataverse org URL.
+
+    Probes the URL for tenant ID via WWW-Authenticate, writes .env, copies
+    auth.py to scripts/, and saves to user-level config. Uses only stdlib
+    (urllib) so it works before pip install.
+    """
+    import shutil
+    from urllib.error import HTTPError, URLError
+    from urllib.request import Request, urlopen
+
+    url = url.rstrip("/")
+    if not url.startswith("https://"):
+        print(f"ERROR: URL must start with https:// (got: {url})", flush=True)
+        sys.exit(1)
+
+    print(f"Probing {url} for tenant ID...", flush=True)
+    www_auth = ""
+    try:
+        req = Request(f"{url}/api/data/v9.2/", method="GET")
+        req.add_header("Accept", "application/json")
+        urlopen(req, timeout=20)
+        print("ERROR: Unexpected 200 from unauthenticated probe.", flush=True)
+        sys.exit(1)
+    except HTTPError as e:
+        www_auth = e.headers.get("WWW-Authenticate", "")
+    except URLError as e:
+        print(f"ERROR: Cannot reach {url}: {e.reason}", flush=True)
+        sys.exit(1)
+
+    match = re.search(r"login\.microsoftonline\.com/([^/,\s]+)", www_auth)
+    if not match:
+        print(f"ERROR: Could not discover tenant from {url}", flush=True)
+        print(f"  WWW-Authenticate: {www_auth[:200]}", flush=True)
+        sys.exit(1)
+    tenant_id = match.group(1)
+
+    workspace = Path.cwd()
+    env_path = workspace / ".env"
+    env_path.write_text(
+        f"DATAVERSE_URL={url}\nTENANT_ID={tenant_id}\n", encoding="utf-8",
+    )
+
+    scripts_dir = workspace / "scripts"
+    dest = scripts_dir / "auth.py"
+    if not dest.exists():
+        scripts_dir.mkdir(parents=True, exist_ok=True)
+        shutil.copy2(Path(__file__).resolve(), dest)
+
+    gitignore = workspace / ".gitignore"
+    existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
+    missing = [e for e in (".env", ".dataverse/") if e not in existing]
+    if missing:
+        with open(gitignore, "a", encoding="utf-8") as f:
+            f.write("\n" + "\n".join(missing) + "\n")
+
+    os.environ["DATAVERSE_URL"] = url
+    os.environ["TENANT_ID"] = tenant_id
+    _save_user_config()
+
+    print(f"BOOTSTRAPPED: {url} (tenant {tenant_id})", flush=True)
+    print(f"  .env: {env_path}", flush=True)
+    print(f"  user config: {_USER_CONFIG_PATH}", flush=True)
+    print("  Next: pip install deps (if needed), then: python scripts/auth.py --check", flush=True)
+
+
+if __name__ == "__main__":
+    import argparse
+
+    parser = argparse.ArgumentParser(
+        description="Acquire a Dataverse token, or verify the environment is reachable."
+    )
+    parser.add_argument(
+        "--check",
+        action="store_true",
+        help="Make a REAL data-plane call and report reachability instead of printing a token. "
+        "A token can be minted while the org domain is blocked (restricted-egress hosts), so "
+        "this is the only proof of an actual connection. Exit 0 = reachable, 2 = not reachable.",
+    )
+    parser.add_argument(
+        "--ping",
+        action="store_true",
+        help="Lightweight reachability check using only stdlib + msal (no SDK import). "
+        "Loads user-level config or .env, acquires a token via the credential chain, makes "
+        "one raw HTTP call to verify the org is reachable. Exit 0 = reachable + prints the "
+        "org URL, 1 = no config or no token, 2 = token OK but org unreachable. Also writes "
+        ".env and copies auth.py to scripts/ if missing.",
+    )
+    parser.add_argument(
+        "--diagnose",
+        action="store_true",
+        help="Print which credential tiers (service principal / shared cache / az CLI / "
+        "interactive) are available, WITHOUT prompting. Use when auth hangs or fails to see "
+        "why and which tier would serve the next call.",
+    )
+    parser.add_argument(
+        "--bootstrap", metavar="URL",
+        help="One-shot setup: probe URL for tenant, write .env, copy auth.py to scripts/, "
+        "save to user-level config. Does not authenticate -- run --check after pip install.",
+    )
+    args = parser.parse_args()
+
+    if args.diagnose:
+        _run_diagnose()
+        sys.exit(0)
+
+    if args.bootstrap:
+        _run_bootstrap(args.bootstrap)
+        sys.exit(0)
+
+    if args.ping:
+        _run_ping()
+        sys.exit(0)
+
+    if not args.check:
+        # Default (unchanged) behavior: print a bearer token.
+        print(get_token())
+        sys.exit(0)
+
+    # Reachability gate. A green token is NOT a connection: auth traffic goes to
+    # login.microsoftonline.com (often reachable) while the org's data plane
+    # (*.dynamics.com) may be blocked by a sandbox egress allowlist. Only a real
+    # call proves it. Bound the wait so a blocked domain fails fast instead of hanging.
+    import socket
+
+    socket.setdefaulttimeout(30)
+    load_env()
+    url = os.environ.get("DATAVERSE_URL", "").rstrip("/")
+    try:
+        client = get_client("dv-connect")
+        tables = client.tables.list(select=["LogicalName"])
+        print(f"REACHABLE: {url} -- {len(tables)} non-private tables")
+        _save_user_config()
+        sys.exit(0)
+    except Exception as e:
+        print(f"NOT REACHABLE: {url} -- {type(e).__name__}: {e}", flush=True)
+        print(
+            "If this is a connection/timeout error, the org domain is blocked by the host "
+            "network egress allowlist -- auth is fine; the data plane is unreachable. Do NOT "
+            "report a table count or query result: nothing was retrieved.",
+            flush=True,
+        )
+        sys.exit(2)
Full technical diff · 88 changed fields

changed /files/.claude-plugin~1plugin.json/sha256

BEFORE
"6e40c3dfa58513854e5ec8e5db8da85ddf561dc41b2ccbdfa149b44c9f4cfa28"
AFTER
"5677ada5e3f5f6af333915498383bfeb0c7afbd40bcd1d0b4915a11b606411e5"

changed /files/.claude-plugin~1plugin.json/size

BEFORE
533
AFTER
544

removed /files/.codex-plugin~1assets~1composer-icon.svg

BEFORE
{
  "sha256": "5eca0f76b4da51091c0bf7aa16b43d1bf05ccf82df8fcc5ed4d35fe1967bcc79",
  "size": 5127
}
AFTER
Field is absent

removed /files/.codex-plugin~1assets~1logo.svg

BEFORE
{
  "sha256": "5eca0f76b4da51091c0bf7aa16b43d1bf05ccf82df8fcc5ed4d35fe1967bcc79",
  "size": 5127
}
AFTER
Field is absent

changed /files/.codex-plugin~1plugin.json/sha256

BEFORE
"fcf33fe56fdf5634f299812115c4a8fcf0101e231c173095cd73c2cc2fed7315"
AFTER
"a1df8dfe6e91d4b59a161aaec0cc6ffd5102863d380c5198425e4acbde681655"

changed /files/.codex-plugin~1plugin.json/size

BEFORE
1739
AFTER
1688

changed /files/.cursor-plugin~1plugin.json/sha256

BEFORE
"20f54a80eabfcc4353f567691a54f62b5635fda657891edeeeeb751f80351c9c"
AFTER
"2744004147112bf197e5c11868676854f41938f68cdfc2c01f5e63ab04575243"

changed /files/.cursor-plugin~1plugin.json/size

BEFORE
749
AFTER
750

changed /files/.github~1plugin~1plugin.json/sha256

BEFORE
"73e0a97d93e1bea8d1960973d1be044e4fe6c8a529d0b9c3397ba86caeacad3b"
AFTER
"9d5b04268440796ceade88d7587e8d973cf82ac8ab9932758d255dfe55a90f67"

changed /files/.github~1plugin~1plugin.json/size

BEFORE
625
AFTER
634

changed /files/assets~1dataverse-logo.svg/sha256

BEFORE
"5eca0f76b4da51091c0bf7aa16b43d1bf05ccf82df8fcc5ed4d35fe1967bcc79"
AFTER
"df71c0111148d116515a02c5cb07483caad2bcb779664fa16fd837a4709504ab"

changed /files/assets~1dataverse-logo.svg/size

BEFORE
5127
AFTER
5067

added /files/gemini-extension.json

BEFORE
Field was absent
AFTER
{
  "sha256": "94bf4a841d8bcd2761b63944ce2fa941d6cc4a123a5c02433425399bdc4252fe",
  "size": 843
}

added /files/plugin.json

BEFORE
Field was absent
AFTER
{
  "sha256": "23135b1c01f00c3edf364dffe6f927d2c6207ec22a27d0b0aa69b66b89ced875",
  "size": 350
}

removed /files/scripts~1__pycache__~1auth.cpython-314.pyc

BEFORE
{
  "sha256": "9c52b0410a6e850b4104ade4b9b2597f109674beb9bb19872c77e181992d8a67",
  "size": 57132
}
AFTER
Field is absent

removed /files/scripts~1__pycache__~1enable-mcp-client.cpython-314.pyc

BEFORE
{
  "sha256": "1ef77901b33cc7b450374952d0152cd247f299f6048e09f51bebf4ca8d86a911",
  "size": 2839
}
AFTER
Field is absent

removed /files/scripts~1__pycache__~1mcp_remote.cpython-314.pyc

BEFORE
{
  "sha256": "e364e0cbc46a938533441df78d198c874e41fe1ab075a0e35e35a76b61469296",
  "size": 12914
}
AFTER
Field is absent

removed /files/scripts~1__pycache__~1test_auth.cpython-314.pyc

BEFORE
{
  "sha256": "6cc979f4ff4e18dd80d192ee18c730ed920650f40e843db6de06464b81856ef9",
  "size": 26593
}
AFTER
Field is absent

changed /files/scripts~1auth.py/sha256

BEFORE
"6d85745167f80bd5dc2c86cb33e954dd52ff6a1fe9b9a982a50337556851fd11"
AFTER
"338d1d3a8f9877d945f0612f0206e3a0e8d7a417ffde97173bae0bbdad7858fb"

changed /files/scripts~1auth.py/size

BEFORE
50960
AFTER
54392

changed /files/scripts~1enable-mcp-client.py/sha256

BEFORE
"f294c5747b6bc89c3bb8dd30f7205c95c2666580779c065070bb9c6ab3fbf67b"
AFTER
"bf832bead4619c958b3b5ff286ae9b8e9bb3a17addc0f0645e154948a8ab3202"

changed /files/scripts~1enable-mcp-client.py/size

BEFORE
2089
AFTER
2027

changed /files/skills~1dv-admin~1SKILL.md/sha256

BEFORE
"ed1e393c51aac454f6394e2a88936192e813335246dbf0b66e9c77a3a5276a55"
AFTER
"77df321dd64521a864e5c4950510974d248bdaa5717d1cee62470af56e72685c"

changed /files/skills~1dv-admin~1SKILL.md/size

BEFORE
20586
AFTER
20373

changed /files/skills~1dv-admin~1references~1erp-batch.md/sha256

BEFORE
"ab11d9c5f426464a8a9daa76dbe5ff662bf796ee0817415aba9069d4da207159"
AFTER
"6261d03bde0ba2705e8118d7b35f8a3ffca8a5949829671558cc190aa701e7d7"

changed /files/skills~1dv-admin~1references~1erp-batch.md/size

BEFORE
6120
AFTER
7032

changed /files/skills~1dv-admin~1references~1orgdb-settings.md/sha256

BEFORE
"cc9039161371e236cecbb065c9ff3f6c65264598f9d9a907db84589e844eb525"
AFTER
"e9b8d2bdd31b7732b429ef909732eb29eef50fca0466fe392bcb66088b45ad0b"

changed /files/skills~1dv-admin~1references~1orgdb-settings.md/size

BEFORE
5276
AFTER
5180

changed /files/skills~1dv-admin~1references~1recycle-bin.md/sha256

BEFORE
"3b32aaa49711227fbe1f8485809afffa85471d0f806877874f9a17ed9f459596"
AFTER
"cc81bc00088fd038808a7c5afb8ef107b0737692b9cbcd4e54bc2aa806883376"

changed /files/skills~1dv-admin~1references~1recycle-bin.md/size

BEFORE
9361
AFTER
9216

changed /files/skills~1dv-admin~1references~1settings-overrides.md/sha256

BEFORE
"2f6bc0dfbdd5eeba77fd9b35a6807882130d68b9140077925118a3acde1444e1"
AFTER
"99dc767be0e68b5c252d8af2c243d79a333b34a45925f35cb1fe58252ea13b6c"

changed /files/skills~1dv-admin~1references~1settings-overrides.md/size

BEFORE
3199
AFTER
3133

changed /files/skills~1dv-connect~1SKILL.md/sha256

BEFORE
"66b2aac1dd9851db68663b82c19d2b4ef04a2ac0c2b61f38e94e7288cd5fab0e"
AFTER
"8cef7f5becce9e57e91601122dba6309eab8f395cc848f4c7210fdd618320fe6"

changed /files/skills~1dv-connect~1SKILL.md/size

BEFORE
20605
AFTER
20151

changed /files/skills~1dv-connect~1references~1erp-detection.md/sha256

BEFORE
"4e331cdc01fc49dea8b7dba05e6e42b078f235cd21de4e9bd999699286509f3f"
AFTER
"3fc083f69346c5286bdf1db20f6fa0690f01cac88bf9390310b791478df4648a"

changed /files/skills~1dv-connect~1references~1erp-detection.md/size

BEFORE
1480
AFTER
1581

changed /files/skills~1dv-connect~1references~1headless-hosts.md/sha256

BEFORE
"5f31867321f86dd61219d188d1c367bfce574ad88c0221329f7c3d106d87ba4d"
AFTER
"a47004bc83112e8ef7edca39088517fd063b0baddb56244ed1fe3119cea374e5"

changed /files/skills~1dv-connect~1references~1headless-hosts.md/size

BEFORE
17525
AFTER
17272

added /files/skills~1dv-connect~1references~1helper-scripts.md

BEFORE
Field was absent
AFTER
{
  "sha256": "ff6a129d8e133675c65bae78b291e75c2a032e31781836e7a7ee3b415a892903",
  "size": 1378
}

changed /files/skills~1dv-connect~1references~1mcp-configuration.md/sha256

BEFORE
"e6455f037882407b5d23c7a167c544de4d130d12fcb6c49c744ccdb6dc49a59b"
AFTER
"4e19a787b7b70a9a822a2a5057e9602905c11e05b4997d04a404d15d155e0451"

changed /files/skills~1dv-connect~1references~1mcp-configuration.md/size

BEFORE
29925
AFTER
35226

changed /files/skills~1dv-connect~1references~1tools-setup.md/sha256

BEFORE
"89ea95de02b4f26ca2d92f16be1d63cd868858b0e0b829e627c859bb44882627"
AFTER
"c8c66a5573731db56fb4ec0cf778ffdca3628b5ef73b0f56995f12e634b72aed"

changed /files/skills~1dv-connect~1references~1tools-setup.md/size

BEFORE
12225
AFTER
12468

changed /files/skills~1dv-data~1SKILL.md/sha256

BEFORE
"0fe26813b039263468010e8f486e086d2233a0d2252a1011b662a89c66fa595e"
AFTER
"c5c59e46c1648520c634f9c0f84b9f4503c36f852cb81437b977081b8efe7337"

changed /files/skills~1dv-data~1SKILL.md/size

BEFORE
19497
AFTER
19495

changed /files/skills~1dv-data~1references~1erp-writes.md/sha256

BEFORE
"4603db5c0415a14355af3a16b4adddd9bc85610160aff485f48e72fb9e21583e"
AFTER
"2dc9436568a22c5298fc4c7179e0f1eb8e1b7f544aac00c4cbce8cc554a20340"

changed /files/skills~1dv-data~1references~1erp-writes.md/size

BEFORE
2356
AFTER
3013

changed /files/skills~1dv-data~1references~1multi-table-fk-import.md/sha256

BEFORE
"2ffbe78c453868d686e98b35d9dbc7a170cdcf04286b940524ece6f0d96ad18d"
AFTER
"f0ed0ad62c7ec92c0eb3d267279f33d94c7cb6573e6e344bd896987cdc9fe160"

changed /files/skills~1dv-data~1references~1multi-table-fk-import.md/size

BEFORE
11259
AFTER
11047

changed /files/skills~1dv-data~1references~1sample-data-generation.md/sha256

BEFORE
"d066313d84a00681ee9960587a925b884048f68b8c67a2015e291f60eca8ef6e"
AFTER
"508c1ef32daf740a297f1f0f767a72f9d895e847bc6181d6cc599393348f7c72"

changed /files/skills~1dv-data~1references~1sample-data-generation.md/size

BEFORE
6901
AFTER
6769

changed /files/skills~1dv-metadata~1SKILL.md/sha256

BEFORE
"cfd7d87efde7ed8b05199470bc1704a9320bcad78b80aaeac630bbcdb2963a3a"
AFTER
"8ae59235772c1de3db53f7d8628ced486822166b6fb6ede146167017e916cfd8"

changed /files/skills~1dv-metadata~1SKILL.md/size

BEFORE
22156
AFTER
21708

changed /files/skills~1dv-metadata~1references~1alternate-keys.md/sha256

BEFORE
"4dc441b6045711f07ee2e7f4b0255b070d9616eb25e43ef5cdb8f7d8236606ea"
AFTER
"57d75957fffe4cc1a8ef0c207d3eff221a4272d2b0e6d110d9b1ca6c7b3a4599"

changed /files/skills~1dv-metadata~1references~1alternate-keys.md/size

BEFORE
4163
AFTER
4086

changed /files/skills~1dv-metadata~1references~1forms-and-views.md/sha256

BEFORE
"bd1f6f8e4079f96d4e446f60752d95492dad8b45b30e816e027798cccc09c462"
AFTER
"0a4e53748dd6fa57e2f12c1e3f546b0dcb8420a5414cab52751b8f3d7a6f125e"

changed /files/skills~1dv-metadata~1references~1forms-and-views.md/size

BEFORE
9387
AFTER
9192

changed /files/skills~1dv-metadata~1references~1metadata-propagation.md/sha256

BEFORE
"6480224b15a192152e6437d80f6e26d942f7ad1d4093ca498dc3f847f096108f"
AFTER
"325cc285f47e44515f39c0500aee0454f493f41fb44746631148b189273ec5d4"

changed /files/skills~1dv-metadata~1references~1metadata-propagation.md/size

BEFORE
2322
AFTER
2279

changed /files/skills~1dv-overview~1SKILL.md/sha256

BEFORE
"702103f7eee1154037cb1d9bd0e71da689c1a503bbd1c57d41dc991bf6878bc5"
AFTER
"6c4f76edcbe2b69db56a33c64aa612ed2ca4b849c530f4c07683666a7ff93838"

changed /files/skills~1dv-overview~1SKILL.md/size

BEFORE
21413
AFTER
21491

changed /files/skills~1dv-overview~1references~1erp-target.md/sha256

BEFORE
"eb51c682c4e310d1dce51930f5a65d794d4cd29077ef5573066b11bdb487f994"
AFTER
"83065699935c03dccea3fb63e79db7224eb2216384adb58b813f33a2c389fbf5"

changed /files/skills~1dv-overview~1references~1erp-target.md/size

BEFORE
7215
AFTER
9668

changed /files/skills~1dv-overview~1references~1windows-scripting.md/sha256

BEFORE
"b32267f44c88e6be0d61798fe94c82d423c249ab1bb18f3abc7c3889ff0f27b1"
AFTER
"0c00f89adcbcfb4013d8eca09daa80182b71a093588a9ce5d96c45e9a6c07904"

changed /files/skills~1dv-overview~1references~1windows-scripting.md/size

BEFORE
1276
AFTER
1267

changed /files/skills~1dv-query~1SKILL.md/sha256

BEFORE
"4aed7ed14c67dab4269061639eeec1d1421fb065dec427cfc0bed03638e3a24e"
AFTER
"c5193acb192537638c2f3fd367ed505c104d5ac538c3ac5ef1f21e82fdcb23fb"

changed /files/skills~1dv-query~1SKILL.md/size

BEFORE
18242
AFTER
18097

changed /files/skills~1dv-query~1references~1erp-reads.md/sha256

BEFORE
"c21646931461f45773a81b2d8d165b0a01de25a0b7e9255b68a0098ce49461bf"
AFTER
"f0e24cdebb507e20b7dfd6bfd8629ef1f8171f1266b0c3d4df0f5ec38c60caa3"

changed /files/skills~1dv-query~1references~1erp-reads.md/size

BEFORE
2829
AFTER
4192

changed /files/skills~1dv-query~1references~1jupyter-setup.md/sha256

BEFORE
"0b48389607a72e1699bab0453fa0a56651d1bea4d0c0183a80d44dca72f995e0"
AFTER
"e68e02caac2936a5942bb797e24f3cba19e09ef4d70157decb8fc6ed51be8c54"

changed /files/skills~1dv-query~1references~1jupyter-setup.md/size

BEFORE
1125
AFTER
1968

changed /files/skills~1dv-query~1references~1querybuilder.md/sha256

BEFORE
"38025a0f0356a8d3034bc40d0ac8e26a684abda45a8de3315324f7f051e75c14"
AFTER
"1a92ddb4ac818dfa10494137d8443e47a43bcd09af8bfbf0020e8ffcdbb0f45f"

changed /files/skills~1dv-query~1references~1querybuilder.md/size

BEFORE
5155
AFTER
5053

changed /files/skills~1dv-query~1references~1web-api-advanced.md/sha256

BEFORE
"8b5c1f81a29e632216bfeedc5f5eb2ac5d5c20b0791626d1d4d2f7289b78933a"
AFTER
"c56495572f1c06e33f5f38952e4ea42964f8d32d9fff18621dcfded26fde568e"

changed /files/skills~1dv-query~1references~1web-api-advanced.md/size

BEFORE
6392
AFTER
6290

changed /files/skills~1dv-security~1SKILL.md/sha256

BEFORE
"5a26142850d2821e40c64e590f6ecb5c9378677de742242e2ae3e396231d60a2"
AFTER
"f40e1d1e9429fcad38e020c8de188a502208e7d34e92725576ad9b44d548d09d"

changed /files/skills~1dv-security~1SKILL.md/size

BEFORE
8593
AFTER
8444

changed /files/skills~1dv-solution~1SKILL.md/sha256

BEFORE
"cec6555f1d36f465476f66a38605b36c5a6319468189666ed63d33d58826c326"
AFTER
"b847302a0b8cef5a2bf40f8a109e1b0fe90c46eec0428b2c3e0130eaf47f21ca"

changed /files/skills~1dv-solution~1SKILL.md/size

BEFORE
12519
AFTER
12187

added /files/skills~1erp-xpp~1SKILL.md

BEFORE
Field was absent
AFTER
{
  "sha256": "ed0c6ee0f3dc825671953a7ddee895655fa9ac5fdf091723b887971a51e9b711",
  "size": 15223
}

added /files/skills~1erp-xpp~1references~1authoring.md

BEFORE
Field was absent
AFTER
{
  "sha256": "a3feeb190766aad69ef7f8cebb45d8181ba3ca53cc5f2c6ad6ef59ecbc1135d6",
  "size": 16807
}

added /files/skills~1erp-xpp~1references~1commands.md

BEFORE
Field was absent
AFTER
{
  "sha256": "6daf1640d88fbfe0f03d29b142648b6e90eef9a9274e51a8b13ce757e5b1b54d",
  "size": 5321
}

added /files/skills~1erp-xpp~1references~1validation.md

BEFORE
Field was absent
AFTER
{
  "sha256": "3b059247f1b5fccd8ba259a81376a07f1e89c3cef6ca1a895868966028b70ec0",
  "size": 15876
}

added /files/skills~1erp-xpp~1references~1workflows.md

BEFORE
Field was absent
AFTER
{
  "sha256": "486989fc3f059ef93131f533c243301a51f25051f82cb6170134a9cb0be80e20",
  "size": 8955
}

changed /files/templates~1CLAUDE.md/sha256

BEFORE
"765cba0f802d6f3f55d5dac3d2ac34621843b2de1cd25c578545471af6334bd1"
AFTER
"4904f011d33634d81504dd80e222726d744eaafcedff3a79f000eb4138705e11"

changed /files/templates~1CLAUDE.md/size

BEFORE
2492
AFTER
2433

changed /files/templates~1deploy.yml/sha256

BEFORE
"45cc9bf9254dddb0366a7fef438b7618a5fad157352fb2ac351de75b166c1f7d"
AFTER
"40893882b55c8f43a3afd753f609d78daf0e3a03ef6634a7c7374cf25c3cab01"

changed /files/templates~1deploy.yml/size

BEFORE
1113
AFTER
1078
Full snapshot data
{
  "files": {
    ".claude-plugin/plugin.json": {
      "sha256": "5677ada5e3f5f6af333915498383bfeb0c7afbd40bcd1d0b4915a11b606411e5",
      "size": 544
    },
    ".codex-plugin/plugin.json": {
      "sha256": "a1df8dfe6e91d4b59a161aaec0cc6ffd5102863d380c5198425e4acbde681655",
      "size": 1688
    },
    ".cursor-plugin/plugin.json": {
      "sha256": "2744004147112bf197e5c11868676854f41938f68cdfc2c01f5e63ab04575243",
      "size": 750
    },
    ".github/plugin/plugin.json": {
      "sha256": "9d5b04268440796ceade88d7587e8d973cf82ac8ab9932758d255dfe55a90f67",
      "size": 634
    },
    "assets/dataverse-logo-256.png": {
      "sha256": "2b5d4aea7419a84a432b7282bdcd53173141dc4957665d95de587b924d2773f7",
      "size": 52720
    },
    "assets/dataverse-logo-48.png": {
      "sha256": "57cba12bc75cb249794806f42966a5807ce3cb546ebd4a8083aa149ebd8a907d",
      "size": 3806
    },
    "assets/dataverse-logo.svg": {
      "sha256": "df71c0111148d116515a02c5cb07483caad2bcb779664fa16fd837a4709504ab",
      "size": 5067
    },
    "gemini-extension.json": {
      "sha256": "94bf4a841d8bcd2761b63944ce2fa941d6cc4a123a5c02433425399bdc4252fe",
      "size": 843
    },
    "plugin.json": {
      "sha256": "23135b1c01f00c3edf364dffe6f927d2c6207ec22a27d0b0aa69b66b89ced875",
      "size": 350
    },
    "scripts/auth.py": {
      "sha256": "338d1d3a8f9877d945f0612f0206e3a0e8d7a417ffde97173bae0bbdad7858fb",
      "size": 54392
    },
    "scripts/enable-mcp-client.py": {
      "sha256": "bf832bead4619c958b3b5ff286ae9b8e9bb3a17addc0f0645e154948a8ab3202",
      "size": 2027
    },
    "skills/dv-admin/SKILL.md": {
      "sha256": "77df321dd64521a864e5c4950510974d248bdaa5717d1cee62470af56e72685c",
      "size": 20373
    },
    "skills/dv-admin/references/erp-batch.md": {
      "sha256": "6261d03bde0ba2705e8118d7b35f8a3ffca8a5949829671558cc190aa701e7d7",
      "size": 7032
    },
    "skills/dv-admin/references/orgdb-settings.md": {
      "sha256": "e9b8d2bdd31b7732b429ef909732eb29eef50fca0466fe392bcb66088b45ad0b",
      "size": 5180
    },
    "skills/dv-admin/references/recycle-bin.md": {
      "sha256": "cc81bc00088fd038808a7c5afb8ef107b0737692b9cbcd4e54bc2aa806883376",
      "size": 9216
    },
    "skills/dv-admin/references/settings-overrides.md": {
      "sha256": "99dc767be0e68b5c252d8af2c243d79a333b34a45925f35cb1fe58252ea13b6c",
      "size": 3133
    },
    "skills/dv-connect/SKILL.md": {
      "sha256": "8cef7f5becce9e57e91601122dba6309eab8f395cc848f4c7210fdd618320fe6",
      "size": 20151
    },
    "skills/dv-connect/references/erp-detection.md": {
      "sha256": "3fc083f69346c5286bdf1db20f6fa0690f01cac88bf9390310b791478df4648a",
      "size": 1581
    },
    "skills/dv-connect/references/headless-hosts.md": {
      "sha256": "a47004bc83112e8ef7edca39088517fd063b0baddb56244ed1fe3119cea374e5",
      "size": 17272
    },
    "skills/dv-connect/references/helper-scripts.md": {
      "sha256": "ff6a129d8e133675c65bae78b291e75c2a032e31781836e7a7ee3b415a892903",
      "size": 1378
    },
    "skills/dv-connect/references/mcp-configuration.md": {
      "sha256": "4e19a787b7b70a9a822a2a5057e9602905c11e05b4997d04a404d15d155e0451",
      "size": 35226
    },
    "skills/dv-connect/references/tools-setup.md": {
      "sha256": "c8c66a5573731db56fb4ec0cf778ffdca3628b5ef73b0f56995f12e634b72aed",
      "size": 12468
    },
    "skills/dv-data/SKILL.md": {
      "sha256": "c5c59e46c1648520c634f9c0f84b9f4503c36f852cb81437b977081b8efe7337",
      "size": 19495
    },
    "skills/dv-data/references/erp-writes.md": {
      "sha256": "2dc9436568a22c5298fc4c7179e0f1eb8e1b7f544aac00c4cbce8cc554a20340",
      "size": 3013
    },
    "skills/dv-data/references/multi-table-fk-import.md": {
      "sha256": "f0ed0ad62c7ec92c0eb3d267279f33d94c7cb6573e6e344bd896987cdc9fe160",
      "size": 11047
    },
    "skills/dv-data/references/sample-data-generation.md": {
      "sha256": "508c1ef32daf740a297f1f0f767a72f9d895e847bc6181d6cc599393348f7c72",
      "size": 6769
    },
    "skills/dv-metadata/SKILL.md": {
      "sha256": "8ae59235772c1de3db53f7d8628ced486822166b6fb6ede146167017e916cfd8",
      "size": 21708
    },
    "skills/dv-metadata/references/alternate-keys.md": {
      "sha256": "57d75957fffe4cc1a8ef0c207d3eff221a4272d2b0e6d110d9b1ca6c7b3a4599",
      "size": 4086
    },
    "skills/dv-metadata/references/forms-and-views.md": {
      "sha256": "0a4e53748dd6fa57e2f12c1e3f546b0dcb8420a5414cab52751b8f3d7a6f125e",
      "size": 9192
    },
    "skills/dv-metadata/references/metadata-propagation.md": {
      "sha256": "325cc285f47e44515f39c0500aee0454f493f41fb44746631148b189273ec5d4",
      "size": 2279
    },
    "skills/dv-overview/SKILL.md": {
      "sha256": "6c4f76edcbe2b69db56a33c64aa612ed2ca4b849c530f4c07683666a7ff93838",
      "size": 21491
    },
    "skills/dv-overview/references/erp-target.md": {
      "sha256": "83065699935c03dccea3fb63e79db7224eb2216384adb58b813f33a2c389fbf5",
      "size": 9668
    },
    "skills/dv-overview/references/windows-scripting.md": {
      "sha256": "0c00f89adcbcfb4013d8eca09daa80182b71a093588a9ce5d96c45e9a6c07904",
      "size": 1267
    },
    "skills/dv-query/SKILL.md": {
      "sha256": "c5193acb192537638c2f3fd367ed505c104d5ac538c3ac5ef1f21e82fdcb23fb",
      "size": 18097
    },
    "skills/dv-query/references/erp-reads.md": {
      "sha256": "f0e24cdebb507e20b7dfd6bfd8629ef1f8171f1266b0c3d4df0f5ec38c60caa3",
      "size": 4192
    },
    "skills/dv-query/references/jupyter-setup.md": {
      "sha256": "e68e02caac2936a5942bb797e24f3cba19e09ef4d70157decb8fc6ed51be8c54",
      "size": 1968
    },
    "skills/dv-query/references/querybuilder.md": {
      "sha256": "1a92ddb4ac818dfa10494137d8443e47a43bcd09af8bfbf0020e8ffcdbb0f45f",
      "size": 5053
    },
    "skills/dv-query/references/web-api-advanced.md": {
      "sha256": "c56495572f1c06e33f5f38952e4ea42964f8d32d9fff18621dcfded26fde568e",
      "size": 6290
    },
    "skills/dv-security/SKILL.md": {
      "sha256": "f40e1d1e9429fcad38e020c8de188a502208e7d34e92725576ad9b44d548d09d",
      "size": 8444
    },
    "skills/dv-solution/SKILL.md": {
      "sha256": "b847302a0b8cef5a2bf40f8a109e1b0fe90c46eec0428b2c3e0130eaf47f21ca",
      "size": 12187
    },
    "skills/erp-xpp/SKILL.md": {
      "sha256": "ed0c6ee0f3dc825671953a7ddee895655fa9ac5fdf091723b887971a51e9b711",
      "size": 15223
    },
    "skills/erp-xpp/references/authoring.md": {
      "sha256": "a3feeb190766aad69ef7f8cebb45d8181ba3ca53cc5f2c6ad6ef59ecbc1135d6",
      "size": 16807
    },
    "skills/erp-xpp/references/commands.md": {
      "sha256": "6daf1640d88fbfe0f03d29b142648b6e90eef9a9274e51a8b13ce757e5b1b54d",
      "size": 5321
    },
    "skills/erp-xpp/references/validation.md": {
      "sha256": "3b059247f1b5fccd8ba259a81376a07f1e89c3cef6ca1a895868966028b70ec0",
      "size": 15876
    },
    "skills/erp-xpp/references/workflows.md": {
      "sha256": "486989fc3f059ef93131f533c243301a51f25051f82cb6170134a9cb0be80e20",
      "size": 8955
    },
    "templates/CLAUDE.md": {
      "sha256": "4904f011d33634d81504dd80e222726d744eaafcedff3a79f000eb4138705e11",
      "size": 2433
    },
    "templates/deploy.yml": {
      "sha256": "40893882b55c8f43a3afd753f609d78daf0e3a03ef6634a7c7374cf25c3cab01",
      "size": 1078
    }
  }
}

SHA-256 of public snapshot: 58c492ead8697609cee56bbf657fc84de5d1044469cad20cb9af8e740281e814