Compare commits

..

17 Commits

Author SHA1 Message Date
cottongin
392183692e Disable shell execution hooks to fix broken notifications
beforeShellExecution in hooks.json required a JSON response we never
provided, likely causing Cursor to silently break the entire hook
pipeline. Commenting out those entries (and afterShellExecution) from
HOOKS_CONFIG restores reliable preToolUse/postToolUse delivery. All
Python handler code is retained as dead code for reference.

Also reverts the is_cursor_frontmost() gate in _check_pending — pulses
should fire unconditionally when the approval delay expires.

Made-with: Cursor
2026-03-11 15:24:43 -04:00
cottongin
6610919a58 Show overlay on all Spaces and alongside fullscreen apps
Set canJoinAllSpaces + fullScreenAuxiliary on overlay windows so the
border renders regardless of which Space or fullscreen app is active.
In window mode, fall back to screen-edge border when Cursor isn't
frontmost to avoid a floating rectangle on other Spaces.

Made-with: Cursor
2026-03-11 03:07:12 -04:00
cottongin
23fe6ac101 Keep NSScreen list current across sleep/wake and display changes
Register for NSApplicationDidChangeScreenParametersNotification and
NSWorkspaceDidWakeNotification so the daemon refreshes NSScreen.screens()
after external monitors connect/disconnect or the system wakes from sleep.

Made-with: Cursor
2026-03-11 03:06:47 -04:00
cottongin
730f6ec1cf Add AI-assistance disclaimer to README
Made-with: Cursor
2026-03-10 14:30:31 -04:00
cottongin
5fc378e558 Fix per-workspace pulse dismiss for multi-window setups
Interacting with one Cursor window no longer dismisses overlays from
other windows. The overlay manager now tracks panels by tag (workspace),
the daemon maintains per-workspace pending/active/cooldown state, and
dismiss logic identifies the focused window via AXFocusedWindow to
target only that workspace's pulse.

Made-with: Cursor
2026-03-10 09:33:42 -04:00
cottongin
a3203e2970 Add dark/light theme support with real-time OS appearance detection
Config now uses top-level dark/light sections (each with running/completed
styles) and a theme option ("dark", "light", "auto"). The daemon resolves
the active theme at flash time via NSApp.effectiveAppearance().

Made-with: Cursor
2026-03-10 09:06:09 -04:00
cottongin
49c03e4b71 Add design doc for dark/light theme support
Defines the config format, data model, and daemon integration
approach for theme-aware styling.

Made-with: Cursor
2026-03-10 08:34:43 -04:00
cottongin
eefb908268 Tidy repo for public release
- Add MIT LICENSE
- Polish README: tagline, permissions docs, clone URL
- Add license, authors, readme, and repository URL to pyproject.toml
- Remove stale docs/plans/ (relocated to .cursor/) and requirements.txt
- Deduplicate and clean up .gitignore

Made-with: Cursor
2026-03-10 08:03:22 -04:00
cottongin
d71bac7b93 Remove tracked files now covered by .gitignore
Add .pytest_cache/, .cursor/, and chat-summaries/ to .gitignore
and untrack the previously committed chat-summaries.

Made-with: Cursor
2026-03-10 07:40:21 -04:00
cottongin
5b71b2275b Restructure config for per-mode style/sound and fix pulse dismiss
Major changes:
- Add StyleConfig dataclass with independent color, width, opacity,
  duration, pulse_speed, sound, and volume per mode (running/completed)
- Replace flat flash_*/sound_*/play_on config with running: and
  completed: YAML sections
- Replace CGEventTap (silently fails in forked daemon) with
  CGEventSourceSecondsSinceLastEventType polling for reliable
  input-based pulse dismissal when Cursor is already frontmost
- Update overlay, sound, and daemon to pass StyleConfig per call
- Rewrite tests for new config shape and dismiss mechanism

Made-with: Cursor
2026-03-10 07:01:52 -04:00
cottongin
c0477d2f40 fix: prevent false positives from stale approval buttons in chat history
State machine no longer transitions directly from IDLE to WAITING_FOR_USER
on approval signals. Must see AGENT_WORKING first — this prevents stale
buttons like "Run this time only" persisting in chat history from
triggering the flash when no agent task is active.

Also removed "Continue" and "Resume" from approval keywords (too generic,
appear in normal chat text).

Made-with: Cursor
2026-03-10 03:17:34 -04:00
cottongin
ba656291ab feat: switch to uv, add check command, fix silent a11y failures
- Added pyobjc-framework-ApplicationServices to dependencies (was
  implicitly available via pyenv's system packages but missing in
  clean venvs)
- Added `cursor-flasher check` command that verifies Cursor is running
  and accessibility permissions are working
- Detector now logs a warning when a11y tree reads fail (previously
  failed silently, making permission issues invisible)
- Switched to uv for dependency management: `uv sync` + `uv run`
- Updated README with uv-based workflow and accessibility
  troubleshooting guide

Made-with: Cursor
2026-03-10 03:12:10 -04:00
cottongin
1a5de8cf8a docs: add README with installation and usage instructions
Made-with: Cursor
2026-03-10 02:57:31 -04:00
cottongin
a5ca7f5d33 fix: tune detection patterns and add state transition logging
- Added "Resume" and "Continue" to approval keywords
- Added state transition logging to daemon for observability
- Guarded signal handler against duplicate SIGTERM delivery
- Verified end-to-end: daemon detects approval prompt, transitions
  to waiting_for_user, overlays 1 window, plays sound

Made-with: Cursor
2026-03-10 02:57:01 -04:00
cottongin
b31f39268e feat: per-window detection — only flash windows needing attention
Detector now walks each AXWindow subtree independently and returns
both aggregate signals (for state machine) and a list of AXWindow
element refs for windows with active approval signals.

Overlay reads position/size directly from AXWindow elements via
AXValueGetValue, eliminating the CGWindowList dependency (which
returned empty names for Electron windows anyway).

Daemon passes only the active AXWindow refs to the overlay, so
only the specific window(s) waiting for user input get flashed.

Made-with: Cursor
2026-03-10 02:54:15 -04:00
cottongin
bce6ec39f8 feat: add CLI with start/stop/status commands
Made-with: Cursor
2026-03-10 02:46:38 -04:00
cottongin
bcd8d4da1a feat: add main daemon loop wiring detection, overlay, and sound
Made-with: Cursor
2026-03-10 02:45:17 -04:00
27 changed files with 2827 additions and 2192 deletions

6
.gitignore vendored
View File

@@ -6,3 +6,9 @@ build/
.pytest_cache/
*.egg
.venv/
a11y_dump.txt
agent-tools/
.cursor/
docs/
chat-summaries/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 cottongin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

130
README.md Normal file
View File

@@ -0,0 +1,130 @@
> [!NOTE]
> This project was developed entirely with AI coding assistance (Claude Opus 4.6 via Cursor IDE) and has not undergone manual review. It is provided as-is and may require adjustments for other environments.
# cursor-flasher
A macOS daemon that flashes a pulsing border and plays a sound when your [Cursor](https://cursor.com) AI agent needs attention.
## How It Works
Uses [Cursor hooks](https://cursor.com/docs/agent/hooks) for reliable detection:
- `**preToolUse**` — fires when the agent wants to run a shell command, write a file, or use any tool that may need approval. **Pulses** the border continuously and plays a sound until you click the Cursor window.
- `**stop`** — fires when the agent loop ends. **Flashes** the border once, briefly.
Only tools in the `approval_tools` list trigger the pulse (default: `Shell`, `Write`, `Delete`). Auto-approved tools like `Read` and `Grep` are ignored.
## Prerequisites
- macOS
- [uv](https://docs.astral.sh/uv/)
- Cursor IDE
- **Accessibility** permission for your terminal (System Settings → Privacy & Security → Accessibility) — needed for window enumeration
- **Input Monitoring** permission for the daemon process (System Settings → Privacy & Security → Input Monitoring) — needed for input-based pulse dismissal
## Installation
```bash
# Clone and install
git clone https://code.cottongin.xyz/cursor-flasher && cd cursor-flasher
uv sync
# Install Cursor hooks (global, applies to all projects)
uv run cursor-flasher install
# Start the daemon
uv run cursor-flasher start
```
The `install` command copies the hook script to `~/.cursor/hooks/` and adds entries to `~/.cursor/hooks.json`. Cursor auto-reloads hooks.
## Usage
```bash
uv run cursor-flasher start # background daemon
uv run cursor-flasher start --foreground # foreground (for debugging)
uv run cursor-flasher status
uv run cursor-flasher stop
```
## Configuration
Optional config file at `~/.cursor-flasher/config.yaml`:
```yaml
theme: "auto" # "dark", "light", or "auto" (follows macOS appearance)
dark: # styles used when OS is in dark mode
running: # approval pulse (continuous until you interact)
color: "#FF9500" # border color (hex)
width: 4 # border thickness in pixels
opacity: 0.85 # max border opacity
pulse_speed: 1.5 # pulse cycle speed in seconds
sound: "Glass" # macOS system sound ("" to disable)
volume: 0.5 # 0.0 to 1.0
# sounds: Basso, Blow, Bottle, Frog, Funk, Glass, Hero,
# Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink
completed: # agent stop flash (brief fade-in/out)
color: "#00FF00" # different color for completion
width: 4
opacity: 0.85
duration: 1.5 # flash duration in seconds
sound: "" # no sound by default (Cursor plays its own)
volume: 0.0
light: # styles used when OS is in light mode
running:
color: "#3B82F6"
width: 4
opacity: 0.9
pulse_speed: 1.5
sound: "Glass"
volume: 0.5
completed:
color: "#22C55E"
width: 4
opacity: 0.9
duration: 1.5
sound: ""
volume: 0.0
flash:
mode: "screen" # "window", "screen", or "allscreens"
# Tools that trigger the pulse + sound (approval mode).
# Others are silently ignored (e.g., Read, Grep, Glob, Task).
approval_tools:
- Shell
- Write
- Delete
general:
approval_delay: 2.5 # seconds to wait before pulsing (filters auto-approvals)
cooldown: 2.0 # minimum seconds between flashes
```
Styles are organized under `dark` and `light` theme sections, each containing `running` (approval pulse) and `completed` (stop flash) modes with their own color, border, and sound settings. The `theme` option controls which styles are active: set `"auto"` to follow macOS appearance in real-time, or force `"dark"` / `"light"`. Set `sound: ""` to disable sound for a particular mode.
## Uninstall
```bash
uv run cursor-flasher uninstall
uv run cursor-flasher stop
```
## Troubleshooting
**Flashing on every tool call (too noisy):**
- Edit `~/.cursor-flasher/config.yaml` and narrow down `approval_tools` to just `Shell`.
**No flash at all:**
- Check daemon: `uv run cursor-flasher status`
- Check hooks installed: `ls ~/.cursor/hooks/cursor-flasher-notify.py`
- Check Cursor Settings → Hooks tab for execution logs
**Pulse doesn't stop:**
- Click the Cursor window to bring it to focus — the pulse auto-dismisses when Cursor is the frontmost app.

View File

@@ -1,102 +0,0 @@
# Cursor Flasher — Design Document
**Date:** 2026-03-10
**Status:** Approved
## Problem
When Cursor's AI agent finishes its turn and is waiting for user input (approval, answering a question, or continuing the conversation), there's no visual or audible signal. The user has to keep checking the window manually.
## Solution
A macOS background daemon that monitors Cursor's accessibility tree and displays a pulsing border overlay around the Cursor window when the agent is waiting for user input. Optionally plays a system sound.
## Architecture
### Detection: Accessibility Tree Polling
A Python process using `pyobjc` polls Cursor's accessibility tree every ~500ms via `AXUIElement` APIs.
**Detection signals:**
- **Approval needed:** Accept/Reject button elements appear in the a11y tree
- **Agent turn complete:** Stop/Cancel button disappears, or thinking indicator goes away
- **Chat input active:** Chat text area becomes the focused element after being inactive
**State machine:**
- `IDLE` — not monitoring (Cursor not in chat or not running)
- `AGENT_WORKING` — agent is generating (Stop button visible, thinking indicator present)
- `WAITING_FOR_USER` — agent is done, user hasn't interacted yet → **trigger flash**
- `USER_INTERACTING` — user started typing/clicking → dismiss flash, return to IDLE
The detection heuristics will be tuned during development after dumping Cursor's full a11y tree.
### Visual Effect: Native macOS Overlay
- A borderless, transparent, non-interactive `NSWindow` positioned over Cursor's window frame
- Draws only a pulsing border (interior is fully click-through)
- Core Animation drives the pulse: opacity oscillates between configurable min/max
- Default: ~4px amber border, 1.5s cycle
**Dismissal triggers:**
- Keyboard input to Cursor (via accessibility or `CGEventTap`)
- Mouse click in Cursor's chat area
- Timeout (default 5 minutes)
- Agent starts working again (Stop button reappears)
### Sound
On transition to `WAITING_FOR_USER`, optionally plays a macOS system sound (default: "Glass"). Configurable sound name, volume, and on/off toggle.
### Configuration
File: `~/.cursor-flasher/config.yaml`
```yaml
pulse:
color: "#FF9500"
width: 4
speed: 1.5
opacity_min: 0.3
opacity_max: 1.0
sound:
enabled: true
name: "Glass"
volume: 0.5
detection:
poll_interval: 0.5
cooldown: 3.0
timeout:
auto_dismiss: 300
```
### Process Management
- CLI: `cursor-flasher start`, `cursor-flasher stop`, `cursor-flasher status`
- Runs as a background daemon
- No menu bar icon (MVP scope)
## Tech Stack
- Python 3.10+
- `pyobjc-framework-Cocoa` — NSWindow, NSApplication, Core Animation
- `pyobjc-framework-Quartz` — AXUIElement, CGEventTap, window management
- `PyYAML` — configuration file parsing
## Installation
- `pip install -e .` from the project directory
- Requires Accessibility permission: System Settings > Privacy & Security > Accessibility (grant to Terminal or Python)
## Scope / Non-goals
- **In scope:** Detection, overlay, sound, CLI, config file
- **Not in scope (MVP):** Menu bar icon, auto-start on login, multi-monitor awareness, Linux/Windows support
## Risks
- **A11y tree fragility:** Cursor UI updates could change element names/structure, breaking detection. Mitigation: make detection patterns configurable, log warnings on detection failures.
- **Accessibility permissions:** Users must grant permission manually. Mitigation: clear error message and instructions on first run.
- **Performance:** Polling a11y tree every 500ms could have CPU cost. Mitigation: only poll when Cursor is the frontmost app or recently active.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
# Dark/Light Theme Support — Design
## Summary
Add dark/light mode theme support to cursor-flasher. Users can define separate border styles for each OS appearance mode via new `dark` and `light` config sections. A `theme` option controls which styles are active: `"dark"`, `"light"`, or `"auto"` (follows macOS appearance in real-time).
## Config Format
The old top-level `running`/`completed` format is replaced (breaking change). Modes are now nested under theme sections:
```yaml
theme: auto # "dark" | "light" | "auto"
dark:
running:
color: "#FF9500"
width: 4
opacity: 0.85
pulse_speed: 1.5
sound: "Glass"
volume: 0.5
completed:
color: "#00FF00"
width: 4
opacity: 0.85
duration: 1.5
sound: ""
volume: 0.0
light:
running:
color: "#3B82F6"
width: 4
opacity: 0.9
pulse_speed: 1.5
sound: "Glass"
volume: 0.5
completed:
color: "#22C55E"
width: 4
opacity: 0.9
duration: 1.5
sound: ""
volume: 0.0
flash:
mode: "screen"
approval_tools:
- Shell
- Write
- Delete
general:
approval_delay: 2.5
cooldown: 2.0
```
## Data Model
- `StyleConfig` — unchanged (color, width, opacity, duration, pulse_speed, sound, volume).
- New `ThemeStyles` dataclass — groups `running: StyleConfig` and `completed: StyleConfig` for one theme.
- `Config` — replaces `running`/`completed` with `dark: ThemeStyles` and `light: ThemeStyles`. Adds `theme: str` field. Exposes `active_styles(system_appearance: str) -> ThemeStyles` method that resolves the correct theme based on the `theme` setting and the passed-in system appearance string.
## Appearance Detection
The daemon detects macOS appearance via `NSApplication.sharedApplication().effectiveAppearance().name()`. If the name contains "Dark", the appearance is `"dark"`; otherwise `"light"`. This check happens at flash/pulse trigger time (not polled), so it picks up OS appearance changes between flashes with zero overhead.
## Daemon Integration
Two call sites change: `_check_pending()` and `_handle_stop()`. Each resolves the active theme styles at trigger time:
```python
styles = self.config.active_styles(_get_system_appearance())
self.overlay.pulse(frames, styles.running)
play_alert(styles.running)
```
## Decisions
- **Modes under themes** (not themes under modes) — `dark.running` rather than `running.dark`.
- **Old format not supported** — top-level `running`/`completed` keys are ignored.
- **Real-time detection** — appearance checked at each flash trigger, not just at startup.
- **Config stays pure** — no Cocoa imports in config.py; appearance detection lives in daemon.py.

55
hooks/notify.py Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Cursor hook script that notifies the cursor-flasher daemon via Unix socket.
Installed as a Cursor hook to trigger a window flash when the agent needs
user attention. Reads hook JSON from stdin, extracts workspace and event
info, and sends it to the daemon's socket.
Shell-specific hook mapping (beforeShellExecution -> shellApproved,
afterShellExecution -> shellCompleted) is retained but currently dead
code — those hooks are disabled in HOOKS_CONFIG / hooks.json because
beforeShellExecution fires pre-approval and Cursor expects a JSON
response we don't provide.
"""
import json
import os
import socket
import sys
SOCKET_PATH = os.path.expanduser("~/.cursor-flasher/flasher.sock")
_SHELL_EVENT_MAP = {
"beforeShellExecution": "shellApproved",
"afterShellExecution": "shellCompleted",
}
def main() -> None:
try:
data = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
return
workspace_roots = data.get("workspace_roots") or []
workspace = workspace_roots[0] if workspace_roots else ""
event = data.get("hook_event_name", "")
mapped_event = _SHELL_EVENT_MAP.get(event)
if mapped_event:
msg = json.dumps({"workspace": workspace, "event": mapped_event, "tool": "Shell"})
else:
tool = data.get("tool_name", "")
msg = json.dumps({"workspace": workspace, "event": event, "tool": tool})
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(1)
s.connect(SOCKET_PATH)
s.sendall(msg.encode())
s.close()
except (ConnectionRefusedError, FileNotFoundError, OSError):
pass
if __name__ == "__main__":
main()

View File

@@ -4,10 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "cursor-flasher"
version = "0.1.0"
description = "Flash Cursor's window when the AI agent is waiting for input"
version = "0.2.0"
description = "Flash Cursor's window when the AI agent needs attention"
readme = "README.md"
license = "MIT"
authors = [{ name = "cottongin" }]
requires-python = ">=3.10"
dependencies = [
"pyobjc-framework-applicationservices>=12.1",
"pyobjc-framework-Cocoa",
"pyobjc-framework-Quartz",
"PyYAML",
@@ -16,8 +20,14 @@ dependencies = [
[project.optional-dependencies]
dev = ["pytest", "pytest-mock"]
[project.urls]
Repository = "https://code.cottongin.xyz/cursor-flasher"
[project.scripts]
cursor-flasher = "cursor_flasher.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.uv]
dev-dependencies = ["pytest", "pytest-mock"]

View File

@@ -1,5 +0,0 @@
pyobjc-framework-Cocoa
pyobjc-framework-Quartz
PyYAML
pytest
pytest-mock

View File

@@ -1,92 +0,0 @@
"""Dump the accessibility tree of the Cursor application.
Usage: python scripts/dump_a11y_tree.py [--depth N]
Requires Accessibility permissions for the running terminal.
"""
import argparse
import sys
from ApplicationServices import (
AXUIElementCreateApplication,
AXUIElementCopyAttributeNames,
AXUIElementCopyAttributeValue,
)
from Cocoa import NSWorkspace
CURSOR_BUNDLE_ID = "com.todesktop.230313mzl4w4u92"
def find_cursor_pid() -> int | None:
"""Find the PID of the running Cursor application."""
workspace = NSWorkspace.sharedWorkspace()
for app in workspace.runningApplications():
bundle = app.bundleIdentifier() or ""
if bundle == CURSOR_BUNDLE_ID:
return app.processIdentifier()
return None
def dump_element(element, depth: int = 0, max_depth: int = 5) -> None:
"""Recursively print an AXUIElement's attributes."""
if depth > max_depth:
return
indent = " " * depth
names_err, attr_names = AXUIElementCopyAttributeNames(element, None)
if names_err or not attr_names:
return
role = ""
title = ""
value = ""
description = ""
for name in attr_names:
err, val = AXUIElementCopyAttributeValue(element, name, None)
if err:
continue
if name == "AXRole":
role = str(val)
elif name == "AXTitle":
title = str(val) if val else ""
elif name == "AXValue":
value_str = str(val)[:100] if val else ""
value = value_str
elif name == "AXDescription":
description = str(val) if val else ""
label = role
if title:
label += f' title="{title}"'
if description:
label += f' desc="{description}"'
if value:
label += f' value="{value}"'
print(f"{indent}{label}")
err, children = AXUIElementCopyAttributeValue(element, "AXChildren", None)
if not err and children:
for child in children:
dump_element(child, depth + 1, max_depth)
def main():
parser = argparse.ArgumentParser(description="Dump Cursor's accessibility tree")
parser.add_argument("--depth", type=int, default=8, help="Max depth to traverse")
args = parser.parse_args()
pid = find_cursor_pid()
if pid is None:
print("Cursor is not running.", file=sys.stderr)
sys.exit(1)
print(f"Found Cursor at PID {pid}")
app_element = AXUIElementCreateApplication(pid)
dump_element(app_element, max_depth=args.depth)
if __name__ == "__main__":
main()

View File

@@ -1,32 +0,0 @@
"""Manual test: shows a pulsing border around Cursor for 10 seconds."""
import sys
import time
from Cocoa import NSApplication, NSRunLoop, NSDate
from cursor_flasher.config import Config
from cursor_flasher.overlay import OverlayManager
from cursor_flasher.detector import CursorDetector
app = NSApplication.sharedApplication()
config = Config()
overlay = OverlayManager(config)
detector = CursorDetector()
pid = detector._find_cursor_pid()
if pid is None:
print("Cursor not running")
sys.exit(1)
print(f"Showing overlay for PID {pid} for 10 seconds...")
overlay.show(pid)
end_time = time.time() + 10
while time.time() < end_time:
NSRunLoop.currentRunLoop().runUntilDate_(
NSDate.dateWithTimeIntervalSinceNow_(0.1)
)
overlay.hide()
print("Done.")

228
src/cursor_flasher/cli.py Normal file
View File

@@ -0,0 +1,228 @@
"""CLI for cursor-flasher: install hooks, start/stop daemon."""
import argparse
import json
import os
import shutil
import signal
import sys
from pathlib import Path
PID_FILE = Path.home() / ".cursor-flasher" / "daemon.pid"
CURSOR_HOOKS_DIR = Path.home() / ".cursor" / "hooks"
CURSOR_HOOKS_JSON = Path.home() / ".cursor" / "hooks.json"
HOOK_SCRIPT_NAME = "cursor-flasher-notify.py"
HOOKS_CONFIG = {
"preToolUse": [
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
],
"postToolUse": [
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
],
"postToolUseFailure": [
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
],
# DEAD CODE: shell execution hooks disabled — Cursor expects a JSON
# response from beforeShellExecution that we don't provide, and the
# daemon handlers (shellApproved/shellCompleted) are no-ops or redundant
# with postToolUse. Re-enable by uncommenting if a use is found.
# "beforeShellExecution": [
# {"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
# ],
# "afterShellExecution": [
# {"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
# ],
"stop": [
{"command": f"./hooks/{HOOK_SCRIPT_NAME}"}
],
}
# Events to clean from hooks.json on uninstall, including disabled ones.
_ALL_HOOK_EVENTS = list(HOOKS_CONFIG.keys()) + [
"beforeShellExecution",
"afterShellExecution",
]
def _write_pid() -> None:
PID_FILE.parent.mkdir(parents=True, exist_ok=True)
PID_FILE.write_text(str(os.getpid()))
def _read_pid() -> int | None:
if not PID_FILE.exists():
return None
try:
pid = int(PID_FILE.read_text().strip())
os.kill(pid, 0)
return pid
except (ValueError, ProcessLookupError, PermissionError):
PID_FILE.unlink(missing_ok=True)
return None
def _remove_pid() -> None:
PID_FILE.unlink(missing_ok=True)
def _find_hook_source() -> Path:
"""Find the hook script source in the package."""
pkg_dir = Path(__file__).resolve().parent.parent.parent
return pkg_dir / "hooks" / "notify.py"
def cmd_install(args: argparse.Namespace) -> None:
"""Install Cursor hooks for flash notifications."""
CURSOR_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
src = _find_hook_source()
dst = CURSOR_HOOKS_DIR / HOOK_SCRIPT_NAME
if not src.exists():
print(f"ERROR: Hook source not found at {src}")
sys.exit(1)
shutil.copy2(src, dst)
dst.chmod(0o755)
print(f" Installed hook script: {dst}")
existing: dict = {}
if CURSOR_HOOKS_JSON.exists():
try:
existing = json.loads(CURSOR_HOOKS_JSON.read_text())
except (json.JSONDecodeError, ValueError):
existing = {}
existing.setdefault("version", 1)
hooks = existing.setdefault("hooks", {})
for event, entries in HOOKS_CONFIG.items():
event_hooks = hooks.setdefault(event, [])
for entry in entries:
already = any(h.get("command") == entry["command"] for h in event_hooks)
if not already:
event_hooks.append(entry)
CURSOR_HOOKS_JSON.write_text(json.dumps(existing, indent=2) + "\n")
print(f" Updated hooks config: {CURSOR_HOOKS_JSON}")
print("\nInstallation complete. Cursor will auto-reload hooks.")
print("Start the daemon with: cursor-flasher start")
def cmd_uninstall(args: argparse.Namespace) -> None:
"""Remove Cursor hooks."""
dst = CURSOR_HOOKS_DIR / HOOK_SCRIPT_NAME
if dst.exists():
dst.unlink()
print(f" Removed hook script: {dst}")
if CURSOR_HOOKS_JSON.exists():
try:
config = json.loads(CURSOR_HOOKS_JSON.read_text())
except (json.JSONDecodeError, ValueError):
config = {}
hooks = config.get("hooks", {})
changed = False
for event in _ALL_HOOK_EVENTS:
if event in hooks:
before = len(hooks[event])
hooks[event] = [
h for h in hooks[event]
if h.get("command") != f"./hooks/{HOOK_SCRIPT_NAME}"
]
if len(hooks[event]) < before:
changed = True
if not hooks[event]:
del hooks[event]
if changed:
CURSOR_HOOKS_JSON.write_text(json.dumps(config, indent=2) + "\n")
print(f" Cleaned hooks config: {CURSOR_HOOKS_JSON}")
print("\nUninstall complete.")
def cmd_start(args: argparse.Namespace) -> None:
existing = _read_pid()
if existing is not None:
print(f"Daemon already running (PID {existing})")
sys.exit(1)
if args.foreground:
from cursor_flasher.daemon import run_daemon
_write_pid()
try:
run_daemon()
finally:
_remove_pid()
else:
pid = os.fork()
if pid > 0:
print(f"Daemon started (PID {pid})")
return
os.setsid()
from cursor_flasher.daemon import run_daemon
_write_pid()
try:
run_daemon()
finally:
_remove_pid()
def cmd_stop(args: argparse.Namespace) -> None:
pid = _read_pid()
if pid is None:
print("Daemon is not running")
sys.exit(1)
os.kill(pid, signal.SIGTERM)
_remove_pid()
print(f"Daemon stopped (PID {pid})")
def cmd_status(args: argparse.Namespace) -> None:
pid = _read_pid()
if pid is None:
print("Daemon is not running")
else:
print(f"Daemon is running (PID {pid})")
def main() -> None:
parser = argparse.ArgumentParser(
prog="cursor-flasher",
description="Flash the Cursor window when the AI agent needs attention",
)
sub = parser.add_subparsers(dest="command")
install_parser = sub.add_parser("install", help="Install Cursor hooks")
install_parser.set_defaults(func=cmd_install)
uninstall_parser = sub.add_parser("uninstall", help="Remove Cursor hooks")
uninstall_parser.set_defaults(func=cmd_uninstall)
start_parser = sub.add_parser("start", help="Start the daemon")
start_parser.add_argument(
"--foreground", "-f", action="store_true",
help="Run in the foreground (don't daemonize)",
)
start_parser.set_defaults(func=cmd_start)
stop_parser = sub.add_parser("stop", help="Stop the daemon")
stop_parser.set_defaults(func=cmd_stop)
status_parser = sub.add_parser("status", help="Check daemon status")
status_parser.set_defaults(func=cmd_status)
args = parser.parse_args()
if not hasattr(args, "func"):
parser.print_help()
sys.exit(1)
args.func(args)

View File

@@ -1,54 +1,118 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
STYLE_FIELDS = {
"color": str,
"width": int,
"opacity": float,
"duration": float,
"pulse_speed": float,
"sound": str,
"volume": float,
}
VALID_THEMES = {"dark", "light", "auto"}
@dataclass
class StyleConfig:
color: str = "#FF9500"
width: int = 4
opacity: float = 0.85
duration: float = 1.5
pulse_speed: float = 1.5
sound: str = "Glass"
volume: float = 0.5
def _default_running() -> StyleConfig:
return StyleConfig()
def _default_completed() -> StyleConfig:
return StyleConfig(sound="", volume=0.0)
@dataclass
class ThemeStyles:
"""Running and completed styles for a single theme (dark or light)."""
running: StyleConfig = field(default_factory=_default_running)
completed: StyleConfig = field(default_factory=_default_completed)
@dataclass
class Config:
pulse_color: str = "#FF9500"
pulse_width: int = 4
pulse_speed: float = 1.5
pulse_opacity_min: float = 0.3
pulse_opacity_max: float = 1.0
dark: ThemeStyles = field(default_factory=ThemeStyles)
light: ThemeStyles = field(default_factory=ThemeStyles)
sound_enabled: bool = True
sound_name: str = "Glass"
sound_volume: float = 0.5
theme: str = "auto"
flash_mode: str = "screen"
poll_interval: float = 0.5
cooldown: float = 3.0
approval_tools: list[str] = field(
default_factory=lambda: ["Shell", "Write", "Delete"]
)
auto_dismiss: int = 300
approval_delay: float = 2.5
cooldown: float = 2.0
def active_styles(self, system_appearance: str) -> ThemeStyles:
"""Return the ThemeStyles matching the current theme setting.
Args:
system_appearance: "dark" or "light" as detected from the OS.
"""
if self.theme == "auto":
return self.dark if system_appearance == "dark" else self.light
return self.dark if self.theme == "dark" else self.light
FIELD_MAP: dict[str, dict[str, str]] = {
"pulse": {
"color": "pulse_color",
"width": "pulse_width",
"speed": "pulse_speed",
"opacity_min": "pulse_opacity_min",
"opacity_max": "pulse_opacity_max",
},
"sound": {
"enabled": "sound_enabled",
"name": "sound_name",
"volume": "sound_volume",
},
"detection": {
"poll_interval": "poll_interval",
"cooldown": "cooldown",
},
"timeout": {
"auto_dismiss": "auto_dismiss",
},
GENERAL_FIELD_MAP: dict[str, str] = {
"approval_delay": "approval_delay",
"cooldown": "cooldown",
}
DEFAULT_CONFIG_PATH = Path.home() / ".cursor-flasher" / "config.yaml"
def _parse_style(raw_section: dict, defaults: StyleConfig) -> StyleConfig:
"""Build a StyleConfig from a YAML section, falling back to defaults."""
overrides: dict[str, Any] = {}
for key, typ in STYLE_FIELDS.items():
if key in raw_section:
overrides[key] = typ(raw_section[key])
return StyleConfig(
color=overrides.get("color", defaults.color),
width=overrides.get("width", defaults.width),
opacity=overrides.get("opacity", defaults.opacity),
duration=overrides.get("duration", defaults.duration),
pulse_speed=overrides.get("pulse_speed", defaults.pulse_speed),
sound=overrides.get("sound", defaults.sound),
volume=overrides.get("volume", defaults.volume),
)
def _parse_theme_styles(raw_section: dict) -> ThemeStyles:
"""Build a ThemeStyles from a YAML theme section (dark or light)."""
kwargs: dict[str, StyleConfig] = {}
running_raw = raw_section.get("running")
if isinstance(running_raw, dict):
kwargs["running"] = _parse_style(running_raw, _default_running())
completed_raw = raw_section.get("completed")
if isinstance(completed_raw, dict):
kwargs["completed"] = _parse_style(completed_raw, _default_completed())
return ThemeStyles(**kwargs)
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
"""Load config from YAML, falling back to defaults for missing values."""
if not path.exists():
@@ -60,13 +124,32 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
if not raw or not isinstance(raw, dict):
return Config()
overrides: dict[str, Any] = {}
for section, mapping in FIELD_MAP.items():
section_data = raw.get(section, {})
if not isinstance(section_data, dict):
continue
for yaml_key, field_name in mapping.items():
if yaml_key in section_data:
overrides[field_name] = section_data[yaml_key]
config_kwargs: dict[str, Any] = {}
return Config(**overrides)
theme = raw.get("theme")
if isinstance(theme, str) and theme in VALID_THEMES:
config_kwargs["theme"] = theme
dark_raw = raw.get("dark")
if isinstance(dark_raw, dict):
config_kwargs["dark"] = _parse_theme_styles(dark_raw)
light_raw = raw.get("light")
if isinstance(light_raw, dict):
config_kwargs["light"] = _parse_theme_styles(light_raw)
flash_raw = raw.get("flash")
if isinstance(flash_raw, dict) and "mode" in flash_raw:
config_kwargs["flash_mode"] = flash_raw["mode"]
general_raw = raw.get("general", {})
if isinstance(general_raw, dict):
for yaml_key, field_name in GENERAL_FIELD_MAP.items():
if yaml_key in general_raw:
config_kwargs[field_name] = general_raw[yaml_key]
tools = raw.get("approval_tools")
if isinstance(tools, list):
config_kwargs["approval_tools"] = [str(t) for t in tools]
return Config(**config_kwargs)

View File

@@ -0,0 +1,410 @@
"""Daemon that listens for flash triggers from Cursor hooks via a Unix socket."""
import json
import logging
import os
import signal
import socket
import time
from Cocoa import (
NSApplication, NSRunLoop, NSDate,
NSNotificationCenter, NSObject, NSScreen, NSWorkspace,
)
from Quartz import (
CGEventSourceSecondsSinceLastEventType,
kCGEventSourceStateHIDSystemState,
kCGEventLeftMouseDown,
kCGEventRightMouseDown,
kCGEventKeyDown,
)
from cursor_flasher.config import Config, load_config
from cursor_flasher.overlay import OverlayManager
from cursor_flasher.sound import play_alert
from cursor_flasher.windows import (
find_window_by_workspace,
get_focused_cursor_window,
screen_frame_for_window,
all_screen_frames,
is_cursor_frontmost,
)
logger = logging.getLogger("cursor_flasher")
SOCKET_DIR = os.path.expanduser("~/.cursor-flasher")
SOCKET_PATH = os.path.join(SOCKET_DIR, "flasher.sock")
INPUT_DISMISS_GRACE = 0.5
class _DisplayObserver(NSObject):
"""Listens for macOS display-change and wake notifications.
Registering for NSApplicationDidChangeScreenParametersNotification forces
AppKit to keep NSScreen.screens() current in long-running daemon processes.
Without this, the screen list can go stale after sleep/wake cycles, causing
modes like "allscreens" to miss external displays.
"""
def screenParametersChanged_(self, notification):
screens = NSScreen.screens()
count = len(screens) if screens else 0
logger.info("Display configuration changed — %d screen(s) detected", count)
def workspaceDidWake_(self, notification):
screens = NSScreen.screens()
count = len(screens) if screens else 0
logger.info("System woke from sleep — %d screen(s) detected", count)
def _get_system_appearance() -> str:
"""Return "dark" or "light" based on the current macOS appearance."""
app = NSApplication.sharedApplication()
name = app.effectiveAppearance().name()
return "dark" if "Dark" in name else "light"
class _PendingApproval:
"""An approval trigger waiting for the delay to expire before pulsing."""
__slots__ = ("workspace", "tool", "timestamp")
def __init__(self, workspace: str, tool: str, timestamp: float):
self.workspace = workspace
self.tool = tool
self.timestamp = timestamp
class _ActivePulse:
"""A workspace that is currently pulsing."""
__slots__ = ("workspace", "window_title", "started_at")
def __init__(self, workspace: str, window_title: str, started_at: float):
self.workspace = workspace
self.window_title = window_title
self.started_at = started_at
class FlasherDaemon:
def __init__(self, config: Config):
self.config = config
self.overlay = OverlayManager()
self._running = False
self._server: socket.socket | None = None
self._last_flash: dict[str, float] = {}
self._pending_approvals: dict[str, _PendingApproval] = {}
self._active_pulses: dict[str, _ActivePulse] = {}
self._cursor_was_frontmost: bool = False
self._display_observer: NSObject | None = None
def run(self) -> None:
NSApplication.sharedApplication()
self._running = True
self._setup_socket()
self._setup_display_notifications()
signal.signal(signal.SIGTERM, self._handle_signal)
signal.signal(signal.SIGINT, self._handle_signal)
logger.info("Cursor Flasher daemon started (socket: %s)", SOCKET_PATH)
logger.info(
"Approval tools: %s delay: %.1fs",
self.config.approval_tools,
self.config.approval_delay,
)
while self._running:
self._check_socket()
self._check_pending()
self._check_input_dismiss()
self._check_focus()
NSRunLoop.currentRunLoop().runUntilDate_(
NSDate.dateWithTimeIntervalSinceNow_(0.1)
)
self._cleanup()
def stop(self) -> None:
self._running = False
def _setup_socket(self) -> None:
os.makedirs(SOCKET_DIR, exist_ok=True)
if os.path.exists(SOCKET_PATH):
os.unlink(SOCKET_PATH)
self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._server.bind(SOCKET_PATH)
self._server.listen(5)
self._server.setblocking(False)
def _setup_display_notifications(self) -> None:
"""Subscribe to macOS display-change and wake events.
This is required for NSScreen.screens() to stay current in a
long-running daemon. Without these observers, AppKit may not process
screen-configuration changes after sleep/wake, leaving the screen
list stale until the process is restarted.
"""
self._display_observer = _DisplayObserver.alloc().init()
NSNotificationCenter.defaultCenter().addObserver_selector_name_object_(
self._display_observer,
"screenParametersChanged:",
"NSApplicationDidChangeScreenParametersNotification",
None,
)
NSWorkspace.sharedWorkspace().notificationCenter().addObserver_selector_name_object_(
self._display_observer,
"workspaceDidWake:",
"NSWorkspaceDidWakeNotification",
None,
)
screens = NSScreen.screens()
count = len(screens) if screens else 0
logger.info("Display notifications registered — %d screen(s) currently", count)
def _check_socket(self) -> None:
if self._server is None:
return
try:
conn, _ = self._server.accept()
try:
data = conn.recv(4096)
if data:
self._handle_message(data)
finally:
conn.close()
except BlockingIOError:
pass
def _check_pending(self) -> None:
"""Promote pending approvals whose delay has expired."""
promoted: list[str] = []
for workspace, pending in self._pending_approvals.items():
elapsed = time.monotonic() - pending.timestamp
if elapsed < self.config.approval_delay:
continue
window = find_window_by_workspace(pending.workspace)
if window is None:
logger.warning(
"No Cursor window found for pending approval: %s", workspace
)
promoted.append(workspace)
continue
frames = self._resolve_frames(window["frame"])
styles = self.config.active_styles(_get_system_appearance())
logger.info(
"Pulsing for approval (after %.1fs delay): tool=%s window=%s",
elapsed, pending.tool, window["title"],
)
self.overlay.add_pulse(workspace, frames, styles.running)
self._active_pulses[workspace] = _ActivePulse(
workspace, window["title"], time.monotonic()
)
self._cursor_was_frontmost = is_cursor_frontmost()
play_alert(styles.running)
self._last_flash[workspace] = time.monotonic()
promoted.append(workspace)
for workspace in promoted:
self._pending_approvals.pop(workspace, None)
def _check_input_dismiss(self) -> None:
"""Dismiss the focused window's pulse when the user clicks or types.
Identifies which Cursor window has focus and only dismisses that
workspace's pulse, leaving other workspaces pulsing.
"""
if not self._active_pulses:
return
if not is_cursor_frontmost():
return
oldest_start = min(p.started_at for p in self._active_pulses.values())
pulse_age = time.monotonic() - oldest_start
if pulse_age < INPUT_DISMISS_GRACE:
return
last_click = CGEventSourceSecondsSinceLastEventType(
kCGEventSourceStateHIDSystemState, kCGEventLeftMouseDown
)
last_rclick = CGEventSourceSecondsSinceLastEventType(
kCGEventSourceStateHIDSystemState, kCGEventRightMouseDown
)
last_key = CGEventSourceSecondsSinceLastEventType(
kCGEventSourceStateHIDSystemState, kCGEventKeyDown
)
last_input = min(last_click, last_rclick, last_key)
if last_input >= (pulse_age - INPUT_DISMISS_GRACE):
return
workspace = self._find_focused_workspace()
if workspace is None:
return
logger.info(
"User input in Cursor — dismissing pulse for %s "
"(input %.1fs ago, pulse %.1fs old)",
workspace, last_input, pulse_age,
)
self._dismiss_workspace(workspace)
def _check_focus(self) -> None:
"""Dismiss the focused window's pulse when user switches TO Cursor."""
if not self._active_pulses:
self._cursor_was_frontmost = is_cursor_frontmost()
return
frontmost = is_cursor_frontmost()
if frontmost and not self._cursor_was_frontmost:
workspace = self._find_focused_workspace()
if workspace is not None:
logger.info(
"Cursor became frontmost — dismissing pulse for %s", workspace
)
self._dismiss_workspace(workspace)
self._cursor_was_frontmost = frontmost
def _find_focused_workspace(self) -> str | None:
"""Match the currently focused Cursor window to an active pulse."""
focused = get_focused_cursor_window()
if focused is None:
return None
for workspace, pulse in self._active_pulses.items():
if pulse.window_title == focused["title"]:
return workspace
return None
def _dismiss_workspace(self, workspace: str) -> None:
"""Dismiss a single workspace's pulse."""
self._active_pulses.pop(workspace, None)
self.overlay.dismiss_tag(workspace)
def _handle_message(self, raw: bytes) -> None:
try:
msg = json.loads(raw)
except (json.JSONDecodeError, UnicodeDecodeError):
logger.warning("Invalid message received")
return
workspace = msg.get("workspace", "")
event = msg.get("event", "")
tool = msg.get("tool", "")
logger.info("Received: event=%s tool=%s pulsing=%s pending=%s",
event, tool, self.overlay.is_pulsing,
bool(self._pending_approvals))
if event == "preToolUse":
self._handle_approval(workspace, tool)
elif event in ("postToolUse", "postToolUseFailure"):
self._handle_dismiss(workspace, event, tool)
# DEAD CODE: shell execution hooks are disabled in hooks.json.
# shellApproved (beforeShellExecution) fires pre-approval so it
# can't distinguish "waiting for user" from "auto-approved".
# shellCompleted (afterShellExecution) is redundant with postToolUse.
elif event in ("shellApproved", "shellCompleted"):
logger.debug("Shell execution hook (disabled): event=%s workspace=%s", event, workspace)
elif event == "stop":
self._handle_stop(workspace)
else:
logger.debug("Ignoring event: %s", event)
def _handle_approval(self, workspace: str, tool: str) -> None:
if tool and tool not in self.config.approval_tools:
return
now = time.monotonic()
last = self._last_flash.get(workspace, 0)
if (now - last) < self.config.cooldown:
return
logger.debug("Queuing approval: tool=%s workspace=%s", tool, workspace)
self._pending_approvals[workspace] = _PendingApproval(workspace, tool, now)
def _handle_dismiss(self, workspace: str, event: str, tool: str) -> None:
if workspace in self._pending_approvals:
logger.debug(
"Cancelled pending approval (auto-approved): %s tool=%s workspace=%s",
event, tool, workspace,
)
self._pending_approvals.pop(workspace, None)
if workspace in self._active_pulses:
logger.info(
"Dismissing pulse: %s tool=%s workspace=%s", event, tool, workspace
)
self._dismiss_workspace(workspace)
def _handle_stop(self, workspace: str) -> None:
self._pending_approvals.pop(workspace, None)
if workspace in self._active_pulses:
self._dismiss_workspace(workspace)
now = time.monotonic()
last = self._last_flash.get(workspace, 0)
if (now - last) < self.config.cooldown:
return
window = find_window_by_workspace(workspace)
if window is None:
return
styles = self.config.active_styles(_get_system_appearance())
frames = self._resolve_frames(window["frame"])
logger.info("Flash for stop: window=%s", window["title"])
self.overlay.add_flash(workspace, frames, styles.completed)
play_alert(styles.completed)
self._last_flash[workspace] = now
def _resolve_frames(self, window_frame: tuple) -> list[tuple]:
"""Return frame(s) based on flash_mode config.
In "window" mode, falls back to screen frame when Cursor is not
frontmost (e.g. different Space or behind fullscreen app) to avoid
drawing a floating rectangle at stale coordinates.
"""
mode = self.config.flash_mode
if mode == "allscreens":
return all_screen_frames()
if mode == "screen":
return [screen_frame_for_window(window_frame)]
if not is_cursor_frontmost():
return [screen_frame_for_window(window_frame)]
return [window_frame]
def _cleanup(self) -> None:
if self._display_observer is not None:
NSNotificationCenter.defaultCenter().removeObserver_(self._display_observer)
NSWorkspace.sharedWorkspace().notificationCenter().removeObserver_(
self._display_observer
)
self._display_observer = None
self.overlay.hide()
if self._server is not None:
self._server.close()
self._server = None
if os.path.exists(SOCKET_PATH):
os.unlink(SOCKET_PATH)
logger.info("Cursor Flasher daemon stopped")
def _handle_signal(self, signum, frame):
if not self._running:
return
logger.info("Received signal %d, shutting down", signum)
self.stop()
def run_daemon() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
)
config = load_config()
daemon = FlasherDaemon(config)
daemon.run()

View File

@@ -1,131 +0,0 @@
"""Accessibility-based detection of Cursor's agent state.
Detection strategy (based on a11y tree analysis):
- Cursor is an Electron app; web content is exposed via AXWebArea.
- In-app buttons render as AXStaticText with their label in the 'value' attr,
NOT as AXButton elements (those are native window controls only).
- We collect both AXStaticText values and AXButton titles, then match against
known keywords for "agent working" and "approval needed" states.
"""
from dataclasses import dataclass
import re
from ApplicationServices import (
AXUIElementCreateApplication,
AXUIElementCopyAttributeNames,
AXUIElementCopyAttributeValue,
)
from Cocoa import NSWorkspace
CURSOR_BUNDLE_ID = "com.todesktop.230313mzl4w4u92"
AGENT_WORKING_EXACT = {"Stop", "Cancel generating"}
AGENT_WORKING_PATTERNS = [re.compile(r"^Generating\b", re.IGNORECASE)]
APPROVAL_EXACT = {"Accept", "Reject", "Accept All", "Deny"}
APPROVAL_PATTERNS = [
re.compile(r"^Run\b", re.IGNORECASE),
re.compile(r"^Allow\b", re.IGNORECASE),
]
@dataclass
class UISignals:
agent_working: bool = False
approval_needed: bool = False
def _text_matches(text: str, exact_set: set[str], patterns: list[re.Pattern]) -> bool:
if text in exact_set:
return True
return any(p.search(text) for p in patterns)
def parse_ui_signals(elements: list[dict]) -> UISignals:
"""Parse flattened UI elements into detection signals."""
agent_working = False
approval_needed = False
for el in elements:
role = el.get("role", "")
label = ""
if role == "AXStaticText":
label = el.get("value", "")
elif role == "AXButton":
label = el.get("title", "")
if not label:
continue
if _text_matches(label, AGENT_WORKING_EXACT, AGENT_WORKING_PATTERNS):
agent_working = True
if _text_matches(label, APPROVAL_EXACT, APPROVAL_PATTERNS):
approval_needed = True
return UISignals(agent_working=agent_working, approval_needed=approval_needed)
class CursorDetector:
"""Polls Cursor's accessibility tree for agent state signals."""
def __init__(self):
self._pid: int | None = None
def poll(self) -> UISignals | None:
"""Poll Cursor's a11y tree and return detected signals, or None if Cursor isn't running."""
pid = self._find_cursor_pid()
if pid is None:
self._pid = None
return None
self._pid = pid
app_element = AXUIElementCreateApplication(pid)
elements = self._collect_elements(app_element, max_depth=15)
return parse_ui_signals(elements)
def _find_cursor_pid(self) -> int | None:
workspace = NSWorkspace.sharedWorkspace()
for app in workspace.runningApplications():
bundle = app.bundleIdentifier() or ""
if bundle == CURSOR_BUNDLE_ID:
return app.processIdentifier()
return None
def _collect_elements(
self, element, max_depth: int = 15, depth: int = 0
) -> list[dict]:
"""Walk the a11y tree collecting button and static text elements."""
if depth > max_depth:
return []
results: list[dict] = []
err, attr_names = AXUIElementCopyAttributeNames(element, None)
if err or not attr_names:
return results
role = ""
title = ""
value = ""
for name in attr_names:
val_err, val = AXUIElementCopyAttributeValue(element, name, None)
if val_err:
continue
if name == "AXRole":
role = str(val)
elif name == "AXTitle":
title = str(val) if val else ""
elif name == "AXValue":
value = str(val) if val else ""
if role == "AXStaticText" and value:
results.append({"role": role, "value": value})
elif role == "AXButton" and title:
results.append({"role": role, "title": title})
err, children = AXUIElementCopyAttributeValue(element, "AXChildren", None)
if not err and children:
for child in children:
results.extend(self._collect_elements(child, max_depth, depth + 1))
return results

View File

@@ -1,33 +1,27 @@
"""Native macOS overlay window that draws a pulsing border around a target window."""
"""Native macOS overlay that draws a flash or pulsing border around one or more frames."""
import enum
import logging
import math
import objc
from Cocoa import (
NSApplication,
NSWindow,
NSBorderlessWindowMask,
NSColor,
NSView,
NSBezierPath,
NSTimer,
NSScreen,
NSWindowCollectionBehaviorCanJoinAllSpaces,
NSWindowCollectionBehaviorFullScreenAuxiliary,
)
from Foundation import NSInsetRect
from Quartz import (
CGWindowListCopyWindowInfo,
kCGWindowListOptionOnScreenOnly,
kCGNullWindowID,
kCGWindowOwnerPID,
kCGWindowBounds,
kCGWindowLayer,
kCGWindowNumber,
)
from cursor_flasher.config import Config
from cursor_flasher.config import StyleConfig
logger = logging.getLogger("cursor_flasher")
def hex_to_nscolor(hex_str: str, alpha: float = 1.0) -> NSColor:
"""Convert a hex color string to NSColor."""
hex_str = hex_str.lstrip("#")
r = int(hex_str[0:2], 16) / 255.0
g = int(hex_str[2:4], 16) / 255.0
@@ -35,185 +29,220 @@ def hex_to_nscolor(hex_str: str, alpha: float = 1.0) -> NSColor:
return NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, alpha)
class PulseBorderView(NSView):
"""Custom view that draws a pulsing border rectangle."""
class FlashBorderView(NSView):
"""View that draws a solid border rectangle at a given opacity."""
def initWithFrame_config_(self, frame, config):
self = objc.super(PulseBorderView, self).initWithFrame_(frame)
def initWithFrame_(self, frame):
self = objc.super(FlashBorderView, self).initWithFrame_(frame)
if self is None:
return None
self._config = config
self._phase = 0.0
self._style = StyleConfig()
self._alpha = 0.0
return self
def drawRect_(self, rect):
opacity_range = self._config.pulse_opacity_max - self._config.pulse_opacity_min
alpha = self._config.pulse_opacity_min + opacity_range * (
0.5 + 0.5 * math.sin(self._phase)
)
color = hex_to_nscolor(self._config.pulse_color, alpha)
if self._alpha <= 0:
return
color = hex_to_nscolor(self._style.color, self._alpha)
color.setStroke()
width = self._config.pulse_width
width = self._style.width
inset = width / 2.0
bounds = objc.super(PulseBorderView, self).bounds()
bounds = objc.super(FlashBorderView, self).bounds()
inset_rect = NSInsetRect(bounds, inset, inset)
path = NSBezierPath.bezierPathWithRect_(inset_rect)
path.setLineWidth_(width)
path.stroke()
def setPhase_(self, phase):
self._phase = phase
def setAlpha_(self, alpha):
self._alpha = alpha
self.setNeedsDisplay_(True)
class _OverlayEntry:
"""A single overlay window + view pair."""
__slots__ = ("window", "view")
class _Mode(enum.Enum):
IDLE = "idle"
FLASH = "flash"
PULSE = "pulse"
def __init__(self, window: NSWindow, view: PulseBorderView):
self.window = window
self.view = view
class _TagState:
"""Per-tag state: mode, style, panels, and (for FLASH) its own elapsed."""
__slots__ = ("mode", "style", "panels", "elapsed")
def __init__(
self,
mode: _Mode,
style: StyleConfig,
panels: list[tuple[NSWindow, FlashBorderView]],
):
self.mode = mode
self.style = style
self.panels = panels
self.elapsed = 0.0
class OverlayManager:
"""Manages overlay windows for all Cursor windows belonging to a PID.
"""Manages overlay borders grouped by tag (typically workspace path).
Creates one transparent overlay per Cursor window and keeps them
positioned and animated in sync.
Supports multiple simultaneous pulse/flash groups. All PULSE tags share
a single elapsed counter so they animate in sync. Each FLASH tag has its
own elapsed counter for independent fade-in/hold/fade-out.
"""
def __init__(self, config: Config):
self._config = config
self._overlays: list[_OverlayEntry] = []
def __init__(self):
self._tags: dict[str, _TagState] = {}
self._timer: NSTimer | None = None
self._phase = 0.0
self._target_pid: int | None = None
self._pulse_elapsed: float = 0.0
def show(self, pid: int) -> None:
"""Show pulsing overlays around every window belonging to `pid`."""
self._target_pid = pid
frames = self._get_all_window_frames(pid)
if not frames:
return
@property
def is_pulsing(self) -> bool:
return any(ts.mode == _Mode.PULSE for ts in self._tags.values())
self._sync_overlays(frames)
@property
def active_tags(self) -> set[str]:
return set(self._tags.keys())
for entry in self._overlays:
entry.window.orderFrontRegardless()
def has_tag(self, tag: str) -> bool:
return tag in self._tags
self._start_animation()
def add_pulse(self, tag: str, frames: list[tuple], style: StyleConfig) -> None:
"""Start pulsing panels for this tag. Reuses tag if it already exists."""
self._remove_tag(tag)
panels = [self._create_overlay(f) for f in frames]
self._tags[tag] = _TagState(_Mode.PULSE, style, panels)
for window, view in panels:
view._style = style
view.setAlpha_(style.opacity)
window.setAlphaValue_(1.0)
window.orderFrontRegardless()
self._ensure_timer()
def add_flash(self, tag: str, frames: list[tuple], style: StyleConfig) -> None:
"""Start a brief flash for this tag. Auto-removes when done."""
self._remove_tag(tag)
panels = [self._create_overlay(f) for f in frames]
self._tags[tag] = _TagState(_Mode.FLASH, style, panels)
for window, view in panels:
view._style = style
view.setAlpha_(0.0)
window.setAlphaValue_(1.0)
window.orderFrontRegardless()
self._ensure_timer()
def dismiss_tag(self, tag: str) -> None:
"""Hide and remove panels for a specific tag."""
self._remove_tag(tag)
if not self._tags:
self._stop_timer()
self._pulse_elapsed = 0.0
def dismiss_all(self) -> None:
"""Hide everything and stop the timer."""
for tag in list(self._tags):
self._remove_tag(tag)
self._stop_timer()
self._pulse_elapsed = 0.0
logger.debug("All overlays dismissed")
def hide(self) -> None:
"""Hide all overlay windows."""
self._stop_animation()
for entry in self._overlays:
entry.window.orderOut_(None)
self.dismiss_all()
def update_positions(self) -> None:
"""Reposition overlays to track current Cursor window positions."""
if self._target_pid is None:
def _remove_tag(self, tag: str) -> None:
state = self._tags.pop(tag, None)
if state is None:
return
frames = self._get_all_window_frames(self._target_pid)
self._sync_overlays(frames)
for window, view in state.panels:
view.setAlpha_(0.0)
window.setAlphaValue_(0.0)
window.orderOut_(None)
logger.debug("Tag '%s' dismissed (%d panels hidden)", tag, len(state.panels))
def _sync_overlays(self, frames: list[tuple]) -> None:
"""Ensure we have exactly len(frames) overlays, positioned correctly.
Reuses existing overlay windows where possible, creates new ones
if more windows appeared, and hides extras if windows closed.
"""
needed = len(frames)
existing = len(self._overlays)
for i in range(needed):
frame = frames[i]
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
if i < existing:
entry = self._overlays[i]
entry.window.setFrame_display_(frame, True)
entry.view.setFrame_(content_rect)
else:
entry = self._create_overlay(frame)
self._overlays.append(entry)
entry.window.orderFrontRegardless()
for i in range(needed, existing):
self._overlays[i].window.orderOut_(None)
if needed < existing:
self._overlays = self._overlays[:needed]
def _create_overlay(self, frame) -> _OverlayEntry:
def _create_overlay(self, frame) -> tuple[NSWindow, FlashBorderView]:
window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
frame, NSBorderlessWindowMask, 2, False # NSBackingStoreBuffered
frame, NSBorderlessWindowMask, 2, False
)
window.setOpaque_(False)
window.setBackgroundColor_(NSColor.clearColor())
window.setLevel_(25) # Above normal windows
window.setLevel_(2147483631)
window.setCollectionBehavior_(
NSWindowCollectionBehaviorCanJoinAllSpaces
| NSWindowCollectionBehaviorFullScreenAuxiliary
)
window.setIgnoresMouseEvents_(True)
window.setHasShadow_(False)
content_rect = ((0, 0), (frame[1][0], frame[1][1]))
view = PulseBorderView.alloc().initWithFrame_config_(
content_rect, self._config
)
view = FlashBorderView.alloc().initWithFrame_(content_rect)
window.setContentView_(view)
return _OverlayEntry(window, view)
return window, view
def _start_animation(self) -> None:
def _ensure_timer(self) -> None:
if self._timer is not None:
return
interval = 1.0 / 30.0 # 30 fps
interval = 1.0 / 30.0
self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
interval, self, "_tick:", None, True
)
def _stop_animation(self) -> None:
def _stop_timer(self) -> None:
if self._timer is not None:
self._timer.invalidate()
self._timer = None
self._phase = 0.0
@objc.python_method
def _tick_impl(self):
speed = self._config.pulse_speed
step = (2.0 * math.pi) / (speed * 30.0)
self._phase += step
for entry in self._overlays:
entry.view.setPhase_(self._phase)
self.update_positions()
if not self._tags:
self._stop_timer()
return
dt = 1.0 / 30.0
self._pulse_elapsed += dt
tags_to_remove: list[str] = []
for tag, state in self._tags.items():
match state.mode:
case _Mode.PULSE:
self._tick_pulse_tag(state)
case _Mode.FLASH:
state.elapsed += dt
if not self._tick_flash_tag(tag, state):
tags_to_remove.append(tag)
for tag in tags_to_remove:
self._remove_tag(tag)
if not self._tags:
self._stop_timer()
self._pulse_elapsed = 0.0
def _tick_pulse_tag(self, state: _TagState) -> None:
speed = state.style.pulse_speed
phase = (2.0 * math.pi * self._pulse_elapsed) / speed
opacity_min = 0.3
t = 0.5 + 0.5 * math.sin(phase)
alpha = opacity_min + (state.style.opacity - opacity_min) * t
for _, view in state.panels:
view.setAlpha_(alpha)
def _tick_flash_tag(self, tag: str, state: _TagState) -> bool:
"""Tick a flash tag. Returns False when the flash is finished."""
duration = state.style.duration
fade_in = 0.15
fade_out = 0.4
hold_end = duration - fade_out
elapsed = state.elapsed
if elapsed < fade_in:
alpha = state.style.opacity * (elapsed / fade_in)
elif elapsed < hold_end:
alpha = state.style.opacity
elif elapsed < duration:
progress = (elapsed - hold_end) / fade_out
alpha = state.style.opacity * (1.0 - progress)
else:
return False
for _, view in state.panels:
view.setAlpha_(alpha)
return True
def _tick_(self, timer) -> None:
self._tick_impl()
def _get_all_window_frames(self, pid: int) -> list[tuple]:
"""Get screen frames for all on-screen windows belonging to `pid`."""
window_list = CGWindowListCopyWindowInfo(
kCGWindowListOptionOnScreenOnly, kCGNullWindowID
)
if not window_list:
return []
screen_height = NSScreen.mainScreen().frame().size.height
frames = []
for info in window_list:
if info.get(kCGWindowOwnerPID) != pid:
continue
if info.get(kCGWindowLayer, 999) != 0:
continue
bounds = info.get(kCGWindowBounds)
if bounds is None:
continue
w = bounds["Width"]
h = bounds["Height"]
if w < 100 or h < 100:
continue
x = bounds["X"]
y = screen_height - bounds["Y"] - h
frames.append(((x, y), (w, h)))
return frames

View File

@@ -1,17 +1,14 @@
"""System sound playback."""
from Cocoa import NSSound
from cursor_flasher.config import Config
from cursor_flasher.config import StyleConfig
def play_alert(config: Config) -> None:
"""Play the configured alert sound if enabled."""
if not config.sound_enabled:
def play_alert(style: StyleConfig) -> None:
if not style.sound:
return
sound = NSSound.soundNamed_(config.sound_name)
sound = NSSound.soundNamed_(style.sound)
if sound is None:
return
sound.setVolume_(config.sound_volume)
sound.setVolume_(style.volume)
sound.play()

View File

@@ -1,53 +0,0 @@
import enum
import time
class FlasherState(enum.Enum):
IDLE = "idle"
AGENT_WORKING = "agent_working"
WAITING_FOR_USER = "waiting_for_user"
class StateMachine:
def __init__(self, cooldown: float = 3.0):
self.state = FlasherState.IDLE
self.cooldown = cooldown
self._last_dismiss_time: float = 0
def update(self, *, agent_working: bool, approval_needed: bool) -> bool:
"""Update state based on detected signals. Returns True if state changed."""
old = self.state
if approval_needed and self.state != FlasherState.WAITING_FOR_USER:
if not self._in_cooldown():
self.state = FlasherState.WAITING_FOR_USER
return self.state != old
match self.state:
case FlasherState.IDLE:
if agent_working:
self.state = FlasherState.AGENT_WORKING
case FlasherState.AGENT_WORKING:
if not agent_working:
if not self._in_cooldown():
self.state = FlasherState.WAITING_FOR_USER
else:
self.state = FlasherState.IDLE
case FlasherState.WAITING_FOR_USER:
if agent_working:
self.state = FlasherState.AGENT_WORKING
return self.state != old
def dismiss(self) -> bool:
"""User interaction detected — dismiss the flash."""
if self.state == FlasherState.WAITING_FOR_USER:
self.state = FlasherState.IDLE
self._last_dismiss_time = time.monotonic()
return True
return False
def _in_cooldown(self) -> bool:
if self._last_dismiss_time == 0:
return False
return (time.monotonic() - self._last_dismiss_time) < self.cooldown

View File

@@ -0,0 +1,180 @@
"""Cursor window discovery and geometry via macOS accessibility APIs.
Only used for finding Cursor windows and reading their screen position.
Detection of agent state is handled by Cursor hooks, not a11y polling.
"""
import logging
from ApplicationServices import (
AXUIElementCreateApplication,
AXUIElementCopyAttributeValue,
AXValueGetValue,
kAXValueTypeCGPoint,
kAXValueTypeCGSize,
)
from Cocoa import NSScreen, NSWorkspace
logger = logging.getLogger("cursor_flasher")
CURSOR_BUNDLE_ID = "com.todesktop.230313mzl4w4u92"
def is_cursor_frontmost() -> bool:
"""Return True if Cursor is the frontmost (active) application."""
app = NSWorkspace.sharedWorkspace().frontmostApplication()
if app is None:
return False
return (app.bundleIdentifier() or "") == CURSOR_BUNDLE_ID
def find_cursor_pid() -> int | None:
workspace = NSWorkspace.sharedWorkspace()
for app in workspace.runningApplications():
bundle = app.bundleIdentifier() or ""
if bundle == CURSOR_BUNDLE_ID:
return app.processIdentifier()
return None
def get_cursor_windows() -> list[dict]:
"""Return a list of Cursor windows with their titles and AX-coordinate frames.
Each entry: {"title": str, "frame": ((x, y), (w, h))}
Frame is in AppKit coordinates (bottom-left origin).
"""
pid = find_cursor_pid()
if pid is None:
return []
app = AXUIElementCreateApplication(pid)
err, children = AXUIElementCopyAttributeValue(app, "AXChildren", None)
if err or not children:
return []
screen_height = NSScreen.mainScreen().frame().size.height
results = []
for child in children:
err, role = AXUIElementCopyAttributeValue(child, "AXRole", None)
if err or str(role) != "AXWindow":
continue
err, title = AXUIElementCopyAttributeValue(child, "AXTitle", None)
title = str(title) if not err and title else ""
frame = _read_frame(child, screen_height)
if frame is None:
continue
results.append({"title": title, "frame": frame})
return results
def find_window_by_workspace(workspace_path: str) -> dict | None:
"""Find the Cursor window whose title contains the workspace folder name.
Cursor titles are typically "<filename> — <project-name>".
Returns None if no match or ambiguous (avoids flashing the wrong window).
Only falls back to the sole window if there's exactly one.
"""
windows = get_cursor_windows()
if not windows:
return None
if not workspace_path:
return windows[0] if len(windows) == 1 else None
folder_name = workspace_path.rstrip("/").rsplit("/", 1)[-1]
if not folder_name:
return windows[0] if len(windows) == 1 else None
matches = [w for w in windows if folder_name in w["title"]]
if len(matches) == 1:
return matches[0]
if len(matches) > 1:
exact = [w for w in matches if w["title"].endswith(folder_name)]
if len(exact) == 1:
return exact[0]
return matches[0]
return windows[0] if len(windows) == 1 else None
def get_focused_cursor_window() -> dict | None:
"""Return the currently focused (key) Cursor window.
Uses the AXFocusedWindow attribute to identify which specific Cursor
window has keyboard focus. Returns {"title": str, "frame": tuple} or
None if Cursor isn't running or has no focused window.
"""
pid = find_cursor_pid()
if pid is None:
return None
app = AXUIElementCreateApplication(pid)
err, focused = AXUIElementCopyAttributeValue(app, "AXFocusedWindow", None)
if err or focused is None:
return None
err, title = AXUIElementCopyAttributeValue(focused, "AXTitle", None)
title = str(title) if not err and title else ""
screen_height = NSScreen.mainScreen().frame().size.height
frame = _read_frame(focused, screen_height)
if frame is None:
return None
return {"title": title, "frame": frame}
def screen_frame_for_window(window_frame: tuple) -> tuple:
"""Return the NSScreen frame of the monitor containing the window's center.
Falls back to the main screen if no screen contains the center point.
"""
wx, wy = window_frame[0]
ww, wh = window_frame[1]
cx = wx + ww / 2.0
cy = wy + wh / 2.0
for screen in NSScreen.screens():
sf = screen.frame()
sx, sy = sf.origin.x, sf.origin.y
sw, sh = sf.size.width, sf.size.height
if sx <= cx < sx + sw and sy <= cy < sy + sh:
return ((sx, sy), (sw, sh))
main = NSScreen.mainScreen().frame()
return ((main.origin.x, main.origin.y), (main.size.width, main.size.height))
def all_screen_frames() -> list[tuple]:
"""Return frames for every connected screen."""
frames = []
for screen in NSScreen.screens():
sf = screen.frame()
frames.append(((sf.origin.x, sf.origin.y), (sf.size.width, sf.size.height)))
return frames
def _read_frame(ax_window, screen_height: float) -> tuple | None:
_, pos_val = AXUIElementCopyAttributeValue(ax_window, "AXPosition", None)
_, size_val = AXUIElementCopyAttributeValue(ax_window, "AXSize", None)
if pos_val is None or size_val is None:
return None
_, point = AXValueGetValue(pos_val, kAXValueTypeCGPoint, None)
_, size = AXValueGetValue(size_val, kAXValueTypeCGSize, None)
if point is None or size is None:
return None
x = point.x
w = size.width
h = size.height
y = screen_height - point.y - h
return ((x, y), (w, h))

View File

@@ -1,56 +1,241 @@
import pytest
from pathlib import Path
from cursor_flasher.config import Config, load_config, DEFAULT_CONFIG_PATH
from cursor_flasher.config import (
Config,
StyleConfig,
ThemeStyles,
load_config,
)
class TestDefaultConfig:
def test_has_pulse_settings(self):
cfg = Config()
assert cfg.pulse_color == "#FF9500"
assert cfg.pulse_width == 4
assert cfg.pulse_speed == 1.5
assert cfg.pulse_opacity_min == 0.3
assert cfg.pulse_opacity_max == 1.0
def test_dark_running_defaults(self):
c = Config()
assert c.dark.running.color == "#FF9500"
assert c.dark.running.width == 4
assert c.dark.running.duration == 1.5
assert c.dark.running.opacity == 0.85
assert c.dark.running.pulse_speed == 1.5
assert c.dark.running.sound == "Glass"
assert c.dark.running.volume == 0.5
def test_has_sound_settings(self):
cfg = Config()
assert cfg.sound_enabled is True
assert cfg.sound_name == "Glass"
assert cfg.sound_volume == 0.5
def test_dark_completed_defaults(self):
c = Config()
assert c.dark.completed.color == "#FF9500"
assert c.dark.completed.width == 4
assert c.dark.completed.sound == ""
assert c.dark.completed.volume == 0.0
def test_has_detection_settings(self):
cfg = Config()
assert cfg.poll_interval == 0.5
assert cfg.cooldown == 3.0
def test_light_running_defaults(self):
c = Config()
assert c.light.running.color == "#FF9500"
assert c.light.running.sound == "Glass"
def test_has_timeout_settings(self):
cfg = Config()
assert cfg.auto_dismiss == 300
def test_light_completed_defaults(self):
c = Config()
assert c.light.completed.sound == ""
assert c.light.completed.volume == 0.0
def test_theme_defaults_to_auto(self):
c = Config()
assert c.theme == "auto"
def test_has_approval_tools(self):
c = Config()
assert c.approval_tools == ["Shell", "Write", "Delete"]
def test_has_cooldown(self):
c = Config()
assert c.cooldown == 2.0
def test_has_flash_mode(self):
c = Config()
assert c.flash_mode == "screen"
class TestActiveStyles:
def test_auto_returns_dark_when_system_dark(self):
c = Config(theme="auto")
dark_styles = ThemeStyles(running=StyleConfig(color="#111111"))
light_styles = ThemeStyles(running=StyleConfig(color="#EEEEEE"))
c.dark = dark_styles
c.light = light_styles
assert c.active_styles("dark").running.color == "#111111"
def test_auto_returns_light_when_system_light(self):
c = Config(theme="auto")
dark_styles = ThemeStyles(running=StyleConfig(color="#111111"))
light_styles = ThemeStyles(running=StyleConfig(color="#EEEEEE"))
c.dark = dark_styles
c.light = light_styles
assert c.active_styles("light").running.color == "#EEEEEE"
def test_explicit_dark_ignores_system(self):
c = Config(theme="dark")
c.dark = ThemeStyles(running=StyleConfig(color="#111111"))
c.light = ThemeStyles(running=StyleConfig(color="#EEEEEE"))
assert c.active_styles("light").running.color == "#111111"
def test_explicit_light_ignores_system(self):
c = Config(theme="light")
c.dark = ThemeStyles(running=StyleConfig(color="#111111"))
c.light = ThemeStyles(running=StyleConfig(color="#EEEEEE"))
assert c.active_styles("dark").running.color == "#EEEEEE"
def test_active_styles_includes_completed(self):
c = Config(theme="dark")
c.dark = ThemeStyles(completed=StyleConfig(color="#AA0000"))
assert c.active_styles("light").completed.color == "#AA0000"
class TestLoadConfig:
def test_loads_from_yaml(self, tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text(
"pulse:\n"
' color: "#00FF00"\n'
" width: 8\n"
"sound:\n"
" enabled: false\n"
)
cfg = load_config(config_file)
assert cfg.pulse_color == "#00FF00"
assert cfg.pulse_width == 8
assert cfg.sound_enabled is False
assert cfg.pulse_speed == 1.5
assert cfg.sound_name == "Glass"
def test_missing_file_returns_defaults(self, tmp_path):
cfg = load_config(tmp_path / "nonexistent.yaml")
assert cfg.pulse_color == "#FF9500"
c = load_config(tmp_path / "nope.yaml")
assert c == Config()
def test_empty_file_returns_defaults(self, tmp_path):
config_file = tmp_path / "config.yaml"
config_file.write_text("")
cfg = load_config(config_file)
assert cfg.pulse_color == "#FF9500"
p = tmp_path / "config.yaml"
p.write_text("")
c = load_config(p)
assert c == Config()
def test_loads_theme(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text("theme: dark\n")
c = load_config(p)
assert c.theme == "dark"
def test_invalid_theme_uses_default(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text("theme: neon\n")
c = load_config(p)
assert c.theme == "auto"
def test_loads_dark_running_overrides(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"dark:\n"
" running:\n"
" color: '#00FF00'\n"
" duration: 2.0\n"
" sound: Ping\n"
)
c = load_config(p)
assert c.dark.running.color == "#00FF00"
assert c.dark.running.duration == 2.0
assert c.dark.running.sound == "Ping"
assert c.dark.running.width == 4
def test_loads_dark_completed_overrides(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"dark:\n"
" completed:\n"
" color: '#0000FF'\n"
" sound: Hero\n"
" volume: 0.8\n"
)
c = load_config(p)
assert c.dark.completed.color == "#0000FF"
assert c.dark.completed.sound == "Hero"
assert c.dark.completed.volume == 0.8
def test_loads_light_running_overrides(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"light:\n"
" running:\n"
" color: '#3B82F6'\n"
" opacity: 0.9\n"
)
c = load_config(p)
assert c.light.running.color == "#3B82F6"
assert c.light.running.opacity == 0.9
assert c.light.running.width == 4
def test_missing_dark_section_uses_defaults(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"light:\n"
" running:\n"
" color: '#FFFFFF'\n"
)
c = load_config(p)
assert c.dark == ThemeStyles()
def test_missing_running_within_theme_uses_defaults(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"dark:\n"
" completed:\n"
" color: '#FF0000'\n"
)
c = load_config(p)
assert c.dark.running == StyleConfig()
assert c.dark.completed.color == "#FF0000"
def test_loads_flash_mode(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text("flash:\n mode: allscreens\n")
c = load_config(p)
assert c.flash_mode == "allscreens"
def test_loads_general_overrides(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text("general:\n cooldown: 5.0\n approval_delay: 3.0\n")
c = load_config(p)
assert c.cooldown == 5.0
assert c.approval_delay == 3.0
def test_loads_approval_tools(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text("approval_tools:\n - Shell\n - MCP\n")
c = load_config(p)
assert c.approval_tools == ["Shell", "MCP"]
def test_full_config(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"theme: light\n"
"dark:\n"
" running:\n"
" color: '#FF0000'\n"
" width: 6\n"
" opacity: 0.9\n"
" pulse_speed: 2.0\n"
" sound: Glass\n"
" volume: 0.8\n"
" completed:\n"
" color: '#00FF00'\n"
" sound: ''\n"
"light:\n"
" running:\n"
" color: '#3B82F6'\n"
" width: 3\n"
" completed:\n"
" color: '#22C55E'\n"
" duration: 2.0\n"
"flash:\n"
" mode: window\n"
"general:\n"
" approval_delay: 1.0\n"
" cooldown: 3.0\n"
"approval_tools:\n"
" - Shell\n"
)
c = load_config(p)
assert c.theme == "light"
assert c.dark.running.color == "#FF0000"
assert c.dark.running.width == 6
assert c.dark.running.opacity == 0.9
assert c.dark.running.pulse_speed == 2.0
assert c.dark.running.sound == "Glass"
assert c.dark.running.volume == 0.8
assert c.dark.completed.color == "#00FF00"
assert c.dark.completed.sound == ""
assert c.light.running.color == "#3B82F6"
assert c.light.running.width == 3
assert c.light.completed.color == "#22C55E"
assert c.light.completed.duration == 2.0
assert c.flash_mode == "window"
assert c.approval_delay == 1.0
assert c.cooldown == 3.0
assert c.approval_tools == ["Shell"]

651
tests/test_daemon.py Normal file
View File

@@ -0,0 +1,651 @@
"""Tests for the daemon's message handling logic."""
import json
import time
from unittest.mock import patch, MagicMock
from cursor_flasher.config import Config, StyleConfig, ThemeStyles
from cursor_flasher.daemon import FlasherDaemon
PATCH_APPEARANCE = "cursor_flasher.daemon._get_system_appearance"
PATCH_FOCUSED = "cursor_flasher.daemon.get_focused_cursor_window"
class TestFlasherDaemon:
def _make_daemon(self, **config_overrides) -> FlasherDaemon:
config = Config(**config_overrides)
with patch("cursor_flasher.daemon.OverlayManager") as MockOverlay:
daemon = FlasherDaemon(config)
daemon.overlay.is_pulsing = False
daemon.overlay.active_tags = set()
daemon.overlay.has_tag = lambda tag: tag in daemon.overlay.active_tags
return daemon
# --- preToolUse / pending ---
def test_preToolUse_queues_pending(self):
daemon = self._make_daemon()
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
assert "/path" in daemon._pending_approvals
assert daemon._pending_approvals["/path"].tool == "Shell"
daemon.overlay.add_pulse.assert_not_called()
def test_pending_promotes_after_delay(self):
daemon = self._make_daemon(approval_delay=0.0, flash_mode="screen")
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
screen = ((0, 0), (1920, 1080))
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._check_pending()
daemon.overlay.add_pulse.assert_called_once_with(
"/path", [screen], daemon.config.dark.running
)
assert "/path" in daemon._active_pulses
assert "/path" not in daemon._pending_approvals
def test_postToolUse_cancels_pending(self):
"""Auto-approved tools: postToolUse arrives before delay, cancels the pulse."""
daemon = self._make_daemon(approval_delay=10.0)
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
assert "/path" in daemon._pending_approvals
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "postToolUse", "tool": "Shell"}).encode()
)
assert "/path" not in daemon._pending_approvals
daemon.overlay.add_pulse.assert_not_called()
def test_preToolUse_skips_non_approval_tool(self):
daemon = self._make_daemon()
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Read"}).encode()
)
assert not daemon._pending_approvals
def test_preToolUse_respects_custom_tool_list(self):
daemon = self._make_daemon(approval_delay=0.0, flash_mode="screen", approval_tools=["Shell", "MCP"])
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "MCP"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=((0, 0), (1920, 1080))), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._check_pending()
daemon.overlay.add_pulse.assert_called_once()
# --- stop / flash ---
def test_stop_flashes_briefly(self):
daemon = self._make_daemon(flash_mode="screen")
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
screen = ((0, 0), (1920, 1080))
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
daemon.overlay.add_flash.assert_called_once_with(
"/path", [screen], daemon.config.dark.completed
)
def test_stop_flashes_window_frame_when_window_mode(self):
daemon = self._make_daemon(flash_mode="window")
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="light"):
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
daemon.overlay.add_flash.assert_called_once_with(
"/path", [((0, 0), (800, 600))], daemon.config.light.completed
)
def test_window_mode_falls_back_to_screen_when_cursor_not_frontmost(self):
"""Window mode falls back to screen frame when Cursor isn't frontmost."""
daemon = self._make_daemon(approval_delay=0.0, flash_mode="window")
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
screen = ((0, 0), (1920, 1080))
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._check_pending()
daemon.overlay.add_pulse.assert_called_once_with(
"/path", [screen], daemon.config.dark.running
)
def test_stop_falls_back_to_screen_when_cursor_not_frontmost(self):
"""Stop flash in window mode falls back to screen when Cursor isn't frontmost."""
daemon = self._make_daemon(flash_mode="window")
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
screen = ((0, 0), (1920, 1080))
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
daemon.overlay.add_flash.assert_called_once_with(
"/path", [screen], daemon.config.dark.completed
)
def test_allscreens_mode_uses_all_screens(self):
daemon = self._make_daemon(flash_mode="allscreens", approval_delay=0.0)
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
screens = [((0, 0), (1920, 1080)), ((-1920, 0), (1920, 1080))]
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.all_screen_frames", return_value=screens), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._check_pending()
daemon.overlay.add_pulse.assert_called_once_with(
"/path", screens, daemon.config.dark.running
)
# --- stop interactions with active pulse ---
def test_stop_dismisses_active_pulse_for_workspace(self):
daemon = self._make_daemon()
daemon._active_pulses["/path"] = MagicMock()
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=None), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
daemon.overlay.dismiss_tag.assert_called_with("/path")
assert "/path" not in daemon._active_pulses
def test_stop_clears_pending(self):
daemon = self._make_daemon(approval_delay=10.0)
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
assert "/path" in daemon._pending_approvals
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
assert "/path" not in daemon._pending_approvals
# --- postToolUse / dismiss ---
def test_postToolUse_dismisses_active_pulse(self):
daemon = self._make_daemon()
daemon._active_pulses["/path"] = MagicMock()
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "postToolUse", "tool": "Shell"}).encode()
)
daemon.overlay.dismiss_tag.assert_called_with("/path")
assert "/path" not in daemon._active_pulses
def test_postToolUseFailure_dismisses_pulse(self):
daemon = self._make_daemon()
daemon._active_pulses["/path"] = MagicMock()
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "postToolUseFailure", "tool": "Shell"}).encode()
)
daemon.overlay.dismiss_tag.assert_called_with("/path")
# --- cooldown ---
def test_cooldown_prevents_rapid_triggers(self):
daemon = self._make_daemon(cooldown=5.0)
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
daemon._last_flash["/path"] = time.monotonic()
daemon._pending_approvals.clear()
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
assert "/path" not in daemon._pending_approvals
def test_cooldown_is_per_workspace(self):
daemon = self._make_daemon(cooldown=5.0)
daemon._last_flash["/pathA"] = time.monotonic()
daemon._handle_message(
json.dumps({"workspace": "/pathB", "event": "preToolUse", "tool": "Shell"}).encode()
)
assert "/pathB" in daemon._pending_approvals
# --- misc ---
def test_invalid_json_ignored(self):
daemon = self._make_daemon()
daemon._handle_message(b"not json")
daemon.overlay.add_pulse.assert_not_called()
daemon.overlay.add_flash.assert_not_called()
def test_no_window_found(self):
daemon = self._make_daemon(approval_delay=0.0)
daemon._handle_message(
json.dumps({"workspace": "/nope", "event": "preToolUse", "tool": "Shell"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=None):
daemon._check_pending()
daemon.overlay.add_pulse.assert_not_called()
# --- focus dismiss ---
def test_focus_transition_dismisses_focused_workspace(self):
"""Pulse dismisses when user switches TO Cursor, only for the focused window."""
from cursor_flasher.daemon import _ActivePulse
daemon = self._make_daemon()
daemon._active_pulses["/pathA"] = _ActivePulse("/pathA", "project-a", time.monotonic())
daemon._active_pulses["/pathB"] = _ActivePulse("/pathB", "project-b", time.monotonic())
daemon._cursor_was_frontmost = False
focused = {"title": "project-a", "frame": ((0, 0), (800, 600))}
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch(PATCH_FOCUSED, return_value=focused):
daemon._check_focus()
daemon.overlay.dismiss_tag.assert_called_once_with("/pathA")
assert "/pathA" not in daemon._active_pulses
assert "/pathB" in daemon._active_pulses
def test_focus_no_dismiss_when_cursor_already_frontmost(self):
"""No dismiss if Cursor was already frontmost (no transition)."""
from cursor_flasher.daemon import _ActivePulse
daemon = self._make_daemon()
daemon._active_pulses["/path"] = _ActivePulse("/path", "proj", time.monotonic())
daemon._cursor_was_frontmost = True
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch(PATCH_FOCUSED, return_value={"title": "proj", "frame": ((0, 0), (800, 600))}):
daemon._check_focus()
daemon.overlay.dismiss_tag.assert_not_called()
def test_focus_tracks_state_changes(self):
"""_cursor_was_frontmost updates each tick."""
from cursor_flasher.daemon import _ActivePulse
daemon = self._make_daemon()
daemon._active_pulses["/path"] = _ActivePulse("/path", "proj", time.monotonic())
daemon._cursor_was_frontmost = True
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False):
daemon._check_focus()
assert daemon._cursor_was_frontmost is False
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch(PATCH_FOCUSED, return_value={"title": "proj", "frame": ((0, 0), (800, 600))}):
daemon._check_focus()
daemon.overlay.dismiss_tag.assert_called_once_with("/path")
def test_focus_no_dismiss_when_not_pulsing(self):
daemon = self._make_daemon()
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
daemon._check_focus()
daemon.overlay.dismiss_tag.assert_not_called()
# --- input dismiss ---
def test_input_dismiss_targets_focused_workspace(self):
"""Recent input + Cursor frontmost + past grace period = dismiss focused workspace only."""
from cursor_flasher.daemon import _ActivePulse
daemon = self._make_daemon()
daemon._active_pulses["/pathA"] = _ActivePulse("/pathA", "project-a", time.monotonic() - 2.0)
daemon._active_pulses["/pathB"] = _ActivePulse("/pathB", "project-b", time.monotonic() - 2.0)
focused = {"title": "project-a", "frame": ((0, 0), (800, 600))}
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=0.2), \
patch(PATCH_FOCUSED, return_value=focused):
daemon._check_input_dismiss()
daemon.overlay.dismiss_tag.assert_called_once_with("/pathA")
assert "/pathA" not in daemon._active_pulses
assert "/pathB" in daemon._active_pulses
def test_input_dismiss_skipped_during_grace_period(self):
"""No dismiss if pulse just started (within grace period)."""
from cursor_flasher.daemon import _ActivePulse
daemon = self._make_daemon()
daemon._active_pulses["/path"] = _ActivePulse("/path", "proj", time.monotonic() - 0.1)
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=0.05):
daemon._check_input_dismiss()
daemon.overlay.dismiss_tag.assert_not_called()
def test_input_dismiss_skipped_when_not_pulsing(self):
daemon = self._make_daemon()
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=0.1):
daemon._check_input_dismiss()
daemon.overlay.dismiss_tag.assert_not_called()
def test_input_dismiss_skipped_when_cursor_not_frontmost(self):
from cursor_flasher.daemon import _ActivePulse
daemon = self._make_daemon()
daemon._active_pulses["/path"] = _ActivePulse("/path", "proj", time.monotonic() - 2.0)
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False):
daemon._check_input_dismiss()
daemon.overlay.dismiss_tag.assert_not_called()
def test_input_dismiss_ignores_old_input(self):
"""Input from before the pulse started should not trigger dismiss."""
from cursor_flasher.daemon import _ActivePulse
daemon = self._make_daemon()
daemon._active_pulses["/path"] = _ActivePulse("/path", "proj", time.monotonic() - 2.0)
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=5.0):
daemon._check_input_dismiss()
daemon.overlay.dismiss_tag.assert_not_called()
# --- sound ---
def test_running_style_sound_plays_on_approval(self):
"""Running style with sound configured plays on approval pulse."""
running = StyleConfig(sound="Glass", volume=0.5)
dark = ThemeStyles(running=running)
daemon = self._make_daemon(approval_delay=0.0, flash_mode="window", dark=dark)
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert") as mock_alert, \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._check_pending()
mock_alert.assert_called_once_with(running)
def test_completed_style_sound_plays_on_stop(self):
"""Completed style with sound configured plays on stop flash."""
completed = StyleConfig(sound="Ping", volume=0.7)
dark = ThemeStyles(completed=completed)
daemon = self._make_daemon(flash_mode="window", dark=dark)
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert") as mock_alert, \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
mock_alert.assert_called_once_with(completed)
def test_no_sound_when_style_sound_empty(self):
"""No sound plays when the style has sound="" (the completed default)."""
completed = StyleConfig(sound="", volume=0.0)
dark = ThemeStyles(completed=completed)
daemon = self._make_daemon(flash_mode="window", dark=dark)
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert") as mock_alert, \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
mock_alert.assert_called_once_with(completed)
def test_custom_colors_per_mode(self):
"""Different colors for running vs completed are passed through."""
running = StyleConfig(color="#FF0000")
completed = StyleConfig(color="#00FF00")
dark = ThemeStyles(running=running, completed=completed)
daemon = self._make_daemon(
approval_delay=0.0, flash_mode="window", dark=dark,
)
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._check_pending()
daemon.overlay.add_pulse.assert_called_once_with(
"/path", [((0, 0), (800, 600))], running
)
daemon.overlay.add_pulse.reset_mock()
daemon._last_flash.clear()
daemon._active_pulses.clear()
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
daemon.overlay.add_flash.assert_called_once_with(
"/path", [((0, 0), (800, 600))], completed
)
def test_theme_auto_uses_light_when_system_light(self):
"""Auto theme resolves to light styles when system is in light mode."""
dark_running = StyleConfig(color="#111111")
light_running = StyleConfig(color="#EEEEEE")
daemon = self._make_daemon(
approval_delay=0.0, flash_mode="window",
dark=ThemeStyles(running=dark_running),
light=ThemeStyles(running=light_running),
theme="auto",
)
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="light"):
daemon._check_pending()
daemon.overlay.add_pulse.assert_called_once_with(
"/path", [((0, 0), (800, 600))], light_running
)
# --- multi-workspace scenarios ---
def test_two_workspaces_pulse_simultaneously(self):
"""Two workspaces can pulse at the same time."""
daemon = self._make_daemon(approval_delay=0.0, flash_mode="window")
win_a = {"title": "project-a", "frame": ((0, 0), (800, 600))}
win_b = {"title": "project-b", "frame": ((900, 0), (800, 600))}
daemon._handle_message(
json.dumps({"workspace": "/a", "event": "preToolUse", "tool": "Shell"}).encode()
)
daemon._handle_message(
json.dumps({"workspace": "/b", "event": "preToolUse", "tool": "Shell"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", side_effect=[win_a, win_b]), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._check_pending()
assert daemon.overlay.add_pulse.call_count == 2
assert "/a" in daemon._active_pulses
assert "/b" in daemon._active_pulses
def test_dismiss_one_workspace_keeps_other_pulsing(self):
"""postToolUse for workspace A only dismisses A, B keeps pulsing."""
from cursor_flasher.daemon import _ActivePulse
daemon = self._make_daemon()
daemon._active_pulses["/a"] = _ActivePulse("/a", "project-a", time.monotonic())
daemon._active_pulses["/b"] = _ActivePulse("/b", "project-b", time.monotonic())
daemon._handle_message(
json.dumps({"workspace": "/a", "event": "postToolUse", "tool": "Shell"}).encode()
)
daemon.overlay.dismiss_tag.assert_called_once_with("/a")
assert "/a" not in daemon._active_pulses
assert "/b" in daemon._active_pulses
def test_stop_only_affects_its_workspace(self):
"""Stop for workspace A dismisses A's pulse and flashes A, B keeps pulsing."""
from cursor_flasher.daemon import _ActivePulse
daemon = self._make_daemon(flash_mode="window")
daemon._active_pulses["/a"] = _ActivePulse("/a", "project-a", time.monotonic())
daemon._active_pulses["/b"] = _ActivePulse("/b", "project-b", time.monotonic())
window = {"title": "project-a", "frame": ((0, 0), (800, 600))}
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._handle_message(
json.dumps({"workspace": "/a", "event": "stop"}).encode()
)
daemon.overlay.dismiss_tag.assert_called_with("/a")
daemon.overlay.add_flash.assert_called_once()
assert "/a" not in daemon._active_pulses
assert "/b" in daemon._active_pulses
def test_postToolUse_only_cancels_matching_workspace_pending(self):
"""postToolUse for workspace A doesn't cancel workspace B's pending."""
daemon = self._make_daemon(approval_delay=10.0)
daemon._handle_message(
json.dumps({"workspace": "/a", "event": "preToolUse", "tool": "Shell"}).encode()
)
daemon._handle_message(
json.dumps({"workspace": "/b", "event": "preToolUse", "tool": "Shell"}).encode()
)
assert "/a" in daemon._pending_approvals
assert "/b" in daemon._pending_approvals
daemon._handle_message(
json.dumps({"workspace": "/a", "event": "postToolUse", "tool": "Shell"}).encode()
)
assert "/a" not in daemon._pending_approvals
assert "/b" in daemon._pending_approvals
# --- shellApproved / shellCompleted (disabled, debug-logged only) ---
def test_shellApproved_does_not_cancel_pending(self):
"""shellApproved is a no-op — pending approval must survive."""
daemon = self._make_daemon(approval_delay=10.0)
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
assert "/path" in daemon._pending_approvals
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "shellApproved", "tool": "Shell"}).encode()
)
assert "/path" in daemon._pending_approvals
def test_shellCompleted_does_not_dismiss(self):
"""shellCompleted is a no-op — active pulse must survive."""
daemon = self._make_daemon()
daemon._active_pulses["/path"] = MagicMock()
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "shellCompleted", "tool": "Shell"}).encode()
)
daemon.overlay.dismiss_tag.assert_not_called()
assert "/path" in daemon._active_pulses
def test_shellCompleted_does_not_cancel_pending(self):
"""shellCompleted is a no-op — pending approval must survive."""
daemon = self._make_daemon(approval_delay=10.0)
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
assert "/path" in daemon._pending_approvals
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "shellCompleted", "tool": "Shell"}).encode()
)
assert "/path" in daemon._pending_approvals

View File

@@ -1,72 +0,0 @@
import pytest
from unittest.mock import patch, MagicMock
from cursor_flasher.detector import (
CursorDetector,
parse_ui_signals,
UISignals,
)
class TestParseUISignals:
def test_no_elements_means_no_signals(self):
signals = parse_ui_signals([])
assert signals.agent_working is False
assert signals.approval_needed is False
def test_stop_text_means_agent_working(self):
elements = [{"role": "AXStaticText", "value": "Stop"}]
signals = parse_ui_signals(elements)
assert signals.agent_working is True
def test_cancel_generating_means_agent_working(self):
elements = [{"role": "AXStaticText", "value": "Cancel generating"}]
signals = parse_ui_signals(elements)
assert signals.agent_working is True
def test_accept_text_means_approval_needed(self):
elements = [{"role": "AXStaticText", "value": "Accept"}]
signals = parse_ui_signals(elements)
assert signals.approval_needed is True
def test_reject_text_means_approval_needed(self):
elements = [{"role": "AXStaticText", "value": "Reject"}]
signals = parse_ui_signals(elements)
assert signals.approval_needed is True
def test_run_this_time_means_approval_needed(self):
elements = [{"role": "AXStaticText", "value": "Run this time only (⏎)"}]
signals = parse_ui_signals(elements)
assert signals.approval_needed is True
def test_both_signals(self):
elements = [
{"role": "AXStaticText", "value": "Stop"},
{"role": "AXStaticText", "value": "Accept"},
]
signals = parse_ui_signals(elements)
assert signals.agent_working is True
assert signals.approval_needed is True
def test_irrelevant_text_ignored(self):
elements = [{"role": "AXStaticText", "value": "Settings"}]
signals = parse_ui_signals(elements)
assert signals.agent_working is False
assert signals.approval_needed is False
def test_button_role_also_detected(self):
elements = [{"role": "AXButton", "title": "Accept"}]
signals = parse_ui_signals(elements)
assert signals.approval_needed is True
def test_partial_match_on_run_command(self):
elements = [{"role": "AXStaticText", "value": "Run command"}]
signals = parse_ui_signals(elements)
assert signals.approval_needed is True
class TestCursorDetector:
def test_returns_none_when_cursor_not_running(self):
detector = CursorDetector()
with patch.object(detector, "_find_cursor_pid", return_value=None):
signals = detector.poll()
assert signals is None

176
tests/test_hook.py Normal file
View File

@@ -0,0 +1,176 @@
"""Tests for the hook notification script."""
import json
import os
import socket
import tempfile
import threading
import pytest
def _short_sock_path():
"""Create a short socket path that fits macOS's 104-char limit."""
fd, path = tempfile.mkstemp(suffix=".sock", dir="/tmp")
os.close(fd)
os.unlink(path)
return path
def _run_hook_main(stdin_data: str, socket_path: str):
"""Run the hook's main() with patched stdin and socket path."""
import io
import sys
from unittest.mock import patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "hooks"))
import notify
with patch.object(notify, "SOCKET_PATH", socket_path), \
patch("sys.stdin", io.StringIO(stdin_data)):
notify.main()
class TestHookNotify:
def test_sends_message_to_socket(self):
sock_path = _short_sock_path()
received = []
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
def accept():
conn, _ = server.accept()
data = conn.recv(4096)
received.append(json.loads(data))
conn.close()
t = threading.Thread(target=accept)
t.start()
try:
hook_input = json.dumps({
"workspace_roots": ["/Users/me/project"],
"hook_event_name": "preToolUse",
"tool_name": "Shell",
})
_run_hook_main(hook_input, sock_path)
t.join(timeout=2)
finally:
server.close()
if os.path.exists(sock_path):
os.unlink(sock_path)
assert len(received) == 1
assert received[0]["workspace"] == "/Users/me/project"
assert received[0]["event"] == "preToolUse"
assert received[0]["tool"] == "Shell"
def test_handles_missing_socket_gracefully(self):
hook_input = json.dumps({
"workspace_roots": ["/Users/me/project"],
"hook_event_name": "stop",
})
_run_hook_main(hook_input, "/tmp/nonexistent.sock")
def test_handles_empty_workspace_roots(self):
sock_path = _short_sock_path()
received = []
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
def accept():
conn, _ = server.accept()
data = conn.recv(4096)
received.append(json.loads(data))
conn.close()
t = threading.Thread(target=accept)
t.start()
try:
hook_input = json.dumps({
"workspace_roots": [],
"hook_event_name": "stop",
})
_run_hook_main(hook_input, sock_path)
t.join(timeout=2)
finally:
server.close()
if os.path.exists(sock_path):
os.unlink(sock_path)
assert received[0]["workspace"] == ""
def test_beforeShellExecution_mapped_to_shellApproved(self):
sock_path = _short_sock_path()
received = []
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
def accept():
conn, _ = server.accept()
data = conn.recv(4096)
received.append(json.loads(data))
conn.close()
t = threading.Thread(target=accept)
t.start()
try:
hook_input = json.dumps({
"workspace_roots": ["/Users/me/project"],
"hook_event_name": "beforeShellExecution",
"command": "npm install",
})
_run_hook_main(hook_input, sock_path)
t.join(timeout=2)
finally:
server.close()
if os.path.exists(sock_path):
os.unlink(sock_path)
assert len(received) == 1
assert received[0]["event"] == "shellApproved"
assert received[0]["tool"] == "Shell"
assert received[0]["workspace"] == "/Users/me/project"
def test_afterShellExecution_mapped_to_shellCompleted(self):
sock_path = _short_sock_path()
received = []
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
def accept():
conn, _ = server.accept()
data = conn.recv(4096)
received.append(json.loads(data))
conn.close()
t = threading.Thread(target=accept)
t.start()
try:
hook_input = json.dumps({
"workspace_roots": ["/Users/me/project"],
"hook_event_name": "afterShellExecution",
"command": "npm install",
"output": "added 100 packages",
"duration": 5432,
})
_run_hook_main(hook_input, sock_path)
t.join(timeout=2)
finally:
server.close()
if os.path.exists(sock_path):
os.unlink(sock_path)
assert len(received) == 1
assert received[0]["event"] == "shellCompleted"
assert received[0]["tool"] == "Shell"

View File

@@ -1,20 +0,0 @@
import pytest
from unittest.mock import patch, MagicMock
from cursor_flasher.sound import play_alert
from cursor_flasher.config import Config
class TestPlayAlert:
def test_does_nothing_when_disabled(self):
config = Config(sound_enabled=False)
play_alert(config)
@patch("cursor_flasher.sound.NSSound")
def test_plays_named_sound(self, mock_nssound):
mock_sound_obj = MagicMock()
mock_nssound.soundNamed_.return_value = mock_sound_obj
config = Config(sound_enabled=True, sound_name="Glass", sound_volume=0.7)
play_alert(config)
mock_nssound.soundNamed_.assert_called_once_with("Glass")
mock_sound_obj.setVolume_.assert_called_once_with(0.7)
mock_sound_obj.play.assert_called_once()

View File

@@ -1,75 +0,0 @@
import pytest
from cursor_flasher.state import FlasherState, StateMachine
class TestStateMachine:
def test_initial_state_is_idle(self):
sm = StateMachine()
assert sm.state == FlasherState.IDLE
def test_idle_to_agent_working(self):
sm = StateMachine()
changed = sm.update(agent_working=True, approval_needed=False)
assert sm.state == FlasherState.AGENT_WORKING
assert changed is True
def test_agent_working_to_waiting(self):
sm = StateMachine()
sm.update(agent_working=True, approval_needed=False)
changed = sm.update(agent_working=False, approval_needed=False)
assert sm.state == FlasherState.WAITING_FOR_USER
assert changed is True
def test_approval_needed_triggers_waiting(self):
sm = StateMachine()
sm.update(agent_working=True, approval_needed=False)
changed = sm.update(agent_working=False, approval_needed=True)
assert sm.state == FlasherState.WAITING_FOR_USER
assert changed is True
def test_idle_does_not_jump_to_waiting(self):
sm = StateMachine()
changed = sm.update(agent_working=False, approval_needed=False)
assert sm.state == FlasherState.IDLE
assert changed is False
def test_waiting_to_user_interacting(self):
sm = StateMachine()
sm.update(agent_working=True, approval_needed=False)
sm.update(agent_working=False, approval_needed=False)
assert sm.state == FlasherState.WAITING_FOR_USER
changed = sm.dismiss()
assert sm.state == FlasherState.IDLE
assert changed is True
def test_waiting_to_agent_working(self):
sm = StateMachine()
sm.update(agent_working=True, approval_needed=False)
sm.update(agent_working=False, approval_needed=False)
changed = sm.update(agent_working=True, approval_needed=False)
assert sm.state == FlasherState.AGENT_WORKING
assert changed is True
def test_no_change_returns_false(self):
sm = StateMachine()
sm.update(agent_working=True, approval_needed=False)
changed = sm.update(agent_working=True, approval_needed=False)
assert changed is False
def test_cooldown_prevents_immediate_retrigger(self):
sm = StateMachine(cooldown=5.0)
sm.update(agent_working=True, approval_needed=False)
sm.update(agent_working=False, approval_needed=False)
assert sm.state == FlasherState.WAITING_FOR_USER
sm.dismiss()
sm.update(agent_working=True, approval_needed=False)
changed = sm.update(agent_working=False, approval_needed=False)
assert sm.cooldown == 5.0
def test_direct_approval_from_idle(self):
"""If we detect approval buttons without seeing agent_working first,
still transition to WAITING_FOR_USER."""
sm = StateMachine()
changed = sm.update(agent_working=False, approval_needed=True)
assert sm.state == FlasherState.WAITING_FOR_USER
assert changed is True

348
uv.lock generated Normal file
View File

@@ -0,0 +1,348 @@
version = 1
requires-python = ">=3.10"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
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 = "cursor-flasher"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "pyobjc-framework-applicationservices" },
{ name = "pyobjc-framework-cocoa" },
{ name = "pyobjc-framework-quartz" },
{ name = "pyyaml" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-mock" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-mock" },
]
[package.metadata]
requires-dist = [
{ name = "pyobjc-framework-applicationservices", specifier = ">=12.1" },
{ name = "pyobjc-framework-cocoa" },
{ name = "pyobjc-framework-quartz" },
{ name = "pytest", marker = "extra == 'dev'" },
{ name = "pytest-mock", marker = "extra == 'dev'" },
{ name = "pyyaml" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest" },
{ name = "pytest-mock" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
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 = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
]
[[package]]
name = "pyobjc-core"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/bf/3dbb1783388da54e650f8a6b88bde03c101d9ba93dfe8ab1b1873f1cd999/pyobjc_core-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:93418e79c1655f66b4352168f8c85c942707cb1d3ea13a1da3e6f6a143bacda7", size = 676748 },
{ url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263 },
{ url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335 },
{ url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370 },
{ url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586 },
{ url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164 },
{ url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204 },
]
[[package]]
name = "pyobjc-framework-applicationservices"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
{ name = "pyobjc-framework-coretext" },
{ name = "pyobjc-framework-quartz" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/6a/d4e613c8e926a5744fc47a9e9fea08384a510dc4f27d844f7ad7a2d793bd/pyobjc_framework_applicationservices-12.1.tar.gz", hash = "sha256:c06abb74f119bc27aeb41bf1aef8102c0ae1288aec1ac8665ea186a067a8945b", size = 103247 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/9d/3cf36e7b08832e71f5d48ddfa1047865cf2dfc53df8c0f2a82843ea9507a/pyobjc_framework_applicationservices-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c4fd1b008757182b9e2603a63c6ffa930cc412fab47294ec64260ab3f8ec695d", size = 32791 },
{ url = "https://files.pythonhosted.org/packages/17/86/d07eff705ff909a0ffa96d14fc14026e9fc9dd716233648c53dfd5056b8e/pyobjc_framework_applicationservices-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bdddd492eeac6d14ff2f5bd342aba29e30dffa72a2d358c08444da22129890e2", size = 32784 },
{ url = "https://files.pythonhosted.org/packages/37/a7/55fa88def5c02732c4b747606ff1cbce6e1f890734bbd00f5596b21eaa02/pyobjc_framework_applicationservices-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c8f6e2fb3b3e9214ab4864ef04eee18f592b46a986c86ea0113448b310520532", size = 32835 },
{ url = "https://files.pythonhosted.org/packages/fc/21/79e42ee836f1010f5fe9e97d2817a006736bd287c15a3674c399190a2e77/pyobjc_framework_applicationservices-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bd1f4dbb38234a24ae6819f5e22485cf7dd3dd4074ff3bf9a9fdb4c01a3b4a38", size = 32859 },
{ url = "https://files.pythonhosted.org/packages/66/3a/0f1d4dcf2345e875e5ea9761d5a70969e241d24089133d21f008dde596f5/pyobjc_framework_applicationservices-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8a5d2845249b6a85ba9e320a9848468c3f8cd6f59605a9a43f406a7810eaa830", size = 33115 },
{ url = "https://files.pythonhosted.org/packages/40/44/3196b40fec68b4413c92875311f17ccf4c3ff7d2e53676f8fc18ad29bd18/pyobjc_framework_applicationservices-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f43c9a24ad97a9121276d4d571aa04a924282c80d7291cfb3b29839c3e2013a8", size = 32997 },
{ url = "https://files.pythonhosted.org/packages/fd/bb/dab21d2210d3ef7dd0616df7e8ea89b5d8d62444133a25f76e649a947168/pyobjc_framework_applicationservices-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1f72e20009a4ebfd5ed5b23dc11c1528ad6b55cc63ee71952ddb2a5e5f1cb7da", size = 33238 },
]
[[package]]
name = "pyobjc-framework-cocoa"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/aa/2b2d7ec3ac4b112a605e9bd5c5e5e4fd31d60a8a4b610ab19cc4838aa92a/pyobjc_framework_cocoa-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9b880d3bdcd102809d704b6d8e14e31611443aa892d9f60e8491e457182fdd48", size = 383825 },
{ url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812 },
{ url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590 },
{ url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689 },
{ url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843 },
{ url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932 },
{ url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970 },
]
[[package]]
name = "pyobjc-framework-coretext"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
{ name = "pyobjc-framework-quartz" },
]
sdist = { url = "https://files.pythonhosted.org/packages/29/da/682c9c92a39f713bd3c56e7375fa8f1b10ad558ecb075258ab6f1cdd4a6d/pyobjc_framework_coretext-12.1.tar.gz", hash = "sha256:e0adb717738fae395dc645c9e8a10bb5f6a4277e73cba8fa2a57f3b518e71da5", size = 90124 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/1c/ddecc72a672d681476c668bcedcfb8ade16383c028eac566ac7458fb91ef/pyobjc_framework_coretext-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1c8315dcef6699c2953461d97117fe81402f7c29cff36d2950dacce028a362fd", size = 29987 },
{ url = "https://files.pythonhosted.org/packages/f0/81/7b8efc41e743adfa2d74b92dec263c91bcebfb188d2a8f5eea1886a195ff/pyobjc_framework_coretext-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4f6742ba5b0bb7629c345e99eff928fbfd9e9d3d667421ac1a2a43bdb7ba9833", size = 29990 },
{ url = "https://files.pythonhosted.org/packages/cd/0f/ddf45bf0e3ba4fbdc7772de4728fd97ffc34a0b5a15e1ab1115b202fe4ae/pyobjc_framework_coretext-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d246fa654bdbf43bae3969887d58f0b336c29b795ad55a54eb76397d0e62b93c", size = 30108 },
{ url = "https://files.pythonhosted.org/packages/20/a2/a3974e3e807c68e23a9d7db66fc38ac54f7ecd2b7a9237042006699a76e1/pyobjc_framework_coretext-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7cbb2c28580e6704ce10b9a991ccd9563a22b3a75f67c36cf612544bd8b21b5f", size = 30110 },
{ url = "https://files.pythonhosted.org/packages/0f/5d/85e059349e9cfbd57269a1f11f56747b3ff5799a3bcbd95485f363c623d8/pyobjc_framework_coretext-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:14100d1e39efb30f57869671fb6fce8d668f80c82e25e7930fb364866e5c0dab", size = 30697 },
{ url = "https://files.pythonhosted.org/packages/ef/c3/adf9d306e9ead108167ab7a974ab7d171dbacf31c72fad63e12585f58023/pyobjc_framework_coretext-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:782a1a9617ea267c05226e9cd81a8dec529969a607fe1e037541ee1feb9524e9", size = 30095 },
{ url = "https://files.pythonhosted.org/packages/bd/ca/6321295f47a47b0fca7de7e751ddc0ddc360413f4e506335fe9b0f0fb085/pyobjc_framework_coretext-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7afe379c5a870fa3e66e6f65231c3c1732d9ccd2cd2a4904b2cd5178c9e3c562", size = 30702 },
]
[[package]]
name = "pyobjc-framework-quartz"
version = "12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyobjc-core" },
{ name = "pyobjc-framework-cocoa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/f4/50c42c84796886e4d360407fb629000bb68d843b2502c88318375441676f/pyobjc_framework_quartz-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c6f312ae79ef8b3019dcf4b3374c52035c7c7bc4a09a1748b61b041bb685a0ed", size = 217799 },
{ url = "https://files.pythonhosted.org/packages/b7/ef/dcd22b743e38b3c430fce4788176c2c5afa8bfb01085b8143b02d1e75201/pyobjc_framework_quartz-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19f99ac49a0b15dd892e155644fe80242d741411a9ed9c119b18b7466048625a", size = 217795 },
{ url = "https://files.pythonhosted.org/packages/e9/9b/780f057e5962f690f23fdff1083a4cfda5a96d5b4d3bb49505cac4f624f2/pyobjc_framework_quartz-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7730cdce46c7e985535b5a42c31381af4aa6556e5642dc55b5e6597595e57a16", size = 218798 },
{ url = "https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860", size = 219206 },
{ url = "https://files.pythonhosted.org/packages/67/43/b1f0ad3b842ab150a7e6b7d97f6257eab6af241b4c7d14cb8e7fde9214b8/pyobjc_framework_quartz-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:53b84e880c358ba1ddcd7e8d5ea0407d760eca58b96f0d344829162cda5f37b3", size = 224317 },
{ url = "https://files.pythonhosted.org/packages/4a/00/96249c5c7e5aaca5f688ca18b8d8ad05cd7886ebd639b3c71a6a4cadbe75/pyobjc_framework_quartz-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:42d306b07f05ae7d155984503e0fb1b701fecd31dcc5c79fe8ab9790ff7e0de0", size = 219558 },
{ url = "https://files.pythonhosted.org/packages/4d/a6/708a55f3ff7a18c403b30a29a11dccfed0410485a7548c60a4b6d4cc0676/pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a", size = 224580 },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 },
]
[[package]]
name = "pytest-mock"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095 },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 },
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 },
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 },
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 },
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 },
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 },
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 },
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 },
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 },
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 },
{ 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 = "tomli"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663 },
{ url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469 },
{ url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039 },
{ url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007 },
{ url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875 },
{ url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271 },
{ url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770 },
{ url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626 },
{ url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842 },
{ url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894 },
{ url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053 },
{ url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481 },
{ url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720 },
{ url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014 },
{ url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820 },
{ url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712 },
{ url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296 },
{ url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553 },
{ url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915 },
{ url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038 },
{ url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245 },
{ url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335 },
{ url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962 },
{ url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396 },
{ url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530 },
{ url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227 },
{ url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748 },
{ url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725 },
{ url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901 },
{ url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375 },
{ url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639 },
{ url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897 },
{ url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697 },
{ url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567 },
{ url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556 },
{ url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014 },
{ url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339 },
{ url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490 },
{ url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398 },
{ url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515 },
{ url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806 },
{ url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340 },
{ url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106 },
{ url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504 },
{ url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561 },
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477 },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
]