Metadata-Version: 2.5
Name: sportalliance-mcp
Version: 0.2.0
Summary: Sport Alliance MCP — one MCP server for Magicline and PerfectGym Next. Run once with `uvx sportalliance-mcp setup`, pick your platform, and it just works. Not for legacy PerfectGym (perfectgym.pl).
Project-URL: Repository, https://github.com/sportalliance/spa-openapi-mcp-wrapper
Project-URL: Documentation, https://developer.sportalliance.com
Author: Karl Foster
License: MIT
License-File: LICENSE
Keywords: fitness,llm,magicline,mcp,perfectgym,perfectgym-next,sportalliance
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: keyring>=25.0
Requires-Dist: mcp<2,>=1.2.0
Requires-Dist: questionary>=2.0
Requires-Dist: redshift-connector>=2.1
Requires-Dist: rich>=13.0
Description-Content-Type: text/markdown

# Sport Alliance MCP

Run your [Magicline](https://www.magicline.com) or
[PerfectGym Next](https://www.perfectgym.com) studio with natural language.
Ask questions or give instructions like *"Book Jonas Weber into tonight's Spin
class"*, *"Pause Anna Schmidt's contract for August"*, or *"When can Anna
cancel her contract at the latest?"* — and get instant answers without opening
dashboards or writing code.

One install serves every Sport Alliance platform: setup asks which platform
your studio is on — **Magicline** or **PerfectGym Next** — and configures the
rest. Multi-brand operators can connect a Magicline studio and a PerfectGym
Next club side by side in the same assistant, each with its own API key and
its own permissions.

Built on the Sport Alliance Open API
([Magicline docs](https://developer.sportalliance.com/apis/magicline/openapi/general-information)
· [PerfectGym Next docs](https://developer.sportalliance.com/apis/perfectgym/openapi/general-information)),
with optional SQL analytics over the enterprise data warehouse
([Magicline](https://data-warehouse-docs.magicline.com/index.html)
· [PerfectGym Next](https://data-warehouse-docs.perfectgym.com/index.html),
Amazon Redshift).

> **Not for legacy PerfectGym.** This package connects to **PerfectGym Next**,
> Sport Alliance's current Open API platform. The classic PerfectGym product
> (`perfectgym.pl`, "Perfect Gym Manager") uses a different API with different
> authentication and is not supported here.

**Supported AI clients:**

- [Claude Desktop](https://www.claude.com/download)
- [Claude Code](https://github.com/anthropics/claude-code)
- [Cursor](https://cursor.com/)
- [Windsurf](https://windsurf.com/)
- [Gemini CLI](https://github.com/google-gemini/gemini-cli)
- [Antigravity by Google](https://antigravity.google/)
- any other MCP-compatible client (manual config below)

## What is MCP?

[Model Context Protocol](https://modelcontextprotocol.io) (MCP) is an open
standard that lets AI assistants securely connect to external tools and APIs.
This server acts as a bridge between your AI client and your studio platform,
translating natural language requests into safe, scoped API calls. There is no
server to run or keep alive — your AI client starts it on demand and shuts it
down when finished.

## Installation

**Prerequisites:**

- [uv](https://docs.astral.sh/uv/) (installs its own Python — nothing else needed):

  ```sh
  # macOS / Linux
  curl -LsSf https://astral.sh/uv/install.sh | sh

  # Windows (PowerShell)
  powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
  ```

- An Open API key (tenant name + key are in your integration activation
  email — the base URL looks like `https://<tenant>.open-api.magicline.com`
  for Magicline studios or `https://<tenant>.open-api.perfectgym.com` for
  PerfectGym Next)

Then run the setup wizard:

```sh
uvx sportalliance-mcp setup
```

The **first question is your platform** — Magicline or PerfectGym Next (with a
built-in "help me tell them apart" if you're unsure: check the hostname in
your activation email). Everything after that is painted in your platform's
colours. Setup asks whether the studio is **production or sandbox**, shows the
fully resolved API host right in the key prompt so a key can't land on the
wrong platform unnoticed, validates the credentials against the live API and
shows the studio name the key actually opens (it is stored and re-checked
every time the server starts), stores the key in your OS secret store (macOS
Keychain / Windows Credential Manager / Secret Service — never in a
plain-text config file), then lets you pick which AI clients to configure
from an interactive checklist — detected clients are pre-selected, and Claude
Code is configured via `claude mcp add --scope user` automatically. Restart
your AI client and you're done. Run setup again any time to configure your
other platform alongside the first.

To always use the latest version (auto-update on each client restart), add `--auto-update`:

```sh
uvx sportalliance-mcp setup --auto-update
```

To skip the platform question (e.g. scripted installs):

```sh
uvx sportalliance-mcp setup --platform perfectgym-next
```

By default, setup configures the server in a **safe, read-only mode** (no
member data, no writes). To selectively enable elevated capabilities during
setup, pass `--advanced`:

```sh
uvx sportalliance-mcp setup --advanced
```

What you'll choose (optional):

- **Member data / PII** (`pii` tier): access member profiles (including lookup
  by card/barcode/QR), contracts, check-in history, account balances, payment
  details, transactions, and membership switch/add previews.
- **Writes** (`write` tier): book and cancel classes and appointments, check
  members in and out, create customers and leads, assign memberships (zero-due
  offers), cancel contracts, manage idle periods (contract freezes), and log
  communications.
- **Individual tools** (optional): after picking tiers, setup offers the same
  per-category tool editor as `sportalliance-mcp permissions` — e.g. keep
  class bookings but disable `cancel_membership` entirely. Existing per-tool
  disables survive a re-run of setup either way.

> **IMPORTANT:** Enabling writes allows the AI agent to take real, potentially
> irreversible actions in your studio — including cancelling real member
> contracts and creating real bookings. Enabling member data exposes personal
> data of real members, with everything that implies under data-protection
> law (GDPR, CCPA, and their local equivalents). If you are not in a position
> to review each tool call before approving it, keep these disabled —
> especially against a production studio. If you enable them, it is your
> responsibility to review every action before allowing the agent to proceed.

You can change permissions at any time without re-running setup:

```sh
uvx sportalliance-mcp permissions
```

When both platforms are configured, `permissions` asks which one you mean
(or pass `--platform`). Besides switching tiers, this opens a per-category
tool editor: pick a category (memberships, appointments, classes, ...), then
check or uncheck individual tools. Disabled tools are never registered with
the AI client — the agent doesn't see them at all. Selections are stored as a
deny-list (`SPORTALLIANCE_TOOLS_EXCLUDE`), so tools added in future versions
are enabled by default.

### Skipping permission prompts (Claude Code)

Independently of the tiers above, Claude Code asks for approval before
**every** tool call ("Claude wants to use List customers…"). When setup
configures Claude Code, it offers to pre-approve tools so those prompts
disappear:

- **Non-personal reads** (recommended): schedules, offers and studio info run
  without prompting; anything touching member data, and every write action
  (bookings, cancelations, check-ins), still asks first.
- **Everything except destructive actions**: no prompts for reads and routine
  writes; contract signups/cancellations (including withdrawing a
  cancellation) and financial exports **always** ask — they are never
  pre-approved, by design.
- **No**: keep Claude's default ask-every-time behavior.

To skip the question and pre-approve everything (e.g. scripted installs):

```sh
uvx sportalliance-mcp setup --allow-all
```

This works by merging `mcp__Magicline…` / `mcp__PerfectGymNext…` rules into
the `permissions.allow` list in `~/.claude/settings.json`, which both the
Claude Code CLI and its desktop app read. Your existing rules are preserved
(the file is backed up before every rewrite); remove the entries from that
file to get the prompts back. Rules are per platform — pre-approving your
Magicline studio changes nothing for your PerfectGym Next club.

> **Claude Desktop** has no pre-approval config file — use the "Always allow"
> option in its permission dialog, once per tool. Other clients (Cursor,
> Windsurf, Gemini CLI) have their own auto-approval settings in their UIs.

## What you can do

Run `uvx sportalliance-mcp tools` to see every tool, grouped by
permission tier. Tools are hand-curated over the Open API with
pagination, rate limiting and retries handled internally, and are identical
on both platforms — the two share one API surface. The warehouse tools
only appear when the optional [data warehouse](#data-warehouse-optional)
connection is configured.

Try these prompts — a day at the front desk:

- "Book Jonas Weber into tonight's Spin class"
- "Check Anna Schmidt in"
- "When can Anna cancel her contract at the latest?"
- "What payment method does she have on file, and what's her balance?"
- "Pause her contract for August — vacation"
- "Extend that freeze by a month — what would it cost?"
- "Create a lead for Max Mustermann, max@example.com, and book him a free
  trial session for tomorrow morning"
- "Assign the 12 Month Fixed membership to customer 10023 from next month"
- "Log that phone call on their record"

…and for the back office:

- "What classes are running tomorrow, and which still have free spots?"
- "How busy is the gym right now?"
- "What membership offers do we sell, and what would Premium cost customer 10023?"
- "Show the account balance and upcoming charges for customer 10023"

Every booking, contract and validate/preview result carries a `studio` field
naming the platform, tenant and environment it executed against — with
**PRODUCTION** spelled out — so the assistant (and you, reading the
transcript) always know which studio a write went to.

## Data warehouse (optional)

Operators with access to the enterprise data warehouse
([Magicline](https://data-warehouse-docs.magicline.com/index.html) ·
[PerfectGym Next](https://data-warehouse-docs.perfectgym.com/index.html),
Amazon Redshift) can enable extra SQL analytics tools. The setup wizard
asks about this ("Configure a Redshift data warehouse connection?") — skip it
and nothing changes: the warehouse tools are only registered when a
connection is configured.

| Tool | Tier | What it does |
| --- | --- | --- |
| `warehouse_docs` | read | Topic lookup in the built-in glossary (business terms, join keys, KPI definitions, SQL rules) |
| `warehouse_list_schemas` | read | List queryable schemas |
| `warehouse_list_tables` | read | List tables/views in a schema (stale `__old` copies flagged deprecated) |
| `warehouse_describe_table` | read | Column names, types, nullability |
| `warehouse_query` | read | Read-only SQL with PII values masked |
| `warehouse_query_pii` | pii | Read-only SQL, unmasked rows |

Queries run with guardrails: results are capped at 500 rows (with a
`truncated` flag), a 60-second statement timeout is set, multi-statement
SQL is rejected, and driver errors come back cleaned up with corrective
hints for common Redshift-dialect mistakes.

At read tier, `warehouse_query` masks values in string/varchar columns and
known personal-data columns such as `date_of_birth` as `"<withheld>"` —
row counts and structure stay intact, and enum-like status/type columns
plus period aliases (`AS month`, `AS year`, …) stay visible. This masking
is **best-effort hygiene, not a security boundary** — it keys on result
column names, so aliased queries can slip past it. If you must guarantee
no PII at read tier, disable `warehouse_query` (via
`sportalliance-mcp permissions`) or enforce masking in the warehouse itself.

> **IMPORTANT:** connect with a warehouse user that has **read-only grants**.
> The server opens read-only transactions and rolls every query back, but
> arbitrary SQL could escape that — the database user's permissions are the
> real enforcement.

The password lives in the OS keyring (manage it with
`sportalliance-mcp warehouse set|status|delete`); host, port, database and
user go into the client config's env block as `WAREHOUSE_*` variables (see
[Environment variables](#environment-variables)). A group sharing one
warehouse across both platforms stores the password once.

When a warehouse is configured, the server also teaches the AI how to query
it: the key-tables cheat sheet is embedded in the server instructions, and
the full business glossary (definitions, join keys, correct status filters
for every `erp_v2` table) is available on demand — by topic through the
`warehouse_docs` tool, or wholesale as MCP resources
(`magicline://warehouse/glossary` or
`perfectgym-next://warehouse/glossary`, per platform).

## API Key Management

**Key storage:** actual key values live in your OS secret store (macOS
Keychain, Windows Credential Manager / DPAPI, Secret Service on Linux),
keyed by `platform:tenant` — the same tenant name can exist on both
platforms without collision. Only tenant *names* are kept in
`~/.sportalliance-mcp/registry.json`, along with each entry's environment
and the verified studio name. On headless Linux without a keyring, fall back
to the `SPORTALLIANCE_API_KEY` environment variable.

**How key selection works:** keys are stored per studio. The *active* tenant
for each platform is whatever `SPORTALLIANCE_TENANT` is written in that
platform's entry in your client configs — `keys activate` rewrites it
everywhere in one command, touching **only that platform's** entries.

```sh
# List stored keys grouped by platform (masked), with the active tenant
uvx sportalliance-mcp keys list

# Add a key for another studio (validated against the live API)
uvx sportalliance-mcp keys add

# Switch a platform's configured clients to another studio
uvx sportalliance-mcp keys activate perfectgym-next/tribegym-us
uvx sportalliance-mcp keys activate acme        # unambiguous tenants work bare

# Delete a stored key (warns if it's the active one)
uvx sportalliance-mcp keys delete magicline/acme
```

Open API keys are **per studio**: multi-studio operators store one key per
tenant and switch with `keys activate`. Each key remembers its environment
(production, sandbox, or a custom URL — chosen when it was added), and
`keys activate` switches the endpoint together with the tenant.

## Checking your setup

```sh
uvx sportalliance-mcp status
```

`status` is a local doctor — no network, safe to run blind. It checks every
configured client entry for the disagreements that actually break things:
entry key vs declared platform, platform vs base-URL host, environment vs
host, the same inline API key pasted under two different platforms, stale
`MAGICLINE_*`/`SPORTALLIANCE_*` twins that disagree, launcher paths that no
longer exist, missing keyring entries, and Claude Code allow rules that match
no configured entry.

`uvx sportalliance-mcp test` goes one step further and verifies every
configured entry against the live API, printing the studio name each key
actually opens.

## Upgrading from magicline-mcp

Run `uvx sportalliance-mcp setup` — it detects an existing magicline-mcp
installation and offers to migrate before anything else. Migration copies
every discoverable key into the new keyring service (verified by read-back,
**deleting nothing**), writes the new registry (leaving the old one in
place), backs up each client config, and rewrites the existing `Magicline`
entry with both env prefixes — so the config keeps working under a cached
old binary, under the new package, and can simply be restored from the
backup if you ever want to roll back. The MCP server key stays exactly
`Magicline`: existing Claude Code allow rules keep working untouched, and
your AI client shows the same server name as before.

Staying on `magicline-mcp 0.1.2` is also fine — it keeps working and nothing
breaks. Configs carried over by hand (just the `MAGICLINE_*` env block) work
with zero action: the server recognises the legacy prefix and serves the
Magicline platform, printing a one-line deprecation note.

## Claude Code

`setup --claude-code` configures Claude Code automatically by running
`claude mcp add --scope user` for you (user scope, so the server is
available in every directory, not just the one setup ran in), and then
offers to pre-approve tools (see
[Skipping permission prompts](#skipping-permission-prompts-claude-code)).
Or do it manually:

```sh
claude mcp add PerfectGymNext --scope user \
  -e SPORTALLIANCE_PLATFORM=perfectgym-next \
  -e SPORTALLIANCE_TENANT=<tenant> \
  -e SPORTALLIANCE_ENVIRONMENT=production \
  -e SPORTALLIANCE_TOOLS=read -- uvx sportalliance-mcp

# Verify it was added
claude mcp list
```

## Manual configuration

The setup wizard edits these files for you, but you can also edit them by hand:

- **Claude Desktop:** `~/Library/Application Support/Claude/claude_desktop_config.json`
  (Windows: `%APPDATA%\Claude\claude_desktop_config.json`)
- **Cursor:** `~/.cursor/mcp.json`
- **Windsurf:** `~/.codeium/windsurf/mcp_config.json`
- **Gemini CLI:** `~/.gemini/settings.json`
- **Antigravity:** `~/.gemini/antigravity/mcp_config.json`

All use the same format (for Gemini CLI, the `mcpServers` block lives inside
its existing `settings.json` — the wizard merges without touching your other
settings). Two platforms from one install look like this (keys come from the
OS keyring, stored by `setup` or `keys add`):

```json
{
  "mcpServers": {
    "Magicline": {
      "command": "uvx",
      "args": ["sportalliance-mcp"],
      "env": {
        "SPORTALLIANCE_PLATFORM": "magicline",
        "SPORTALLIANCE_TENANT": "acme-fitness",
        "SPORTALLIANCE_ENVIRONMENT": "production",
        "SPORTALLIANCE_TOOLS": "read"
      }
    },
    "PerfectGymNext": {
      "command": "uvx",
      "args": ["sportalliance-mcp"],
      "env": {
        "SPORTALLIANCE_PLATFORM": "perfectgym-next",
        "SPORTALLIANCE_TENANT": "tribegym-us",
        "SPORTALLIANCE_ENVIRONMENT": "sandbox",
        "SPORTALLIANCE_TOOLS": "read"
      }
    }
  }
}
```

Alternative — environment variable instead of the keyring (CI, Docker,
headless Linux): add `"SPORTALLIANCE_API_KEY": "your-api-key-here"` to the
env block. Never paste the same key into two different platforms' entries —
keys are per studio, and `status` flags exactly that mistake.

> On Windows, prefer the full path to `uvx.exe` as `command` — GUI apps don't
> always inherit your shell's PATH. The setup wizard does this automatically.

## CLI reference

| Command | What it does |
| --- | --- |
| `sportalliance-mcp setup` | One-time interactive setup (platform question first; safe read-only mode) |
| `sportalliance-mcp setup --platform <id>` | Skip the platform question (`magicline`, `perfectgym-next`) |
| `sportalliance-mcp setup --advanced` | Setup with elevated capabilities (member data, writes, per-tool fine-tuning) |
| `sportalliance-mcp setup --auto-update` | Clients always launch the latest published version |
| `sportalliance-mcp setup --claude-code` | Also configures Claude Code via `claude mcp add --scope user` |
| `sportalliance-mcp setup --allow-all` | Pre-approve tools in Claude Code — writes included, but destructive contract/payment actions still ask |
| `sportalliance-mcp test` | Verify every configured entry against the live API |
| `sportalliance-mcp status` | Local config doctor — spot misconfigurations without any network |
| `sportalliance-mcp tools` | List every tool, grouped by permission tier |
| `sportalliance-mcp permissions [--platform p]` | Change tiers or enable/disable individual tools |
| `sportalliance-mcp keys list` | Show stored keys grouped by platform (masked), environments, active tenants |
| `sportalliance-mcp keys add` | Store a key for another studio |
| `sportalliance-mcp keys activate <p/tenant>` | Switch a platform's clients to another studio (and its environment) |
| `sportalliance-mcp keys delete <p/tenant>` | Remove a stored key |
| `sportalliance-mcp warehouse set\|status\|delete` | Manage the data-warehouse password in the OS keyring |
| `sportalliance-mcp` | Run the MCP server on stdio — your AI client does this for you |

(Prefix with `uvx`, or `uv run` inside the repo.)

## Environment variables

Variables marked *managed* are written by the setup wizard; you only set them
yourself in non-interactive environments. `MAGICLINE_*` equivalents of the
serve-time variables remain supported for existing Magicline configs
(deprecated; if both prefixes are set they must agree).

| Variable | Managed | Description |
| --- | --- | --- |
| `SPORTALLIANCE_PLATFORM` | ✅ | Which platform this entry serves: `magicline` or `perfectgym-next` (aliases `perfectgym`, `pgnext`). Required — the server refuses to guess. |
| `SPORTALLIANCE_TENANT` | ✅ | Your tenant/studio name (the `<tenant>` in the base URL). Required. |
| `SPORTALLIANCE_ENVIRONMENT` | ✅ | `production` (default) or `sandbox`. Explicit — never inferred from other settings. |
| `SPORTALLIANCE_TOOLS` | ✅ | Enabled tiers, comma-separated: `read` (default), `pii`, `write`. `read` is always included. |
| `SPORTALLIANCE_TOOLS_EXCLUDE` | ✅ | Individual tools to disable, comma-separated (e.g. `cancel_contract,create_tax_advisor_export`). Applied after tier filtering; unknown names are ignored with a warning. Managed by `sportalliance-mcp permissions`. |
| `SPORTALLIANCE_API_KEY` |  | API key. Overrides the OS keyring — for CI, Docker, or headless machines. |
| `SPORTALLIANCE_BASE_URL` | ✅ | Custom API base URL. Written by the wizard for sandbox/custom environments. HTTPS required (plain http only for localhost). Refused if the host belongs to a different platform than `SPORTALLIANCE_PLATFORM`. |
| `SPORTALLIANCE_MAX_RPS` | — | Client-side request ceiling in requests/second (default `3`). Keeps the MCP to a small slice of the API key's rate allowance, which your other integrations share. `0` disables the throttle. |
| `WAREHOUSE_HOST` | ✅ | Redshift endpoint of the enterprise data warehouse. Warehouse tools register only when host, database and user are all set. |
| `WAREHOUSE_PORT` | ✅ | Warehouse port (default `5439`). |
| `WAREHOUSE_DATABASE` | ✅ | Warehouse database name. |
| `WAREHOUSE_USER` | ✅ | Warehouse user — use one with read-only grants. |
| `WAREHOUSE_PASSWORD` |  | Warehouse password. Overrides the OS keyring — only needed where no keyring exists. |

## Troubleshooting

- **"SPORTALLIANCE_PLATFORM is not set"** — add it to the entry's env block
  (`magicline` or `perfectgym-next`), or re-run `uvx sportalliance-mcp setup`.
- **"No API key found for tenant"** — run `uvx sportalliance-mcp setup`, or
  set `SPORTALLIANCE_API_KEY`. Verify with `uvx sportalliance-mcp test`.
- **"tenant does not exist" (404)** — sandbox and production are separate
  tenant namespaces: a sandbox studio only resolves on
  `<tenant>.open-api.sandbox.<platform-domain>`. Setup offers the switch
  with one keystroke.
- **401 Unauthorized** — tenant and key don't match, or the integration was
  deactivated. Both come from the same activation email. Also check the key
  isn't from your *other* platform — `uvx sportalliance-mcp status` flags a
  key pasted under two platforms.
- **403 Permission denied** — three possible causes: your API key lacks the
  scope for that operation, the entity belongs to a different studio, or the
  member has opted out of third-party data access (their right — the error
  message says which).
- **The server refuses to start with a "STOPPING" identity message** — the
  API key opens a different studio than the one this entry was configured
  for. Re-run setup for that platform, or inspect with
  `uvx sportalliance-mcp status`.
- **Tools missing in your client** — member data and write tools only appear
  when their tier is enabled, and individual tools may have been disabled:
  check `uvx sportalliance-mcp permissions`. Restart the client after any
  config change.
- **`claude` CLI not found** (with `--claude-code`) — install Claude Code
  first, then re-run `uvx sportalliance-mcp setup --claude-code`.
- **"Server disconnected" in Claude Desktop** — check the server log at
  `~/Library/Logs/Claude/mcp-server-Magicline.log` or
  `mcp-server-PerfectGymNext.log` (macOS); the last Python traceback usually
  names the cause.
- **Keyring issues on Linux** — headless machines often have no Secret
  Service; use the `SPORTALLIANCE_API_KEY` env var in the client config
  instead.

## Development

```sh
uv sync
uv run sportalliance-mcp tools
uv run pytest
```

Project layout:

```
src/sportalliance_mcp/
├── cli.py            # entry point + subcommands
├── platforms.py      # the platform registry: Magicline, PerfectGym Next, ...
├── config.py         # platform/tenant/key/tier resolution (env > keyring)
├── registry.py       # ~/.sportalliance-mcp/registry.json, keyed (platform, tenant)
├── client.py         # httpx wrapper: auth, retries, backoff, pagination
├── server.py         # FastMCP assembly, identity check, studio stamps
├── setup_wizard.py   # the one-time setup experience (platform picker first)
├── manage.py         # keys + permissions + the `status` doctor
├── migrate.py        # copy-forward migration from magicline-mcp
├── warehouse.py      # read-only Redshift client (optional warehouse tools)
├── branding.py       # terminal UI (per-platform palettes and banners)
└── tools/            # one module per API domain
```

> **Note (macOS + iCloud):** if this repo lives in an iCloud-synced folder
> (e.g. `~/Documents`), iCloud recursively stamps the macOS *hidden* flag on
> dot-directories — and Python ≥ 3.13.4 refuses to load hidden `.pth` files,
> which silently breaks the venv. Do **not** work around it with a `.venv`
> symlink — iCloud materializes symlinks into broken directory copies.
> Instead, keep the venv in a `*.nosync` directory (iCloud ignores those) by
> setting, e.g. in `~/.zshenv`:
>
> ```sh
> export UV_PROJECT_ENVIRONMENT=".venv.nosync"
> ```
>
> The simplest alternative: keep code outside iCloud-synced folders entirely.

## License

This project is licensed under the MIT License — see [LICENSE](LICENSE).
