diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..9d74a16e
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,28 @@
+# .env — generated by ./scripts/setup
+#
+# This file is gitignored. Do not commit a real credentials file.
+# Run `./scripts/setup` and pick the auth-credentials step to create
+# the live `.env`. Re-running is safe and idempotent.
+
+# ---- common ----
+# One of: developer_token, jwt, ccg, oauth
+BOX_AUTH_MODE=developer_token
+
+# ---- developer_token ----
+# Mint at https://app.box.com/developers/console — expires after 60 min.
+BOX_DEVELOPER_TOKEN=YOUR_DEVELOPER_TOKEN_HERE
+
+# ---- jwt ----
+# Absolute path to the config.json downloaded from the developer console.
+# BOX_JWT_CONFIG_PATH=/absolute/path/to/box-jwt-config.json
+
+# ---- ccg ----
+# BOX_CCG_CLIENT_ID=YOUR_CLIENT_ID
+# BOX_CCG_CLIENT_SECRET=YOUR_CLIENT_SECRET
+# BOX_CCG_SUBJECT_KIND=enterprise
+# BOX_CCG_SUBJECT_ID=YOUR_ENTERPRISE_OR_USER_ID
+
+# ---- oauth ----
+# BOX_OAUTH_CLIENT_ID=YOUR_CLIENT_ID
+# BOX_OAUTH_CLIENT_SECRET=YOUR_CLIENT_SECRET
+# BOX_OAUTH_REDIRECT_URI=http://localhost:8080/callback
diff --git a/.gitignore b/.gitignore
index 6769e21d..2176848c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -121,6 +121,10 @@ celerybeat.pid
# Environments
.env
+!.env.example
+
+# setup wizard outputs
+box-jwt-config.json
.venv
env/
venv/
diff --git a/WIZARD_FLOWS.md b/WIZARD_FLOWS.md
new file mode 100644
index 00000000..fda2d306
--- /dev/null
+++ b/WIZARD_FLOWS.md
@@ -0,0 +1,191 @@
+# Box Python SDK (v10) — Setup Wizard Quick Reference
+
+A visual reference for the optional setup wizard added by the
+`./scripts/setup` script. The wizard is **opt-in** — the SDK works
+fine without it; this just streamlines first-time onboarding.
+
+---
+
+## At a glance
+
+| Command | What it does |
+| -------------------------------------- | ------------------------------------------------------------------------- |
+| `./scripts/setup` | First-run + repair wizard. Picker-driven, idempotent. |
+| `./scripts/doctor` | Read-only audit. Re-runs each step's check function and prints results. |
+| `./scripts/setup` then `recommended` | Verify Python + pip + SDK importable, capture auth credentials, print a smoke snippet. |
+| `WIZARD_PACE_MS=0 ./scripts/setup` | Disable pacing for CI / scripted onboarding. |
+| `WIZARD_PACE_MS=400 ./scripts/setup` | Slow pacing for first-time users who want extra read time. |
+
+The wizard never modifies SDK source files. It writes one file at the
+repo root: **`.env`** (gitignored). The Python package is named
+`dev_setup/` (not `setup/`) to avoid colliding with `setup.py` and
+`setuptools.find_packages()`.
+
+---
+
+## Decision tree
+
+```mermaid
+flowchart TD
+ Start([./scripts/setup]) --> Banner[Print banner +
3-step picker]
+ Banner --> Picker{Picker}
+ Picker -->|recommended
or `all`| AllSet[Run steps 1, 2, 3]
+ Picker -->|1| OnlyPre[Step 1 only]
+ Picker -->|2| OnlyAuth[Step 2 only]
+ Picker -->|3| OnlySmoke[Step 3 only]
+ Picker -->|none| Exit([Exit cleanly])
+
+ AllSet --> S1[Step 1
Python + dependencies preflight]
+ OnlyPre --> S1
+ S1 --> PythonCheck{Python >= 3.8
and pip works?}
+ PythonCheck -->|no| FailHint[Print install hint, abort step]
+ PythonCheck -->|yes| ImportCheck{box_sdk_gen
importable?}
+ ImportCheck -->|yes| S2[Step 2
Auth credentials]
+ ImportCheck -->|no| OfferInstall{Run pip install -e .[test,dev]?}
+ OfferInstall -->|yes| PipInstall[pip install with progress watchdog]
+ OfferInstall -->|no| S2
+ PipInstall --> S2
+
+ OnlyAuth --> S2
+ S2 --> ModePicker{Auth mode?}
+ ModePicker -->|1| DevToken[Mint Developer Token
at app.box.com/developers/console
capture via getpass]
+ ModePicker -->|2| JWT[Capture JWT config.json
path]
+ ModePicker -->|3| CCG[Capture client ID/secret/
EID-or-UID]
+ ModePicker -->|4| OAuth[Capture client ID/secret/
redirect URI]
+
+ DevToken --> WriteEnv[Write .env]
+ JWT --> WriteEnv
+ CCG --> WriteEnv
+ OAuth --> WriteEnv
+
+ WriteEnv --> S3[Step 3
Smoke test snippet]
+ OnlySmoke --> S3
+ S3 --> SnippetCheck{.env exists?}
+ SnippetCheck -->|yes| PrintSnippet[Print Python snippet
keyed to chosen mode]
+ SnippetCheck -->|no| HintGoBack[Hint: pick step 2 first]
+
+ PrintSnippet --> Footer[Print completion footer +
doctor + docs links]
+ HintGoBack --> Footer
+ Footer --> Done([User runs ./scripts/doctor
to verify])
+```
+
+---
+
+## Steps
+
+| # | Step | Recommended | Check | Repair | Writes |
+| - | --------------------------------- | ----------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------- | ---------------------------- |
+| 1 | Python + dependencies preflight | yes | `python >= 3.8`, `python -m pip --version`, `import box_sdk_gen` | offer `pip install -e .[test,dev]` (progress watchdog while it runs) | nothing (modifies site-packages via pip) |
+| 2 | Auth credentials | yes | `.env` exists with `BOX_AUTH_MODE` set | inner picker for Developer Token / JWT / CCG / OAuth, then capture creds | `.env` |
+| 3 | Smoke test snippet | yes | (none — printing-only step) | print a copy-pasteable Python program keyed to the chosen auth mode | nothing |
+
+---
+
+## Picker grammar
+
+| Input | Means |
+| ------------- | ------------------------------------------------ |
+| `1,2,3` | comma-separated indices |
+| `1-3` | a range (inclusive) |
+| `all` | every step |
+| `recommended` | every `[R]` step (the default if you press Enter) |
+| `none` | exit without running anything |
+
+---
+
+## Auth modes
+
+| Mode | When to pick | What the wizard collects | Where it lands in `.env` |
+| ----------------------- | ------------------------------------------- | --------------------------------------------------------- | ----------------------------------------- |
+| Developer Token | Local dev, testing on your own account | One token (60-min TTL) | `BOX_DEVELOPER_TOKEN` |
+| JWT | Server-to-server, no user impersonation | Path to JWT config JSON | `BOX_JWT_CONFIG_PATH` |
+| Client Credentials (CCG) | Server-to-server, modern flow | Client ID, secret, enterprise OR user ID | `BOX_CCG_*` |
+| OAuth 2.0 | Multi-user, end-user-facing app | Client ID, secret, redirect URI | `BOX_OAUTH_*` |
+
+The wizard always sets `BOX_AUTH_MODE` to one of `developer_token`,
+`jwt`, `ccg`, or `oauth`. The doctor reads it to decide which
+mode-specific check function should run.
+
+---
+
+## Cross-cutting features
+
+### Pacing
+Output is paced so multi-paragraph blocks become readable. Override
+with `WIZARD_PACE_MS=` (`0` disables, `80` is default, `400` is a
+slow ramp). Pacing auto-disables when stdout isn't a TTY.
+
+### Self-contained credential capture
+The wizard reads `.env` itself when needed and never asks you to
+"first source X" or "first export Y". Cloning the repo and running
+`./scripts/setup` is the only prerequisite.
+
+### Doctor-symmetric checks
+Every wizard step has a check function in `dev_setup/checks.py` that
+the doctor calls. Re-running `./scripts/doctor` after a wizard run
+prints all results in one shot, no prompts, exit code 0 when
+everything's OK.
+
+### Hidden secret entry
+Tokens, client secrets, etc. are read with `getpass.getpass` so they
+never echo to the terminal or land in shell history.
+
+### Owner-only secrets file
+`.env` is chmod 0600 on POSIX systems. Best-effort on Windows /
+unusual filesystems.
+
+### Progress watchdog
+Long-running subprocesses (>5s) print `current action: `
+so they don't look hung. Step 1's `pip install` run uses this; you'll
+see something like `current action: installing SDK in editable mode
+(pip)` after a few seconds, then `→ done in 47s` when complete.
+
+---
+
+## Common recovery paths
+
+| Symptom | What to try |
+| ---------------------------------------------------- | --------------------------------------------------------------------------------- |
+| `[FAIL] python: ... < 3.8` | Install Python 3.8+ from python.org, then re-run from a shell using that interpreter. |
+| `[FAIL] pip: ...` | `python3 -m ensurepip --upgrade`, or reinstall Python. |
+| `[WARN] box_sdk_gen: not importable` | `./scripts/setup` and accept the pip-install prompt. |
+| `[WARN] box-env: not found` | `./scripts/setup` and pick step 2. |
+| `[FAIL] developer-token: empty` | Mint a fresh one at https://app.box.com/developers/console, re-run step 2. |
+| `[FAIL] jwt-config: not at ` | Verify the path; the developer console downloads as `config.json`. |
+| Picker rejected my input | Use commas, ranges, `all`, `recommended`, or `none`. No spaces inside numbers. |
+| Smoke snippet 401's when run | Token expired (Developer Tokens last 60 min). Re-run step 2 for a fresh one. |
+
+---
+
+## Verifying success
+
+```bash
+./scripts/doctor # Should report all OK.
+pytest # Run the SDK's own tests
+```
+
+For a real end-to-end check, paste the snippet from the wizard's step 3
+into a `.py` file with `box-sdk-gen` on `sys.path` (use
+`pip install -e .` from the repo root), run it, and confirm it prints
+your name.
+
+---
+
+## What this wizard does NOT do
+
+- **Run the OAuth callback flow**: OAuth needs an HTTP server to
+ receive the redirect, which is application-specific. The wizard
+ captures credentials; running the flow is on you.
+- **Refresh tokens**: Developer Tokens expire after 60 minutes. The
+ doctor reminds you of that but doesn't auto-refresh.
+- **Manage virtualenvs**: it uses whichever Python runs the wizard.
+ Activate your venv before running `./scripts/setup`.
+- **Manage multiple environments**: one repo = one `.env`. If you need
+ profiles, layer that on top.
+
+---
+
+Built using
+[`setup-wizard-squared`](https://github.com/NatalieNobile/setup-wizard-squared).
+See its `reference/patterns.md` for the seven design principles that
+shape this wizard.
diff --git a/dev_setup/__init__.py b/dev_setup/__init__.py
new file mode 100644
index 00000000..677c1fb8
--- /dev/null
+++ b/dev_setup/__init__.py
@@ -0,0 +1,6 @@
+"""``setup`` — first-run wizard + doctor scaffold (setup-wizard-squared).
+
+Customize ``wizard.py`` and ``checks.py`` per repo. The package layout
+is fixed so ``python3 -m setup.wizard`` and ``python3 -m setup.doctor``
+both resolve cleanly.
+"""
diff --git a/dev_setup/checks.py b/dev_setup/checks.py
new file mode 100644
index 00000000..ce2e3d84
--- /dev/null
+++ b/dev_setup/checks.py
@@ -0,0 +1,366 @@
+"""Health checks shared by the wizard and doctor.
+
+Every check returns one or more :class:`CheckResult`. The same functions
+are called from the wizard (per-step verification) and the doctor (full
+report) so validation logic lives in exactly one place. This is the
+"doctor-symmetric" principle from ``reference/patterns.md``.
+
+When porting:
+
+1. Replace the example checks at the bottom with your repo's checks.
+2. Add a category grouping if you have many checks (the bq_ranger
+ reference splits `core` vs `extras` so the doctor can group output).
+3. Each check that talks to a network service should accept an optional
+ ``timeout`` kwarg — default 10-15s. Long-hung probes break the
+ doctor's "fast read-only audit" contract.
+
+Conventions:
+ * ``fix_hint`` is required on every ``fail``. The hint must name a
+ concrete next action, not generic advice.
+ * Checks never prompt. If you need user input, that's a wizard step.
+ * Checks are pure read-only audits — they never write files or
+ mutate state.
+"""
+
+from __future__ import annotations
+
+import shutil
+import subprocess
+from collections.abc import Iterable
+from dataclasses import dataclass, field
+from typing import Literal
+
+Severity = Literal["ok", "warn", "fail"]
+
+
+@dataclass
+class CheckResult:
+ """One row in the doctor's report.
+
+ ``fix_hint`` is required on every ``fail`` (enforced by convention,
+ not the type system). The hint must name the next concrete action —
+ "re-run ``./scripts/setup``", not "fix your config".
+ """
+
+ name: str
+ severity: Severity
+ message: str
+ fix_hint: str | None = None
+
+ @property
+ def ok(self) -> bool:
+ return self.severity == "ok"
+
+
+@dataclass
+class DoctorReport:
+ """Aggregated checks plus optional structured evidence.
+
+ Add fields here when the wizard needs to consume *typed* values from
+ the doctor (e.g. the resolved current-user name, the active project
+ id) instead of just severity rows. Keep the field count small —
+ most wizards never need more than a half-dozen.
+ """
+
+ results: list[CheckResult] = field(default_factory=list)
+
+ @property
+ def all_ok(self) -> bool:
+ """True iff no check failed (warnings tolerated)."""
+ return all(r.severity != "fail" for r in self.results)
+
+ @property
+ def any_failures(self) -> bool:
+ return any(r.severity == "fail" for r in self.results)
+
+ def add(self, *results: CheckResult) -> None:
+ self.results.extend(results)
+
+
+# ---- generic helpers ----------------------------------------------------
+
+
+def _run(
+ args: list[str],
+ *,
+ timeout: int = 15,
+ input_text: str | None = None,
+ env: dict[str, str] | None = None,
+) -> tuple[int, str, str]:
+ """Run a subprocess and capture ``(returncode, stdout, stderr)``.
+
+ Returns ``(127, "", str(exc))`` if the binary is missing — same
+ convention as ``which`` so callers don't need to special-case
+ ``FileNotFoundError`` separately from non-zero exit codes. Returns
+ ``(124, "", "timed out after Ns")`` on timeout.
+
+ ``env`` lets callers layer dotenv-derived values onto a copy of
+ ``os.environ`` so checks against tools that read tokens from env
+ (jira-cli, gh, gcloud) work without forcing the user to pre-source.
+ """
+ try:
+ proc = subprocess.run(
+ args,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ input=input_text,
+ check=False,
+ env=env,
+ )
+ return proc.returncode, proc.stdout, proc.stderr
+ except FileNotFoundError as exc:
+ return 127, "", str(exc)
+ except subprocess.TimeoutExpired:
+ return 124, "", f"timed out after {timeout}s"
+
+
+def _on_path(name: str) -> bool:
+ """True if ``name`` resolves to an executable on the user's PATH."""
+ return shutil.which(name) is not None
+
+
+def is_ssl_trust_failure(message: str) -> bool:
+ """True if ``message`` looks like a TLS trust-store error.
+
+ SSL verification failures aren't credential / config / network
+ problems — re-prompting the user for tokens or rerunning the check
+ would waste their time. Branch on this so the wizard can route to
+ a "fix your trust store" remediation block instead of treating it
+ as a generic auth failure.
+
+ Patterns cover every flavor of "I couldn't verify the server cert"
+ surfaced by urllib, requests, curl, openssl on macOS / Linux. We
+ don't detect *valid* certs rejected for other reasons (expiry,
+ hostname mismatch) — those are real network problems and a generic
+ "check VPN" hint is correct.
+ """
+ return (
+ "CERTIFICATE_VERIFY_FAILED" in message
+ or "[SSL:" in message
+ or "SSL certificate problem" in message
+ or "unable to get local issuer certificate" in message
+ or "self-signed certificate" in message
+ )
+
+
+def has_ssl_trust_failure(results: Iterable["CheckResult"]) -> bool:
+ """True iff any non-OK result in ``results`` looks like an SSL trust error."""
+ return any(
+ r.severity != "ok" and is_ssl_trust_failure(r.message)
+ for r in results
+ )
+
+
+# ---- box-python-sdk-gen-specific checks --------------------------------
+
+
+import sys
+from pathlib import Path
+
+from . import config as cfg
+
+
+def check_python() -> list[CheckResult]:
+ """Python interpreter on PATH and meets the minimum version."""
+ cur = sys.version_info
+ detail = f"Python {cur.major}.{cur.minor}.{cur.micro}"
+ if (cur.major, cur.minor) < cfg.MIN_PYTHON:
+ need = ".".join(str(x) for x in cfg.MIN_PYTHON)
+ return [
+ CheckResult(
+ "python",
+ "fail",
+ f"{detail} found; SDK requires {need}+",
+ fix_hint=(
+ f"install Python {need}+ from https://www.python.org/downloads/, "
+ "then re-run from a shell using that interpreter."
+ ),
+ )
+ ]
+ return [CheckResult("python", "ok", detail)]
+
+
+def check_pip() -> list[CheckResult]:
+ """``pip`` is available via the active interpreter (``python -m pip``).
+
+ We check ``python -m pip`` rather than a bare ``pip`` binary because
+ pip is reliably bundled with the Python that's running this code,
+ while ``pip`` on PATH varies by install method (homebrew, pyenv,
+ system Python, virtualenv, etc.).
+ """
+ rc, out, err = _run([sys.executable, "-m", "pip", "--version"], timeout=10)
+ if rc != 0:
+ return [
+ CheckResult(
+ "pip",
+ "fail",
+ f"`{sys.executable} -m pip --version` exited {rc}: {(err or out).strip()[:200]}",
+ fix_hint=(
+ "ensure pip is installed: `python3 -m ensurepip --upgrade` "
+ "or reinstall Python from python.org."
+ ),
+ )
+ ]
+ return [CheckResult("pip", "ok", out.strip().splitlines()[0])]
+
+
+def check_sdk_importable() -> list[CheckResult]:
+ """``box_sdk_gen`` resolves on the current sys.path.
+
+ A WARN (not FAIL) when missing — the wizard's preflight step offers
+ to run ``pip install -e .[test,dev]`` to fix it. The user can also
+ skip and install manually.
+ """
+ try:
+ import box_sdk_gen # noqa: F401
+ except ImportError as exc:
+ return [
+ CheckResult(
+ "box_sdk_gen",
+ "warn",
+ f"not importable: {exc}",
+ fix_hint=(
+ "from the repo root, run `python3 -m pip install -e .[test,dev]` "
+ "(or pick the dependency-install option in `./scripts/setup`)."
+ ),
+ )
+ ]
+ return [
+ CheckResult(
+ "box_sdk_gen",
+ "ok",
+ f"importable from {Path(box_sdk_gen.__file__).parent}",
+ )
+ ]
+
+
+def check_box_env() -> list[CheckResult]:
+ """``.env`` exists at the repo root and declares an auth mode."""
+ if not cfg.BOX_ENV_FILE.exists():
+ return [
+ CheckResult(
+ "box-env",
+ "warn",
+ f"{cfg.BOX_ENV_FILE.name} not found",
+ fix_hint="run `./scripts/setup` and pick the auth-credentials step.",
+ )
+ ]
+ values = cfg.read_dotenv(cfg.BOX_ENV_FILE)
+ mode = values.get("BOX_AUTH_MODE", "").strip()
+ if not mode:
+ return [
+ CheckResult(
+ "box-env",
+ "warn",
+ f"{cfg.BOX_ENV_FILE.name} present but `BOX_AUTH_MODE` not set",
+ fix_hint="re-run `./scripts/setup` and pick the auth-credentials step.",
+ )
+ ]
+ if mode not in cfg.ALL_AUTH_MODES:
+ return [
+ CheckResult(
+ "box-env",
+ "fail",
+ f"unknown auth mode `{mode}` (expected one of {cfg.ALL_AUTH_MODES})",
+ fix_hint="re-run `./scripts/setup` to pick a supported mode.",
+ )
+ ]
+ return [
+ CheckResult(
+ "box-env",
+ "ok",
+ f"auth mode = {mode}",
+ )
+ ]
+
+
+def check_developer_token() -> list[CheckResult]:
+ """When mode=developer_token, the token is set in .env.
+
+ No-op for other modes (returns OK so the doctor stays quiet).
+ Developer tokens have a 60-min TTL — freshness, not just presence,
+ matters, but we can't detect expiry without a network call.
+ """
+ if not cfg.BOX_ENV_FILE.exists():
+ return [CheckResult("developer-token", "ok", "n/a (no .env yet)")]
+ values = cfg.read_dotenv(cfg.BOX_ENV_FILE)
+ if values.get("BOX_AUTH_MODE", "").strip() != cfg.AUTH_MODE_DEV_TOKEN:
+ return [CheckResult("developer-token", "ok", "n/a (other auth mode)")]
+ token = values.get("BOX_DEVELOPER_TOKEN", "").strip()
+ if not token:
+ return [
+ CheckResult(
+ "developer-token",
+ "fail",
+ "mode=developer_token but `BOX_DEVELOPER_TOKEN` is empty",
+ fix_hint=(
+ "re-run `./scripts/setup` and pick auth-credentials. Mint "
+ f"a fresh token at {cfg.DEV_CONSOLE_URL}."
+ ),
+ )
+ ]
+ return [
+ CheckResult(
+ "developer-token",
+ "ok",
+ f"token set ({len(token)} chars). Note: tokens expire after 60 min.",
+ )
+ ]
+
+
+def check_jwt_config() -> list[CheckResult]:
+ """When mode=jwt, the JWT config JSON exists and parses."""
+ if not cfg.BOX_ENV_FILE.exists():
+ return [CheckResult("jwt-config", "ok", "n/a (no .env yet)")]
+ values = cfg.read_dotenv(cfg.BOX_ENV_FILE)
+ if values.get("BOX_AUTH_MODE", "").strip() != cfg.AUTH_MODE_JWT:
+ return [CheckResult("jwt-config", "ok", "n/a (other auth mode)")]
+ path_str = values.get("BOX_JWT_CONFIG_PATH", "").strip()
+ if not path_str:
+ return [
+ CheckResult(
+ "jwt-config",
+ "fail",
+ "mode=jwt but `BOX_JWT_CONFIG_PATH` not set",
+ fix_hint="re-run `./scripts/setup` and pick auth-credentials.",
+ )
+ ]
+ p = Path(path_str).expanduser()
+ if not p.exists():
+ return [
+ CheckResult(
+ "jwt-config",
+ "fail",
+ f"JWT config not at {p}",
+ fix_hint=(
+ "verify the path; the developer console downloads it as "
+ "config.json and you can place it anywhere."
+ ),
+ )
+ ]
+ import json
+
+ try:
+ data = json.loads(p.read_text(encoding="utf-8"))
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
+ return [
+ CheckResult(
+ "jwt-config",
+ "fail",
+ f"JWT config at {p} is not valid JSON: {exc}",
+ fix_hint="re-download the JWT app config from the developer console.",
+ )
+ ]
+ expected_keys = {"boxAppSettings", "enterpriseID"}
+ missing = expected_keys - set(data.keys())
+ if missing:
+ return [
+ CheckResult(
+ "jwt-config",
+ "warn",
+ f"JWT config at {p} missing keys: {sorted(missing)}",
+ fix_hint="re-download the JWT app config from the developer console.",
+ )
+ ]
+ return [CheckResult("jwt-config", "ok", f"valid JWT config at {p}")]
diff --git a/dev_setup/config.py b/dev_setup/config.py
new file mode 100644
index 00000000..6df89a1e
--- /dev/null
+++ b/dev_setup/config.py
@@ -0,0 +1,202 @@
+"""Paths and small config helpers shared by the wizard and doctor.
+
+Discovers the target repo root relative to this file so the package
+works no matter where the user invokes it from. Per-user state lives
+in ``~/./`` (overridable via ``$WIZARD_STATE_DIR``) so the
+wizard never writes per-machine state into the git checkout.
+
+Customization checklist when porting to a new repo:
+
+1. Update ``PROJECT_NAME`` to whatever short name you want for the
+ ``~/./`` state dir.
+2. Decide where ``parents[N]`` should land — the scaffold assumes the
+ ``setup/`` package sits one directory below the repo root. If you
+ nest deeper (e.g. ``scripts/setup/``), bump the index.
+3. Add module-level constants for any well-known config files the
+ wizard creates (``BOX_CONFIG_FILE``, ``LOCAL_PROPERTIES``, etc.).
+ Mirror the ``ZD_ENV_FILE`` / ``PAT_ENV_FILE`` shape from bq_ranger:
+ accept an env-var override so tests can swap paths.
+4. Drop any constants you don't need. There's no charge for empty
+ space here — the file should be small.
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+# ---- repo + state -------------------------------------------------------
+
+PROJECT_NAME: str = "box-python-sdk-gen"
+
+# REPO_ROOT discovery. Adjust the parents[N] depth to match where you
+# install the setup package. Default: setup/ sits at /setup/, so
+# parents[1] resolves to .
+REPO_ROOT: Path = Path(__file__).resolve().parents[1]
+
+# Per-user state directory (created lazily, never at import time so
+# read-only callers don't trigger a filesystem mutation just by
+# importing this module).
+_STATE_DIR_ENV = f"{PROJECT_NAME.upper().replace('-', '_')}_STATE_DIR"
+STATE_DIR: Path = Path(
+ os.environ.get(_STATE_DIR_ENV, str(Path.home() / f".{PROJECT_NAME}"))
+).expanduser()
+
+
+def ensure_state_dir() -> Path:
+ """Create ``STATE_DIR`` if missing and return it.
+
+ Lazy so read-only callers (the doctor, ad-hoc imports in CI or
+ sandboxed environments without a writable ``$HOME``) don't trigger
+ a filesystem mutation just by importing the package. Idempotent.
+ """
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
+ return STATE_DIR
+
+
+# ---- Box-specific config files ------------------------------------------
+#
+# The wizard writes one ``.env`` at the repo root with the user's chosen
+# auth mode and credentials. ``.env`` is the Python ecosystem's default
+# convention; the wizard's ``read_dotenv``/``write_dotenv_key`` helpers
+# parse it natively.
+#
+# JWT auth uses an additional JSON config file (the one the developer
+# console exports). The wizard records its absolute path in ``.env``
+# under ``BOX_JWT_CONFIG_PATH``.
+
+BOX_ENV_FILE: Path = Path(
+ os.environ.get("BOX_SDK_DOTENV", str(REPO_ROOT / ".env"))
+).expanduser()
+
+BOX_ENV_EXAMPLE: Path = REPO_ROOT / ".env.example"
+
+DEFAULT_JWT_CONFIG_PATH: Path = Path(
+ os.environ.get("BOX_JWT_CONFIG_PATH", str(REPO_ROOT / "box-jwt-config.json"))
+).expanduser()
+
+# URLs printed in the token-entry prompts so the user knows where to
+# mint each credential. Centralized so docs + wizard cite the same
+# canonical pages.
+DEV_CONSOLE_URL: str = "https://app.box.com/developers/console"
+JWT_AUTH_DOCS_URL: str = (
+ "https://developer.box.com/guides/authentication/jwt/jwt-setup/"
+)
+OAUTH_DOCS_URL: str = (
+ "https://developer.box.com/guides/authentication/oauth2/"
+)
+CCG_DOCS_URL: str = (
+ "https://developer.box.com/guides/authentication/client-credentials/"
+)
+
+# Minimum required Python (matches setup.py classifiers).
+MIN_PYTHON: tuple[int, int] = (3, 8)
+
+# Auth mode constants. Stored verbatim in .env under BOX_AUTH_MODE and
+# consumed by the smoke-test step.
+AUTH_MODE_DEV_TOKEN: str = "developer_token"
+AUTH_MODE_JWT: str = "jwt"
+AUTH_MODE_CCG: str = "ccg"
+AUTH_MODE_OAUTH: str = "oauth"
+
+ALL_AUTH_MODES: tuple[str, ...] = (
+ AUTH_MODE_DEV_TOKEN,
+ AUTH_MODE_JWT,
+ AUTH_MODE_CCG,
+ AUTH_MODE_OAUTH,
+)
+
+
+# ---- dotenv helpers -----------------------------------------------------
+
+
+def read_dotenv(path: Path) -> dict[str, str]:
+ """Parse a tiny .env-style file into a dict.
+
+ Handles ``KEY=value`` and ``KEY="value"`` / ``KEY='value'``. Ignores
+ blank lines and ``#`` comments. Does NOT support multiline values,
+ backslash escapes, or shell expansion — kept intentionally minimal
+ so we don't pull in python-dotenv as a dependency.
+ """
+ out: dict[str, str] = {}
+ if not path.exists():
+ return out
+ for raw in path.read_text(encoding="utf-8").splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ if "=" not in line:
+ continue
+ k, v = line.split("=", 1)
+ k = k.strip()
+ v = v.strip()
+ if (len(v) >= 2) and (v[0] == v[-1]) and v[0] in ("'", '"'):
+ v = v[1:-1]
+ out[k] = v
+ return out
+
+
+def write_dotenv_key(path: Path, key: str, value: str) -> None:
+ """Idempotently set ``key=value`` in a tiny .env file.
+
+ Creates the file if missing, replaces an existing key in place, or
+ appends if the key isn't there yet. Quotes the value with double
+ quotes if it contains a space, ``#``, or ``=`` so the same parser
+ that wrote it can read it back.
+
+ Sets owner-only (0600) permissions on POSIX systems — best-effort
+ on Windows / unusual filesystems where chmod isn't supported.
+ """
+ path.parent.mkdir(parents=True, exist_ok=True)
+ needs_quote = any(c in value for c in (" ", "#", "="))
+ safe_value = value.replace('"', r"\"") if needs_quote else value
+ rendered = f'{key}="{safe_value}"' if needs_quote else f"{key}={value}"
+
+ if not path.exists():
+ path.write_text(rendered + "\n", encoding="utf-8")
+ chmod_owner_only(path)
+ return
+
+ lines = path.read_text(encoding="utf-8").splitlines()
+ replaced = False
+ for i, line in enumerate(lines):
+ stripped = line.lstrip()
+ if stripped.startswith(f"{key}="):
+ lines[i] = rendered
+ replaced = True
+ break
+ if not replaced:
+ lines.append(rendered)
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ chmod_owner_only(path)
+
+
+def chmod_owner_only(path: Path) -> None:
+ """Best-effort owner-only (0600) permissions for local secrets.
+
+ Windows and some network filesystems don't support POSIX modes.
+ The write still succeeded — keep setup flowing and lean on the
+ docs to call out platform-specific hardening if needed.
+ """
+ try:
+ path.chmod(0o600)
+ except OSError:
+ pass
+
+
+def atomic_write_text(path: Path, content: str, *, mode: int = 0o644) -> None:
+ """Write ``content`` to ``path`` atomically.
+
+ Writes to a sibling tempfile, then ``os.replace`` to swap it in.
+ Avoids leaving a half-written secrets file if the process is killed
+ mid-write. ``mode`` is best-effort (skipped on filesystems without
+ POSIX permissions).
+ """
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(path.suffix + ".tmp")
+ tmp.write_text(content, encoding="utf-8")
+ try:
+ tmp.chmod(mode)
+ except OSError:
+ pass
+ os.replace(tmp, path)
diff --git a/dev_setup/doctor.py b/dev_setup/doctor.py
new file mode 100644
index 00000000..5f96b100
--- /dev/null
+++ b/dev_setup/doctor.py
@@ -0,0 +1,93 @@
+"""``doctor`` — read-only environment audit.
+
+Calls every registered check function and prints results. Same
+``CheckResult`` rendering as the wizard, so the user gets the same
+``[OK]`` / ``[WARN]`` / ``[FAIL]`` glyphs from both surfaces.
+
+Exits non-zero only on hard failures. Warnings are tolerated — they
+mean "optional thing isn't set up" rather than "your environment is
+broken". Tune ``DOCTOR_FAIL_ON_WARN`` per repo if your audience needs
+strict exit codes.
+"""
+
+from __future__ import annotations
+
+import sys
+from collections.abc import Callable
+
+from . import checks
+from .checks import CheckResult, DoctorReport
+from .prompts import banner, paced_print, print_results
+
+# ---- customize per repo -------------------------------------------------
+
+DOCTOR_TITLE: str = "Box Python SDK doctor"
+DOCTOR_TAGLINE: str = (
+ "Read-only audit. Re-runs each setup step's check function so you can\n"
+ "see what's wired up and what isn't, without rerunning the wizard."
+)
+
+# Whether to exit non-zero on warnings. Default False — warnings here
+# usually mean "auth mode picked but credential file path doesn't exist
+# yet", which is not a hard failure.
+DOCTOR_FAIL_ON_WARN: bool = False
+
+CheckFn = Callable[[], list[CheckResult]]
+DOCTOR_CHECKS: list[CheckFn] = [
+ checks.check_python,
+ checks.check_pip,
+ checks.check_sdk_importable,
+ checks.check_box_env,
+ checks.check_developer_token,
+ checks.check_jwt_config,
+]
+
+
+# ---- runner -------------------------------------------------------------
+
+
+def run_all_checks() -> DoctorReport:
+ """Call every registered check and assemble a ``DoctorReport``."""
+ report = DoctorReport()
+ for check in DOCTOR_CHECKS:
+ try:
+ results = check()
+ except Exception as exc: # noqa: BLE001 - keep the doctor robust
+ results = [
+ CheckResult(
+ name=check.__name__,
+ severity="fail",
+ message=f"check raised {type(exc).__name__}: {exc}",
+ fix_hint="report this as a doctor bug.",
+ )
+ ]
+ report.add(*results)
+ return report
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Entry point. Prints banner, runs all checks, returns exit code."""
+ argv = list(argv if argv is not None else sys.argv[1:])
+
+ banner(DOCTOR_TITLE)
+ paced_print(DOCTOR_TAGLINE, after_ms=300)
+ print()
+
+ report = run_all_checks()
+ print_results(report.results, show_ok=True, show_fix=True)
+
+ print()
+ fails = sum(1 for r in report.results if r.severity == "fail")
+ warns = sum(1 for r in report.results if r.severity == "warn")
+ oks = sum(1 for r in report.results if r.severity == "ok")
+ paced_print(f" Summary: {oks} OK, {warns} WARN, {fails} FAIL", after_ms=150)
+
+ if fails > 0:
+ return 1
+ if DOCTOR_FAIL_ON_WARN and warns > 0:
+ return 2
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/dev_setup/prompts.py b/dev_setup/prompts.py
new file mode 100644
index 00000000..f92f9ff0
--- /dev/null
+++ b/dev_setup/prompts.py
@@ -0,0 +1,393 @@
+"""TTY helpers for the wizard and doctor.
+
+Stdlib-only. Centralized here so the wizard and doctor render identically —
+the user gets the same ``[OK]`` / ``[WARN]`` / ``[FAIL]`` glyphs and the
+same fix-hint formatting from both tools.
+
+This is part of the ``setup-wizard-squared`` scaffold. Drop the file
+into a target repo's ``setup/`` package and customize ``wizard.py``;
+``prompts.py`` itself shouldn't usually need editing per repo.
+
+Tunables:
+ ``WIZARD_PACE_MS=`` — per-line pause when pacing framing text.
+ Default 80ms. ``0`` disables pacing. Auto-disabled when stdout
+ isn't a TTY.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import os
+import sys
+import threading
+import time
+from collections.abc import Iterable, Iterator
+from typing import Literal
+
+# Import readline so input() supports arrow-key line editing and history
+# instead of inserting raw escape codes (^[[A / ^[[B). On macOS, Python's
+# stdlib readline links against libedit rather than GNU readline; on
+# Windows the module isn't shipped — input() still works, just without
+# the line editor.
+try:
+ import readline # noqa: F401 -- side-effect import (enables line editing)
+except ImportError:
+ pass
+
+from .checks import CheckResult
+
+Severity = Literal["ok", "warn", "fail"]
+
+_GLYPH = {"ok": "[OK] ", "warn": "[WARN]", "fail": "[FAIL]"}
+
+# ---- pacing -------------------------------------------------------------
+#
+# Sectional pacing: small inter-line pauses inside framing blocks
+# (banners, step intros, completion summaries) plus a longer settle
+# pause AFTER each block. Status rows, prompts, and command output stay
+# instant so the wizard never feels frozen.
+#
+# Tuning: WIZARD_PACE_MS= overrides the default per-line ms.
+# 0 -> pacing disabled (same as non-TTY).
+# 80 -> default; calm but not slow.
+# 200+ -> deliberate / dramatic for live demos or first-time users.
+# Auto-disabled when stdout isn't a TTY (CI, piped output, capture).
+
+_DEFAULT_PACE_MS = 80
+_PACE_ENV_VAR = "WIZARD_PACE_MS"
+
+
+def _pace_ms() -> int:
+ """Resolve effective per-line pause in ms; 0 disables pacing."""
+ if not sys.stdout.isatty():
+ return 0
+ raw = os.environ.get(_PACE_ENV_VAR)
+ if raw is None:
+ return _DEFAULT_PACE_MS
+ try:
+ v = int(raw)
+ except ValueError:
+ return _DEFAULT_PACE_MS
+ return max(0, v)
+
+
+def paced_print(*lines: str, pause_ms: int | None = None, after_ms: int = 0) -> None:
+ """Print each line with the per-line pause between, then sleep ``after_ms``.
+
+ Use for FRAMING text — banners, step intros, completion summaries.
+ Don't pace status rows, command output, or prompt text: pacing
+ those makes the wizard feel slow without aiding reading.
+
+ ``pause_ms`` overrides the global per-line rate FOR THIS CALL ONLY.
+ Use it to slow-roll a long prose block ("Star Wars crawl" feel) so
+ a multi-paragraph explainer scrolls in one breath instead of dumping
+ all at once. Typical values:
+
+ None -> use global WIZARD_PACE_MS (default 80ms, snappy)
+ 150 -> slower scroll for paragraph prose
+ 250 -> deliberate "read this carefully" rhythm
+
+ The global kill switch (``WIZARD_PACE_MS=0`` or non-TTY) still wins —
+ passing ``pause_ms`` does NOT force pacing on when it would otherwise
+ be off.
+
+ ``after_ms`` is the sectional settle pause that separates this block
+ from whatever prints next (a prompt, a status row, the next framing
+ block). Typical values:
+
+ 0 -> no settle (block flows directly into the next line)
+ 200 -> short settle (after a step completion line)
+ 400 -> banner settle (give the user a beat to read it)
+ """
+ pace = _pace_ms()
+ if pace and pause_ms is not None:
+ pace = max(0, pause_ms)
+ last = len(lines) - 1
+ for i, line in enumerate(lines):
+ print(line)
+ if pace and i < last:
+ time.sleep(pace / 1000)
+ if pace and after_ms > 0:
+ time.sleep(after_ms / 1000)
+
+
+def paced_pause(ms: int) -> None:
+ """Sleep ``ms`` milliseconds when pacing is enabled; no-op otherwise."""
+ pace = _pace_ms()
+ if pace and ms > 0:
+ time.sleep(ms / 1000)
+
+
+# ---- progress watchdog --------------------------------------------------
+#
+# Wrap subprocess calls (or any potentially-slow block) so the user gets
+# a "current action: " status line if the wrapped block goes silent
+# for more than a few seconds. Without this, long silent stretches during
+# `brew install`, `npm install -g`, dependency syncs, etc. look exactly
+# like a hung wizard.
+#
+# Don't wrap commands that have their own interactive prompts (e.g.
+# `gh auth login`'s device-flow prompts, CLIs that ask for tokens) —
+# the watcher line interleaves with the active prompt and confuses
+# the user. Use this only for silent or progress-streaming subprocesses.
+
+_PROGRESS_THRESHOLD_MS = 5000
+
+
+@contextlib.contextmanager
+def progress_watch(
+ description: str,
+ *,
+ threshold_ms: int = _PROGRESS_THRESHOLD_MS,
+) -> Iterator[None]:
+ """Print ``current action: `` if the block runs >threshold_ms.
+
+ Use as a context manager around subprocess calls that can stall::
+
+ with progress_watch("installing jira-cli (brew)"):
+ rc = _run_interactive(["brew", "install", "jira-cli"])
+
+ A daemon thread sleeps for ``threshold_ms``; if the block hasn't
+ exited by then it prints the heads-up line once. On exit, if the
+ threshold was crossed, also prints ``done in Ns`` so the user knows
+ the silent stretch ended.
+
+ No-op for non-TTY (CI / piped output / capture). Re-entrant: each
+ nested level announces independently with its own watcher thread.
+ """
+ if not sys.stdout.isatty():
+ yield
+ return
+
+ done = threading.Event()
+ crossed = threading.Event()
+
+ def _watch() -> None:
+ if not done.wait(threshold_ms / 1000):
+ crossed.set()
+ print(f" current action: {description}")
+
+ t = threading.Thread(target=_watch, daemon=True)
+ t.start()
+ start = time.monotonic()
+ try:
+ yield
+ finally:
+ done.set()
+ if crossed.is_set():
+ elapsed = time.monotonic() - start
+ print(f" → done in {elapsed:.0f}s")
+
+
+# ---- framing ------------------------------------------------------------
+
+
+def banner(title: str, *, after_ms: int = 400) -> None:
+ """Print a thick bordered title line with a settle pause after."""
+ bar = "=" * (len(title) + 4)
+ paced_print(bar, f"= {title} =", bar, after_ms=after_ms)
+
+
+def step(num: int, total: int, title: str) -> None:
+ """Print ``--- Step N/T: title ---`` with a leading blank + settle pause."""
+ print()
+ paced_print(f"--- Step {num}/{total}: {title} ---", after_ms=200)
+
+
+# ---- input prompts ------------------------------------------------------
+
+
+def yes_no(prompt: str, default: bool = True) -> bool:
+ """Prompt for yes/no. Empty input picks ``default``.
+
+ Treats EOF / Ctrl-D as "decline" so piping ``echo "" |`` to the
+ wizard doesn't accidentally agree to destructive actions.
+ """
+ suffix = "Y/n" if default else "y/N"
+ try:
+ raw = input(f"{prompt} ({suffix}): ").strip().lower()
+ except EOFError:
+ return False
+ if not raw:
+ return default
+ return raw in {"y", "yes"}
+
+
+def ask(label: str, *, hint: str | None = None, default: str | None = None) -> str:
+ """Single-line free-text prompt with optional hint and default."""
+ if hint:
+ print(f" ({hint})")
+ suffix = f" [{default}]" if default else ""
+ try:
+ raw = input(f" {label}{suffix}: ").strip()
+ except EOFError:
+ return default or ""
+ return raw or (default or "")
+
+
+def ask_secret(label: str, *, hint: str | None = None) -> str:
+ """Hidden-input prompt for a token / password / API key.
+
+ Wraps ``getpass.getpass`` so the value doesn't echo to the terminal
+ or land in shell history. EOF returns an empty string (caller should
+ detect and re-prompt or skip).
+ """
+ from getpass import getpass
+
+ if hint:
+ print(f" ({hint})")
+ try:
+ return getpass(f" {label} (input hidden): ").strip()
+ except EOFError:
+ return ""
+
+
+# ---- step picker --------------------------------------------------------
+#
+# Render a numbered "what would you like to set up?" menu and parse
+# the user's response into the set of step ids to run. Lets users
+# pick a subset up front instead of being walked through every
+# component sequentially.
+
+PickerItem = tuple[str, str, bool]
+"""A single menu item: ``(step_id, label, recommended)``.
+
+``step_id`` is the caller's stable identifier (returned in the selection
+list). ``label`` is what the user sees on the menu line.
+``recommended=True`` tags the item with ``[R]`` and includes it in the
+default selection when the user just hits Enter.
+"""
+
+
+def _parse_picker_input(
+ raw: str,
+ items: list[PickerItem],
+ default_ids: list[str],
+) -> tuple[list[str] | None, str | None]:
+ """Parse picker input into a list of selected step ids.
+
+ Returns ``(selected_ids, None)`` on success or ``(None, error_msg)``
+ on parse failure so the caller can re-prompt.
+
+ Accepts:
+ - empty string -> ``default_ids``
+ - ``"all"`` -> every item id
+ - ``"none"`` -> empty list (skip everything)
+ - ``"recommended"`` -> ids of items tagged recommended
+ - comma-separated 1-based numbers: ``"1,3,5"``
+ - inclusive ranges: ``"1-3"``
+ - mixed: ``"1-3,5,7"``
+
+ Selected ids are returned in menu order, not input order, so the
+ caller can iterate the items list once and run picked entries in
+ their canonical sequence.
+ """
+ raw = raw.strip().lower()
+ if not raw:
+ return list(default_ids), None
+ if raw == "all":
+ return [iid for iid, _, _ in items], None
+ if raw == "none":
+ return [], None
+ if raw == "recommended":
+ return [iid for iid, _, recommended in items if recommended], None
+
+ selected_indices: set[int] = set()
+ for token in raw.replace(" ", "").split(","):
+ if not token:
+ continue
+ if "-" in token:
+ try:
+ lo_s, hi_s = token.split("-", 1)
+ lo, hi = int(lo_s), int(hi_s)
+ except ValueError:
+ return None, f"bad range `{token}`"
+ if lo < 1 or hi > len(items) or lo > hi:
+ return None, f"range `{token}` out of bounds (1-{len(items)})"
+ selected_indices.update(range(lo, hi + 1))
+ else:
+ try:
+ n = int(token)
+ except ValueError:
+ return None, f"not a number: `{token}`"
+ if n < 1 or n > len(items):
+ return None, f"`{n}` out of range (1-{len(items)})"
+ selected_indices.add(n)
+
+ return [iid for i, (iid, _, _) in enumerate(items, 1) if i in selected_indices], None
+
+
+def pick_steps(
+ items: list[PickerItem],
+ *,
+ title: str = "What would you like to set up?",
+ indent: str = " ",
+) -> list[str]:
+ """Render a numbered picker; return selected step ids in menu order.
+
+ Each entry's ``recommended`` flag shows as a leading ``[R]`` tag and
+ determines the default selection when the user just hits Enter. The
+ caller iterates its own item list and runs any step whose id is in
+ the returned list — this helper doesn't invoke step bodies itself.
+
+ Empty selection (``none`` or no recommended items) is valid and
+ returns an empty list; callers should handle that gracefully (e.g.,
+ print "skipping all extras" and exit cleanly).
+
+ EOF / Ctrl-D returns the default selection so non-interactive
+ invocations (CI, ``echo "" | ...``) don't accidentally pick or skip
+ everything.
+ """
+ default_ids = [iid for iid, _, recommended in items if recommended]
+
+ paced_print(title, after_ms=150)
+ print()
+ for i, (_, label, recommended) in enumerate(items, 1):
+ tag = "[R]" if recommended else " "
+ print(f"{indent}{i}. {tag} {label}")
+ print()
+ print(f"{indent}Pick: comma-separated numbers (1,3,5), ranges (1-3),")
+ print(f"{indent} `all`, `recommended`, or `none`. [R] = recommended.")
+
+ while True:
+ try:
+ raw = input(f"{indent}Choice [recommended]: ")
+ except EOFError:
+ return list(default_ids)
+
+ selected, err = _parse_picker_input(raw, items, default_ids)
+ if selected is None:
+ print(f"{indent} invalid input: {err}. Try again.")
+ continue
+ return selected
+
+
+# ---- result rendering ---------------------------------------------------
+
+
+def print_results(
+ results: Iterable[CheckResult],
+ *,
+ show_ok: bool = True,
+ show_fix: bool = True,
+ indent: str = " ",
+) -> None:
+ """Render a sequence of CheckResult.
+
+ By default prints all severities so the doctor's full report is
+ visible; set ``show_ok=False`` in the wizard to suppress noise from
+ successful steps and only surface the warnings/failures.
+
+ Set ``show_fix=False`` at call sites where the wizard immediately
+ follows up with its own multi-line remediation block, so the user
+ doesn't see the same fix command twice (once as the terse one-line
+ ``fix:`` hint, then again in the prose block). The doctor leaves
+ this at the default so its standalone report is fully self-contained.
+ """
+ for r in results:
+ if r.severity == "ok" and not show_ok:
+ continue
+ glyph = _GLYPH[r.severity]
+ print(f"{indent}{glyph} {r.name}: {r.message}")
+ if r.fix_hint and r.severity != "ok" and show_fix:
+ print(f"{indent} fix: {r.fix_hint}")
diff --git a/dev_setup/wizard.py b/dev_setup/wizard.py
new file mode 100644
index 00000000..9f5b08a1
--- /dev/null
+++ b/dev_setup/wizard.py
@@ -0,0 +1,468 @@
+"""``setup-wizard`` — first-run / repair wizard for box-python-sdk-gen.
+
+Picker-driven, idempotent. Three steps:
+
+1. **Python + dependencies preflight** — verifies Python 3.8+, pip, and
+ that ``box_sdk_gen`` is importable. Optionally runs
+ ``pip install -e .[test,dev]``.
+2. **Auth credentials** — pick one of Developer Token / JWT / Client
+ Credentials / OAuth, then capture the credentials it needs and write
+ them to ``.env`` at the repo root.
+3. **Smoke test snippet** — print a short Python program the user can
+ run to verify their setup talks to Box (``client.users.get_user_me()``).
+
+Re-running is safe. The wizard writes only ``.env`` (gitignored).
+
+Run from the repo root::
+
+ ./scripts/setup
+ ./scripts/doctor # read-only audit afterwards
+
+Built using `setup-wizard-squared
+`_ — see that
+repo's ``reference/patterns.md`` for design rationale. The package is
+named ``dev_setup/`` (not ``setup/``) to avoid colliding with
+``setup.py`` and ``setuptools.find_packages()``.
+"""
+
+from __future__ import annotations
+
+import subprocess
+import sys
+from collections.abc import Callable
+from pathlib import Path
+
+from . import checks
+from . import config as cfg
+from .prompts import (
+ ask,
+ ask_secret,
+ banner,
+ paced_print,
+ pick_steps,
+ print_results,
+ progress_watch,
+ step,
+ yes_no,
+)
+
+WIZARD_TITLE: str = "Box Python SDK setup"
+WIZARD_TAGLINE: str = (
+ "Walks you through verifying Python + pip, installing the SDK in\n"
+ "editable mode, picking an auth mode, and capturing credentials.\n"
+ "Re-running is always safe — every step skips work already done."
+)
+
+COMPLETION_FOOTER_LINES: list[str] = [
+ " Health check: ./scripts/doctor",
+ " Auth docs: docs/authentication.md",
+ " Wizard flows: WIZARD_FLOWS.md",
+ f" Mint a token: {cfg.DEV_CONSOLE_URL}",
+]
+
+
+# ---- subprocess runners -------------------------------------------------
+
+
+def _run_interactive(
+ args: list[str],
+ *,
+ timeout: int | None = None,
+ env: dict[str, str] | None = None,
+) -> int:
+ """Run a command with the user's TTY connected.
+
+ ``env`` overrides subprocess environment when provided. Build it by
+ copying ``os.environ`` and layering ``.env`` values on top so child
+ commands see ``BOX_*`` variables even if the user never sourced
+ ``.env`` in their shell.
+ """
+ print(f" $ {' '.join(args)}")
+ try:
+ return subprocess.run(args, check=False, timeout=timeout, env=env).returncode
+ except FileNotFoundError:
+ print(f" !! {args[0]} not found on PATH; skipping.")
+ return 127
+
+
+# ---- step 1: preflight + dependency install -----------------------------
+
+
+def step_preflight() -> bool:
+ """Verify Python + pip, optionally install dev dependencies."""
+ step(1, len(WIZARD_STEPS), "Python + dependencies preflight")
+ print(" Verifies Python is recent enough and the SDK installs cleanly.")
+ print()
+ py = checks.check_python()
+ pip_ = checks.check_pip()
+ print_results(py + pip_, show_ok=True)
+ if any(r.severity == "fail" for r in py + pip_):
+ return False
+
+ sdk = checks.check_sdk_importable()
+ print_results(sdk, show_ok=True)
+ if all(r.severity == "ok" for r in sdk):
+ return True
+
+ print()
+ paced_print(
+ " The SDK isn't importable yet. Install it in editable mode so",
+ " changes you make show up immediately:",
+ f" {sys.executable} -m pip install -e .[test,dev]",
+ after_ms=200,
+ )
+ print()
+ if not yes_no("Run `pip install -e .[test,dev]` now?", default=True):
+ print(" Skipped. Install manually when ready, then re-run ./scripts/doctor.")
+ return True
+
+ with progress_watch("installing SDK in editable mode (pip)"):
+ rc = _run_interactive(
+ [sys.executable, "-m", "pip", "install", "-e", ".[test,dev]"]
+ )
+ if rc != 0:
+ print(" [FAIL] pip install exited non-zero. See output above.")
+ return False
+ paced_print(" → install complete.", after_ms=150)
+ return True
+
+
+# ---- step 2: auth credentials -------------------------------------------
+
+
+_AUTH_MODE_LABELS: dict[str, str] = {
+ cfg.AUTH_MODE_DEV_TOKEN: "Developer Token",
+ cfg.AUTH_MODE_JWT: "JWT",
+ cfg.AUTH_MODE_CCG: "Client Credentials Grant (CCG)",
+ cfg.AUTH_MODE_OAUTH: "OAuth 2.0",
+}
+
+
+def _pick_auth_mode() -> str | None:
+ """Inline mini-picker for auth mode. Returns mode string or None."""
+ paced_print(
+ " Which auth mode are you setting up?",
+ "",
+ " 1. Developer Token — fastest; 60-min TTL; testing only",
+ " 2. JWT — server-to-server; needs config.json from devconsole",
+ " 3. Client Credentials — server-to-server; client ID + secret",
+ " 4. OAuth 2.0 — end-user app; client ID + secret + redirect URI",
+ after_ms=200,
+ )
+ map_choice = {
+ "1": cfg.AUTH_MODE_DEV_TOKEN,
+ "2": cfg.AUTH_MODE_JWT,
+ "3": cfg.AUTH_MODE_CCG,
+ "4": cfg.AUTH_MODE_OAUTH,
+ }
+ while True:
+ raw = ask("Choice", default="1").strip()
+ if raw in map_choice:
+ return map_choice[raw]
+ if raw == "":
+ return None
+ print(f" Pick 1, 2, 3, or 4 (got {raw!r}). Try again, or empty to skip.")
+
+
+def _collect_developer_token() -> bool:
+ paced_print(
+ " Mint a Developer Token:",
+ f" {cfg.DEV_CONSOLE_URL}",
+ " → Open your app's configuration page → 'Generate Developer Token'",
+ " → copy the value (it's shown once).",
+ "",
+ " Tokens expire after 60 minutes; re-run this step for a fresh one.",
+ after_ms=250,
+ )
+ token = ask_secret("Developer Token", hint="not echoed; ~64-char alphanumeric")
+ if not token:
+ print(" Skipped — no token entered.")
+ return False
+ cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_AUTH_MODE", cfg.AUTH_MODE_DEV_TOKEN)
+ cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_DEVELOPER_TOKEN", token)
+ paced_print(f" → wrote {cfg.BOX_ENV_FILE.name}", after_ms=150)
+ return True
+
+
+def _collect_jwt_config() -> bool:
+ paced_print(
+ " JWT auth reads a JSON config the developer console exports.",
+ f" Setup guide: {cfg.JWT_AUTH_DOCS_URL}",
+ "",
+ " → In the developer console, open your JWT-enabled app →",
+ " Configuration → Add and Manage Public Keys → Generate Keypair.",
+ " The browser downloads config.json. Save it somewhere durable",
+ " (outside the repo if you can — it's a private key).",
+ after_ms=300,
+ )
+ default = str(cfg.DEFAULT_JWT_CONFIG_PATH)
+ raw = ask("Path to JWT config JSON", default=default)
+ p = Path(raw).expanduser()
+ if not p.exists():
+ print(f" [WARN] {p} doesn't exist yet. Saving the path anyway —")
+ print(" re-run ./scripts/doctor once the file's in place to validate.")
+ cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_AUTH_MODE", cfg.AUTH_MODE_JWT)
+ cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_JWT_CONFIG_PATH", str(p))
+ paced_print(f" → wrote {cfg.BOX_ENV_FILE.name}", after_ms=150)
+ return True
+
+
+def _collect_ccg_creds() -> bool:
+ paced_print(
+ " Client Credentials Grant uses client ID + secret to mint tokens.",
+ f" Setup: {cfg.CCG_DOCS_URL}",
+ "",
+ " CCG can act as the Service Account (enterpriseID) OR a specific",
+ " user (userID), depending on what the app's authorized for.",
+ after_ms=250,
+ )
+ client_id = ask("Client ID", hint="from devconsole → Configuration").strip()
+ client_secret = ask_secret("Client Secret", hint="not echoed")
+ if not (client_id and client_secret):
+ print(" Skipped — client ID or secret empty.")
+ return False
+ subject_kind = ask(
+ "Subject", hint="`enterprise` for service account, `user` for user-based",
+ default="enterprise",
+ ).strip().lower()
+ if subject_kind not in {"enterprise", "user"}:
+ print(f" Unrecognized subject `{subject_kind}`; defaulting to enterprise.")
+ subject_kind = "enterprise"
+ subject_id = ask(
+ f"{subject_kind.capitalize()} ID",
+ hint="numeric (EID for enterprise, UID for user)",
+ ).strip()
+ if not subject_id:
+ print(" Skipped — subject ID empty.")
+ return False
+ cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_AUTH_MODE", cfg.AUTH_MODE_CCG)
+ cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_CCG_CLIENT_ID", client_id)
+ cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_CCG_CLIENT_SECRET", client_secret)
+ cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_CCG_SUBJECT_KIND", subject_kind)
+ cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_CCG_SUBJECT_ID", subject_id)
+ paced_print(f" → wrote {cfg.BOX_ENV_FILE.name}", after_ms=150)
+ return True
+
+
+def _collect_oauth_creds() -> bool:
+ paced_print(
+ " OAuth 2.0 is for apps that ask end-users to grant Box access.",
+ f" Setup: {cfg.OAUTH_DOCS_URL}",
+ "",
+ " You'll need client ID, client secret, and a redirect URI",
+ " registered in the developer console.",
+ after_ms=250,
+ )
+ client_id = ask("Client ID", hint="from devconsole → Configuration").strip()
+ client_secret = ask_secret("Client Secret", hint="not echoed")
+ if not (client_id and client_secret):
+ print(" Skipped — client ID or secret empty.")
+ return False
+ redirect = ask(
+ "Redirect URI",
+ hint="must match what's registered in the devconsole",
+ default="http://localhost:8080/callback",
+ ).strip()
+ cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_AUTH_MODE", cfg.AUTH_MODE_OAUTH)
+ cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_OAUTH_CLIENT_ID", client_id)
+ cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_OAUTH_CLIENT_SECRET", client_secret)
+ cfg.write_dotenv_key(cfg.BOX_ENV_FILE, "BOX_OAUTH_REDIRECT_URI", redirect)
+ paced_print(f" → wrote {cfg.BOX_ENV_FILE.name}", after_ms=150)
+ return True
+
+
+def step_auth_credentials() -> bool:
+ """Pick an auth mode and capture credentials. Mode lives in BOX_AUTH_MODE."""
+ step(2, len(WIZARD_STEPS), "Auth credentials")
+ print(" Picks an auth mode and captures the credentials it needs.")
+ print()
+ existing = cfg.read_dotenv(cfg.BOX_ENV_FILE) if cfg.BOX_ENV_FILE.exists() else {}
+ current_mode = existing.get("BOX_AUTH_MODE", "").strip()
+ if current_mode:
+ label = _AUTH_MODE_LABELS.get(current_mode, current_mode)
+ paced_print(f" Existing auth mode: {label}", after_ms=150)
+ if not yes_no("Re-collect / change auth mode?", default=False):
+ return True
+ print()
+
+ mode = _pick_auth_mode()
+ if mode is None:
+ print(" Skipped.")
+ return True
+ print()
+
+ if mode == cfg.AUTH_MODE_DEV_TOKEN:
+ return _collect_developer_token()
+ if mode == cfg.AUTH_MODE_JWT:
+ return _collect_jwt_config()
+ if mode == cfg.AUTH_MODE_CCG:
+ return _collect_ccg_creds()
+ if mode == cfg.AUTH_MODE_OAUTH:
+ return _collect_oauth_creds()
+ return False
+
+
+# ---- step 3: smoke test snippet -----------------------------------------
+
+
+def step_smoke_test() -> bool:
+ """Print a copy-pasteable Python snippet that calls users.get_user_me()."""
+ step(3, len(WIZARD_STEPS), "Smoke test snippet")
+ print(" A 6-line Python program that authenticates and prints your name.")
+ print()
+
+ if not cfg.BOX_ENV_FILE.exists():
+ paced_print(
+ " [WARN] no .env yet — pick step 2 (Auth credentials) first,",
+ " then re-run setup and pick step 3.",
+ after_ms=200,
+ )
+ return True
+
+ values = cfg.read_dotenv(cfg.BOX_ENV_FILE)
+ mode = values.get("BOX_AUTH_MODE", "").strip()
+ label = _AUTH_MODE_LABELS.get(mode, "(unknown)")
+ paced_print(
+ f" Auth mode in {cfg.BOX_ENV_FILE.name}: {label}",
+ "",
+ " Drop this into a .py file at the repo root (or any project that",
+ " has box-sdk-gen on its sys.path):",
+ "",
+ after_ms=150,
+ )
+
+ if mode == cfg.AUTH_MODE_DEV_TOKEN:
+ token = values.get("BOX_DEVELOPER_TOKEN", "")
+ preview = token[:8] + "…" if len(token) > 8 else ""
+ print(' import os')
+ print(' from box_sdk_gen import BoxClient, BoxDeveloperTokenAuth')
+ print('')
+ print(f' auth = BoxDeveloperTokenAuth(token=os.environ["BOX_DEVELOPER_TOKEN"]) # or "{preview}"')
+ print(' client = BoxClient(auth=auth)')
+ print(' me = client.users.get_user_me()')
+ print(' print("Hello,", me.name)')
+ print()
+ print(" Run after `set -a; source .env; set +a` (or `python-dotenv` etc).")
+ elif mode == cfg.AUTH_MODE_JWT:
+ path = values.get("BOX_JWT_CONFIG_PATH", "")
+ print(' from box_sdk_gen import BoxClient, BoxJWTAuth, JWTConfig')
+ print('')
+ print(f' config = JWTConfig.from_config_file({path!r})')
+ print(' auth = BoxJWTAuth(config=config)')
+ print(' client = BoxClient(auth=auth)')
+ print(' print("Hello,", client.users.get_user_me().name)')
+ elif mode == cfg.AUTH_MODE_CCG:
+ kind = values.get("BOX_CCG_SUBJECT_KIND", "enterprise")
+ sid = values.get("BOX_CCG_SUBJECT_ID", "")
+ print(' import os')
+ print(' from box_sdk_gen import BoxClient, BoxCCGAuth, CCGConfig')
+ print('')
+ if kind == "enterprise":
+ print(' config = CCGConfig(')
+ print(' client_id=os.environ["BOX_CCG_CLIENT_ID"],')
+ print(' client_secret=os.environ["BOX_CCG_CLIENT_SECRET"],')
+ print(f' enterprise_id="{sid}",')
+ print(' )')
+ else:
+ print(' config = CCGConfig(')
+ print(' client_id=os.environ["BOX_CCG_CLIENT_ID"],')
+ print(' client_secret=os.environ["BOX_CCG_CLIENT_SECRET"],')
+ print(f' user_id="{sid}",')
+ print(' )')
+ print(' auth = BoxCCGAuth(config=config)')
+ print(' client = BoxClient(auth=auth)')
+ print(' print("Hello,", client.users.get_user_me().name)')
+ elif mode == cfg.AUTH_MODE_OAUTH:
+ print(' import os')
+ print(' from box_sdk_gen import BoxClient, BoxOAuth, OAuthConfig')
+ print('')
+ print(' config = OAuthConfig(')
+ print(' client_id=os.environ["BOX_OAUTH_CLIENT_ID"],')
+ print(' client_secret=os.environ["BOX_OAUTH_CLIENT_SECRET"],')
+ print(' )')
+ print(' auth = BoxOAuth(config=config)')
+ print(' # Then run the OAuth flow — see docs/authentication.md.')
+ else:
+ print(" (see docs/authentication.md for the snippet matching your mode)")
+
+ print()
+ paced_print(
+ " Run it from the repo root with the SDK on sys.path:",
+ " python3 smoke.py",
+ "",
+ " Or use python-dotenv to auto-load .env:",
+ " pip install python-dotenv",
+ " python3 -c \"from dotenv import load_dotenv; load_dotenv(); exec(open('smoke.py').read())\"",
+ after_ms=200,
+ )
+ return True
+
+
+# ---- step registry ------------------------------------------------------
+
+WizardStepFn = Callable[[], bool]
+WIZARD_STEPS: list[tuple[str, str, bool, WizardStepFn]] = [
+ ("preflight", "Python + dependencies preflight", True, step_preflight),
+ ("auth", "Auth credentials (Developer Token / JWT / CCG / OAuth)", True, step_auth_credentials),
+ ("smoke", "Smoke test snippet", True, step_smoke_test),
+]
+
+
+# ---- runner -------------------------------------------------------------
+
+
+def run_steps(selected_ids: list[str]) -> int:
+ if not selected_ids:
+ paced_print(" Nothing selected. Exiting.", after_ms=200)
+ return 0
+
+ paced_print(
+ f" → Will set up: {', '.join(sid for sid in selected_ids)}",
+ after_ms=200,
+ )
+
+ failures: list[str] = []
+ for sid, _label, _recommended, fn in WIZARD_STEPS:
+ if sid not in selected_ids:
+ continue
+ try:
+ ok = fn()
+ except KeyboardInterrupt:
+ print("\n Interrupted by user.")
+ return 130
+ except Exception as exc: # noqa: BLE001 - wizard must stay friendly
+ print(f" [FAIL] step `{sid}` raised {type(exc).__name__}: {exc}")
+ failures.append(sid)
+ continue
+ if not ok:
+ failures.append(sid)
+
+ print()
+ banner(f"{WIZARD_TITLE} complete")
+ for line in COMPLETION_FOOTER_LINES:
+ paced_print(line, after_ms=80)
+
+ if failures:
+ print()
+ print(f" {len(failures)} step(s) reported issues: {', '.join(failures)}")
+ print(" Re-run `./scripts/setup` and pick those steps to retry.")
+ return 1
+ return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ argv = list(argv if argv is not None else sys.argv[1:])
+
+ banner(WIZARD_TITLE)
+ paced_print(*WIZARD_TAGLINE.splitlines(), after_ms=300)
+ print()
+
+ picker_items = [(sid, label, recommended) for sid, label, recommended, _ in WIZARD_STEPS]
+ selected = pick_steps(picker_items)
+ print()
+
+ return run_steps(selected)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/doctor b/scripts/doctor
new file mode 100755
index 00000000..c07bf04d
--- /dev/null
+++ b/scripts/doctor
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+# Doctor launcher (read-only audit). See scripts/setup for why the
+# package is named dev_setup/ rather than setup/.
+
+set -euo pipefail
+cd "$(dirname "$0")/.."
+exec python3 -m dev_setup.doctor "$@"
diff --git a/scripts/setup b/scripts/setup
new file mode 100755
index 00000000..77ac10da
--- /dev/null
+++ b/scripts/setup
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+# Setup wizard launcher.
+#
+# The wizard package is named `dev_setup/` (not `setup/`) to avoid
+# colliding with the SDK's own `setup.py` and `find_packages()`
+# discovery. `dev_setup/` is intentionally NOT installed by pip — it's
+# a developer-only onboarding helper and stays in the source tree.
+
+set -euo pipefail
+cd "$(dirname "$0")/.."
+exec python3 -m dev_setup.wizard "$@"
diff --git a/setup.py b/setup.py
index 1f89d304..4ea9beab 100644
--- a/setup.py
+++ b/setup.py
@@ -59,7 +59,7 @@ def main():
install_requires=install_requires,
tests_require=tests_require,
extras_require=extras_require,
- packages=find_packages(exclude=['docs', '*test*']),
+ packages=find_packages(exclude=['docs', '*test*', 'dev_setup', 'dev_setup.*']),
)