diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..539c69c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,78 @@ +# AGENTS.md + +## Design Philosophy + +1. **Signal-Driven Intelligence** — All agent intelligence derives from observing real-world signals, not from hardcoded rules. If a behavior cannot be learned from signals, it is not in scope. + +2. **Context Pipeline as Core** — Signal → Filter (SNR) → Compress (intent-preserving) → Store (multi-layer) → Retrieve (goal-dependent) → Decide. Every feature must map to this pipeline. + +3. **Progressive Trust** — Never auto-execute on first encounter. Earn autonomy through repeated success: DRAFT → CANDIDATE → VERIFIED → PRODUCTION. + +4. **Occam's Razor** — The simplest correct solution wins. Reject complexity that doesn't directly serve user value. Every abstraction must pay for itself. + +5. **LLM-Native Design** — Design for LLM reasoning first. Protocols over classes. Declarative over imperative. Context over configuration. + +## Code Quality Requirements + +- SOLID principles are non-negotiable +- Occam's Razor: maximize elegance and efficiency, reject unnecessary complexity +- Design for generalization and universality +- Easy to extend, avoid hardcoding and hard rules +- Industrial-grade robustness: every external call has timeout, retry, and fallback +- All comments and docstrings in English +- Type annotations on all public APIs +- No bare except — always specify exception types + +## Architecture Principles + +- **Dependency Inversion**: Core logic depends on Protocol abstractions, never on concrete implementations +- **Protocol over ABC**: Use `typing.Protocol` with `runtime_checkable` for all extension points +- **Event-Driven Communication**: Modules interact through typed events on EventBus, not direct imports +- **Immutable Domain Types**: Use `@dataclass(frozen=True)` or `NamedTuple` for domain objects +- **Config-Driven Behavior**: Thresholds, intervals, feature flags — all configurable via env vars +- **Graceful Degradation**: Every optional component (LLM, Hub, OS Host) can be absent without crash +- **Single Source of Truth**: DuckDB for persistence, EventBus for communication, Settings for configuration + +## Implementation Guidelines + +- Define the Protocol first — the contract is the design +- Implement against the Protocol, never against another implementation +- Write unit tests before or alongside the implementation +- Integrate via EventBus events, not direct function calls between modules +- Every module must be importable standalone without side effects +- No placeholder stubs — implement fully or do not add the code +- ANSI output must check `sys.stdout.isatty()` before emitting escape codes + +## Testing Philosophy + +- **Unit tests must be hermetic**: no network, no OS Host, no LLM calls +- **py_compile all modified files**: syntax errors caught before test run +- **Import chain verification**: every new module must be importable standalone +- **Existing tests must not regress**: all tests must pass after every change +- **Verification sequence**: compile → import → unit test → integration (if applicable) +- **Behavior contracts over snapshots**: assert invariants, not frozen values +- **Mock at boundaries only**: mock external I/O (network, disk, OS Host), never internal logic + +## What to Avoid + +- Over-engineering: if you need 3+ files for a simple feature, rethink +- Premature optimization: measure first, optimize only bottlenecks +- God objects: no class should exceed 300 lines +- Magic strings: use constants or enums +- Blocking the event loop: all IO must be async or `run_in_executor` +- Hardcoded paths, URLs, thresholds without config escape hatch +- Chinese comments in source code (English only) +- Speculative infrastructure: no hooks or extension points without a concrete consumer +- Bare `except:` clauses — always specify the exception type +- `# TODO: implement` stubs — implement or don't commit + +## Naming Conventions + +- **Files**: `snake_case.py` — noun for types (`signal_event.py`), verb for actions (`compress_context.py`) +- **Classes**: `PascalCase` — Protocols suffixed with purpose (`SignalSource`, `SkillStore`) +- **Functions/Methods**: `snake_case` — verb-first (`filter_noise`, `retrieve_context`) +- **Constants**: `UPPER_SNAKE_CASE` — grouped in module-level or dedicated `constants.py` +- **Private**: single underscore prefix (`_internal_method`) — never double underscore +- **Modules/Packages**: short, singular nouns (`perception`, `causal`, `memory`) +- **Events**: past-tense domain verbs (`SignalReceived`, `SkillVerified`, `ContextCompressed`) +- **Config keys**: `dot.separated.lowercase` in env/settings (`copilot.idle_threshold_ms`) diff --git a/Makefile b/Makefile index 507faee..1a79702 100644 --- a/Makefile +++ b/Makefile @@ -1,30 +1,7 @@ # ── Variables ───────────────────────────────────────────────────────────────── LEAPFLOW_DATA_DIR ?= $(HOME)/.leapflow -HOST_ROOT := $(LEAPFLOW_DATA_DIR)/host -HOST_SOCKET := $(LEAPFLOW_DATA_DIR)/var/host.sock -HOST_PID := $(LEAPFLOW_DATA_DIR)/var/host.pid -HOST_LOG := $(LEAPFLOW_DATA_DIR)/var/host.log -# ── OS Host platform detection ──────────────────────────────────────────────── -HOST_OS := $(shell uname -s) - -ifeq ($(HOST_OS),Darwin) - # macOS: direct swiftc (workaround for CLT 26.x SPM manifest bug) - HOST_SRC := os_host/darwin/Sources/OSHost - HOST_SOURCES := $(shell find $(HOST_SRC) -name '*.swift') - SWIFTC_FLAGS := -module-name OSHost -swift-version 5 \ - -target arm64-apple-macosx14.0 \ - -sdk $(shell xcrun --show-sdk-path) \ - -vfsoverlay os_host/darwin/vfs_overlay.yaml -else ifeq ($(HOST_OS),Linux) - $(info OS Host for Linux: not yet implemented) -else - $(info OS Host for $(HOST_OS): not yet implemented) -endif - -.PHONY: setup sync test brain host swift-build lint \ - host-build host-install host-start host-stop host-restart \ - host-status host-setup host-clean host-dev +.PHONY: setup sync test brain lint cua-check setup: ## Setup scripts permissions and environment chmod +x scripts/setup.sh scripts/run.sh @@ -39,73 +16,9 @@ test: ## Run tests lint: ## Lint source code uv run ruff check src/leapflow/ tests/ -# LeapFlow CLI (pass PROMPT via ARGS, e.g. make brain ARGS='--mock-host --prompt "hello"') +# LeapFlow CLI (pass PROMPT via ARGS, e.g. make brain ARGS='--prompt "hello"') brain: ## Start Brain process uv run leap $(ARGS) -swift-build: ## Build OS Host (debug) -ifeq ($(HOST_OS),Darwin) - @mkdir -p os_host/darwin/.build/debug - swiftc $(SWIFTC_FLAGS) -g -Onone -o os_host/darwin/.build/debug/OSHost $(HOST_SOURCES) - @echo "Built: os_host/darwin/.build/debug/OSHost" -else - @echo "Error: OS Host build not yet supported on $(HOST_OS)" && exit 1 -endif - -host: ## Run OS Host in foreground (debug) - @$(MAKE) swift-build - LEAPFLOW_BRIDGE_SOCKET=$${LEAPFLOW_BRIDGE_SOCKET:-$(HOST_SOCKET)} os_host/darwin/.build/debug/OSHost - -# ── OS Host Service Management ──────────────────────────────────────────────── - -host-build: ## Build OS Host (release) -ifeq ($(HOST_OS),Darwin) - @mkdir -p os_host/darwin/.build/release - swiftc $(SWIFTC_FLAGS) -O -o os_host/darwin/.build/release/OSHost $(HOST_SOURCES) - @echo "Built: os_host/darwin/.build/release/OSHost" -else - @echo "Error: OS Host build not yet supported on $(HOST_OS)" && exit 1 -endif - -host-install: host-build ## Build + package as .app bundle + deploy to ~/.leapflow/host/ - @mkdir -p $(HOST_ROOT)/LeapHost.app/Contents/MacOS - @mkdir -p $(HOST_ROOT)/LeapHost.app/Contents/Resources - @cp os_host/darwin/.build/release/OSHost $(HOST_ROOT)/LeapHost.app/Contents/MacOS/LeapHost - @cp os_host/darwin/Resources/Info.plist $(HOST_ROOT)/LeapHost.app/Contents/Info.plist - @chmod +x $(HOST_ROOT)/LeapHost.app/Contents/MacOS/LeapHost - @echo "Installed to $(HOST_ROOT)/LeapHost.app" - -host-start: ## Start OS Host service - @mkdir -p $(LEAPFLOW_DATA_DIR)/var - $(HOST_ROOT)/LeapHost.app/Contents/MacOS/LeapHost \ - --daemon --socket $(HOST_SOCKET) --pid-file $(HOST_PID) --log-file $(HOST_LOG) & - @echo "OS Host started" - -host-stop: ## Stop OS Host service - @if [ -f $(HOST_PID) ]; then \ - kill $$(cat $(HOST_PID)) 2>/dev/null || true; \ - echo "OS Host stopped"; \ - else \ - echo "OS Host not running"; \ - fi - -host-restart: host-stop host-start ## Restart OS Host - -host-status: ## Show OS Host status - @if [ -f $(HOST_PID) ] && kill -0 $$(cat $(HOST_PID)) 2>/dev/null; then \ - echo "● Running (PID $$(cat $(HOST_PID)))"; \ - else \ - echo "○ Stopped"; \ - fi - -host-setup: host-install ## Full setup: build + install + register launchd - @python -m leapflow.cli.commands.host _register_launchd 2>/dev/null || \ - echo "Run 'leap host setup' for full LaunchAgent registration" - @echo "Setup complete. Run 'leap host start' or re-login for auto-start." - -host-clean: ## Remove installed host - @rm -rf $(HOST_ROOT)/LeapHost.app - @echo "Host app removed" - -host-dev: ## Run OS Host in dev mode (auto-rebuild on changes) - uv run leap host dev +cua-check: ## Check cua-driver installation status + @which cua-driver > /dev/null 2>&1 && echo "✓ cua-driver: $$(cua-driver --version 2>/dev/null || echo 'installed')" || echo "✗ cua-driver not found. Install: brew install trycua/tap/cua-driver" diff --git a/README.md b/README.md index d58698f..04c6fa4 100644 --- a/README.md +++ b/README.md @@ -28,37 +28,61 @@ Unlike instruction-driven agents (Computer-Use, RPA) that reason from scratch on ## Architecture Overview -LeapFlow implements a layered cognitive pipeline: +LeapFlow implements a **three-layer hybrid architecture** — a Python intelligence core, a protocol-driven platform adaptation layer, and pluggable execution backends — communicating via the MCP (Model Control Protocol) standard: ``` -┌───────────────────────────────────────────────────────────┐ +┌─────────────────────────────────────────────────────────┐ +│ Intelligence Core (Python) │ +│ ├── Engine / OODA Loop + Learning + Copilot │ +│ ├── Signal Fusion → Causal Engine → World Model │ +│ └── Skill Synthesis + Memory System │ +├─────────────────────────────────────────────────────────┤ +│ Platform Adaptation Layer │ +│ ├── Protocol Client (MCP stdio / WebSocket / gRPC) │ +│ ├── Event Normalizer + Reorder Buffer │ +│ └── Capability Negotiation │ +├─────────────────────────────────────────────────────────┤ +│ Execution Layer (pluggable backends) │ +│ ├── cua-driver (macOS native — default) │ +│ ├── Mock Host (in-process, for testing) │ +│ └── (future: remote VM, cloud sandbox, ...) │ +└─────────────────────────────────────────────────────────┘ +``` + +The cognitive pipeline built on top: + +``` +┌───────────────────────────────────────────────────────────────┐ │ Copilot Workflow-level next-step prediction │ -├───────────────────────────────────────────────────────────┤ +├───────────────────────────────────────────────────────────────┤ │ World Model State prediction · Experience replay │ -├───────────────────────────────────────────────────────────┤ +├───────────────────────────────────────────────────────────────┤ │ Skill Synthesis Hypothesis → Draft → Verified → Prod │ -├───────────────────────────────────────────────────────────┤ +├───────────────────────────────────────────────────────────────┤ │ Causal Engine Rule · Heuristic · VLM verification │ -├───────────────────────────────────────────────────────────┤ +├───────────────────────────────────────────────────────────────┤ │ Perception Multi-channel signal fusion (7 ch) │ -└───────────────────────────────────────────────────────────┘ +├───────────────────────────────────────────────────────────────┤ +│ Execution Layer OS interaction (screen, input, AX) │ +└───────────────────────────────────────────────────────────────┘ ``` -**Perception** fuses raw signals into a causal timeline. The **Causal Engine** infers why things happened, not just what. The **World Model** builds an internal representation of the environment and learns from prediction errors. **Skill Synthesis** distills observations into parameterized, reusable skills with maturity tracking. The **Copilot** predicts your next workflow step and offers proactive suggestions — like GitHub Copilot, but for everything you do on your computer. +The **Execution Layer** provides native OS interactions — screen capture, accessibility tree queries, and input injection. The default backend is `cua-driver` (macOS, MCP stdio transport), but the architecture is backend-agnostic via the Platform Adaptation Layer. **Perception** fuses raw signals into a causal timeline. The **Causal Engine** infers why things happened, not just what. The **World Model** builds an internal representation of the environment and learns from prediction errors. **Skill Synthesis** distills observations into parameterized, reusable skills with maturity tracking. The **Copilot** predicts your next workflow step and offers proactive suggestions — like GitHub Copilot, but for everything you do on your computer. + --- ## Prerequisites | Component | Version | Purpose | -|-----------|---------|---------| +|-----------|---------|----------| | Python | ≥ 3.11 | Runtime (3.11–3.14 supported) | | [uv](https://github.com/astral-sh/uv) | latest | Fast package manager & virtualenv | -| macOS | 14.0+ (Sonoma) | Required for native OS Host perception | -| Xcode Command Line Tools | latest | Swift compiler for OS Host build | +| macOS | 14.0+ (Sonoma) | Required for native perception (execution backend) | +| [cua-driver](https://github.com/trycua/cua) | latest | Default execution backend — screen capture, input injection, accessibility | | LLM API Key | — | DashScope, OpenAI, DeepSeek, or any OpenAI-compatible provider | -> **Note:** You can run LeapFlow on any platform with `--mock-host` (no native perception), but full signal capture requires macOS with Accessibility permissions. +> **Note:** You can run LeapFlow on any platform with `--mock-host` (no native perception), but full signal capture requires macOS with an execution backend (currently `cua-driver`) installed and Accessibility permissions granted. ## Installation @@ -96,22 +120,22 @@ LEAPFLOW_LLM_BASE_URL=https://api.openai.com/v1 LEAPFLOW_LLM_MODEL=gpt-4o ``` -### 3. Build OS Host (Optional — macOS only) +### 3. Install Execution Backend (macOS only) -The native OS Host captures screen recordings, accessibility trees, and input events. Skip this step if you just want to explore with `--mock-host`. +The default execution backend is `cua-driver`, which provides screen capture, accessibility tree access, and input injection via the MCP protocol. Skip this step if you just want to explore with `--mock-host`. ```bash -make swift-build # Debug build +brew install trycua/tap/cua-driver ``` -This compiles the Swift host binary to `os_host/darwin/.build/debug/OSHost`. - -For production deployment: +Verify the driver is available: ```bash -make host-install # Release build + .app bundle → ~/.leapflow/host/ +uv run leap host doctor # Checks execution backend status and permissions ``` +> **Tip:** macOS will prompt for Accessibility and Screen Recording permissions on first use. Grant both in System Settings → Privacy & Security. + ### 4. Verify Installation ```bash @@ -131,7 +155,7 @@ The `.env` file lives in your project root (or `~/.leapflow/.env` for global def | `LEAPFLOW_LLM_API_KEY` | **Yes** | — | Your LLM provider API key | | `LEAPFLOW_LLM_BASE_URL` | No | DashScope endpoint | OpenAI-compatible base URL | | `LEAPFLOW_LLM_MODEL` | No | `qwen3.7-plus` | Model identifier | -| `LEAPFLOW_MOCK_HOST` | No | `0` | Set `1` to skip native Host | +| `LEAPFLOW_MOCK_HOST` | No | `0` | Set `1` to skip cua-driver | | `LEAPFLOW_RECORDING_MODE` | No | `video` | `video` / `default` / `vision_only` | | `LEAPFLOW_LOG_LEVEL` | No | `INFO` | `DEBUG` / `INFO` / `WARNING` | | `LEAPFLOW_DUCKDB_PATH` | No | `~/.leapflow/memory.duckdb` | Persistent storage location | @@ -148,8 +172,8 @@ The `.env` file lives in your project root (or `~/.leapflow/.env` for global def | `LEAPFLOW_LLM_MODEL` | `qwen3.7-plus` | Model identifier | | `LEAPFLOW_LLM_MAX_RETRIES` | `3` | Retry attempts on transient LLM errors | | **Bridge / Host** | | | -| `LEAPFLOW_BRIDGE_SOCKET` | `/tmp/leapflow.sock` | Unix socket for Brain↔Host IPC | -| `LEAPFLOW_MOCK_HOST` | `0` | `1` to skip native Host (in-process mock) | +| `LEAPFLOW_BRIDGE_SOCKET` | `/tmp/leapflow.sock` | Unix socket for event push (observers) | +| `LEAPFLOW_MOCK_HOST` | `0` | `1` to skip cua-driver (in-process mock) | | **Storage** | | | | `LEAPFLOW_DUCKDB_PATH` | `~/.leapflow/memory.duckdb` | Persistent DuckDB path | | `LEAPFLOW_DATA_DIR` | `~/.leapflow` | Root data directory | @@ -311,7 +335,7 @@ Skills start at `STEP` tier (human confirms each action) and graduate to `AUTO` | `run` | `leap run [prompt] [options]` | Execute a matched skill | | `skills` | `leap skills [action] [name]` | Manage the skill library | | `relearn` | `leap relearn ` | Re-run learning pipeline on a saved trajectory | -| `host` | `leap host ` | Manage native OS Host lifecycle | +| `host` | `leap host ` | Manage execution backend connection and diagnostics | **Global Flags:** @@ -355,15 +379,8 @@ Skills start at `STEP` tier (human confirms each action) and graduate to `AUTO` | Action | Description | |--------|-------------| -| `start` | Start the OS Host daemon | -| `stop` | Gracefully stop the OS Host | -| `restart` | Restart the OS Host | -| `status` | Show host status and macOS permissions | -| `logs [-f]` | View host logs (`-f` to stream) | -| `install` | Build and deploy `.app` bundle | -| `setup` | Install + register LaunchAgent + permission guidance | -| `uninstall` | Stop, unregister, and remove bundle | -| `dev` | Development mode (auto-rebuild on source changes) | +| `doctor` | Check execution backend installation, version, and macOS permissions | +| `status` | Show connection status to execution backend | @@ -455,23 +472,22 @@ LEAPFLOW_VERBOSE_PROGRESS=true # Show tool execution progress inline --- -## OS Host Management +## Host Management (Execution Backend) -For full perception (screen capture, accessibility tree, input events), you need the native OS Host running: +For full perception (screen capture, accessibility tree, input events), you need an execution backend installed. The default is `cua-driver`: ```bash -# Development (foreground, debug build) -make host # Terminal 1: builds + runs OS Host -uv run leap # Terminal 2: interactive REPL +# Check execution backend and permissions +uv run leap host doctor # Verifies backend binary, version, permissions + +# Start with full perception +uv run leap # Connects to execution backend via MCP automatically -# Production (daemon mode) -uv run leap host setup # Build, install, register as LaunchAgent -uv run leap host start # Start the daemon -uv run leap host status # Check if running -uv run leap host stop # Stop gracefully +# Without native perception +uv run leap --mock-host # Runs with in-process mock (for testing/exploration) ``` -> **Important:** macOS will prompt for Accessibility and Screen Recording permissions on first launch. Grant both in System Settings → Privacy & Security. +> **Important:** macOS will prompt for Accessibility and Screen Recording permissions on first use. Grant both in System Settings → Privacy & Security. --- @@ -481,9 +497,6 @@ uv run leap host stop # Stop gracefully make setup # Initialize environment make test # Run tests (pytest) make lint # Lint (ruff) -make swift-build # Build Swift Host (debug) -make host-build # Build Swift Host (release) -make host-dev # Auto-rebuild on source changes ```
@@ -507,13 +520,12 @@ leapflow/ │ ├── memory/ # Three-tier memory system │ ├── recording/ # Video recording orchestration │ ├── llm/ # LLM provider abstraction -│ ├── platform/ # RPC bridge + platform layer +│ ├── platform/ # MCP client + platform layer (cua-driver) │ ├── domain/ # Shared types & events │ ├── storage/ # DuckDB persistence │ ├── tools/ # Built-in tool registry │ ├── prompts/ # LLM prompt templates │ └── utils/ # Shared utilities -├── os_host/darwin/ # Native macOS Host (Swift) ├── tests/ # Pytest suite ├── docs/design/ # Design documents └── scripts/ # Setup & run scripts @@ -586,8 +598,8 @@ uv run pytest -k "test_world_model" -q # By keyword | `analysis/` | Six-layer denoising pipeline for trajectory refinement | | `engine/` | Session orchestration and ReAct execution loop | | `memory/` | Three-tier event-driven memory (working → episodic → long-term) | -| `platform/` | Platform adaptation layer and RPC bridge | -| `os_host/` | Native host service — macOS (Swift), Linux & Windows (planned) | +| `platform/` | Platform adaptation layer — protocol abstraction for pluggable execution backends | +| `hub/` | Multi-source skill hub (ModelScope, GitHub, local) |
Architecture — Detailed Module Map @@ -605,13 +617,13 @@ uv run pytest -k "test_world_model" -q # By keyword | Engine | `src/leapflow/engine/` | session, react_loop, tools | Session orchestration, ReAct loop, tool dispatch, context compression | | Memory | `src/leapflow/memory/` | working, episodic, long_term | Three-tier event-driven memory with promotion/eviction | | LLM | `src/leapflow/llm/` | provider, message_builder | LLM abstraction (OpenAI-compatible), streaming, retry logic | -| Platform | `src/leapflow/platform/` | rpc_client, bridge, adapter | RPC transport (msgpack over Unix socket), platform abstraction | +| Platform | `src/leapflow/platform/` | mcp_client, bridge, adapter | Platform adaptation layer — protocol client, event normalization, capability negotiation | | Domain | `src/leapflow/domain/` | events, perception, types | Shared domain types, event definitions, perception models | | Recording | `src/leapflow/recording/` | recorder, video, segmenter | Video recording orchestration, segmentation, caching | | Tools | `src/leapflow/tools/` | registry, builtins | Built-in tool definitions for the ReAct loop | | CLI | `src/leapflow/cli/` | cli, commands/, banner | Argument parsing, subcommand dispatch, interactive REPL | | Storage | `src/leapflow/storage/` | duckdb, skill_library | DuckDB-backed persistent storage for skills, trajectories, audit | -| OS Host (macOS) | `os_host/darwin/Sources/OSHost/` | Perception/, Execution/, Bridge/ | Native Swift host: screen capture, AX tree, input events, RPC server | +| Execution Backend | external (pluggable) | — | OS interaction: screen capture, AX tree, input injection (default: `cua-driver` via MCP) |
@@ -634,20 +646,18 @@ uv run pytest -k "test_world_model" -q # By keyword | `FeedbackSignal` | Structured user response (accept/ignore/correct/reject + latency) | | `FeedbackType` | Enum: `ACCEPT`, `IGNORE`, `CORRECT`, `EXPLICIT_REJECT` | -**RPC Schema (Brain ↔ OS Host):** +**MCP Protocol (LeapFlow → Execution Backend):** -Defined in `os_host/protocol/rpc_schema.yaml`. Transport: length-prefixed msgpack over Unix domain socket. +Transport: stdio (JSON-RPC over stdin/stdout) by default. The `PlatformClient` in `platform/` manages the connection lifecycle and abstracts the specific backend. | Method | Direction | Description | |--------|-----------|-------------| -| `video.start` / `video.stop` | Brain → Host | Start/stop video recording | -| `recording.start` / `recording.stop` | Brain → Host | Start/stop event-level recording | -| `ax.tree` | Brain → Host | Snapshot current accessibility tree | -| `ax.perform` | Brain → Host | Perform an action on a UI element | -| `input.type_text` / `input.shortcut` | Brain → Host | Inject keyboard input | -| `screen.capture_frame` | Brain → Host | Capture a single screen frame | -| `system.manifest` | Brain → Host | Query platform capabilities | -| `event.*` (6 types) | Host → Brain | Push real-time events (focus, clipboard, fs, UI action, etc.) | +| `screen.capture` | LeapFlow → Backend | Capture screen frame(s) | +| `accessibility.tree` | LeapFlow → Backend | Query accessibility tree | +| `accessibility.perform` | LeapFlow → Backend | Perform action on UI element | +| `input.type` / `input.shortcut` | LeapFlow → Backend | Inject keyboard input | +| `input.click` / `input.scroll` | LeapFlow → Backend | Inject mouse input | +| `system.info` | LeapFlow → Backend | Query platform capabilities |
@@ -657,12 +667,11 @@ Defined in `os_host/protocol/rpc_schema.yaml`. Transport: length-prefixed msgpac | Symptom | Cause | Fix | |---------|-------|-----| -| `OS Host connection failed` | Host not running or socket mismatch | Run `make host` in another terminal, or use `--mock-host` | +| `cua-driver not found` | Execution backend not installed | `brew install trycua/tap/cua-driver` | +| `MCP connection failed` | Backend process not responding | Run `leap host doctor` to diagnose; ensure backend is on PATH | | `LEAPFLOW_LLM_API_KEY is empty` | Missing API key | Set `LEAPFLOW_LLM_API_KEY` in `.env` | -| `Accessibility permission denied` | macOS privacy gate | System Settings → Privacy & Security → Accessibility → enable LeapHost | -| `Screen Recording blocked` | macOS privacy gate | System Settings → Privacy & Security → Screen Recording → enable LeapHost | -| `swiftc: command not found` | Xcode CLT missing | `xcode-select --install` | -| Host builds but crashes | SDK version mismatch | Ensure macOS 14+ and latest Xcode CLT | +| `Accessibility permission denied` | macOS privacy gate | System Settings → Privacy & Security → Accessibility → grant permission | +| `Screen Recording blocked` | macOS privacy gate | System Settings → Privacy & Security → Screen Recording → grant permission | --- diff --git a/pyproject.toml b/pyproject.toml index d7b8d2a..ea4d482 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ dependencies = [ "duckdb>=1.0.0", "pyyaml>=6.0", "Pillow>=10.0", + "mcp>=1.26.0", + "watchdog>=3.0", "gnureadline>=8.0; sys_platform == 'darwin'", ] diff --git a/src/leapflow/cli/cli.py b/src/leapflow/cli/cli.py index 139663e..fa3fe3e 100644 --- a/src/leapflow/cli/cli.py +++ b/src/leapflow/cli/cli.py @@ -140,18 +140,13 @@ def main(argv: list[str] | None = None) -> int: relearn_parser.add_argument("trajectory_id", help="Trajectory ID to re-process") # leap host - host_parser = subparsers.add_parser("host", help="Manage OS Host lifecycle") + host_parser = subparsers.add_parser("host", help="Manage cua-driver and ObservationDaemon") host_sub = host_parser.add_subparsers(dest="host_action") - host_sub.add_parser("start", help="Start the OS Host daemon") - host_sub.add_parser("stop", help="Gracefully stop the OS Host") - host_sub.add_parser("restart", help="Restart the OS Host") - host_sub.add_parser("status", help="Show host status and permissions") - logs_parser = host_sub.add_parser("logs", help="View host logs") - logs_parser.add_argument("--follow", "-f", action="store_true", help="Stream logs in real-time") - host_sub.add_parser("install", help="Build and deploy .app bundle") - host_sub.add_parser("setup", help="Install + register launchd + permission guidance") - host_sub.add_parser("uninstall", help="Stop, unregister, and remove bundle") - host_sub.add_parser("dev", help="Development mode (auto-rebuild on changes)") + host_sub.add_parser("start", help="Start ObservationDaemon (background observers)") + host_sub.add_parser("stop", help="Stop ObservationDaemon") + host_sub.add_parser("status", help="Show cua-driver and daemon status") + host_sub.add_parser("doctor", help="Run cua-driver connectivity health check") + host_sub.add_parser("install", help="Install cua-driver") # ── Pre-parse: detect if first non-flag arg is a known subcommand ── # If not, treat everything non-flag as a chat prompt. diff --git a/src/leapflow/cli/commands/host.py b/src/leapflow/cli/commands/host.py index 31dad38..be0aa1f 100644 --- a/src/leapflow/cli/commands/host.py +++ b/src/leapflow/cli/commands/host.py @@ -1,8 +1,16 @@ -"""OS Host lifecycle management commands.""" +"""OS Host lifecycle management commands — cua-driver + ObservationDaemon. + +Manages the cua-driver execution layer and ObservationDaemon background +observers for passive signal collection. +""" from __future__ import annotations import argparse +import os +import platform as platform_mod +import shutil +import signal import subprocess import sys import time @@ -10,14 +18,6 @@ from typing import Optional from leapflow.config import load_config -from leapflow.host import ( - HostManager, - HostState, - HostStatus, - check_permissions, - open_accessibility_settings, - open_screen_recording_settings, -) # ── ANSI colors ────────────────────────────────────────────────────────── @@ -31,11 +31,11 @@ def _ok(msg: str) -> None: - print(f" {_GREEN}✓{_RESET} {msg}") + print(f" {_GREEN}\u2713{_RESET} {msg}") def _fail(msg: str) -> None: - print(f" {_RED}✗{_RESET} {msg}") + print(f" {_RED}\u2717{_RESET} {msg}") def _warn(msg: str) -> None: @@ -48,300 +48,384 @@ def _info(msg: str) -> None: # ── Helpers ────────────────────────────────────────────────────────────── - -def _format_uptime(seconds: Optional[float]) -> str: - if seconds is None: - return "unknown" - s = int(seconds) - if s < 60: - return f"{s}s" - if s < 3600: - return f"{s // 60}m {s % 60}s" - h = s // 3600 - m = (s % 3600) // 60 - return f"{h}h {m:02d}m" +_CUA_DRIVER_CMD = os.environ.get("LEAPFLOW_CUA_DRIVER_CMD", "cua-driver") +_CUA_INSTALL_URL = "https://github.com/trycua/cua" -def _perm_icon(value: Optional[bool]) -> str: - if value is True: - return f"{_GREEN}✓ Granted{_RESET}" - if value is False: - return f"{_RED}✗ Not granted{_RESET}" - return f"{_YELLOW}○ Unknown{_RESET}" +def _daemon_pid_file() -> Path: + """PID file for the ObservationDaemon background process.""" + settings = load_config() + return settings.data_dir.expanduser() / "var" / "observation_daemon.pid" -def _build_manager() -> HostManager: +def _daemon_log_file() -> Path: + """Log file for ObservationDaemon.""" settings = load_config() - return HostManager( - host_root=settings.host_root, - host_socket=settings.host_socket, - pid_file=settings.host_pid_file, - log_file=settings.host_log_file, - bundle_id=settings.host_bundle_id, - ) + return settings.data_dir.expanduser() / "var" / "observation_daemon.log" -# ── Subcommand implementations ────────────────────────────────────────── +def _cua_driver_installed() -> bool: + """Check if cua-driver binary is on PATH.""" + return bool(shutil.which(_CUA_DRIVER_CMD)) -async def _cmd_start() -> int: - manager = _build_manager() - print(f"{_CYAN}LEAP OS Host{_RESET}") +def _cua_driver_version() -> Optional[str]: + """Try to get cua-driver version string.""" try: - status = await manager.start() - _ok(f"Started (PID {status.pid})") - return 0 - except FileNotFoundError as exc: - _fail(f"Cannot start: {exc}") - _info("Run 'leap host install' first to deploy the host binary.") - return 1 - except TimeoutError as exc: - _fail(f"Start timeout: {exc}") - return 1 - except OSError as exc: - _fail(f"Start failed: {exc}") - return 1 + result = subprocess.run( + [_CUA_DRIVER_CMD, "--version"], + capture_output=True, + text=True, + timeout=5.0, + ) + if result.returncode == 0 and result.stdout.strip(): + return result.stdout.strip() + except Exception: + pass + return None + + +def _read_pid_file() -> Optional[int]: + """Read PID from daemon pid file. Returns None if not present or stale.""" + pid_file = _daemon_pid_file() + if not pid_file.exists(): + return None + try: + pid = int(pid_file.read_text().strip()) + # Check if process is alive + os.kill(pid, 0) + return pid + except (ValueError, OSError): + # Stale or invalid PID file + try: + pid_file.unlink() + except OSError: + pass + return None -async def _cmd_stop() -> int: - manager = _build_manager() - print(f"{_CYAN}LEAP OS Host{_RESET}") - stopped = await manager.stop() - if stopped: - _ok("Stopped") - else: - _warn("Host was not running") - return 0 +def _write_pid_file(pid: int) -> None: + """Write PID to daemon pid file.""" + pid_file = _daemon_pid_file() + pid_file.parent.mkdir(parents=True, exist_ok=True) + pid_file.write_text(str(pid)) -async def _cmd_restart() -> int: - manager = _build_manager() - print(f"{_CYAN}LEAP OS Host{_RESET}") +def _remove_pid_file() -> None: + """Remove daemon PID file.""" + pid_file = _daemon_pid_file() try: - status = await manager.restart() - _ok(f"Restarted (PID {status.pid})") - return 0 - except FileNotFoundError as exc: - _fail(f"Cannot restart: {exc}") - return 1 - except TimeoutError as exc: - _fail(f"Restart timeout: {exc}") - return 1 - except OSError as exc: - _fail(f"Restart failed: {exc}") - return 1 + pid_file.unlink() + except OSError: + pass -async def _cmd_status() -> int: - manager = _build_manager() - status: HostStatus = manager.status() - - print(f"{_CYAN}LEAP OS Host{_RESET}") +# ── Subcommand implementations ────────────────────────────────────────── - # State line - if status.state == HostState.RUNNING: - uptime = _format_uptime(status.uptime_seconds) - print(f" Status: {_GREEN}●{_RESET} Running (PID {status.pid}, uptime {uptime})") - elif status.state == HostState.STALE: - print(f" Status: {_YELLOW}●{_RESET} Stale (PID file exists but process gone)") - else: - print(f" Status: {_DIM}○{_RESET} Stopped") - # Socket - socket_status = f"{_GREEN}exists{_RESET}" if status.socket_alive else f"{_DIM}absent{_RESET}" - settings = load_config() - print(f" Socket: {settings.host_socket} ({socket_status})") +async def _cmd_status() -> int: + """Show cua-driver installation and ObservationDaemon status.""" + print(f"{_CYAN}LEAP Host \u2014 Status{_RESET}") + print() - # Bundle - if status.bundle_path: - print(f" Bundle: {status.bundle_path}") + # cua-driver installation + print(f" {_BOLD}cua-driver{_RESET}") + if _cua_driver_installed(): + version = _cua_driver_version() + version_str = version if version else "installed (version unknown)" + _ok(f"Installed: {version_str}") + _info(f"Command: {shutil.which(_CUA_DRIVER_CMD)}") else: - print(f" Bundle: {_DIM}not installed{_RESET}") - - # Permissions - perms = status.permissions - print(" Permissions:") - print(f" Accessibility: {_perm_icon(perms.get('accessibility'))}") - print(f" Screen Recording: {_perm_icon(perms.get('screen_recording'))}") - fda = perms.get("full_disk_access") - if fda is None: - print(f" Full Disk Access: {_DIM}○ Not required{_RESET}") + _fail("Not installed") + _info(f"Install: {_CUA_INSTALL_URL}") + print() + + # ObservationDaemon status + print(f" {_BOLD}ObservationDaemon{_RESET}") + pid = _read_pid_file() + if pid is not None: + _ok(f"Running (PID {pid})") + log_file = _daemon_log_file() + if log_file.exists(): + _info(f"Log: {log_file}") else: - print(f" Full Disk Access: {_perm_icon(fda)}") + _info("Stopped") + _info("Start with: leap host start") return 0 -async def _cmd_logs(follow: bool) -> int: - settings = load_config() - log_file = settings.host_log_file - - if not log_file.exists(): - _fail(f"Log file not found: {log_file}") - return 1 +async def _cmd_start() -> int: + """Start ObservationDaemon as a background process.""" + print(f"{_CYAN}LEAP Host \u2014 Start{_RESET}") - if follow: - # Use tail -f for streaming - try: - proc = subprocess.Popen( - ["tail", "-f", str(log_file)], - stdout=sys.stdout, - stderr=sys.stderr, - ) - proc.wait() - except KeyboardInterrupt: - proc.terminate() - return 0 - else: - # Print last 50 lines - try: - lines = log_file.read_text(encoding="utf-8", errors="replace").splitlines() - for line in lines[-50:]: - print(line) - except OSError as exc: - _fail(f"Cannot read log: {exc}") - return 1 + # Check if already running + pid = _read_pid_file() + if pid is not None: + _warn(f"ObservationDaemon already running (PID {pid})") return 0 + # Check cua-driver availability + if not _cua_driver_installed(): + _fail("cua-driver not installed \u2014 cannot start observation daemon") + _info("Install cua-driver first: leap host install") + _info(f"Or manually: {_CUA_INSTALL_URL}") + return 1 -async def _cmd_install() -> int: - manager = _build_manager() - print(f"{_CYAN}LEAP OS Host — Install{_RESET}") - - # Look for a pre-built binary - os_host_dir = Path(__file__).resolve().parents[3] / "os_host" - candidates = [ - os_host_dir / ".build" / "release" / "OSHost", - os_host_dir / ".build" / "debug" / "OSHost", - ] - - binary_path: Optional[Path] = None - for c in candidates: - if c.exists() and c.is_file(): - binary_path = c - break - - if binary_path is None: - _warn("No pre-built binary found. Building with swift build -c release...") - try: - result = subprocess.run( - ["swift", "build", "-c", "release"], - cwd=str(os_host_dir), - capture_output=True, - text=True, - timeout=300, - ) - if result.returncode != 0: - _fail("Swift build failed:") - sys.stderr.write(result.stderr) - return 1 - _ok("Build succeeded") - binary_path = os_host_dir / ".build" / "release" / "OSHost" - if not binary_path.exists(): - _fail(f"Expected binary not found at {binary_path}") - return 1 - except FileNotFoundError: - _fail("'swift' not found in PATH. Install Xcode Command Line Tools.") - return 1 - except subprocess.TimeoutExpired: - _fail("Build timed out (300s)") - return 1 - - _info(f"Source binary: {binary_path}") + # Spawn the daemon as a background subprocess + log_file = _daemon_log_file() + log_file.parent.mkdir(parents=True, exist_ok=True) + + # Module-based runner for the daemon process + daemon_script = ( + "import asyncio, logging, signal, sys; " + "logging.basicConfig(level=logging.INFO, " + "format='%(asctime)s %(name)s %(levelname)s %(message)s'); " + "from leapflow.platform.event_bus import EventBus; " + "from leapflow.platform.observers import ObservationDaemon, ObserverConfig; " + "from leapflow.memory.providers.episodic import EpisodicMemoryProvider; " + "from leapflow.memory.providers.working import WorkingMemoryProvider; " + "from leapflow.config import load_config; " + "settings = load_config(); " + "episodic = EpisodicMemoryProvider(" + "ttl=settings.memory_episodic_ttl_s, " + "max_entries=settings.memory_episodic_max_entries); " + "working = WorkingMemoryProvider(" + "max_tokens=settings.memory_working_max_tokens); " + "bus = EventBus(immediate=episodic, working=working); " + "daemon = ObservationDaemon(bus=bus, config=ObserverConfig()); " + "loop = asyncio.new_event_loop(); " + "asyncio.set_event_loop(loop); " + "loop.run_until_complete(daemon.start()); " + "print('ObservationDaemon started', flush=True); " + "stop_event = asyncio.Event(); " + "def _signal_handler(*a): stop_event.set(); " + "signal.signal(signal.SIGTERM, _signal_handler); " + "signal.signal(signal.SIGINT, _signal_handler); " + "loop.run_until_complete(stop_event.wait()); " + "loop.run_until_complete(daemon.stop()); " + "print('ObservationDaemon stopped', flush=True)" + ) - try: - bundle = manager.install_app(binary_path) - _ok(f"Installed to {bundle}") - return 0 - except (FileNotFoundError, ValueError, OSError) as exc: - _fail(f"Installation failed: {exc}") + with open(log_file, "a") as lf: + proc = subprocess.Popen( + [sys.executable, "-c", daemon_script], + stdout=lf, + stderr=lf, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + + # Wait briefly to confirm startup + time.sleep(1.0) + if proc.poll() is not None: + _fail("ObservationDaemon failed to start (exited immediately)") + _info(f"Check logs: {log_file}") return 1 + _write_pid_file(proc.pid) + _ok(f"ObservationDaemon started (PID {proc.pid})") + _info(f"Log: {log_file}") + return 0 -async def _cmd_setup() -> int: - # Step 1: install - code = await _cmd_install() - if code != 0: - return code - manager = _build_manager() +async def _cmd_stop() -> int: + """Stop ObservationDaemon background process.""" + print(f"{_CYAN}LEAP Host \u2014 Stop{_RESET}") - # Step 2: register launchd - print() - _info("Registering launchd service...") + pid = _read_pid_file() + if pid is None: + _warn("ObservationDaemon is not running") + return 0 + + # Send SIGTERM for graceful shutdown try: - registered = manager.register_launchd() - if registered: - _ok("LaunchAgent registered") + os.kill(pid, signal.SIGTERM) + _info(f"Sent SIGTERM to PID {pid}, waiting for shutdown...") + # Wait up to 5s for process to exit + for _ in range(50): + time.sleep(0.1) + try: + os.kill(pid, 0) + except OSError: + break else: - _warn("LaunchAgent registration skipped (non-macOS or already registered)") - except FileNotFoundError as exc: - _fail(f"Cannot register launchd: {exc}") - return 1 - except Exception as exc: - _fail(f"LaunchAgent registration failed: {exc}") - return 1 + # Force kill if still alive + _warn("Process did not exit gracefully, sending SIGKILL") + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + except OSError: + _info("Process already gone") + + _remove_pid_file() + _ok("ObservationDaemon stopped") + return 0 + - # Step 3: permission check & guidance +async def _cmd_doctor() -> int: + """Run cua-driver health check: connectivity test via MCP.""" + print(f"{_CYAN}LEAP Host \u2014 Doctor{_RESET}") print() - _info("Checking permissions...") - perms = check_permissions() - needs_guidance = False - if perms.accessibility is not True: - _warn("Accessibility permission not granted") - needs_guidance = True - else: - _ok("Accessibility: granted") + # Step 1: Check binary + print(f" {_BOLD}1. Binary check{_RESET}") + if not _cua_driver_installed(): + _fail("cua-driver not found on PATH") + _info(f"Install from: {_CUA_INSTALL_URL}") + _info("Or run: leap host install") + return 1 + version = _cua_driver_version() + _ok(f"cua-driver binary found: {shutil.which(_CUA_DRIVER_CMD)}") + if version: + _ok(f"Version: {version}") + print() - if perms.screen_recording is not True: - _warn("Screen Recording permission not granted") - needs_guidance = True - else: - _ok("Screen Recording: granted") + # Step 2: MCP session connectivity + print(f" {_BOLD}2. MCP connectivity{_RESET}") + _info("Starting MCP session...") + + try: + from leapflow.platform.cua_client import CuaDriverClient - if needs_guidance: + client = CuaDriverClient(call_timeout=10.0) + client.start() + _ok("MCP session established") + + # Step 3: Ping test (list_apps as health probe) print() - print(f" {_BOLD}Permission Setup Required{_RESET}") - _info("LEAP Host needs Accessibility and Screen Recording access.") - _info("Opening System Settings...") + print(f" {_BOLD}3. Ping test{_RESET}") + _info("Sending probe (list_apps)...") + result = client._session.call_tool_sync("list_apps", {}, timeout=5.0) + if result.get("isError"): + _warn("Probe returned error (non-fatal)") + else: + _ok("Ping successful \u2014 cua-driver responding") + + # Step 4: Capability discovery print() - try: - answer = input(" Open System Settings now? (Y/n) ").strip().lower() - except (EOFError, KeyboardInterrupt): - answer = "n" - - if answer in ("", "y", "yes"): - if perms.accessibility is not True: - open_accessibility_settings() - time.sleep(0.5) - if perms.screen_recording is not True: - open_screen_recording_settings() - else: - _ok("All required permissions granted") + print(f" {_BOLD}4. Capabilities{_RESET}") + tools = client._session.available_tools + if tools: + _ok(f"Tools available: {len(tools)}") + for name in sorted(tools.keys()): + _info(f" \u2022 {name}") + else: + _warn("No tools discovered") - return 0 + cap_version = client._session.capability_version + if cap_version: + _info(f"Capability version: {cap_version}") + client.stop() + _ok("Session closed cleanly") + print() + _ok("All checks passed \u2014 cua-driver is healthy") + return 0 -async def _cmd_uninstall() -> int: - manager = _build_manager() - print(f"{_CYAN}LEAP OS Host — Uninstall{_RESET}") + except Exception as exc: + _fail(f"Health check failed: {exc}") + _info("Ensure cua-driver is properly installed and accessible.") + _info(f"Documentation: {_CUA_INSTALL_URL}") + return 1 - try: - answer = input(" Remove OS Host bundle and launchd service? (y/N) ").strip().lower() - except (EOFError, KeyboardInterrupt): - answer = "n" - if answer not in ("y", "yes"): - _info("Cancelled") +async def _cmd_install() -> int: + """Install cua-driver via upstream installation script.""" + print(f"{_CYAN}LEAP Host \u2014 Install cua-driver{_RESET}") + print() + + if _cua_driver_installed(): + version = _cua_driver_version() + _ok(f"cua-driver already installed: {shutil.which(_CUA_DRIVER_CMD)}") + if version: + _info(f"Version: {version}") + _info("To upgrade, use: pip install --upgrade cua-driver") return 0 - removed = manager.uninstall_app() - if removed: - _ok("Uninstalled successfully") + system = platform_mod.system().lower() + + if system == "darwin": + _info("Installing cua-driver for macOS...") + _info("Running: pip install cua-driver") + try: + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "cua-driver"], + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode == 0: + _ok("cua-driver installed successfully") + _info("Verify with: leap host doctor") + return 0 + else: + _fail("pip install failed:") + if result.stderr: + for line in result.stderr.strip().splitlines()[-5:]: + _info(f" {line}") + _info("Manual install: pip install cua-driver") + _info(f"Or visit: {_CUA_INSTALL_URL}") + return 1 + except subprocess.TimeoutExpired: + _fail("Installation timed out (120s)") + return 1 + except FileNotFoundError: + _fail("pip not found. Ensure Python is properly installed.") + return 1 + + elif system == "windows": + _info("Installing cua-driver for Windows...") + _info("Running: pip install cua-driver") + try: + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "cua-driver"], + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode == 0: + _ok("cua-driver installed successfully") + _info("Verify with: leap host doctor") + return 0 + else: + _fail("pip install failed:") + if result.stderr: + for line in result.stderr.strip().splitlines()[-5:]: + _info(f" {line}") + return 1 + except subprocess.TimeoutExpired: + _fail("Installation timed out (120s)") + return 1 + + elif system == "linux": + _info("Installing cua-driver for Linux...") + _info("Running: pip install cua-driver") + try: + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "cua-driver"], + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode == 0: + _ok("cua-driver installed successfully") + _info("Verify with: leap host doctor") + return 0 + else: + _fail("pip install failed") + _info("Manual install: pip install cua-driver") + _info(f"Or visit: {_CUA_INSTALL_URL}") + return 1 + except subprocess.TimeoutExpired: + _fail("Installation timed out (120s)") + return 1 + else: - _warn("No bundle found to remove (already clean)") - return 0 + _fail(f"Unsupported platform: {system}") + _info(f"Please install manually: {_CUA_INSTALL_URL}") + return 1 # ── Entry point ────────────────────────────────────────────────────────── @@ -352,42 +436,28 @@ async def cmd_host(args: argparse.Namespace) -> int: action = getattr(args, "host_action", None) if action is None: - print("Usage: leap host {start|stop|restart|status|logs|install|setup|uninstall|dev}") + print("Usage: leap host {start|stop|status|doctor|install}") print() - print("Manage the LEAP OS Host lifecycle.") + print("Manage cua-driver and ObservationDaemon lifecycle.") print() print("Commands:") - print(" start Start the OS Host daemon") - print(" stop Gracefully stop the OS Host") - print(" restart Restart the OS Host") - print(" status Show host status, PID, uptime, permissions") - print(" logs View host logs (--follow for streaming)") - print(" install Build and deploy the .app bundle") - print(" setup Install + register launchd + permission guidance") - print(" uninstall Stop, unregister launchd, remove bundle") - print(" dev Development mode (auto-rebuild on file changes)") + print(" start Start the ObservationDaemon (background observers)") + print(" stop Stop the ObservationDaemon") + print(" status Show cua-driver and daemon status") + print(" doctor Run cua-driver connectivity health check") + print(" install Install cua-driver") return 1 if action == "start": return await _cmd_start() elif action == "stop": return await _cmd_stop() - elif action == "restart": - return await _cmd_restart() elif action == "status": return await _cmd_status() - elif action == "logs": - follow = getattr(args, "follow", False) - return await _cmd_logs(follow) + elif action == "doctor": + return await _cmd_doctor() elif action == "install": return await _cmd_install() - elif action == "setup": - return await _cmd_setup() - elif action == "uninstall": - return await _cmd_uninstall() - elif action == "dev": - from leapflow.host.dev import cmd_host_dev - return await cmd_host_dev() else: _fail(f"Unknown host action: {action}") return 1 diff --git a/src/leapflow/cli/context.py b/src/leapflow/cli/context.py index 97e230b..3a19bd2 100644 --- a/src/leapflow/cli/context.py +++ b/src/leapflow/cli/context.py @@ -11,6 +11,7 @@ from typing import Any, Dict, List, Optional from leapflow.platform.client import BridgeClient +from leapflow.platform.cua_client import CuaDriverClient from leapflow.platform.event_bus import EventBus from leapflow.platform.mock import MockBridge from leapflow.config import Settings, load_config @@ -293,10 +294,20 @@ def __init__(self, settings: Settings, mock_host: bool) -> None: self._evolution = evolution self.event_bus = EventBus(immediate=self.imm, working=self.wm) - self.rpc: BridgeClient | MockBridge + self.rpc: BridgeClient | CuaDriverClient | MockBridge if self.effective_mock: self.rpc = MockBridge() self.rpc.on_event(self.event_bus.handle_event) + elif settings.use_cua_driver: + from leapflow.platform.cua_client import cua_driver_available + if not cua_driver_available(): + _emit_status( + "WARNING: use_cua_driver=True but cua-driver not found on PATH" + ) + _emit_status( + " Install with: leap host install | Diagnose with: leap host doctor" + ) + self.rpc = CuaDriverClient() else: self.rpc = BridgeClient( settings.bridge_socket, @@ -387,76 +398,16 @@ async def _post_connect_setup(self) -> None: self.rpc.on_reconnect(self._resubscribe_fs) await self.rpc.fire_reconnect_callbacks() - # ── Host Readiness ────────────────────────────────────────────────────── - - def _build_host_manager(self) -> "HostManager": # noqa: F821 - """Construct a HostManager from current settings.""" - from leapflow.host import HostManager - return HostManager( - host_root=self.settings.host_root, - host_socket=self.settings.host_socket, - pid_file=self.settings.host_pid_file, - log_file=self.settings.host_log_file, - bundle_id=self.settings.host_bundle_id, - ) + # ── Host Readiness (legacy — OS Host removed, cua-driver pending) ──────── async def _ensure_host_ready(self) -> HostReadiness: - """Structured OS Host health check with transparent status feedback. + """OS Host has been removed; always returns DEGRADED (offline mode). - Performs fast-path diagnosis, attempts auto-start when needed, and - reports state to the user in real-time. Never blocks longer than the - configured start timeout (default 5s). - - Returns: - HostReadiness indicating whether the host is usable or degraded. + The legacy OS Host module is deprecated in favor of cua-driver. + Until cua-driver integration is complete, the system runs in offline mode. """ - from leapflow.host import HostState - - mgr = self._build_host_manager() - diag = mgr.diagnose() - - # Fast path: already running - if diag.state == HostState.RUNNING: - pid_info = f" (PID {diag.pid})" if diag.pid else "" - _emit_status(f"OS Host: running{pid_info}") - return HostReadiness.RUNNING - - # Binary not installed — guide user, degrade gracefully - if not diag.executable_found: - _emit_status("OS Host: not installed") - _emit_status(" Run 'leap host setup' to install and configure.") - return HostReadiness.DEGRADED - - # Stale state detected — announce cleanup - if diag.state == HostState.STALE: - _emit_status("OS Host: cleaning stale state...") - - # Attempt start - _emit_status("OS Host: starting...") - try: - status = await mgr.start(timeout=5.0) - pid_info = f" (PID {status.pid})" if status.pid else "" - _emit_status(f"OS Host: started{pid_info}") - return HostReadiness.STARTED - except FileNotFoundError: - _emit_status("OS Host: binary not found") - _emit_status(" Run 'leap host setup' to install and configure.") - return HostReadiness.DEGRADED - except TimeoutError: - _emit_status("OS Host: start timed out") - _emit_status(" Check 'leap host logs' for details.") - return HostReadiness.DEGRADED - except RuntimeError as exc: - _emit_status(f"OS Host: crashed on startup") - _emit_status(f" {exc}") - return HostReadiness.DEGRADED - except OSError as exc: - _emit_status(f"OS Host: start failed ({exc})") - return HostReadiness.DEGRADED - except Exception as exc: - logger.debug("OS Host start unexpected error: %s", exc, exc_info=True) - _emit_status(f"OS Host: error ({type(exc).__name__})") - return HostReadiness.DEGRADED + _emit_status("OS Host: removed (cua-driver migration pending)") + return HostReadiness.DEGRADED async def initialize(self) -> None: """Async initialization: VSI handshake, pipeline assembly. @@ -472,17 +423,14 @@ async def initialize(self) -> None: # Initialize all memory providers (opens DB, starts GC, etc.) await self.memory.initialize_all() - # Phase 1: OS Host readiness — diagnose, auto-start, report status + # Phase 1: Host readiness (OS Host removed; mock or offline) host_readiness: HostReadiness if self.effective_mock: # Mock mode: no host needed, bridge is in-process. host_readiness = HostReadiness.RUNNING - elif settings.host_auto_start: - host_readiness = await self._ensure_host_ready() else: - # Auto-start disabled: user manages host externally (e.g. launchd). - # Assume available; bridge connection will validate. - host_readiness = HostReadiness.RUNNING + # OS Host removed — run in offline/degraded mode until cua-driver ready + host_readiness = await self._ensure_host_ready() # Phase 2: Bridge connection vsi = VirtualSystemInterface(self.rpc) @@ -504,6 +452,24 @@ async def initialize(self) -> None: manifest = PlatformManifest.default_darwin() vsi._manifest = manifest self._bg_connect_task = asyncio.create_task(self._bg_connect()) + elif isinstance(self.rpc, CuaDriverClient): + try: + self.rpc.start() + manifest = await vsi.handshake() + bridge_online = True + except RuntimeError as exc: + _emit_status(f"cua-driver connection failed: {exc}") + if "not found" in str(exc).lower(): + _emit_status( + " Install with: leap host install" + ) + else: + _emit_status( + " Check permissions (macOS TCC) or run: leap host doctor" + ) + _emit_status("Running in degraded mode (no OS execution)") + manifest = PlatformManifest.default_darwin() + vsi._manifest = manifest else: manifest = await vsi.handshake() bridge_online = True @@ -1205,6 +1171,8 @@ async def cleanup(self) -> None: await self.memory.shutdown_all() if isinstance(self.rpc, BridgeClient): await self.rpc.close() + elif isinstance(self.rpc, CuaDriverClient): + self.rpc.stop() if self.skill_lib: self.skill_lib.close() if self.imitation: diff --git a/src/leapflow/config.py b/src/leapflow/config.py index 649fd71..73e5938 100644 --- a/src/leapflow/config.py +++ b/src/leapflow/config.py @@ -93,14 +93,6 @@ class Settings: # ── Data Root ── data_dir: Path = Path("~/.leapflow") - # ── OS Host ── - host_root: Path = Path("~/.leapflow/host") - host_socket: Path = Path("/tmp/leapflow.sock") - host_pid_file: Path = Path("~/.leapflow/var/host.pid") - host_log_file: Path = Path("~/.leapflow/var/host.log") - host_bundle_id: str = "com.leapflow.host" - host_auto_start: bool = True - # Audit audit_log_path: Path = Path("~/.leapflow/audit.jsonl") @@ -318,6 +310,10 @@ class Settings: # overrides live in ``leapflow.platform.client._RPC_TIMEOUT_MAP``. rpc_timeout_default: float = 30.0 + # ── Cua Driver ── + use_cua_driver: bool = True + cua_driver_cmd: str = "cua-driver" + # ── Workflow Copilot ── copilot_enabled: bool = True copilot_min_idle_ms: int = 500 @@ -397,25 +393,8 @@ def load_config(*, env_file: str | Path | None = None) -> Settings: # Data Root – reuse the early-resolved path; derives all default host paths data_dir = _data_dir - # OS Host paths (default derived from data_dir) - host_root = _expand_path( - os.getenv("LEAPFLOW_HOST_ROOT", str(data_dir / "host")).strip() - ) - host_socket_default = "/tmp/leapflow.sock" - host_socket = _expand_path( - os.getenv("LEAPFLOW_HOST_SOCKET", host_socket_default).strip() - ) - host_pid_file = _expand_path( - os.getenv("LEAPFLOW_HOST_PID_FILE", str(data_dir / "var" / "host.pid")).strip() - ) - host_log_file = _expand_path( - os.getenv("LEAPFLOW_HOST_LOG_FILE", str(data_dir / "var" / "host.log")).strip() - ) - host_bundle_id = os.getenv("LEAPFLOW_HOST_BUNDLE_ID", "com.leapflow.host").strip() - host_auto_start = os.getenv("LEAPFLOW_HOST_AUTO_START", "1").strip() in ("1", "true", "True", "yes") - - # bridge_socket: defaults to host_socket; LEAPFLOW_BRIDGE_SOCKET overrides for backward compatibility - bridge = os.getenv("LEAPFLOW_BRIDGE_SOCKET", str(host_socket)).strip() + # bridge_socket: LEAPFLOW_BRIDGE_SOCKET overrides default + bridge = os.getenv("LEAPFLOW_BRIDGE_SOCKET", "/tmp/leapflow.sock").strip() mock_host = os.getenv("LEAPFLOW_MOCK_HOST", "0").strip() in ("1", "true", "True", "yes") duckdb = os.getenv("LEAPFLOW_DUCKDB_PATH", "~/.leapflow/memory.duckdb").strip() log_level = os.getenv("LEAPFLOW_LOG_LEVEL", "INFO").strip() @@ -642,6 +621,10 @@ def load_config(*, env_file: str | Path | None = None) -> Settings: # RPC Transport rpc_timeout_default = float(os.getenv("LEAPFLOW_RPC_TIMEOUT_DEFAULT", "30.0")) + # Cua Driver + use_cua_driver = _bool("LEAPFLOW_USE_CUA_DRIVER", "true") + cua_driver_cmd = os.getenv("LEAPFLOW_CUA_DRIVER_CMD", "cua-driver").strip() + # Workflow Copilot copilot_enabled = _bool("LEAPFLOW_COPILOT_ENABLED", "true") copilot_min_idle_ms = int(os.getenv("LEAPFLOW_COPILOT_MIN_IDLE_MS", "500")) @@ -683,12 +666,6 @@ def load_config(*, env_file: str | Path | None = None) -> Settings: memory_prefetch_timeout_s=memory_prefetch_timeout_s, memory_prefetch_limit=memory_prefetch_limit, data_dir=data_dir, - host_root=host_root, - host_socket=host_socket, - host_pid_file=host_pid_file, - host_log_file=host_log_file, - host_bundle_id=host_bundle_id, - host_auto_start=host_auto_start, audit_log_path=_expand_path(audit_log_path), skills_dir=_expand_path(skills_dir), skill_view_max_chars=skill_view_max_chars, @@ -837,6 +814,9 @@ def load_config(*, env_file: str | Path | None = None) -> Settings: signal_reactive_capture=signal_reactive_capture, # RPC Transport rpc_timeout_default=rpc_timeout_default, + # Cua Driver + use_cua_driver=use_cua_driver, + cua_driver_cmd=cua_driver_cmd, # Workflow Copilot copilot_enabled=copilot_enabled, copilot_min_idle_ms=copilot_min_idle_ms, diff --git a/src/leapflow/host/__init__.py b/src/leapflow/host/__init__.py deleted file mode 100644 index 3d58d30..0000000 --- a/src/leapflow/host/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -"""OS Host lifecycle management package. - -Public surface: - HostManager — start/stop/status/install/launchd coordinator - HostStatus — immutable status snapshot - HostState — RUNNING / STOPPED / STALE - PermissionStatus — Accessibility / Screen Recording / FDA snapshot - LaunchdService — LaunchAgent plist generation + launchctl wrapper - check_permissions — convenience aggregator -""" - -from leapflow.host.launchd import LaunchdError, LaunchdService -from leapflow.host.manager import HostDiagnosis, HostManager, HostState, HostStatus -from leapflow.host.permissions import ( - PermissionStatus, - check_accessibility, - check_full_disk_access, - check_permissions, - check_screen_recording, - open_accessibility_settings, - open_full_disk_access_settings, - open_screen_recording_settings, -) - -__all__ = [ - "HostManager", - "HostStatus", - "HostState", - "HostDiagnosis", - "LaunchdService", - "LaunchdError", - "PermissionStatus", - "check_permissions", - "check_accessibility", - "check_screen_recording", - "check_full_disk_access", - "open_accessibility_settings", - "open_screen_recording_settings", - "open_full_disk_access_settings", -] diff --git a/src/leapflow/host/dev.py b/src/leapflow/host/dev.py deleted file mode 100644 index 5c1cd0e..0000000 --- a/src/leapflow/host/dev.py +++ /dev/null @@ -1,340 +0,0 @@ -"""OS Host development mode with auto-rebuild on file changes.""" - -from __future__ import annotations - -import asyncio -import os -import signal -import subprocess -import sys -import time -from pathlib import Path -from typing import Callable, Dict, Optional - -from leapflow.config import Settings, load_config - -# ── ANSI colors ────────────────────────────────────────────────────────── - -_RESET = "\033[0m" -_DIM = "\033[2m" -_BOLD = "\033[1m" -_GREEN = "\033[32m" -_RED = "\033[31m" -_YELLOW = "\033[33m" -_CYAN = "\033[1;36m" -_BLUE = "\033[34m" - -_PREFIX = f"{_DIM}[dev]{_RESET}" -_SEP = "──────────────────────────────────────" - - -def _dev_print(msg: str, *, color: str = "") -> None: - """Print a dev-mode message with [dev] prefix.""" - if color: - print(f"{_PREFIX} {color}{msg}{_RESET}") - else: - print(f"{_PREFIX} {msg}") - - -class HostDevServer: - """Watch Swift source files and auto-rebuild/restart OS Host. - - Core design: - - File monitoring via mtime polling (zero external dependencies) - - Debounce: coalesces rapid saves into a single rebuild - - Subprocess management: child process stdout/stderr piped to terminal - - Graceful exit: Ctrl+C stops child, exits loop cleanly - """ - - def __init__( - self, - *, - source_dir: Path, - package_file: Path, - build_dir: Path, - socket_path: Path, - pid_file: Optional[Path] = None, - build_config: str = "debug", - poll_interval: float = 1.0, - debounce_delay: float = 0.5, - on_rebuild: Optional[Callable[[], None]] = None, - ) -> None: - self.source_dir = source_dir - self.package_file = package_file - self.build_dir = build_dir - self.socket_path = socket_path - self.pid_file = pid_file - self.build_config = build_config - self.poll_interval = poll_interval - self.debounce_delay = debounce_delay - self.on_rebuild = on_rebuild - - self._host_process: Optional[subprocess.Popen[bytes]] = None - self._file_mtimes: Dict[Path, float] = {} - self._running = False - - # ── Public API ──────────────────────────────────────────────────── - - async def run(self) -> None: - """Main loop: watch → build → run → repeat.""" - self._running = True - self._setup_signal_handlers() - - _dev_print(f"Watching {self.source_dir.relative_to(self.build_dir.parent)}/ for changes...", color=_CYAN) - - # Initial build + start - self._snapshot_files() - success = await self._build() - if success: - self._start_host() - - # Watch loop - try: - while self._running: - await asyncio.sleep(self.poll_interval) - changed = self._detect_changes() - if changed: - # Report changed files - for f in changed[:5]: - rel = self._relative_path(f) - _dev_print(f"File changed: {rel}", color=_YELLOW) - if len(changed) > 5: - _dev_print(f" ... and {len(changed) - 5} more", color=_DIM) - - # Debounce: wait briefly for more saves - await asyncio.sleep(self.debounce_delay) - # Re-snapshot to catch any further changes during debounce - self._snapshot_files() - - # Rebuild - _dev_print("Rebuilding...", color=_CYAN) - success = await self._build() - if success: - _dev_print("Restarting OS Host...", color=_CYAN) - self._stop_host() - self._start_host() - else: - _dev_print("Build failed — waiting for next change...", color=_RED) - except asyncio.CancelledError: - pass - finally: - self._stop_host() - _dev_print("Stopped.", color=_DIM) - - # ── File watching ───────────────────────────────────────────────── - - def _collect_swift_files(self) -> list[Path]: - """Collect all .swift files under source_dir + Package.swift.""" - files: list[Path] = [] - if self.package_file.exists(): - files.append(self.package_file) - if self.source_dir.exists(): - for root, _dirs, filenames in os.walk(self.source_dir): - for name in filenames: - if name.endswith(".swift"): - files.append(Path(root) / name) - return files - - def _snapshot_files(self) -> None: - """Take a snapshot of all tracked files' mtimes.""" - self._file_mtimes = {} - for f in self._collect_swift_files(): - try: - self._file_mtimes[f] = f.stat().st_mtime - except OSError: - continue - - def _detect_changes(self) -> list[Path]: - """Compare current mtimes against snapshot; update snapshot. - - Returns list of changed/new/deleted files. - """ - changed: list[Path] = [] - current_files = self._collect_swift_files() - current_mtimes: Dict[Path, float] = {} - - for f in current_files: - try: - mtime = f.stat().st_mtime - except OSError: - continue - current_mtimes[f] = mtime - prev = self._file_mtimes.get(f) - if prev is None or mtime != prev: - changed.append(f) - - # Detect deletions - for f in self._file_mtimes: - if f not in current_mtimes: - changed.append(f) - - if changed: - self._file_mtimes = current_mtimes - - return changed - - # ── Build ───────────────────────────────────────────────────────── - - async def _build(self) -> bool: - """Run swift build and return True on success.""" - _dev_print(f"Building ({self.build_config})...", color=_BLUE) - start = time.monotonic() - - cmd = ["swift", "build", "-c", self.build_config] - try: - proc = await asyncio.create_subprocess_exec( - *cmd, - cwd=str(self.build_dir), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - ) - stdout, _ = await proc.communicate() - except FileNotFoundError: - _dev_print("'swift' not found in PATH. Install Xcode Command Line Tools.", color=_RED) - return False - - elapsed = time.monotonic() - start - - if proc.returncode == 0: - _dev_print(f"Build succeeded ({elapsed:.1f}s)", color=_GREEN) - if self.on_rebuild: - self.on_rebuild() - return True - else: - _dev_print(f"Build failed ({elapsed:.1f}s):", color=_RED) - # Print compiler output - if stdout: - output = stdout.decode("utf-8", errors="replace") - for line in output.splitlines(): - print(f" {_DIM}{line}{_RESET}") - return False - - # ── Host process ────────────────────────────────────────────────── - - @property - def _executable_path(self) -> Path: - return self.build_dir / ".build" / self.build_config / "OSHost" - - def _start_host(self) -> None: - """Launch OS Host as a child process with output to terminal.""" - exe = self._executable_path - if not exe.exists(): - _dev_print(f"Executable not found: {exe}", color=_RED) - return - - # Ensure socket parent directory exists - self.socket_path.parent.mkdir(parents=True, exist_ok=True) - - cmd = [str(exe), "--socket", str(self.socket_path)] - if self.pid_file: - self.pid_file.parent.mkdir(parents=True, exist_ok=True) - cmd.extend(["--pid-file", str(self.pid_file)]) - - _dev_print(f"Starting OS Host on {self.socket_path}", color=_GREEN) - print(f"{_PREFIX} {_DIM}{_SEP}{_RESET}") - - try: - self._host_process = subprocess.Popen( - cmd, - stdout=sys.stdout, - stderr=sys.stderr, - cwd=str(exe.parent), - # Keep in same process group so Ctrl+C propagates - ) - except OSError as exc: - _dev_print(f"Cannot start OS Host: {exc}", color=_RED) - self._host_process = None - - def _stop_host(self) -> None: - """Stop the running OS Host child process.""" - proc = self._host_process - if proc is None: - return - - print(f"{_PREFIX} {_DIM}{_SEP}{_RESET}") - - if proc.poll() is not None: - # Already exited - self._host_process = None - return - - # Send SIGTERM for graceful shutdown - try: - proc.terminate() - except OSError: - pass - - # Wait briefly for exit - try: - proc.wait(timeout=5.0) - except subprocess.TimeoutExpired: - # Escalate to SIGKILL - try: - proc.kill() - proc.wait(timeout=2.0) - except (OSError, subprocess.TimeoutExpired): - pass - - self._host_process = None - - # Clean up stale socket and pid file - for path in (self.socket_path, self.pid_file): - try: - if path and path.exists(): - path.unlink() - except OSError: - pass - - # ── Signal handling ─────────────────────────────────────────────── - - def _setup_signal_handlers(self) -> None: - """Register signal handlers for graceful exit.""" - loop = asyncio.get_running_loop() - - def _handle_signal() -> None: - self._running = False - - for sig in (signal.SIGINT, signal.SIGTERM): - loop.add_signal_handler(sig, _handle_signal) - - # ── Utils ───────────────────────────────────────────────────────── - - def _relative_path(self, path: Path) -> str: - """Return a short relative path for display.""" - try: - return str(path.relative_to(self.build_dir)) - except ValueError: - return str(path) - - -# ── Factory ────────────────────────────────────────────────────────────── - - -def create_dev_server(settings: Optional[Settings] = None) -> HostDevServer: - """Create a HostDevServer from project configuration. - - Automatically locates os_host/darwin/ relative to the project root. - """ - if settings is None: - settings = load_config() - - # Locate os_host/darwin directory relative to this package - project_root = Path(__file__).resolve().parents[3] - os_host_dir = project_root / "os_host" / "darwin" - - return HostDevServer( - source_dir=os_host_dir / "Sources", - package_file=os_host_dir / "Package.swift", - build_dir=os_host_dir, - socket_path=settings.host_socket, - pid_file=settings.host_pid_file, - build_config="debug", - ) - - -async def cmd_host_dev() -> int: - """Entry point for `leap host dev` command.""" - settings = load_config() - server = create_dev_server(settings) - await server.run() - return 0 diff --git a/src/leapflow/host/launchd.py b/src/leapflow/host/launchd.py deleted file mode 100644 index 2fb5ed0..0000000 --- a/src/leapflow/host/launchd.py +++ /dev/null @@ -1,197 +0,0 @@ -"""macOS LaunchAgent (launchd) registration helper. - -Generates and installs a LaunchAgent plist into ``~/Library/LaunchAgents`` -and toggles its runtime state via ``launchctl``. Falls back from the modern -``bootstrap``/``bootout`` API (macOS 11+) to the legacy ``load``/``unload`` -API automatically. -""" - -from __future__ import annotations - -import logging -import os -import plistlib -import subprocess -from pathlib import Path -from typing import Any, Dict, List, Optional - -logger = logging.getLogger(__name__) - -LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents" - - -class LaunchdError(RuntimeError): - """Raised when a launchctl invocation fails irrecoverably.""" - - -class LaunchdService: - """Manage a per-user LaunchAgent. - - All paths are accepted as ``Path`` and rendered into the plist as their - expanded absolute string form so launchd can resolve them without shell - interpolation. - """ - - def __init__( - self, - *, - label: str, - executable: Path, - socket_path: Path, - pid_file: Path, - log_file: Path, - extra_args: Optional[List[str]] = None, - environment: Optional[Dict[str, str]] = None, - keep_alive: bool = True, - run_at_load: bool = True, - throttle_interval: int = 5, - ) -> None: - if not label or "/" in label: - raise ValueError(f"invalid launchd label: {label!r}") - self.label = label - self.executable = Path(executable).expanduser() - self.socket_path = Path(socket_path).expanduser() - self.pid_file = Path(pid_file).expanduser() - self.log_file = Path(log_file).expanduser() - self.extra_args = list(extra_args or []) - self.environment = dict(environment or {}) - self.keep_alive = keep_alive - self.run_at_load = run_at_load - self.throttle_interval = max(1, int(throttle_interval)) - - # ── plist ──────────────────────────────────────────────────────── - - @property - def plist_path(self) -> Path: - return LAUNCH_AGENTS_DIR / f"{self.label}.plist" - - def generate_plist(self) -> Dict[str, Any]: - """Build the plist dictionary (deterministic, no I/O).""" - program_arguments: List[str] = [ - str(self.executable), - "--daemon", - "--socket", str(self.socket_path), - "--pid-file", str(self.pid_file), - "--log-file", str(self.log_file), - ] - program_arguments.extend(self.extra_args) - - plist: Dict[str, Any] = { - "Label": self.label, - "ProgramArguments": program_arguments, - "RunAtLoad": bool(self.run_at_load), - "KeepAlive": bool(self.keep_alive), - "ThrottleInterval": int(self.throttle_interval), - "ProcessType": "Background", - "StandardOutPath": str(self.log_file), - "StandardErrorPath": str(self.log_file), - "WorkingDirectory": str(self.executable.parent), - } - if self.environment: - plist["EnvironmentVariables"] = dict(self.environment) - return plist - - def write_plist(self) -> Path: - """Serialize the plist to disk; create parent dirs as needed.""" - try: - LAUNCH_AGENTS_DIR.mkdir(parents=True, exist_ok=True) - except OSError as exc: - raise LaunchdError(f"cannot create {LAUNCH_AGENTS_DIR}: {exc}") from exc - - path = self.plist_path - try: - with open(path, "wb") as fh: - plistlib.dump(self.generate_plist(), fh) - except OSError as exc: - raise LaunchdError(f"cannot write {path}: {exc}") from exc - logger.info("Wrote LaunchAgent plist → %s", path) - return path - - # ── launchctl ──────────────────────────────────────────────────── - - def is_registered(self) -> bool: - """True if the plist exists and is currently known to launchd.""" - if not self.plist_path.exists(): - return False - try: - res = subprocess.run( - ["launchctl", "list", self.label], - capture_output=True, text=True, timeout=5, check=False, - ) - except (OSError, subprocess.TimeoutExpired): - return False - return res.returncode == 0 - - def register(self) -> bool: - """Write plist and load it into launchd. Idempotent.""" - path = self.write_plist() - - # Best-effort unload first to make this idempotent. - self._launchctl_unload(path, ignore_errors=True) - - # Modern API (macOS 11+): bootstrap into the user's GUI domain. - uid = os.getuid() - modern = self._run_launchctl( - ["bootstrap", f"gui/{uid}", str(path)], - ignore_errors=True, - ) - if modern is True: - logger.info("launchd bootstrap OK: %s", self.label) - return True - - # Legacy fallback. - legacy = self._run_launchctl(["load", "-w", str(path)], ignore_errors=False) - if legacy: - logger.info("launchd load OK: %s", self.label) - return True - return False - - def unregister(self) -> bool: - """Unload from launchd and delete the plist file. Idempotent.""" - path = self.plist_path - ok = self._launchctl_unload(path, ignore_errors=True) - if path.exists(): - try: - path.unlink() - except OSError as exc: - logger.warning("cannot delete %s: %s", path, exc) - return False - logger.info("launchd unregister done: %s", self.label) - return ok - - # ── internals ──────────────────────────────────────────────────── - - def _launchctl_unload(self, path: Path, *, ignore_errors: bool) -> bool: - uid = os.getuid() - modern = self._run_launchctl( - ["bootout", f"gui/{uid}/{self.label}"], - ignore_errors=True, - ) - if modern is True: - return True - if not path.exists(): - return True - return self._run_launchctl( - ["unload", "-w", str(path)], - ignore_errors=ignore_errors, - ) - - def _run_launchctl(self, args: List[str], *, ignore_errors: bool) -> bool: - cmd = ["launchctl", *args] - try: - res = subprocess.run( - cmd, capture_output=True, text=True, timeout=10, check=False, - ) - except (OSError, subprocess.TimeoutExpired) as exc: - if ignore_errors: - logger.debug("launchctl %s failed: %s", args, exc) - return False - raise LaunchdError(f"launchctl {' '.join(args)}: {exc}") from exc - if res.returncode != 0: - stderr = (res.stderr or "").strip() - msg = f"launchctl {' '.join(args)} → rc={res.returncode}: {stderr}" - if ignore_errors: - logger.debug(msg) - return False - raise LaunchdError(msg) - return True diff --git a/src/leapflow/host/manager.py b/src/leapflow/host/manager.py deleted file mode 100644 index 1df2217..0000000 --- a/src/leapflow/host/manager.py +++ /dev/null @@ -1,606 +0,0 @@ -"""OS Host lifecycle manager. - -Stateless coordinator over an external Swift OS Host process. All runtime -information is reconstructed from the filesystem (PID file, socket file, -log file) so the manager survives Brain restarts. - -The manager is platform-agnostic at the API surface; macOS-specific bits -(LaunchAgent, .app bundle layout) live in :mod:`launchd` and are only -exercised on Darwin. -""" - -from __future__ import annotations - -import asyncio -import errno -import logging -import os -import shutil -import signal -import socket as _socket -import subprocess -import sys -import time -from dataclasses import dataclass, field -from enum import Enum -from pathlib import Path -from typing import Dict, List, Optional - -from leapflow.host.launchd import LaunchdService -from leapflow.host.permissions import check_permissions - -logger = logging.getLogger(__name__) - - -# ─── Status types ─────────────────────────────────────────────────────── - - -class HostState(str, Enum): - """Discrete states for the OS Host process.""" - - RUNNING = "running" - STOPPED = "stopped" - STALE = "stale" # PID file present but process is gone - - -@dataclass(frozen=True) -class HostStatus: - """Immutable snapshot of host runtime state.""" - - state: HostState - pid: Optional[int] - uptime_seconds: Optional[float] - socket_alive: bool - permissions: Dict[str, Optional[bool]] = field(default_factory=dict) - bundle_path: Optional[Path] = None - - @property - def is_running(self) -> bool: - return self.state == HostState.RUNNING - - -@dataclass(frozen=True) -class HostDiagnosis: - """Lightweight diagnostic for fast init-time health checks. - - Unlike :class:`HostStatus`, skips expensive operations (permission queries, - socket connectivity probes) to achieve <10ms on the hot path. - """ - - state: HostState - pid: Optional[int] = None - executable_found: bool = False - detail: str = "" - - @property - def is_startable(self) -> bool: - """True if the host can potentially be started.""" - return self.state != HostState.RUNNING and self.executable_found - - -# ─── Constants ────────────────────────────────────────────────────────── - -_DEFAULT_BUNDLE_NAME = "LeapHost.app" -_DEFAULT_EXECUTABLE_NAME = "LeapHost" -_POLL_INTERVAL_S = 0.1 - - -# ─── Manager ──────────────────────────────────────────────────────────── - - -class HostManager: - """OS Host lifecycle coordinator. - - The manager has no in-memory state beyond its configuration: every query - re-derives the answer from PID/socket files. This makes it safe to use - from multiple CLI invocations concurrently. - """ - - def __init__( - self, - *, - host_root: Path, - host_socket: Path, - pid_file: Path, - log_file: Path, - bundle_id: str = "com.leapflow.host", - bundle_name: str = _DEFAULT_BUNDLE_NAME, - executable_name: str = _DEFAULT_EXECUTABLE_NAME, - ) -> None: - self.host_root = Path(host_root).expanduser() - self.host_socket = Path(host_socket).expanduser() - self.pid_file = Path(pid_file).expanduser() - self.log_file = Path(log_file).expanduser() - self.bundle_id = bundle_id - self.bundle_name = bundle_name - self.executable_name = executable_name - - # ── Paths ──────────────────────────────────────────────────────── - - @property - def app_bundle_path(self) -> Path: - return self.host_root / self.bundle_name - - @property - def bundle_executable(self) -> Path: - return self.app_bundle_path / "Contents" / "MacOS" / self.executable_name - - @property - def info_plist_path(self) -> Path: - return self.app_bundle_path / "Contents" / "Info.plist" - - # ── Status queries ─────────────────────────────────────────────── - - def _read_pid(self) -> Optional[int]: - try: - raw = self.pid_file.read_text(encoding="utf-8").strip() - except FileNotFoundError: - return None - except OSError as exc: - logger.warning("cannot read pid file %s: %s", self.pid_file, exc) - return None - if not raw: - return None - try: - return int(raw.splitlines()[0].strip()) - except ValueError: - logger.warning("pid file %s contains garbage: %r", self.pid_file, raw) - return None - - @staticmethod - def _process_alive(pid: int) -> bool: - if pid <= 0: - return False - try: - os.kill(pid, 0) - except ProcessLookupError: - return False - except PermissionError: - # The process exists but is owned by another user. - return True - except OSError as exc: - if exc.errno == errno.ESRCH: - return False - return True - return True - - def _socket_exists(self) -> bool: - try: - return self.host_socket.exists() - except OSError: - return False - - def _process_uptime(self, pid: int) -> Optional[float]: - """Best-effort uptime via PID-file mtime (portable, low cost).""" - try: - mtime = self.pid_file.stat().st_mtime - except OSError: - return None - delta = time.time() - mtime - return max(0.0, delta) - - def is_running(self) -> bool: - """True iff the host is reachable. - - Checks PID file first; falls back to a socket connectivity probe - so externally managed hosts (e.g. ``leap host dev``) are detected - even without a PID file. - - Side effect: removes a stale PID file when the process is gone. - """ - pid = self._read_pid() - if pid is not None: - if self._process_alive(pid): - return self._socket_exists() - # PID is stale, but the socket might belong to an externally - # managed host (e.g. dev server restarted with a new PID). - # Check socket before concluding nothing is running. - self._cleanup_stale() - if self._socket_exists() and self._socket_responsive(retries=1, timeout=2.0): - logger.info("PID stale but socket responsive — external host detected") - return True - return False - # No PID file — rely on socket probe with retry for reliability. - if not self._socket_exists(): - return False - return self._socket_responsive(retries=1, timeout=2.0) - - def diagnose(self) -> HostDiagnosis: - """Fast-path diagnostic without network I/O or permission checks. - - Examines only PID file, process table, and filesystem to deliver - a diagnosis in <10ms. Suitable for startup health checks where - latency matters more than completeness. - """ - pid = self._read_pid() - executable_found = self._resolve_executable() is not None - - if pid is not None: - if self._process_alive(pid): - return HostDiagnosis( - state=HostState.RUNNING, - pid=pid, - executable_found=executable_found, - ) - return HostDiagnosis( - state=HostState.STALE, - pid=pid, - executable_found=executable_found, - detail="PID file present but process gone", - ) - - # No PID file — check if socket exists (externally managed host) - if self._socket_exists(): - return HostDiagnosis( - state=HostState.RUNNING, - pid=None, - executable_found=executable_found, - detail="Socket present (external host)", - ) - - return HostDiagnosis( - state=HostState.STOPPED, - pid=None, - executable_found=executable_found, - detail="" if executable_found else "Host binary not installed", - ) - - def status(self) -> HostStatus: - pid = self._read_pid() - socket_alive = self._socket_exists() - bundle = self.app_bundle_path if self.app_bundle_path.exists() else None - perms = check_permissions().to_dict() - - if pid is None: - # No PID file — check if something is listening on the socket - # (e.g. an externally managed dev server). - if socket_alive and self._socket_responsive(): - return HostStatus( - state=HostState.RUNNING, - pid=None, - uptime_seconds=None, - socket_alive=True, - permissions=perms, - bundle_path=bundle, - ) - return HostStatus( - state=HostState.STOPPED, - pid=None, - uptime_seconds=None, - socket_alive=socket_alive, - permissions=perms, - bundle_path=bundle, - ) - if not self._process_alive(pid): - return HostStatus( - state=HostState.STALE, - pid=pid, - uptime_seconds=None, - socket_alive=socket_alive, - permissions=perms, - bundle_path=bundle, - ) - return HostStatus( - state=HostState.RUNNING, - pid=pid, - uptime_seconds=self._process_uptime(pid), - socket_alive=socket_alive, - permissions=perms, - bundle_path=bundle, - ) - - # ── Lifecycle ──────────────────────────────────────────────────── - - async def start(self, timeout: float = 10.0) -> HostStatus: - """Spawn the host as a detached daemon and wait for the socket. - - Raises: - FileNotFoundError: if no executable can be located. - TimeoutError: if the socket does not appear within ``timeout``. - """ - if self.is_running(): - logger.info("Host already running; skipping start") - return self.status() - - # Clean up stale PID file from a previous crash. - self._cleanup_stale() - - # If a socket file exists but nothing responds (thorough check with - # retries), remove it so the new daemon can bind. This is safe because - # is_running() above already confirmed nothing is alive. - if self._socket_exists() and not self._socket_responsive(retries=2, timeout=2.0): - logger.info("Removing unresponsive stale socket before daemon start") - try: - self.host_socket.unlink() - except (FileNotFoundError, OSError): - pass - - executable = self._resolve_executable() - if executable is None: - raise FileNotFoundError( - f"OS Host executable not found under {self.host_root}; " - f"run install_app() first or provide a bundled binary." - ) - - for parent in (self.host_socket.parent, self.pid_file.parent, self.log_file.parent): - try: - parent.mkdir(parents=True, exist_ok=True) - except OSError as exc: - logger.warning("cannot create %s: %s", parent, exc) - - cmd: List[str] = [ - str(executable), - "--daemon", - "--socket", str(self.host_socket), - "--pid-file", str(self.pid_file), - "--log-file", str(self.log_file), - ] - - # Detach so the host outlives the parent CLI process. - try: - log_fh = open(self.log_file, "ab", buffering=0) - except OSError as exc: - raise OSError(f"cannot open log file {self.log_file}: {exc}") from exc - - try: - proc = subprocess.Popen( - cmd, - stdin=subprocess.DEVNULL, - stdout=log_fh, - stderr=log_fh, - start_new_session=True, - close_fds=True, - cwd=str(executable.parent), - ) - except OSError as exc: - log_fh.close() - raise OSError(f"failed to spawn host {cmd[0]}: {exc}") from exc - finally: - # Popen dups the fd; we can release ours. - try: - log_fh.close() - except OSError: - pass - - logger.info("Spawned OS Host pid=%d via %s", proc.pid, executable) - - # Wait for socket to appear. - deadline = time.monotonic() + max(0.1, timeout) - while time.monotonic() < deadline: - if self._socket_exists() and self._process_alive(proc.pid): - return self.status() - if proc.poll() is not None: - raise RuntimeError( - f"OS Host exited prematurely with code {proc.returncode}; " - f"see {self.log_file}" - ) - await asyncio.sleep(_POLL_INTERVAL_S) - - raise TimeoutError( - f"OS Host socket {self.host_socket} did not appear within {timeout}s" - ) - - async def stop(self, timeout: float = 10.0) -> bool: - """Send SIGTERM, await graceful exit, escalate to SIGKILL on timeout. - - Returns True if the host is gone after the call (regardless of which - signal succeeded), False if the PID file was missing entirely. - """ - pid = self._read_pid() - if pid is None: - self._cleanup_stale() - return False - - if not self._process_alive(pid): - self._cleanup_stale() - return True - - try: - os.kill(pid, signal.SIGTERM) - except ProcessLookupError: - self._cleanup_stale() - return True - except PermissionError as exc: - logger.warning("permission denied stopping pid %d: %s", pid, exc) - return False - - deadline = time.monotonic() + max(0.1, timeout) - while time.monotonic() < deadline: - if not self._process_alive(pid): - self._cleanup_stale() - return True - await asyncio.sleep(_POLL_INTERVAL_S) - - # Escalate. - logger.warning("SIGTERM timed out; escalating to SIGKILL on pid=%d", pid) - try: - os.kill(pid, signal.SIGKILL) - except ProcessLookupError: - self._cleanup_stale() - return True - except PermissionError: - return False - - # Brief grace window for the kernel to reap. - for _ in range(20): - if not self._process_alive(pid): - self._cleanup_stale() - return True - await asyncio.sleep(_POLL_INTERVAL_S) - - return not self._process_alive(pid) - - async def restart(self, *, stop_timeout: float = 10.0, start_timeout: float = 10.0) -> HostStatus: - await self.stop(timeout=stop_timeout) - return await self.start(timeout=start_timeout) - - async def ensure_running(self, timeout: float = 5.0) -> HostStatus: - """Idempotent: start the host iff it is not already up.""" - if self.is_running(): - return self.status() - return await self.start(timeout=timeout) - - # ── App bundle ─────────────────────────────────────────────────── - - def install_app(self, binary_path: Path) -> Path: - """Lay out a minimal macOS .app bundle around ``binary_path``. - - Idempotent: re-installation overwrites the executable in place. The - binary is **copied** (not symlinked) so removal of the source tree - does not break the bundle. - """ - binary_path = Path(binary_path).expanduser() - if not binary_path.exists(): - raise FileNotFoundError(f"binary not found: {binary_path}") - if not binary_path.is_file(): - raise ValueError(f"binary is not a file: {binary_path}") - - bundle = self.app_bundle_path - contents = bundle / "Contents" - macos_dir = contents / "MacOS" - resources_dir = contents / "Resources" - - for d in (macos_dir, resources_dir): - d.mkdir(parents=True, exist_ok=True) - - target = self.bundle_executable - try: - shutil.copy2(binary_path, target) - except OSError as exc: - raise OSError(f"cannot copy {binary_path} → {target}: {exc}") from exc - - try: - mode = target.stat().st_mode - target.chmod(mode | 0o111) - except OSError as exc: - logger.warning("cannot mark %s executable: %s", target, exc) - - self._write_info_plist() - logger.info("Installed app bundle at %s", bundle) - return bundle - - def uninstall_app(self) -> bool: - """Stop, unregister launchd, and remove the bundle directory.""" - try: - asyncio.get_event_loop().run_until_complete(self.stop()) - except RuntimeError: - # No running event loop; spin a private one. - try: - asyncio.run(self.stop()) - except Exception as exc: - logger.debug("stop during uninstall failed: %s", exc) - except Exception as exc: - logger.debug("stop during uninstall failed: %s", exc) - - # Best-effort launchd cleanup. - try: - self.unregister_launchd() - except Exception as exc: - logger.debug("launchd unregister during uninstall failed: %s", exc) - - bundle = self.app_bundle_path - if not bundle.exists(): - return False - try: - shutil.rmtree(bundle) - except OSError as exc: - logger.warning("cannot remove %s: %s", bundle, exc) - return False - logger.info("Removed app bundle %s", bundle) - return True - - def _write_info_plist(self) -> None: - """Write a minimal Info.plist so the bundle is recognized by macOS.""" - import plistlib - - info: Dict[str, object] = { - "CFBundleIdentifier": self.bundle_id, - "CFBundleName": "LeapHost", - "CFBundleDisplayName": "LEAP Host", - "CFBundleExecutable": self.executable_name, - "CFBundlePackageType": "APPL", - "CFBundleShortVersionString": "1.0.0", - "CFBundleVersion": "1", - "LSMinimumSystemVersion": "14.0", - "LSUIElement": True, # background agent, no Dock icon - "NSHighResolutionCapable": True, - } - try: - with open(self.info_plist_path, "wb") as fh: - plistlib.dump(info, fh) - except OSError as exc: - logger.warning("cannot write Info.plist %s: %s", self.info_plist_path, exc) - - # ── launchd ────────────────────────────────────────────────────── - - def _make_launchd_service(self) -> LaunchdService: - return LaunchdService( - label=self.bundle_id, - executable=self.bundle_executable, - socket_path=self.host_socket, - pid_file=self.pid_file, - log_file=self.log_file, - ) - - def register_launchd(self) -> bool: - """Generate the LaunchAgent plist and load it into launchd.""" - if sys.platform != "darwin": - logger.warning("register_launchd is a macOS-only operation; skipped") - return False - if not self.bundle_executable.exists(): - raise FileNotFoundError( - f"bundle executable missing: {self.bundle_executable}; install_app() first" - ) - return self._make_launchd_service().register() - - def unregister_launchd(self) -> bool: - if sys.platform != "darwin": - return False - return self._make_launchd_service().unregister() - - # ── Helpers ────────────────────────────────────────────────────── - - def _resolve_executable(self) -> Optional[Path]: - """Locate the host executable, preferring the installed .app bundle.""" - candidates = [ - self.bundle_executable, - self.host_root / self.executable_name, - ] - for c in candidates: - if c.exists() and c.is_file() and os.access(c, os.X_OK): - return c - return None - - def _socket_responsive(self, *, retries: int = 1, timeout: float = 2.0) -> bool: - """True if something is actively listening on the socket. - - Uses retries to avoid false negatives from transient server busyness. - """ - for attempt in range(retries + 1): - try: - s = _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) - s.settimeout(timeout) - s.connect(str(self.host_socket)) - s.close() - return True - except (ConnectionRefusedError, FileNotFoundError, OSError): - if attempt < retries: - time.sleep(0.3) - continue - return False - - def _cleanup_stale(self) -> None: - """Remove a stale PID file. Best-effort. - - Only the PID file is cleaned up. The socket file is NEVER deleted - here — it belongs to the server process. Deleting a socket file - while a server is running (but temporarily unresponsive to probes) - permanently orphans that server. The server is responsible for - cleaning up its own socket on startup (unlink-before-bind). - """ - try: - self.pid_file.unlink() - except FileNotFoundError: - pass - except OSError as exc: - logger.debug("cannot remove %s: %s", self.pid_file, exc) diff --git a/src/leapflow/host/permissions.py b/src/leapflow/host/permissions.py deleted file mode 100644 index 05b3bca..0000000 --- a/src/leapflow/host/permissions.py +++ /dev/null @@ -1,160 +0,0 @@ -"""macOS permission detection and settings deep-links. - -Detects Accessibility / Screen Recording / Full Disk Access status without -requiring elevated privileges. Some permissions cannot be reliably probed -from Python; in those cases the corresponding field is ``None`` (unknown). -""" - -from __future__ import annotations - -import logging -import subprocess -import sys -from dataclasses import dataclass -from typing import Optional - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class PermissionStatus: - """Snapshot of macOS TCC permission state. - - A value of ``None`` denotes "cannot be determined from the Brain side" - (typically because the public API is only callable from a Cocoa process). - """ - - accessibility: Optional[bool] - screen_recording: Optional[bool] - full_disk_access: Optional[bool] = None - - def all_granted(self) -> bool: - """True only when every known field is explicitly True.""" - return all( - v is True - for v in (self.accessibility, self.screen_recording, self.full_disk_access) - ) - - def to_dict(self) -> dict[str, Optional[bool]]: - return { - "accessibility": self.accessibility, - "screen_recording": self.screen_recording, - "full_disk_access": self.full_disk_access, - } - - -# ─── Detection ────────────────────────────────────────────────────────── - - -def _is_macos() -> bool: - return sys.platform == "darwin" - - -def check_accessibility() -> Optional[bool]: - """Probe Accessibility (AX) trust via PyObjC if available, else None. - - The TCC database is SIP-protected and we deliberately avoid reading it. - """ - if not _is_macos(): - return False - try: - # PyObjC is an optional dependency; fall back to unknown if absent. - from ApplicationServices import AXIsProcessTrusted # type: ignore - except Exception: - return None - try: - return bool(AXIsProcessTrusted()) - except Exception as exc: # pragma: no cover — defensive - logger.debug("AXIsProcessTrusted failed: %s", exc) - return None - - -def check_screen_recording() -> Optional[bool]: - """Probe Screen Recording permission via CoreGraphics preflight. - - macOS exposes ``CGPreflightScreenCaptureAccess`` (10.15+) which returns a - bool without prompting. Without PyObjC we cannot reliably detect — return - ``None`` so the caller can guide the user explicitly. - """ - if not _is_macos(): - return False - try: - from Quartz import CGPreflightScreenCaptureAccess # type: ignore - except Exception: - return None - try: - return bool(CGPreflightScreenCaptureAccess()) - except Exception as exc: # pragma: no cover - logger.debug("CGPreflightScreenCaptureAccess failed: %s", exc) - return None - - -def check_full_disk_access() -> Optional[bool]: - """Probe Full Disk Access via a read attempt on a TCC-protected path. - - Reading ``~/Library/Application Support/com.apple.TCC/TCC.db`` requires - FDA. A successful ``open()`` (even read-only) implies the permission is - granted to the calling process. - """ - if not _is_macos(): - return False - from pathlib import Path - - probe = Path.home() / "Library" / "Application Support" / "com.apple.TCC" / "TCC.db" - if not probe.exists(): - return None - try: - with open(probe, "rb") as fh: - fh.read(1) - return True - except PermissionError: - return False - except OSError as exc: - logger.debug("FDA probe failed: %s", exc) - return None - - -def check_permissions() -> PermissionStatus: - """Aggregate snapshot of all detectable permissions.""" - return PermissionStatus( - accessibility=check_accessibility(), - screen_recording=check_screen_recording(), - full_disk_access=check_full_disk_access(), - ) - - -# ─── Settings deep-links ──────────────────────────────────────────────── - - -_PRIVACY_URLS = { - "accessibility": "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", - "screen_recording": "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture", - "full_disk_access": "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles", -} - - -def _open_url(url: str) -> bool: - if not _is_macos(): - logger.debug("Skip opening %s on non-macOS host", url) - return False - try: - subprocess.run(["open", url], check=False, timeout=5) - return True - except (OSError, subprocess.TimeoutExpired) as exc: - logger.warning("open(%s) failed: %s", url, exc) - return False - - -def open_accessibility_settings() -> bool: - """Open System Settings → Privacy → Accessibility.""" - return _open_url(_PRIVACY_URLS["accessibility"]) - - -def open_screen_recording_settings() -> bool: - """Open System Settings → Privacy → Screen Recording.""" - return _open_url(_PRIVACY_URLS["screen_recording"]) - - -def open_full_disk_access_settings() -> bool: - """Open System Settings → Privacy → Full Disk Access.""" - return _open_url(_PRIVACY_URLS["full_disk_access"]) diff --git a/src/leapflow/platform/__init__.py b/src/leapflow/platform/__init__.py index 9015a44..94e112d 100644 --- a/src/leapflow/platform/__init__.py +++ b/src/leapflow/platform/__init__.py @@ -14,18 +14,24 @@ make_response_ok, ) from leapflow.platform.client import BridgeClient +from leapflow.platform.cua_client import CuaDriverClient from leapflow.platform.event_bus import EventBus from leapflow.platform.facade import VirtualSystemInterface from leapflow.platform.normalizer import EventNormalizer +from leapflow.platform.observers import ObservationDaemon, Observer, ObserverConfig __all__ = [ "BridgeClient", + "CuaDriverClient", "EventBus", "EventHandler", "EventNormalizer", "EventTypes", "HostRpc", "Methods", + "ObservationDaemon", + "Observer", + "ObserverConfig", "RpcError", "VirtualSystemInterface", "decode_packet", diff --git a/src/leapflow/platform/cua_client.py b/src/leapflow/platform/cua_client.py new file mode 100644 index 0000000..6cb3135 --- /dev/null +++ b/src/leapflow/platform/cua_client.py @@ -0,0 +1,904 @@ +"""CuaDriverClient — MCP stdio bridge to cua-driver for unified OS execution. + +Implements the HostRpc Protocol by mapping LeapFlow's Methods constants to +cua-driver MCP tool calls. Designed for LLM-native context pipelines where +the execution layer is fully delegated to cua-driver-rs. + +Architecture: + - _AsyncBridge: daemon thread running an asyncio event loop, bridging + sync/async boundaries transparently. + - _McpSession: lifecycle coroutine owning the MCP stdio contexts + (enter + exit in the SAME task — anyio cancel-scope invariant). + - CuaDriverClient: public facade implementing HostRpc.call(), dispatching + Methods → cua-driver MCP tools via a declarative routing table. +""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import json +import logging +import os +import platform +import shutil +import subprocess +import sys +import threading +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Set, Tuple + +from leapflow.platform.protocol import HostRpc, Methods, RpcError + +logger = logging.getLogger(__name__) + +# ── Configuration (all overridable via env) ────────────────────────────────── + +_CUA_DRIVER_CMD = os.environ.get("LEAPFLOW_CUA_DRIVER_CMD", "cua-driver") +_CUA_DRIVER_ARGS_DEFAULT: List[str] = ["mcp"] +_CUA_TELEMETRY_ENV_VAR = "CUA_DRIVER_RS_TELEMETRY_ENABLED" + +_SESSION_READY_TIMEOUT_S = float(os.environ.get("LEAPFLOW_CUA_SESSION_TIMEOUT", "15.0")) +_CALL_TIMEOUT_S = float(os.environ.get("LEAPFLOW_CUA_CALL_TIMEOUT", "30.0")) +_KEEPALIVE_INTERVAL_S = float(os.environ.get("LEAPFLOW_CUA_KEEPALIVE_INTERVAL", "20.0")) +_MANIFEST_TIMEOUT_S = float(os.environ.get("LEAPFLOW_CUA_MANIFEST_TIMEOUT", "6.0")) + + +# ── Telemetry policy ───────────────────────────────────────────────────────── + +def _telemetry_disabled() -> bool: + """Default: disable cua-driver telemetry unless explicitly opted-in.""" + val = os.environ.get(_CUA_TELEMETRY_ENV_VAR, "") + if val == "1": + return False + return True + + +def _child_env(base: Optional[Dict[str, str]] = None) -> Dict[str, str]: + """Build environment dict for spawning cua-driver subprocess.""" + env = dict(base if base is not None else os.environ) + if _telemetry_disabled(): + env[_CUA_TELEMETRY_ENV_VAR] = "0" + return env + + +# ── Driver discovery ───────────────────────────────────────────────────────── + +def _resolve_mcp_invocation( + driver_cmd: str, + *, + timeout: float = _MANIFEST_TIMEOUT_S, +) -> Tuple[str, List[str]]: + """Discover MCP spawn args via `cua-driver manifest`. Falls back gracefully.""" + try: + proc = subprocess.run( + [driver_cmd, "manifest"], + capture_output=True, + text=True, + timeout=timeout, + stdin=subprocess.DEVNULL, + ) + except Exception: + return driver_cmd, list(_CUA_DRIVER_ARGS_DEFAULT) + + if proc.returncode != 0 or not (proc.stdout or "").strip(): + return driver_cmd, list(_CUA_DRIVER_ARGS_DEFAULT) + + try: + manifest = json.loads(proc.stdout.strip()) + except (ValueError, TypeError): + return driver_cmd, list(_CUA_DRIVER_ARGS_DEFAULT) + + if not isinstance(manifest, dict): + return driver_cmd, list(_CUA_DRIVER_ARGS_DEFAULT) + + invocation = manifest.get("mcp_invocation") + if not isinstance(invocation, dict): + return driver_cmd, list(_CUA_DRIVER_ARGS_DEFAULT) + + args = invocation.get("args") + command = invocation.get("command") + if not isinstance(args, list) or not all(isinstance(a, str) for a in args): + return driver_cmd, list(_CUA_DRIVER_ARGS_DEFAULT) + if not isinstance(command, str) or not command: + return driver_cmd, args + return command, args + + +def cua_driver_available() -> bool: + """True if cua-driver binary is discoverable on PATH.""" + return bool(shutil.which(_CUA_DRIVER_CMD)) + + +# ── AsyncBridge ────────────────────────────────────────────────────────────── + +class _AsyncBridge: + """Daemon thread running an asyncio event loop. Marshals coroutines + from any thread into that loop and returns results synchronously.""" + + def __init__(self) -> None: + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._thread: Optional[threading.Thread] = None + self._ready = threading.Event() + + @property + def loop(self) -> Optional[asyncio.AbstractEventLoop]: + return self._loop + + def start(self) -> None: + if self._thread and self._thread.is_alive(): + return + self._ready.clear() + + def _run() -> None: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._ready.set() + try: + self._loop.run_forever() + finally: + try: + self._loop.close() + except Exception: + pass + + self._thread = threading.Thread( + target=_run, daemon=True, name="cua-driver-bridge" + ) + self._thread.start() + if not self._ready.wait(timeout=5.0): + raise RuntimeError("cua-driver asyncio bridge failed to start") + + def run(self, coro: Any, timeout: Optional[float] = _CALL_TIMEOUT_S) -> Any: + """Schedule a coroutine on the bridge loop and block until result.""" + if not self._loop or not self._thread or not self._thread.is_alive(): + if asyncio.iscoroutine(coro): + coro.close() + raise RuntimeError("cua-driver bridge not running") + fut = asyncio.run_coroutine_threadsafe(coro, self._loop) + return fut.result(timeout=timeout) + + def stop(self) -> None: + if self._loop and self._loop.is_running(): + self._loop.call_soon_threadsafe(self._loop.stop) + if self._thread: + self._thread.join(timeout=3.0) + self._thread = None + self._loop = None + + +# ── MCP Session ────────────────────────────────────────────────────────────── + +class _McpSession: + """Manages the MCP stdio connection lifecycle in a single coroutine task. + + The lifecycle coroutine opens stdio_client + ClientSession, populates + tool capabilities, signals ready, then blocks until shutdown. Tool + calls run as independent coroutines on the same loop. + """ + + def __init__(self, bridge: _AsyncBridge) -> None: + self._bridge = bridge + self._session: Any = None + self._lock = threading.Lock() + self._started = False + self._tools: Dict[str, Set[str]] = {} # tool_name → capabilities + self._capability_version: str = "" + self._ready_event = threading.Event() + self._shutdown_event: Optional[asyncio.Event] = None + self._lifecycle_future: Optional[concurrent.futures.Future] = None + self._setup_error: Optional[BaseException] = None + + @property + def started(self) -> bool: + return self._started + + @property + def available_tools(self) -> Dict[str, Set[str]]: + return self._tools + + @property + def capability_version(self) -> str: + return self._capability_version + + def has_tool(self, name: str) -> bool: + """True if tools/list advertised this tool name.""" + return name in self._tools + + def supports_capability(self, capability: str, tool: Optional[str] = None) -> bool: + """Check if a capability is advertised (optionally scoped to a tool).""" + if tool is not None: + return capability in self._tools.get(tool, set()) + return any(capability in caps for caps in self._tools.values()) + + async def _lifecycle_coro(self) -> None: + """Long-lived owner of MCP contexts. Enter and exit happen in the + SAME asyncio task to preserve anyio cancel-scope invariant.""" + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + + self._shutdown_event = asyncio.Event() + + try: + if not cua_driver_available(): + raise RuntimeError( + "cua-driver not found on PATH. Set LEAPFLOW_CUA_DRIVER_CMD " + "or install: https://github.com/trycua/cua" + ) + + command, args = _resolve_mcp_invocation(_CUA_DRIVER_CMD) + params = StdioServerParameters( + command=command, + args=args, + env=_child_env(), + ) + + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + await self._discover_capabilities(session) + self._session = session + self._ready_event.set() + await self._shutdown_event.wait() + except BaseException as e: + self._setup_error = e + self._ready_event.set() + raise + finally: + self._session = None + + async def _discover_capabilities(self, session: Any) -> None: + """Populate per-tool capability sets from tools/list.""" + try: + tools_response = await session.list_tools() + for tool in getattr(tools_response, "tools", []) or []: + name = getattr(tool, "name", None) + if not isinstance(name, str): + continue + caps = getattr(tool, "capabilities", None) + if caps is None: + extra = getattr(tool, "model_extra", None) or {} + caps = extra.get("capabilities") + if isinstance(caps, list): + self._tools[name] = {c for c in caps if isinstance(c, str)} + else: + self._tools[name] = set() + + cv = getattr(tools_response, "capability_version", None) + if cv is None: + extra = getattr(tools_response, "model_extra", None) or {} + cv = extra.get("capability_version") + if isinstance(cv, str): + self._capability_version = cv + except Exception as e: + logger.debug("cua-driver capability discovery failed: %s", e) + + def start(self) -> None: + with self._lock: + if self._started: + return + self._bridge.start() + self._start_lifecycle() + self._started = True + + def _start_lifecycle(self) -> None: + """Spawn lifecycle coroutine and wait for ready. Caller must hold lock.""" + self._ready_event = threading.Event() + self._setup_error = None + self._shutdown_event = None + self._tools = {} + self._capability_version = "" + + loop = self._bridge.loop + if loop is None: + raise RuntimeError("cua-driver bridge loop not available") + + self._lifecycle_future = asyncio.run_coroutine_threadsafe( + self._lifecycle_coro(), loop + ) + if not self._ready_event.wait(timeout=_SESSION_READY_TIMEOUT_S): + self._signal_shutdown() + raise RuntimeError( + f"cua-driver session not ready within {_SESSION_READY_TIMEOUT_S}s" + ) + if self._setup_error is not None: + raise RuntimeError( + f"cua-driver session setup failed: {self._setup_error}" + ) from self._setup_error + + def stop(self) -> None: + with self._lock: + if not self._started: + return + self._started = False + self._stop_lifecycle() + + def _stop_lifecycle(self) -> None: + """Signal shutdown and wait for lifecycle unwind. Caller must hold lock.""" + self._signal_shutdown() + fut = self._lifecycle_future + if fut is None: + return + try: + fut.result(timeout=5.0) + except concurrent.futures.TimeoutError: + logger.warning("cua-driver session shutdown timed out") + except Exception as e: + logger.debug("cua-driver shutdown: %s", e) + finally: + self._lifecycle_future = None + + def _signal_shutdown(self) -> None: + loop = self._bridge.loop + event = self._shutdown_event + if loop and event and loop.is_running(): + try: + loop.call_soon_threadsafe(event.set) + except RuntimeError: + pass + + def _restart(self) -> None: + """Reconnect after session drop. Caller must hold lock.""" + if self._started: + try: + self._stop_lifecycle() + except Exception as e: + logger.debug("cleanup before reconnect: %s", e) + self._started = False + self._start_lifecycle() + self._started = True + + async def call_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]: + """Invoke an MCP tool and return extracted result dict.""" + if self._session is None: + raise RuntimeError("cua-driver session not active") + result = await self._session.call_tool(name, args) + return _extract_result(result) + + def call_tool_sync( + self, name: str, args: Dict[str, Any], timeout: float = _CALL_TIMEOUT_S + ) -> Dict[str, Any]: + """Synchronous tool call with reconnect-once on session drop.""" + if not self._started: + raise RuntimeError("cua-driver session not started") + try: + return self._bridge.run(self.call_tool(name, args), timeout=timeout) + except Exception as e: + if not _is_closed_session_error(e): + raise + logger.warning("cua-driver session closed during %s; reconnecting", name) + with self._lock: + self._restart() + return self._bridge.run(self.call_tool(name, args), timeout=timeout) + + +# ── Result extraction ──────────────────────────────────────────────────────── + +def _extract_result(mcp_result: Any) -> Dict[str, Any]: + """Flatten an MCP CallToolResult into a plain dict.""" + data: Any = None + images: List[str] = [] + is_error = bool(getattr(mcp_result, "isError", False)) + structured: Optional[Dict] = getattr(mcp_result, "structuredContent", None) or None + text_parts: List[str] = [] + + for part in getattr(mcp_result, "content", []) or []: + ptype = getattr(part, "type", None) + if ptype == "text": + text_parts.append(getattr(part, "text", "") or "") + elif ptype == "image": + b64 = getattr(part, "data", None) + if b64: + images.append(b64) + + if text_parts: + joined = "\n".join(t for t in text_parts if t) + try: + data = json.loads(joined) if joined.strip().startswith(("{", "[")) else joined + except json.JSONDecodeError: + data = joined + + return { + "data": data, + "images": images, + "structuredContent": structured, + "isError": is_error, + } + + +def _is_closed_session_error(exc: Exception) -> bool: + """Detect MCP/stdio failures recoverable by reconnecting.""" + name = exc.__class__.__name__ + module = getattr(exc.__class__, "__module__", "") + return ( + name in {"ClosedResourceError", "BrokenResourceError", "EndOfStream"} + or (module.startswith("anyio") and "Resource" in name) + or isinstance(exc, (BrokenPipeError, EOFError)) + ) + + +# ── Local operations (clipboard, file) ────────────────────────────────────── + +def _clipboard_get() -> str: + """Read clipboard via platform-native command.""" + if sys.platform == "darwin": + cmd = ["pbpaste"] + elif sys.platform == "win32": + cmd = ["powershell", "-command", "Get-Clipboard"] + else: + cmd = ["xclip", "-selection", "clipboard", "-o"] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=5.0) + return result.stdout + except Exception as e: + raise RpcError("clipboard_error", f"Failed to read clipboard: {e}", {}) + + +def _clipboard_set(text: str) -> None: + """Write clipboard via platform-native command.""" + if sys.platform == "darwin": + cmd = ["pbcopy"] + elif sys.platform == "win32": + cmd = ["powershell", "-command", "Set-Clipboard", "-Value", text] + else: + cmd = ["xclip", "-selection", "clipboard"] + + try: + if sys.platform == "win32": + subprocess.run(cmd, capture_output=True, timeout=5.0, check=True) + else: + subprocess.run( + cmd, input=text, capture_output=True, text=True, timeout=5.0, check=True + ) + except Exception as e: + raise RpcError("clipboard_error", f"Failed to set clipboard: {e}", {}) + + +def _file_list(params: Dict[str, Any]) -> List[Dict[str, Any]]: + """List directory contents via pathlib.""" + directory = Path(params.get("path", ".")) + if not directory.exists(): + raise RpcError("file_not_found", f"Directory not found: {directory}", {}) + entries = [] + for entry in sorted(directory.iterdir()): + entries.append({ + "name": entry.name, + "path": str(entry), + "is_dir": entry.is_dir(), + "size": entry.stat().st_size if entry.is_file() else 0, + }) + return entries + + +def _file_move(params: Dict[str, Any]) -> Dict[str, str]: + src = Path(params["source"]) + dst = Path(params["destination"]) + src.rename(dst) + return {"moved": str(dst)} + + +def _file_copy(params: Dict[str, Any]) -> Dict[str, str]: + import shutil as _shutil + src = Path(params["source"]) + dst = Path(params["destination"]) + if src.is_dir(): + _shutil.copytree(str(src), str(dst)) + else: + _shutil.copy2(str(src), str(dst)) + return {"copied": str(dst)} + + +def _file_delete(params: Dict[str, Any]) -> Dict[str, str]: + import shutil as _shutil + target = Path(params["path"]) + if target.is_dir(): + _shutil.rmtree(str(target)) + else: + target.unlink() + return {"deleted": str(target)} + + +# ── Dispatch helpers ───────────────────────────────────────────────────────── + +def _resolve_ax_perform_tool(params: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]: + """Map ax.perform params to the appropriate cua-driver tool + args. + + cua-driver splits AX actions into discrete tools: click, type_text, + set_value. We infer the target from the `action` param. + """ + action = params.get("action", "") + element_index = params.get("element_index") + element_token = params.get("element_token") + + # Common args shared across tools + base_args: Dict[str, Any] = {} + if element_index is not None: + base_args["element_index"] = element_index + if element_token is not None: + base_args["element_token"] = element_token + + # Delivery mode for Verify-Then-Escalate + delivery_mode = params.get("delivery_mode", "background") + if delivery_mode != "background": + base_args["delivery_mode"] = delivery_mode + + if action in ("click", "double_click", "right_click"): + args = {**base_args, "action": action} + if "coordinates" in params: + args["coordinates"] = params["coordinates"] + return "click", args + + elif action in ("type", "type_text"): + args = {**base_args} + if "text" in params: + args["text"] = params["text"] + return "type_text", args + + elif action == "set_value": + args = {**base_args} + if "value" in params: + args["value"] = params["value"] + return "set_value", args + + elif action == "select": + args = {**base_args} + return "click", args + + else: + # Fallback: pass action directly as a click variant + args = {**base_args, "action": action} + return "click", args + + +# ── CuaDriverClient ────────────────────────────────────────────────────────── + +class CuaDriverClient(HostRpc): + """MCP stdio bridge to cua-driver, implementing HostRpc Protocol. + + Design principles: + - AsyncBridge: background thread running asyncio event loop + - Session management: MCP lifecycle_coro with enter/exit in same task + - Capability negotiation: tools/list discovery at startup + - Verify-Then-Escalate: AX background → PX pixel → foreground + - Element Token: opaque token tracking for staleness detection + - Heartbeat keepalive: periodic list_apps probe, auto-reconnect + """ + + def __init__( + self, + *, + call_timeout: float = _CALL_TIMEOUT_S, + keepalive_interval: float = _KEEPALIVE_INTERVAL_S, + timeout_overrides: Optional[Dict[str, float]] = None, + ) -> None: + self._bridge = _AsyncBridge() + self._session = _McpSession(self._bridge) + self._call_timeout = call_timeout + self._keepalive_interval = keepalive_interval + self._keepalive_task: Optional[asyncio.Task] = None + self._closed = False + # Per-method-prefix timeout overrides + self._timeout_map: Dict[str, float] = { + "ping": 3.0, + "ax": 8.0, + "app": 5.0, + "input": 5.0, + "screen": 10.0, + "recording": 10.0, + "clipboard": 3.0, + "file": 15.0, + "system": 5.0, + } + if timeout_overrides: + self._timeout_map.update(timeout_overrides) + + def _resolve_timeout(self, method: str) -> float: + """Resolve timeout by method prefix.""" + prefix = method.split(".", 1)[0] if method else "" + return self._timeout_map.get(prefix, self._call_timeout) + + # ── Lifecycle ──────────────────────────────────────────────────────── + + def start(self) -> None: + """Initialize the bridge and MCP session.""" + self._session.start() + self._start_keepalive() + logger.info("CuaDriverClient started (tools: %d)", len(self._session.available_tools)) + + def stop(self) -> None: + """Gracefully shut down.""" + self._closed = True + self._stop_keepalive() + self._session.stop() + self._bridge.stop() + logger.info("CuaDriverClient stopped") + + def _start_keepalive(self) -> None: + """Start periodic heartbeat on the bridge loop.""" + loop = self._bridge.loop + if loop is None: + return + + async def _heartbeat() -> None: + while not self._closed: + await asyncio.sleep(self._keepalive_interval) + if self._closed: + break + try: + await self._session.call_tool("list_apps", {}) + except Exception as e: + logger.debug("keepalive probe failed: %s", e) + break + + self._keepalive_task = asyncio.run_coroutine_threadsafe( + _heartbeat(), loop + ) + + def _stop_keepalive(self) -> None: + fut = self._keepalive_task + if fut is not None: + fut.cancel() + self._keepalive_task = None + + # ── HostRpc Protocol implementation ────────────────────────────────── + + async def call(self, method: str, params: Optional[Dict[str, Any]] = None) -> Any: + """Dispatch a LeapFlow RPC method to the appropriate handler. + + Routes Methods.* constants to cua-driver MCP tools or local + implementations. Supports Verify-Then-Escalate on action tools. + """ + params = params or {} + timeout = self._resolve_timeout(method) + + # Local-only operations (no cua-driver roundtrip) + handler = _LOCAL_DISPATCH.get(method) + if handler is not None: + return handler(params) + + # cua-driver tool dispatch (may raise _LocalResult for synthesized responses) + try: + tool_name, tool_args = self._map_to_cua_tool(method, params) + except _LocalResult as lr: + return lr.data + + result = await self._call_cua_tool(tool_name, tool_args, timeout) + + # Verify-Then-Escalate: check if response recommends escalation + if self._should_escalate(result): + escalated_args = self._apply_escalation(tool_args, result) + result = await self._call_cua_tool(tool_name, escalated_args, timeout) + + return self._unwrap_result(result) + + async def _call_cua_tool( + self, name: str, args: Dict[str, Any], timeout: float + ) -> Dict[str, Any]: + """Call a cua-driver MCP tool with reconnect-once semantics.""" + if self._session._session is None: + raise RpcError("not_connected", "cua-driver session not active", {}) + try: + return await asyncio.wait_for( + self._session.call_tool(name, args), + timeout=timeout, + ) + except asyncio.TimeoutError: + raise RpcError("timeout", f"cua-driver {name} timed out after {timeout}s", {}) + except Exception as e: + if not _is_closed_session_error(e): + raise RpcError( + "cua_error", + f"cua-driver {name} failed: {e}", + {"tool": name, "original": str(e)}, + ) + # Reconnect once + logger.warning("cua-driver session dropped during %s; reconnecting", name) + with self._session._lock: + self._session._restart() + try: + return await asyncio.wait_for( + self._session.call_tool(name, args), + timeout=timeout, + ) + except Exception as retry_exc: + raise RpcError( + "cua_reconnect_failed", + f"cua-driver {name} failed after reconnect: {retry_exc}", + {"tool": name}, + ) from retry_exc + + # ── Method → Tool mapping ──────────────────────────────────────────── + + def _map_to_cua_tool(self, method: str, params: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]: + """Translate a LeapFlow Methods constant to (cua_tool_name, args).""" + if method == Methods.AX_TREE: + args: Dict[str, Any] = {} + if "app" in params: + args["app"] = params["app"] + if "window_id" in params: + args["window_id"] = params["window_id"] + return "get_window_state", args + + elif method == Methods.AX_PERFORM: + return _resolve_ax_perform_tool(params) + + elif method == Methods.AX_SCROLL: + args = {} + for key in ("x", "y", "direction", "amount", "coordinates", "element_index"): + if key in params: + args[key] = params[key] + return "scroll", args + + elif method == Methods.APP_LAUNCH: + args = {"app_name": params.get("app_name", params.get("name", ""))} + return "launch_app", args + + elif method == Methods.APP_ACTIVATE: + args = {"app_name": params.get("app_name", params.get("name", ""))} + return "launch_app", args + + elif method == Methods.APP_LIST: + return "list_apps", {} + + elif method == Methods.INPUT_TYPE_TEXT: + args = {"text": params.get("text", "")} + return "type_text", args + + elif method == Methods.INPUT_SHORTCUT: + # Parse key combo into cua-driver hotkey format + keys = params.get("keys", params.get("shortcut", "")) + args = {"keys": keys} if isinstance(keys, list) else {"key": keys} + return "hotkey", args + + elif method == Methods.SCREEN_CAPTURE_FRAME: + # Prefer screenshot tool if available, else get_window_state + if self._session.has_tool("screenshot"): + args = {} + if "app" in params: + args["app"] = params["app"] + return "screenshot", args + else: + args = {} + if "app" in params: + args["app"] = params["app"] + return "get_window_state", args + + elif method == Methods.RECORDING_START: + return "start_recording", params + + elif method == Methods.RECORDING_STOP: + return "stop_recording", params + + elif method == Methods.PING: + return "list_apps", {} + + elif method == Methods.SYSTEM_INFO: + return self._build_system_info(params) + + elif method == Methods.SYSTEM_MANIFEST: + return self._build_system_manifest(params) + + else: + # Passthrough: treat method as direct tool name + return method, params + + def _build_system_info(self, params: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]: + """system.info is synthesized locally + from tool list.""" + # We return a sentinel that _unwrap_result handles + raise _LocalResult({ + "platform": sys.platform, + "arch": platform.machine(), + "os_version": platform.version(), + "cua_driver_cmd": _CUA_DRIVER_CMD, + "capability_version": self._session.capability_version, + "tools_available": sorted(self._session.available_tools.keys()), + }) + + def _build_system_manifest(self, params: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]: + """system.manifest built from tools/list discovery.""" + raise _LocalResult({ + "capability_version": self._session.capability_version, + "tools": { + name: sorted(caps) for name, caps in self._session.available_tools.items() + }, + }) + + # ── Verify-Then-Escalate ───────────────────────────────────────────── + + @staticmethod + def _should_escalate(result: Dict[str, Any]) -> bool: + """Check if cua-driver recommends escalation to foreground/PX.""" + structured = result.get("structuredContent") or {} + # Explicit escalation recommendation + escalation = structured.get("escalation") or {} + if escalation.get("recommended") == "foreground": + return True + # Degraded or suspected noop + if structured.get("degraded") is True: + return True + if structured.get("suspected_noop") is True: + return True + return False + + @staticmethod + def _apply_escalation( + original_args: Dict[str, Any], result: Dict[str, Any] + ) -> Dict[str, Any]: + """Modify args for escalated retry (foreground delivery).""" + args = dict(original_args) + structured = result.get("structuredContent") or {} + escalation = structured.get("escalation") or {} + + if escalation.get("recommended") == "foreground": + args["delivery_mode"] = "foreground" + elif structured.get("degraded") or structured.get("suspected_noop"): + # Fall back to pixel coordinates if available + if "coordinates" in structured: + args["coordinates"] = structured["coordinates"] + args["delivery_mode"] = "foreground" + return args + + # ── Result unwrapping ──────────────────────────────────────────────── + + @staticmethod + def _unwrap_result(result: Dict[str, Any]) -> Any: + """Unwrap the flattened tool result into caller-friendly form.""" + if result.get("isError"): + data = result.get("data", "unknown error") + raise RpcError("cua_tool_error", str(data), result) + # Prefer structuredContent, then data, then images + structured = result.get("structuredContent") + if structured is not None: + return structured + data = result.get("data") + if data is not None: + return data + images = result.get("images") + if images: + return {"images": images} + return None + + +# ── Local dispatch table ───────────────────────────────────────────────────── + +class _LocalResult(Exception): + """Sentinel: call() intercepts this to return local data without cua-driver.""" + + def __init__(self, data: Any) -> None: + self.data = data + + +def _local_clipboard_get(params: Dict[str, Any]) -> str: + return _clipboard_get() + + +def _local_clipboard_set(params: Dict[str, Any]) -> None: + _clipboard_set(params.get("text", params.get("content", ""))) + + +def _local_clipboard_last_change(params: Dict[str, Any]) -> Dict[str, Any]: + # Best-effort: return current content with no timestamp + return {"content": _clipboard_get(), "timestamp": None} + + +def _local_fs_subscribe(params: Dict[str, Any]) -> Dict[str, Any]: + """FS events are handled by Python observers (ObservationDaemon), not cua-driver.""" + return {"subscription_id": "local-observer-fs", "path": params.get("path", "")} + + +def _local_screen_permission_status(params: Dict[str, Any]) -> Dict[str, Any]: + """Screen permission is managed by the OS; return best-effort status.""" + return {"status": "unknown", "message": "Permission managed by OS (check System Settings)"} + + +_LOCAL_DISPATCH: Dict[str, Callable[[Dict[str, Any]], Any]] = { + Methods.CLIPBOARD_GET: _local_clipboard_get, + Methods.CLIPBOARD_SET: _local_clipboard_set, + Methods.CLIPBOARD_LAST_CHANGE: _local_clipboard_last_change, + Methods.FILE_LIST: _file_list, + Methods.FILE_MOVE: _file_move, + Methods.FILE_COPY: _file_copy, + Methods.FILE_DELETE: _file_delete, + Methods.FS_SUBSCRIBE: _local_fs_subscribe, + Methods.SCREEN_PERMISSION_STATUS: _local_screen_permission_status, +} diff --git a/src/leapflow/platform/facade.py b/src/leapflow/platform/facade.py index a070c79..06095ed 100644 --- a/src/leapflow/platform/facade.py +++ b/src/leapflow/platform/facade.py @@ -48,7 +48,20 @@ async def handshake(self) -> PlatformManifest: Falls back to a default Darwin manifest if the host does not support the system.manifest RPC (backward compatibility with older OSHost). + + When the underlying transport is CuaDriverClient, capabilities are + derived from the tools/list discovery (no system.manifest RPC needed). """ + from leapflow.platform.cua_client import CuaDriverClient + + if isinstance(self._rpc, CuaDriverClient): + self._manifest = _manifest_from_cua_tools(self._rpc) + logger.info( + "VSI handshake OK (cua-driver): caps=%d", + len(self._manifest.capabilities), + ) + return self._manifest + try: result = await self._rpc.call("system.manifest") self._manifest = _parse_manifest(result) @@ -98,3 +111,62 @@ def _parse_manifest(raw: dict) -> PlatformManifest: capabilities=caps, metadata=metadata, ) + + +# ── Capability mapping from cua-driver tools to PlatformManifest ───────────── + +_CUA_TOOL_TO_CAPABILITIES: dict[str, list[str]] = { + "get_window_state": ["accessibility", "ax_tree"], + "click": ["accessibility", "ax_perform"], + "type_text": ["accessibility", "input"], + "set_value": ["accessibility", "ax_perform"], + "scroll": ["accessibility", "input"], + "hotkey": ["input"], + "screenshot": ["screen_capture"], + "launch_app": ["app_management"], + "list_apps": ["app_management"], + "start_recording": ["recording"], + "stop_recording": ["recording"], +} + + +def _manifest_from_cua_tools(rpc: "CuaDriverClient") -> PlatformManifest: + """Build a PlatformManifest from CuaDriverClient's discovered tools.""" + import platform as _platform + import sys as _sys + + from leapflow.platform.cua_client import CuaDriverClient + + session = rpc._session # noqa: SLF001 + tools = session.available_tools + + # Derive capabilities from discovered tool names + caps_strs: set[str] = set() + for tool_name in tools: + if tool_name in _CUA_TOOL_TO_CAPABILITIES: + caps_strs.update(_CUA_TOOL_TO_CAPABILITIES[tool_name]) + + caps = frozenset( + cap for s in caps_strs if (cap := capability_from_str(s)) is not None + ) + + # Determine platform + if _sys.platform == "darwin": + pid = PlatformID.DARWIN + elif _sys.platform == "win32": + pid = PlatformID.WINDOWS + elif _sys.platform.startswith("linux"): + pid = PlatformID.LINUX + else: + pid = PlatformID.UNKNOWN + + return PlatformManifest( + platform_id=pid, + os_version=_platform.version(), + capabilities=caps, + metadata={ + "driver": "cua-driver", + "capability_version": session.capability_version, + "tools": sorted(tools.keys()), + }, + ) diff --git a/src/leapflow/platform/observers/__init__.py b/src/leapflow/platform/observers/__init__.py new file mode 100644 index 0000000..43e9329 --- /dev/null +++ b/src/leapflow/platform/observers/__init__.py @@ -0,0 +1,76 @@ +"""Cross-platform event observers for passive signal collection. + +Each observer implements the Observer Protocol and publishes events +through EventBus.handle_event(). Platform-specific implementations +are selected transparently based on sys.platform. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Protocol, runtime_checkable + + +@runtime_checkable +class Observer(Protocol): + """Lightweight passive signal observer. + + Contract: + - start() is idempotent: calling on a running observer is a no-op. + - stop() is idempotent: calling on a stopped observer is a no-op. + - Exceptions inside observer loops MUST be caught internally. + - Events are published via EventBus.handle_event(). + """ + + async def start(self) -> None: + """Begin observing. Idempotent.""" + ... + + async def stop(self) -> None: + """Stop observing and release resources. Idempotent.""" + ... + + @property + def running(self) -> bool: + """Whether the observer is actively collecting signals.""" + ... + + +@dataclass +class ObserverConfig: + """Configuration for the observation subsystem.""" + + # Which observers to enable (key = observer name) + enabled: Dict[str, bool] = field(default_factory=lambda: { + "fs_watcher": True, + "app_focus": True, + "clipboard": True, + "input_tap": False, # Requires accessibility permissions + }) + + # fs_watcher settings + fs_watch_paths: List[str] = field(default_factory=list) + fs_debounce_ms: int = 500 + + # clipboard settings + clipboard_poll_interval_s: float = 1.0 + + # input_tap settings + input_throttle_ms: int = 50 + + +from leapflow.platform.observers.fs_watcher import FileSystemObserver # noqa: E402 +from leapflow.platform.observers.app_focus import AppFocusObserver # noqa: E402 +from leapflow.platform.observers.clipboard import ClipboardObserver # noqa: E402 +from leapflow.platform.observers.input_tap import InputTapObserver # noqa: E402 +from leapflow.platform.observers.daemon import ObservationDaemon # noqa: E402 + +__all__ = [ + "Observer", + "ObserverConfig", + "FileSystemObserver", + "AppFocusObserver", + "ClipboardObserver", + "InputTapObserver", + "ObservationDaemon", +] diff --git a/src/leapflow/platform/observers/app_focus.py b/src/leapflow/platform/observers/app_focus.py new file mode 100644 index 0000000..31c3734 --- /dev/null +++ b/src/leapflow/platform/observers/app_focus.py @@ -0,0 +1,316 @@ +"""Application focus change observer (cross-platform). + +Detects when the user switches between foreground applications. + +Backends: +- macOS: NSWorkspace notification center (pyobjc) +- Linux: X11 _NET_ACTIVE_WINDOW property via subprocess (xdotool/xprop) +- Windows: pywin32 SetWinEventHook(EVENT_SYSTEM_FOREGROUND) +""" + +from __future__ import annotations + +import asyncio +import logging +import subprocess +import sys +import time +from typing import Any, Dict, Optional, TYPE_CHECKING + +from leapflow.platform.protocol import EventTypes + +if TYPE_CHECKING: + from leapflow.platform.event_bus import EventBus + +logger = logging.getLogger(__name__) + + +class AppFocusObserver: + """Observes application foreground changes and publishes APP_FOCUS_CHANGE events. + + Automatically selects platform-specific backend. + """ + + def __init__(self, bus: "EventBus") -> None: + self._bus = bus + self._running = False + self._impl: Optional[_FocusBackend] = None + self._task: Optional[asyncio.Task[None]] = None + + @property + def running(self) -> bool: + return self._running + + async def start(self) -> None: + """Start observing focus changes. Idempotent.""" + if self._running: + return + + self._impl = _create_backend(sys.platform) + if self._impl is None: + logger.warning("No focus observer backend for platform: %s", sys.platform) + return + + self._running = True + self._task = asyncio.create_task(self._poll_loop()) + logger.info("AppFocusObserver started (platform=%s)", sys.platform) + + async def stop(self) -> None: + """Stop observing. Idempotent.""" + if not self._running: + return + + self._running = False + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + if self._impl is not None: + self._impl.cleanup() + self._impl = None + + logger.info("AppFocusObserver stopped") + + async def _poll_loop(self) -> None: + """Polling loop that detects focus changes.""" + last_app_id: Optional[str] = None + + while self._running: + try: + info = self._impl.get_active_app() if self._impl else None + if info is not None and info.get("app_id") != last_app_id: + last_app_id = info.get("app_id") + await self._emit(info) + except Exception: + logger.debug("Focus poll error", exc_info=True) + + await asyncio.sleep(0.5) + + async def _emit(self, info: Dict[str, Any]) -> None: + """Emit focus change event through EventBus.""" + payload: Dict[str, Any] = { + "bundle_id": info.get("app_id", ""), + "app_name": info.get("app_name", ""), + "pid": info.get("pid", 0), + "window_title": info.get("window_title", ""), + "ts": time.time(), + } + try: + await self._bus.handle_event(EventTypes.APP_FOCUS_CHANGE, payload) + except Exception: + logger.error("Failed to emit APP_FOCUS_CHANGE event", exc_info=True) + + +# ══════════════════════════════════════════════════════════════════════ +# Platform backends +# ══════════════════════════════════════════════════════════════════════ + + +class _FocusBackend: + """Base class for platform-specific focus detection.""" + + def get_active_app(self) -> Optional[Dict[str, Any]]: + """Return current foreground app info or None.""" + return None + + def cleanup(self) -> None: + """Release platform resources.""" + pass + + +class _MacOSFocusBackend(_FocusBackend): + """macOS backend using NSWorkspace (pyobjc).""" + + def get_active_app(self) -> Optional[Dict[str, Any]]: + try: + from AppKit import NSWorkspace + ws = NSWorkspace.sharedWorkspace() + app = ws.frontmostApplication() + if app is None: + return None + return { + "app_id": app.bundleIdentifier() or "", + "app_name": app.localizedName() or "", + "pid": app.processIdentifier(), + "window_title": self._get_window_title(app.processIdentifier()), + } + except ImportError: + # Fallback: use AppleScript + return self._applescript_fallback() + except Exception: + logger.debug("macOS focus detection failed", exc_info=True) + return None + + def _get_window_title(self, pid: int) -> str: + """Get window title via AppleScript (best-effort).""" + try: + script = ( + 'tell application "System Events" to get name of first window ' + f'of (first process whose unix id is {pid})' + ) + result = subprocess.run( + ["osascript", "-e", script], + capture_output=True, text=True, timeout=2, + ) + return result.stdout.strip() if result.returncode == 0 else "" + except Exception: + return "" + + def _applescript_fallback(self) -> Optional[Dict[str, Any]]: + """Fallback when pyobjc unavailable.""" + try: + script = ( + 'tell application "System Events" to get ' + '{bundle identifier, name, unix id} of first application process ' + 'whose frontmost is true' + ) + result = subprocess.run( + ["osascript", "-e", script], + capture_output=True, text=True, timeout=3, + ) + if result.returncode != 0: + return None + parts = result.stdout.strip().split(", ") + if len(parts) >= 3: + return { + "app_id": parts[0], + "app_name": parts[1], + "pid": int(parts[2]) if parts[2].isdigit() else 0, + "window_title": "", + } + except Exception: + pass + return None + + +class _LinuxFocusBackend(_FocusBackend): + """Linux backend using xdotool/xprop (X11).""" + + def get_active_app(self) -> Optional[Dict[str, Any]]: + try: + # Get active window ID + wid_result = subprocess.run( + ["xdotool", "getactivewindow"], + capture_output=True, text=True, timeout=2, + ) + if wid_result.returncode != 0: + return None + wid = wid_result.stdout.strip() + + # Get window name + name_result = subprocess.run( + ["xdotool", "getactivewindow", "getwindowname"], + capture_output=True, text=True, timeout=2, + ) + window_title = name_result.stdout.strip() if name_result.returncode == 0 else "" + + # Get PID + pid_result = subprocess.run( + ["xdotool", "getactivewindow", "getwindowpid"], + capture_output=True, text=True, timeout=2, + ) + pid = int(pid_result.stdout.strip()) if pid_result.returncode == 0 else 0 + + # Get WM_CLASS for app identification + class_result = subprocess.run( + ["xprop", "-id", wid, "WM_CLASS"], + capture_output=True, text=True, timeout=2, + ) + app_id = "" + app_name = "" + if class_result.returncode == 0: + # WM_CLASS(STRING) = "instance", "class" + parts = class_result.stdout.split('"') + if len(parts) >= 4: + app_id = parts[3] # class name + app_name = parts[3] + elif len(parts) >= 2: + app_id = parts[1] + app_name = parts[1] + + return { + "app_id": app_id, + "app_name": app_name, + "pid": pid, + "window_title": window_title, + } + except FileNotFoundError: + logger.debug("xdotool not found — Linux focus detection unavailable") + return None + except Exception: + logger.debug("Linux focus detection failed", exc_info=True) + return None + + +class _WindowsFocusBackend(_FocusBackend): + """Windows backend using ctypes.""" + + def get_active_app(self) -> Optional[Dict[str, Any]]: + try: + import ctypes + from ctypes import wintypes + + user32 = ctypes.windll.user32 # type: ignore[attr-defined] + + hwnd = user32.GetForegroundWindow() + if not hwnd: + return None + + # Window title + length = user32.GetWindowTextLengthW(hwnd) + buf = ctypes.create_unicode_buffer(length + 1) + user32.GetWindowTextW(hwnd, buf, length + 1) + window_title = buf.value + + # PID + pid = wintypes.DWORD() + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + + # Process name from PID + app_name = self._get_process_name(pid.value) + + return { + "app_id": app_name, + "app_name": app_name, + "pid": pid.value, + "window_title": window_title, + } + except Exception: + logger.debug("Windows focus detection failed", exc_info=True) + return None + + def _get_process_name(self, pid: int) -> str: + """Get process executable name from PID.""" + try: + import ctypes + + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return "" + try: + buf = ctypes.create_unicode_buffer(260) + size = ctypes.c_uint(260) + kernel32.QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)) + path = buf.value + return path.split("\\")[-1] if path else "" + finally: + kernel32.CloseHandle(handle) + except Exception: + return "" + + +def _create_backend(platform: str) -> Optional[_FocusBackend]: + """Factory: select platform-appropriate backend.""" + if platform == "darwin": + return _MacOSFocusBackend() + elif platform.startswith("linux"): + return _LinuxFocusBackend() + elif platform == "win32": + return _WindowsFocusBackend() + return None diff --git a/src/leapflow/platform/observers/clipboard.py b/src/leapflow/platform/observers/clipboard.py new file mode 100644 index 0000000..dd6a766 --- /dev/null +++ b/src/leapflow/platform/observers/clipboard.py @@ -0,0 +1,240 @@ +"""Clipboard change observer (cross-platform). + +Monitors clipboard content via polling and emits CLIPBOARD_CHANGE events +when content changes. Uses content hashing for deduplication. + +Backends: +- macOS: pbpaste subprocess (pyobjc NSPasteboard if available) +- Linux: xclip subprocess +- Windows: ctypes win32clipboard +""" + +from __future__ import annotations + +import asyncio +import hashlib +import logging +import subprocess +import sys +import time +from typing import Any, Dict, Optional, TYPE_CHECKING + +from leapflow.platform.protocol import EventTypes + +if TYPE_CHECKING: + from leapflow.platform.event_bus import EventBus + +logger = logging.getLogger(__name__) + + +class ClipboardObserver: + """Observes clipboard changes via polling and publishes CLIPBOARD_CHANGE events. + + Content hash comparison ensures only genuine changes trigger events. + """ + + def __init__( + self, + bus: "EventBus", + poll_interval_s: float = 1.0, + ) -> None: + self._bus = bus + self._poll_interval = poll_interval_s + self._running = False + self._task: Optional[asyncio.Task[None]] = None + self._last_hash: str = "" + self._reader = _create_clipboard_reader(sys.platform) + + @property + def running(self) -> bool: + return self._running + + async def start(self) -> None: + """Start clipboard polling. Idempotent.""" + if self._running: + return + + if self._reader is None: + logger.warning( + "No clipboard reader for platform: %s", sys.platform + ) + return + + # Capture initial state to avoid spurious first event + initial = self._reader.read() + if initial is not None: + self._last_hash = _content_hash(initial) + + self._running = True + self._task = asyncio.create_task(self._poll_loop()) + logger.info("ClipboardObserver started (interval=%.1fs)", self._poll_interval) + + async def stop(self) -> None: + """Stop polling. Idempotent.""" + if not self._running: + return + + self._running = False + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + logger.info("ClipboardObserver stopped") + + async def _poll_loop(self) -> None: + """Main polling loop.""" + while self._running: + try: + content = await asyncio.get_running_loop().run_in_executor( + None, self._reader.read # type: ignore[union-attr] + ) + if content is not None: + h = _content_hash(content) + if h != self._last_hash: + self._last_hash = h + await self._emit(content) + except asyncio.CancelledError: + break + except Exception: + logger.debug("Clipboard poll error", exc_info=True) + + await asyncio.sleep(self._poll_interval) + + async def _emit(self, content: str) -> None: + """Emit clipboard change event.""" + # Determine content type heuristically + content_type = "text" + if content.startswith("/") or content.startswith("C:\\"): + # Might be a file path + content_type = "file" + + payload: Dict[str, Any] = { + "text": content, + "content_type": content_type, + "source_app": "", # Platform-specific enrichment possible + "change_ts": time.time(), + } + try: + await self._bus.handle_event(EventTypes.CLIPBOARD_CHANGE, payload) + except Exception: + logger.error("Failed to emit CLIPBOARD_CHANGE event", exc_info=True) + + +def _content_hash(content: str) -> str: + """Fast hash for deduplication (not cryptographic).""" + return hashlib.md5(content.encode("utf-8", errors="replace")).hexdigest() + + +# ══════════════════════════════════════════════════════════════════════ +# Platform clipboard readers +# ══════════════════════════════════════════════════════════════════════ + + +class _ClipboardReader: + """Base class for clipboard reading.""" + + def read(self) -> Optional[str]: + """Read current clipboard text content. Returns None on error.""" + return None + + +class _MacOSClipboardReader(_ClipboardReader): + """macOS clipboard via pbpaste.""" + + def read(self) -> Optional[str]: + try: + result = subprocess.run( + ["pbpaste"], + capture_output=True, text=True, timeout=2, + ) + if result.returncode == 0: + return result.stdout + except Exception: + pass + return None + + +class _LinuxClipboardReader(_ClipboardReader): + """Linux clipboard via xclip or xsel.""" + + def __init__(self) -> None: + self._cmd: Optional[list[str]] = None + # Detect available tool + for cmd in [ + ["xclip", "-selection", "clipboard", "-o"], + ["xsel", "--clipboard", "--output"], + ]: + try: + subprocess.run( + cmd, capture_output=True, timeout=1, + ) + self._cmd = cmd + break + except FileNotFoundError: + continue + + def read(self) -> Optional[str]: + if self._cmd is None: + return None + try: + result = subprocess.run( + self._cmd, + capture_output=True, text=True, timeout=2, + ) + if result.returncode == 0: + return result.stdout + except Exception: + pass + return None + + +class _WindowsClipboardReader(_ClipboardReader): + """Windows clipboard via ctypes.""" + + def read(self) -> Optional[str]: + try: + import ctypes + + user32 = ctypes.windll.user32 # type: ignore[attr-defined] + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + + CF_UNICODETEXT = 13 + + if not user32.OpenClipboard(0): + return None + try: + handle = user32.GetClipboardData(CF_UNICODETEXT) + if not handle: + return None + ptr = kernel32.GlobalLock(handle) + if not ptr: + return None + try: + return ctypes.wstring_at(ptr) # type: ignore[attr-defined] + finally: + kernel32.GlobalUnlock(handle) + finally: + user32.CloseClipboard() + except Exception: + return None + + +def _create_clipboard_reader(platform: str) -> Optional[_ClipboardReader]: + """Factory: select platform-appropriate clipboard reader.""" + if platform == "darwin": + return _MacOSClipboardReader() + elif platform.startswith("linux"): + reader = _LinuxClipboardReader() + if reader._cmd is None: + logger.warning( + "No clipboard tool found (xclip/xsel) — ClipboardObserver disabled" + ) + return None + return reader + elif platform == "win32": + return _WindowsClipboardReader() + return None diff --git a/src/leapflow/platform/observers/daemon.py b/src/leapflow/platform/observers/daemon.py new file mode 100644 index 0000000..d736145 --- /dev/null +++ b/src/leapflow/platform/observers/daemon.py @@ -0,0 +1,108 @@ +"""Observation daemon — manages all observer lifecycles for 24/7 resident observation. + +ObservationDaemon is the single entry point for starting/stopping the entire +observation subsystem. It instantiates observers based on config and platform, +manages their independent lifecycles, and provides status reporting. +""" + +from __future__ import annotations + +import logging +from typing import Dict, List, TYPE_CHECKING + +from leapflow.platform.observers import ObserverConfig +from leapflow.platform.observers.fs_watcher import FileSystemObserver +from leapflow.platform.observers.app_focus import AppFocusObserver +from leapflow.platform.observers.clipboard import ClipboardObserver +from leapflow.platform.observers.input_tap import InputTapObserver + +if TYPE_CHECKING: + from leapflow.platform.event_bus import EventBus + +logger = logging.getLogger(__name__) + +# Type alias for observer instances (duck-typed to Observer Protocol) +_ObserverInstance = FileSystemObserver | AppFocusObserver | ClipboardObserver | InputTapObserver + + +class ObservationDaemon: + """Manages all observers lifecycle for 24/7 resident observation. + + Responsibilities: + - Instantiate observers based on config and platform + - Start/stop each observer independently (one failure doesn't block others) + - Report per-observer running status + """ + + def __init__(self, bus: "EventBus", config: ObserverConfig | None = None) -> None: + self._bus = bus + self._config = config or ObserverConfig() + self._observers: Dict[str, _ObserverInstance] = {} + self._build_observers() + + def _build_observers(self) -> None: + """Instantiate enabled observers based on config.""" + enabled = self._config.enabled + + if enabled.get("fs_watcher", True): + self._observers["fs_watcher"] = FileSystemObserver( + bus=self._bus, + watch_paths=self._config.fs_watch_paths or None, + debounce_ms=self._config.fs_debounce_ms, + ) + + if enabled.get("app_focus", True): + self._observers["app_focus"] = AppFocusObserver(bus=self._bus) + + if enabled.get("clipboard", True): + self._observers["clipboard"] = ClipboardObserver( + bus=self._bus, + poll_interval_s=self._config.clipboard_poll_interval_s, + ) + + if enabled.get("input_tap", False): + self._observers["input_tap"] = InputTapObserver( + bus=self._bus, + throttle_ms=self._config.input_throttle_ms, + ) + + async def start(self) -> None: + """Start all observers. Idempotent. One failure doesn't block others.""" + started: List[str] = [] + failed: List[str] = [] + + for name, observer in self._observers.items(): + try: + await observer.start() + if observer.running: + started.append(name) + else: + failed.append(name) + except Exception: + logger.error("Observer '%s' failed to start", name, exc_info=True) + failed.append(name) + + logger.info( + "ObservationDaemon started: %d active, %d failed — active=%s, failed=%s", + len(started), len(failed), started, failed, + ) + + async def stop(self) -> None: + """Stop all observers gracefully. Idempotent.""" + for name, observer in self._observers.items(): + try: + await observer.stop() + except Exception: + logger.error("Observer '%s' failed to stop cleanly", name, exc_info=True) + + logger.info("ObservationDaemon stopped all observers") + + @property + def status(self) -> Dict[str, bool]: + """Per-observer running status.""" + return {name: obs.running for name, obs in self._observers.items()} + + @property + def observer_names(self) -> List[str]: + """List of configured observer names.""" + return list(self._observers.keys()) diff --git a/src/leapflow/platform/observers/fs_watcher.py b/src/leapflow/platform/observers/fs_watcher.py new file mode 100644 index 0000000..d0f1e6b --- /dev/null +++ b/src/leapflow/platform/observers/fs_watcher.py @@ -0,0 +1,168 @@ +"""File system change observer using watchdog (cross-platform). + +Backends: +- macOS: FSEvents (kqueue fallback) +- Linux: inotify +- Windows: ReadDirectoryChangesW + +Events are debounced per-path within a configurable window (default 500ms). +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from pathlib import Path +from typing import Any, Dict, Optional, TYPE_CHECKING + +from leapflow.platform.protocol import EventTypes + +if TYPE_CHECKING: + from leapflow.platform.event_bus import EventBus + +logger = logging.getLogger(__name__) + + +class FileSystemObserver: + """Observes filesystem changes and publishes FS_CHANGE events. + + Uses the watchdog library for cross-platform FS monitoring. + Debounces rapid changes to the same path within a configurable window. + """ + + def __init__( + self, + bus: "EventBus", + watch_paths: Optional[list[str]] = None, + debounce_ms: int = 500, + ) -> None: + self._bus = bus + self._watch_paths = watch_paths or [str(Path.home())] + self._debounce_ms = debounce_ms + self._running = False + self._observer: Any = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + # Debounce state: path -> last_event_monotonic_time + self._last_events: Dict[str, float] = {} + + @property + def running(self) -> bool: + return self._running + + async def start(self) -> None: + """Start watching configured paths. Idempotent.""" + if self._running: + return + + try: + from watchdog.observers import Observer as WatchdogObserver + from watchdog.events import FileSystemEventHandler, FileSystemEvent # noqa: F401 + except ImportError: + logger.warning( + "watchdog not installed — FileSystemObserver disabled. " + "Install with: pip install watchdog" + ) + return + + self._loop = asyncio.get_running_loop() + + handler = _WatchdogHandler(self) + self._observer = WatchdogObserver() + + for path_str in self._watch_paths: + path = Path(path_str) + if not path.exists(): + logger.warning("Watch path does not exist, skipping: %s", path_str) + continue + self._observer.schedule(handler, str(path), recursive=True) + logger.debug("Watching: %s", path_str) + + self._observer.start() + self._running = True + logger.info( + "FileSystemObserver started, watching %d paths", len(self._watch_paths) + ) + + async def stop(self) -> None: + """Stop watching. Idempotent.""" + if not self._running: + return + + if self._observer is not None: + self._observer.stop() + self._observer.join(timeout=5.0) + self._observer = None + + self._running = False + self._last_events.clear() + logger.info("FileSystemObserver stopped") + + def _should_debounce(self, path: str) -> bool: + """Return True if this path event should be suppressed (within debounce window).""" + now = time.monotonic() + last = self._last_events.get(path, 0.0) + if (now - last) * 1000 < self._debounce_ms: + return True + self._last_events[path] = now + # Prune stale entries + if len(self._last_events) > 1000: + cutoff = now - (self._debounce_ms / 1000.0) * 2 + self._last_events = { + k: v for k, v in self._last_events.items() if v > cutoff + } + return False + + def _on_fs_event(self, path: str, action: str, is_dir: bool) -> None: + """Called from watchdog thread — schedules async event dispatch.""" + if self._should_debounce(path): + return + + if self._loop is None or self._loop.is_closed(): + return + + payload: Dict[str, Any] = { + "path": path, + "action": action, + "is_dir": is_dir, + } + # Schedule coroutine from watchdog's thread + asyncio.run_coroutine_threadsafe( + self._emit(payload), self._loop + ) + + async def _emit(self, payload: Dict[str, Any]) -> None: + """Emit event through EventBus.""" + try: + await self._bus.handle_event(EventTypes.FS_CHANGE, payload) + except Exception: + logger.error("Failed to emit FS_CHANGE event", exc_info=True) + + +class _WatchdogHandler: + """Adapts watchdog events to FileSystemObserver._on_fs_event calls.""" + + def __init__(self, observer: FileSystemObserver) -> None: + self._observer = observer + + def dispatch(self, event: Any) -> None: + """Called by watchdog for every FS event.""" + from watchdog.events import ( + EVENT_TYPE_CREATED, + EVENT_TYPE_DELETED, + EVENT_TYPE_MODIFIED, + EVENT_TYPE_MOVED, + ) + + action_map = { + EVENT_TYPE_CREATED: "created", + EVENT_TYPE_DELETED: "deleted", + EVENT_TYPE_MODIFIED: "modified", + EVENT_TYPE_MOVED: "moved", + } + + action = action_map.get(event.event_type, "modified") + path = getattr(event, "dest_path", None) or event.src_path + is_dir = event.is_directory + + self._observer._on_fs_event(path, action, is_dir) diff --git a/src/leapflow/platform/observers/input_tap.py b/src/leapflow/platform/observers/input_tap.py new file mode 100644 index 0000000..8567698 --- /dev/null +++ b/src/leapflow/platform/observers/input_tap.py @@ -0,0 +1,352 @@ +"""Keyboard and mouse input event observer (cross-platform). + +Captures low-level input events and publishes UI_ACTION events. + +Backends: +- macOS: Quartz CGEventTap (requires Accessibility permission) +- Linux: pynput (X11/Wayland) +- Windows: pynput + +Events are throttled per action type within a configurable window (default 50ms) +to prevent high-frequency keyboard/scroll event flooding. +""" + +from __future__ import annotations + +import asyncio +import logging +import sys +import time +from typing import Any, Dict, Optional, TYPE_CHECKING + +from leapflow.platform.protocol import EventTypes + +if TYPE_CHECKING: + from leapflow.platform.event_bus import EventBus + +logger = logging.getLogger(__name__) + + +class InputTapObserver: + """Observes keyboard/mouse input and publishes UI_ACTION events. + + Requires elevated permissions on macOS (Accessibility). + Uses pynput as cross-platform fallback. + Throttles same-type events within configurable window. + """ + + def __init__( + self, + bus: "EventBus", + throttle_ms: int = 50, + ) -> None: + self._bus = bus + self._throttle_ms = throttle_ms + self._running = False + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._listeners: list[Any] = [] + # macOS Quartz: references for proper teardown + self._quartz_tap: Any = None + self._quartz_run_loop: Any = None + # Throttle state: action_type -> last_emit_monotonic + self._last_emit: Dict[str, float] = {} + + @property + def running(self) -> bool: + return self._running + + async def start(self) -> None: + """Start capturing input. Idempotent.""" + if self._running: + return + + self._loop = asyncio.get_running_loop() + + if sys.platform == "darwin": + started = self._start_macos() + else: + started = self._start_pynput() + + if started: + self._running = True + logger.info("InputTapObserver started (platform=%s)", sys.platform) + else: + logger.warning("InputTapObserver failed to start") + + async def stop(self) -> None: + """Stop capturing. Idempotent.""" + if not self._running: + return + + # macOS: disable CGEventTap and stop CFRunLoop + if self._quartz_tap is not None: + try: + from Quartz import CGEventTapEnable, CFRunLoopStop + CGEventTapEnable(self._quartz_tap, False) + if self._quartz_run_loop is not None: + CFRunLoopStop(self._quartz_run_loop) + except Exception: + logger.debug("Error stopping Quartz event tap", exc_info=True) + self._quartz_tap = None + self._quartz_run_loop = None + + for listener in self._listeners: + try: + if hasattr(listener, "stop"): + listener.stop() + except Exception: + logger.debug("Error stopping input listener", exc_info=True) + + self._listeners.clear() + self._running = False + self._last_emit.clear() + logger.info("InputTapObserver stopped") + + def _should_throttle(self, action: str) -> bool: + """Return True if event should be suppressed (within throttle window).""" + now = time.monotonic() + last = self._last_emit.get(action, 0.0) + if (now - last) * 1000 < self._throttle_ms: + return True + self._last_emit[action] = now + return False + + def _schedule_emit(self, payload: Dict[str, Any]) -> None: + """Schedule async event emission from listener thread.""" + if self._loop is None or self._loop.is_closed(): + return + asyncio.run_coroutine_threadsafe(self._emit(payload), self._loop) + + async def _emit(self, payload: Dict[str, Any]) -> None: + """Emit UI_ACTION event through EventBus.""" + try: + await self._bus.handle_event(EventTypes.UI_ACTION, payload) + except Exception: + logger.error("Failed to emit UI_ACTION event", exc_info=True) + + # ── macOS: Quartz CGEventTap ── + + def _start_macos(self) -> bool: + """Start macOS input tap using Quartz.""" + try: + import Quartz + from Quartz import ( + CGEventTapCreate, + kCGSessionEventTap, + kCGHeadInsertEventTap, + kCGEventTapOptionListenOnly, + CGEventMaskBit, + kCGEventLeftMouseDown, + kCGEventRightMouseDown, + kCGEventKeyDown, + kCGEventScrollWheel, + CGEventTapEnable, + CFMachPortCreateRunLoopSource, + CFRunLoopAddSource, + CFRunLoopGetCurrent, + kCFRunLoopCommonModes, + ) + import threading + + mask = ( + CGEventMaskBit(kCGEventLeftMouseDown) + | CGEventMaskBit(kCGEventRightMouseDown) + | CGEventMaskBit(kCGEventKeyDown) + | CGEventMaskBit(kCGEventScrollWheel) + ) + + def callback(proxy: Any, event_type: int, event: Any, refcon: Any) -> Any: + self._handle_quartz_event(event_type, event) + return event + + tap = CGEventTapCreate( + kCGSessionEventTap, + kCGHeadInsertEventTap, + kCGEventTapOptionListenOnly, + mask, + callback, + None, + ) + + if tap is None: + logger.warning( + "CGEventTap creation failed — Accessibility permission required" + ) + return False + + source = CFMachPortCreateRunLoopSource(None, tap, 0) + + def run_loop_thread() -> None: + run_loop = CFRunLoopGetCurrent() + self._quartz_run_loop = run_loop + CFRunLoopAddSource(run_loop, source, kCFRunLoopCommonModes) + CGEventTapEnable(tap, True) + Quartz.CFRunLoopRun() + + self._quartz_tap = tap + thread = threading.Thread(target=run_loop_thread, daemon=True) + thread.start() + self._listeners.append(tap) + return True + + except ImportError: + logger.debug("Quartz not available, falling back to pynput") + return self._start_pynput() + except Exception: + logger.debug("macOS input tap failed", exc_info=True) + return self._start_pynput() + + def _handle_quartz_event(self, event_type: int, event: Any) -> None: + """Process a Quartz CGEvent.""" + try: + from Quartz import ( + kCGEventLeftMouseDown, + kCGEventRightMouseDown, + kCGEventKeyDown, + kCGEventScrollWheel, + CGEventGetLocation, + CGEventGetIntegerValueField, + kCGKeyboardEventKeycode, + kCGScrollWheelEventDeltaAxis1, + ) + + if event_type in (kCGEventLeftMouseDown, kCGEventRightMouseDown): + if self._should_throttle("click"): + return + loc = CGEventGetLocation(event) + payload: Dict[str, Any] = { + "action": "click", + "app_bundle_id": "", + "mouse_x": int(loc.x), + "mouse_y": int(loc.y), + "timestamp": time.time(), + } + self._schedule_emit(payload) + + elif event_type == kCGEventKeyDown: + if self._should_throttle("key"): + return + keycode = CGEventGetIntegerValueField(event, kCGKeyboardEventKeycode) + payload = { + "action": "type", + "app_bundle_id": "", + "key_code": int(keycode), + "timestamp": time.time(), + } + self._schedule_emit(payload) + + elif event_type == kCGEventScrollWheel: + if self._should_throttle("scroll"): + return + delta_y = CGEventGetIntegerValueField( + event, kCGScrollWheelEventDeltaAxis1 + ) + loc = CGEventGetLocation(event) + payload = { + "action": "scroll", + "app_bundle_id": "", + "delta_y": int(delta_y), + "mouse_x": int(loc.x), + "mouse_y": int(loc.y), + "timestamp": time.time(), + } + self._schedule_emit(payload) + + except Exception: + logger.debug("Quartz event processing error", exc_info=True) + + # ── Cross-platform: pynput ── + + def _start_pynput(self) -> bool: + """Start using pynput (works on Linux/Windows, fallback on macOS).""" + try: + from pynput import mouse, keyboard + except ImportError: + logger.warning( + "pynput not installed — InputTapObserver disabled. " + "Install with: pip install pynput" + ) + return False + + try: + # Mouse listener + mouse_listener = mouse.Listener( + on_click=self._on_pynput_click, + on_scroll=self._on_pynput_scroll, + ) + mouse_listener.start() + self._listeners.append(mouse_listener) + + # Keyboard listener + key_listener = keyboard.Listener( + on_press=self._on_pynput_key, + ) + key_listener.start() + self._listeners.append(key_listener) + + return True + except Exception: + logger.warning("pynput listener start failed", exc_info=True) + return False + + def _on_pynput_click( + self, x: int, y: int, button: Any, pressed: bool + ) -> None: + """Handle pynput mouse click.""" + if not pressed: + return + if self._should_throttle("click"): + return + + payload: Dict[str, Any] = { + "action": "click", + "app_bundle_id": "", + "mouse_x": int(x), + "mouse_y": int(y), + "timestamp": time.time(), + } + self._schedule_emit(payload) + + def _on_pynput_scroll( + self, x: int, y: int, dx: int, dy: int + ) -> None: + """Handle pynput scroll.""" + if self._should_throttle("scroll"): + return + + payload: Dict[str, Any] = { + "action": "scroll", + "app_bundle_id": "", + "delta_x": dx, + "delta_y": dy, + "mouse_x": int(x), + "mouse_y": int(y), + "timestamp": time.time(), + } + self._schedule_emit(payload) + + def _on_pynput_key(self, key: Any) -> None: + """Handle pynput key press.""" + if self._should_throttle("key"): + return + + # Extract key information + key_str = "" + key_code = 0 + try: + if hasattr(key, "char") and key.char: + key_str = key.char + elif hasattr(key, "vk"): + key_code = key.vk + key_str = key_str or str(key) + except Exception: + key_str = str(key) + + payload: Dict[str, Any] = { + "action": "type", + "app_bundle_id": "", + "key_code": key_code, + "char": key_str, + "timestamp": time.time(), + } + self._schedule_emit(payload) diff --git a/uv.lock b/uv.lock index 816add7..0a9201e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,12 @@ version = 1 revision = 1 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform != 'win32'", +] [[package]] name = "annotated-types" @@ -24,6 +30,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -33,6 +48,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707 }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476 }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374 }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597 }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574 }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971 }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972 }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078 }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, +] + [[package]] name = "charset-normalizer" version = "3.4.7" @@ -122,6 +207,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958 }, ] +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243 }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -131,6 +228,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100 }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978 }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422 }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503 }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779 }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683 }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874 }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283 }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844 }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290 }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612 }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804 }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026 }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892 }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835 }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239 }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593 }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961 }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145 }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719 }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209 }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285 }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441 }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869 }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948 }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153 }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947 }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429 }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968 }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758 }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863 }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983 }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173 }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298 }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338 }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650 }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820 }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968 }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547 }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685 }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239 }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584 }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885 }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449 }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731 }, +] + [[package]] name = "distro" version = "1.9.0" @@ -242,6 +395,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960 }, +] + [[package]] name = "idna" version = "3.15" @@ -350,17 +512,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613 }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, +] + [[package]] name = "leapflow" source = { editable = "." } dependencies = [ { name = "duckdb" }, { name = "gnureadline", marker = "sys_platform == 'darwin'" }, + { name = "mcp" }, { name = "msgpack" }, { name = "openai" }, { name = "pillow" }, { name = "python-dotenv" }, { name = "pyyaml" }, + { name = "watchdog" }, ] [package.optional-dependencies] @@ -377,6 +568,7 @@ hub = [ requires-dist = [ { name = "duckdb", specifier = ">=1.0.0" }, { name = "gnureadline", marker = "sys_platform == 'darwin'", specifier = ">=8.0" }, + { name = "mcp", specifier = ">=1.26.0" }, { name = "modelscope-hub", marker = "extra == 'hub'", specifier = ">=0.1.0" }, { name = "msgpack", specifier = ">=1.0.8" }, { name = "openai", specifier = ">=1.40" }, @@ -386,9 +578,35 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" }, + { name = "watchdog", specifier = ">=3.0" }, ] provides-extras = ["dev", "hub"] +[[package]] +name = "mcp" +version = "1.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620 }, +] + [[package]] name = "modelscope-hub" version = "0.1.5" @@ -581,6 +799,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -698,6 +925,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071 }, ] +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715 }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -707,6 +948,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274 }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -745,6 +1000,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042 }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659 }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825 }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875 }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877 }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841 }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901 }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184 }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298 }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640 }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928 }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157 }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598 }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159 }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293 }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337 }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -800,6 +1086,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, +] + [[package]] name = "requests" version = "2.34.2" @@ -815,6 +1115,129 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075 }, ] +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174 }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513 }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783 }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316 }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423 }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077 }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315 }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502 }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673 }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964 }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446 }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975 }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453 }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219 }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137 }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691 }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542 }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180 }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067 }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509 }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754 }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189 }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750 }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576 }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807 }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187 }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030 }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185 }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394 }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753 }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012 }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203 }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984 }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815 }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545 }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828 }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678 }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811 }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382 }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832 }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011 }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431 }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710 }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454 }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063 }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510 }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495 }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454 }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583 }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919 }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725 }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255 }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060 }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960 }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356 }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319 }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508 }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504 }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380 }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976 }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840 }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282 }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403 }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055 }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419 }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848 }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369 }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673 }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500 }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978 }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350 }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486 }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068 }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600 }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726 }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587 }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585 }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479 }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418 }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123 }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351 }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827 }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966 }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680 }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853 }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715 }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864 }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430 }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877 }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933 }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274 }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763 }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467 }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689 }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340 }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179 }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993 }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909 }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584 }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357 }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533 }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204 }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719 }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791 }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842 }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094 }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662 }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896 }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545 }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059 }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235 }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008 }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118 }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138 }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486 }, +] + [[package]] name = "ruff" version = "0.15.13" @@ -849,6 +1272,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] +[[package]] +name = "sse-starlette" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/1b/bc9e3e7a72dcdad7dc7888758f5d00f56f8909ed5cfdff822bd72bb4c520/sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a", size = 35249 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/75/c88d3f5dafd59c791da1ce27650d30bf5b70cbf1cbf01cd00e5f9e360915/sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296", size = 16518 }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632 }, +] + [[package]] name = "tqdm" version = "4.67.3" @@ -890,3 +1339,43 @@ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, ] + +[[package]] +name = "uvicorn" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/41/06cce5dbb9f77591512957710ac709e60b12e6216a2f2d0d607fd49706e8/uvicorn-0.50.0.tar.gz", hash = "sha256:0c92e1bc2259cb7faa4fcef774a5966588f2e88542744550b66799fba10b76f1", size = 93257 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/3a/eb70620ca2bf8213603d5c731460687c49fee38b0072f0b4a637781f0a53/uvicorn-0.50.0-py3-none-any.whl", hash = "sha256:05f0eb19edf38208f79f43df8a63081b48df31b0cd1e5997be957a4dc97d1b19", size = 72716 }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393 }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392 }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019 }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471 }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449 }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054 }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480 }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451 }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057 }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, +]