From d97475b5c99fdbc2f74a5fa621cb01a9294eb19d Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Thu, 7 May 2026 23:22:42 +0200 Subject: [PATCH 01/30] chore(config): ignore CLAUDE.md from version control --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 60a2b87..598f04b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea .cursor .claude +CLAUDE.md _bmad # Local configuration — copy config.yaml.example to config.yaml to get started From c52201ef20adac442854f00cd5cbe34c9fff9e5f Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Thu, 7 May 2026 23:30:10 +0200 Subject: [PATCH 02/30] fix(ai): rename prompt to system_prompt to prevent injection misuse Ambiguous parameter name allowed skill authors to accidentally pass user-controlled data into the system role. Explicit name makes the security boundary clear at the call site. --- nimble/tools/ai.py | 18 +++++++++--------- tests/unit/tools/test_ai.py | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/nimble/tools/ai.py b/nimble/tools/ai.py index 9bd571b..4e930ae 100644 --- a/nimble/tools/ai.py +++ b/nimble/tools/ai.py @@ -11,7 +11,7 @@ class AiTool: def __init__(self, config: AiConfig | None) -> None: self._config = config - def ask(self, text: str, prompt: str | None = None) -> str: + def ask(self, text: str, system_prompt: str | None = None) -> str: if self._config is None: raise RuntimeError("AI not configured: add an 'ai' block to config.yaml") api_key = os.environ.get(self._config.api_key_env) @@ -21,15 +21,15 @@ def ask(self, text: str, prompt: str | None = None) -> str: " is empty or unset" ) if self._config.provider == "anthropic": - return self._ask_anthropic(text, prompt, api_key) + return self._ask_anthropic(text, system_prompt, api_key) if self._config.provider == "openai": - return self._ask_openai(text, prompt, api_key) + return self._ask_openai(text, system_prompt, api_key) raise RuntimeError( f"Unsupported AI provider: {self._config.provider!r}." " Supported: anthropic, openai" ) - def _ask_anthropic(self, text: str, prompt: str | None, api_key: str) -> str: + def _ask_anthropic(self, text: str, system_prompt: str | None, api_key: str) -> str: try: import anthropic except ImportError: @@ -43,12 +43,12 @@ def _ask_anthropic(self, text: str, prompt: str | None, api_key: str) -> str: "max_tokens": 1024, "messages": [{"role": "user", "content": text}], } - if prompt is not None: - kwargs["system"] = prompt + if system_prompt is not None: + kwargs["system"] = system_prompt response = client.messages.create(**kwargs) return str(response.content[0].text) - def _ask_openai(self, text: str, prompt: str | None, api_key: str) -> str: + def _ask_openai(self, text: str, system_prompt: str | None, api_key: str) -> str: try: import openai except ImportError: @@ -58,8 +58,8 @@ def _ask_openai(self, text: str, prompt: str | None, api_key: str) -> str: ) client = openai.OpenAI(api_key=api_key) messages: list[dict[str, str]] = [] - if prompt is not None: - messages.append({"role": "system", "content": prompt}) + if system_prompt is not None: + messages.append({"role": "system", "content": system_prompt}) messages.append({"role": "user", "content": text}) response = client.chat.completions.create( model=self._config.model, # type: ignore[union-attr] diff --git a/tests/unit/tools/test_ai.py b/tests/unit/tools/test_ai.py index 6cc5272..afa6ebe 100644 --- a/tests/unit/tools/test_ai.py +++ b/tests/unit/tools/test_ai.py @@ -72,7 +72,7 @@ def test_ask_with_system_prompt_anthropic() -> None: patch.dict("os.environ", {"TEST_KEY": "sk-fake"}), patch.dict("sys.modules", {"anthropic": mock_anthropic}), ): - tool.ask("hello", prompt="You are helpful") + tool.ask("hello", system_prompt="You are helpful") call_kwargs = mock_client.messages.create.call_args.kwargs assert call_kwargs.get("system") == "You are helpful" @@ -88,7 +88,7 @@ def test_ask_with_system_prompt_openai() -> None: patch.dict("os.environ", {"TEST_KEY": "sk-fake"}), patch.dict("sys.modules", {"openai": mock_openai}), ): - tool.ask("hello", prompt="You are helpful") + tool.ask("hello", system_prompt="You are helpful") call_kwargs = mock_client.chat.completions.create.call_args.kwargs messages = call_kwargs["messages"] From 419686a4aee2de38a1c8a5e06c079520c4c2c8f1 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Thu, 7 May 2026 23:33:06 +0200 Subject: [PATCH 03/30] fix(loader): add boundary check to prevent path traversal in skill paths validate_skill_paths only checked existence, not confinement. A skill config with ../relative/path could reference files outside the repo root. Now resolves and asserts relative_to(base_root), matching parser.py. --- nimble/skills/loader.py | 9 ++++++++- tests/unit/skills/test_loader.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/nimble/skills/loader.py b/nimble/skills/loader.py index a42b027..0723e5c 100644 --- a/nimble/skills/loader.py +++ b/nimble/skills/loader.py @@ -12,8 +12,15 @@ def validate_skill_paths( configs: list[SkillConfig], base_path: Path ) -> list[SkillConfig]: + base_root = base_path.resolve() for config in configs: - skill_path = base_path / config.path + skill_path = (base_root / config.path).resolve() + try: + skill_path.relative_to(base_root) + except ValueError: + raise ConfigError( + f"Skill '{config.name}': path '{config.path}' escapes the repository root" + ) if not skill_path.exists(): raise ConfigError( f"Skill '{config.name}': path '{config.path}' does not exist" diff --git a/tests/unit/skills/test_loader.py b/tests/unit/skills/test_loader.py index 752a81a..9f46de7 100644 --- a/tests/unit/skills/test_loader.py +++ b/tests/unit/skills/test_loader.py @@ -48,6 +48,17 @@ def test_validate_multiple_skills_first_missing_raises(tmp_path: Path) -> None: assert "first-missing" in str(exc_info.value) +def test_validate_path_traversal_raises_config_error(tmp_path: Path) -> None: + base = tmp_path / "repo" + base.mkdir() + outside = tmp_path / "secret.py" + outside.write_text("sensitive = True\n") + configs = [_make_config("evil-skill", "../secret.py")] + with pytest.raises(ConfigError) as exc_info: + validate_skill_paths(configs, base) + assert "evil-skill" in str(exc_info.value) + + def test_full_chain_parser_loader_registry(tmp_path: Path) -> None: skill_file = tmp_path / "skills" / "hello.py" skill_file.parent.mkdir() From 97ea289f84eecc46b38e4388a575e8df73fcaaf9 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 00:02:49 +0200 Subject: [PATCH 04/30] docs(spec): add WaylandXWaylandAdapter design --- ...6-05-08-wayland-xwayland-adapter-design.md | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-08-wayland-xwayland-adapter-design.md diff --git a/docs/superpowers/specs/2026-05-08-wayland-xwayland-adapter-design.md b/docs/superpowers/specs/2026-05-08-wayland-xwayland-adapter-design.md new file mode 100644 index 0000000..f1efa68 --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-wayland-xwayland-adapter-design.md @@ -0,0 +1,115 @@ +# WaylandXWaylandAdapter Design + +**Goal:** Make Nimble's global hotkeys work out-of-the-box on Ubuntu Wayland (with XWayland) without any extra user setup or permissions. + +**Problem:** On a Wayland session with XWayland, pynput's X11 RECORD extension only sees keyboard events when at least one X11 window is active. With no X11 app focused, physical keypresses never flow through XWayland's X11 event queue and pynput hears nothing. + +**Solution:** At daemon startup, create an invisible `InputOnly` X11 window that keeps XWayland's X11 routing active. This is enough for pynput to capture all global keypresses regardless of which Wayland app has focus. + +--- + +## Architecture + +One new file, two small edits: + +``` +nimble/hotkeys/ + __init__.py ← update factory detection (5 lines) + base.py ← unchanged + x11.py ← unchanged + wayland.py ← NEW: WaylandXWaylandAdapter + windows.py ← unchanged + +tests/unit/hotkeys/ + test_factory.py ← add 2 new factory cases + test_wayland.py ← NEW: WaylandXWaylandAdapter unit tests +``` + +## Components + +### `nimble/hotkeys/wayland.py` — WaylandXWaylandAdapter + +Extends `X11HotkeyAdapter`. Inherits `register()` and all pynput GlobalHotKeys logic unchanged. + +Overrides: + +**`start()`** +1. Open an Xlib connection to `DISPLAY` +2. Create a 1×1 `InputOnly` window on the root, map it +3. Flush the connection +4. Call `super().start()` (pynput listener) + +**`stop()`** +1. Call `super().stop()` (pynput listener teardown) +2. Destroy the window +3. Close the Xlib connection + +The window uses `X.InputOnly` window class — no visual pixels, no decorations, invisible to the user. Its sole purpose is keeping XWayland's X11 event routing active. + +### `nimble/hotkeys/__init__.py` — factory update + +| Environment | Adapter selected | +|---|---| +| `DISPLAY` set, `WAYLAND_DISPLAY` absent | `X11HotkeyAdapter` | +| `DISPLAY` set, `WAYLAND_DISPLAY` set | `WaylandXWaylandAdapter` | +| `WAYLAND_DISPLAY` set, `DISPLAY` absent | `RuntimeError` — pure Wayland without XWayland is unsupported | +| Windows | `WindowsHotkeyAdapter` (unchanged) | + +Detection uses `os.environ.get()` on `DISPLAY` and `WAYLAND_DISPLAY`. No config option needed. + +## Data Flow + +``` +nimble start + └─ get_adapter() + └─ detects WAYLAND_DISPLAY + DISPLAY → WaylandXWaylandAdapter + +adapter.start() + ├─ Xlib: open display, create InputOnly window, map, flush + └─ pynput GlobalHotKeys.start() (inherited from X11HotkeyAdapter) + +[user presses hotkey] + └─ X11 routing active → pynput RECORD fires callback + └─ daemon dispatches skill → worker runs → popup notification + +adapter.stop() + ├─ pynput listener.stop() + join() (inherited) + └─ Xlib: window.destroy(), display.close() +``` + +## Error Handling + +| Failure point | Behaviour | +|---|---| +| Xlib cannot connect at `start()` | Raise `RuntimeError` — daemon aborts with message | +| Window creation fails at `start()` | Raise `RuntimeError` — daemon aborts before pynput starts | +| Window destroy fails at `stop()` | Log warning, do not raise — daemon is shutting down | + +This matches the existing pattern in `X11HotkeyAdapter.start()`. + +## Testing + +### `tests/unit/hotkeys/test_wayland.py` + +All Xlib calls mocked (same mock pattern as `test_x11.py`): + +- `start()` creates an `InputOnly` window and maps it before starting the pynput listener +- `stop()` destroys the window after stopping the listener +- `register()` translates shortcut strings to pynput format correctly +- Registering a duplicate shortcut raises `ValueError` +- Xlib connection failure at `start()` raises `RuntimeError` + +### `tests/unit/hotkeys/test_factory.py` additions + +- `DISPLAY` set + `WAYLAND_DISPLAY` set → factory returns `WaylandXWaylandAdapter` +- `WAYLAND_DISPLAY` set, `DISPLAY` absent → factory raises `RuntimeError` + +## Behaviour + +Uses pynput RECORD (passive observation). Both the focused app and Nimble receive the key event — no key stealing. Choosing shortcuts not used by common apps avoids visible double-handling. + +## Out of scope + +- Pure Wayland without XWayland (no `DISPLAY`) — raises a clear error +- macOS support — separate concern +- XDG Global Shortcuts portal — future enhancement if pure-Wayland support is needed From 82f2b72fcb7493e09c2661afdbf7d3b2d133789b Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 00:07:18 +0200 Subject: [PATCH 05/30] docs(plan): add WaylandXWaylandAdapter implementation plan --- .../2026-05-08-wayland-xwayland-adapter.md | 412 ++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-08-wayland-xwayland-adapter.md diff --git a/docs/superpowers/plans/2026-05-08-wayland-xwayland-adapter.md b/docs/superpowers/plans/2026-05-08-wayland-xwayland-adapter.md new file mode 100644 index 0000000..470b318 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-wayland-xwayland-adapter.md @@ -0,0 +1,412 @@ +# WaylandXWaylandAdapter Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `WaylandXWaylandAdapter` that makes Nimble's global hotkeys work out-of-the-box on Ubuntu Wayland+XWayland by spawning an invisible X11 keepalive window at daemon start. + +**Architecture:** `WaylandXWaylandAdapter` extends `X11HotkeyAdapter`, overriding `start()` to create a 1×1 `InputOnly` X11 window before starting pynput, and `stop()` to destroy it after. The factory `get_adapter()` detects `WAYLAND_DISPLAY + DISPLAY` and returns the new adapter; pure-Wayland (no `DISPLAY`) raises a clear error. + +**Tech Stack:** Python 3.10+, pynput (already a dependency), python-xlib (already installed as pynput dependency), pytest, unittest.mock. + +--- + +## File map + +| File | Change | +|---|---| +| `nimble/hotkeys/wayland.py` | CREATE — WaylandXWaylandAdapter | +| `nimble/hotkeys/__init__.py` | MODIFY — factory detection | +| `tests/unit/hotkeys/test_wayland.py` | CREATE — adapter unit tests | +| `tests/unit/hotkeys/test_factory.py` | MODIFY — 2 new cases + fix existing X11 case | + +--- + +## Task 1: WaylandXWaylandAdapter + +**Files:** +- Create: `nimble/hotkeys/wayland.py` +- Create: `tests/unit/hotkeys/test_wayland.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/unit/hotkeys/test_wayland.py`: + +```python +from __future__ import annotations + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from nimble.hotkeys.wayland import WaylandXWaylandAdapter + + +def _make_mocks() -> tuple[MagicMock, MagicMock, MagicMock, MagicMock, MagicMock]: + mock_win = MagicMock() + mock_root = MagicMock() + mock_root.create_window.return_value = mock_win + mock_screen = MagicMock() + mock_screen.root = mock_root + mock_disp = MagicMock() + mock_disp.screen.return_value = mock_screen + mock_display_mod = MagicMock() + mock_display_mod.Display.return_value = mock_disp + mock_X = MagicMock() + mock_X.InputOnly = 2 + mock_X.CopyFromParent = 0 + mock_keyboard = MagicMock() + mock_keyboard.GlobalHotKeys.return_value = MagicMock() + return mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard + + +def _start( + adapter: WaylandXWaylandAdapter, + mock_display_mod: MagicMock, + mock_X: MagicMock, + mock_keyboard: MagicMock, +) -> None: + with ( + patch("nimble.hotkeys.wayland._xlib_display", return_value=mock_display_mod), + patch("nimble.hotkeys.wayland._xlib_x", return_value=mock_X), + patch("nimble.hotkeys.x11._pynput_keyboard", return_value=mock_keyboard), + patch.dict(os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"}), + ): + adapter.start() + + +def test_start_creates_inputonly_window_before_listener() -> None: + adapter = WaylandXWaylandAdapter() + mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard = _make_mocks() + mock_listener = mock_keyboard.GlobalHotKeys.return_value + + call_order: list[str] = [] + mock_win.map.side_effect = lambda: call_order.append("map") + mock_disp.flush.side_effect = lambda: call_order.append("flush") + mock_listener.start.side_effect = lambda: call_order.append("listener_start") + + _start(adapter, mock_display_mod, mock_X, mock_keyboard) + + mock_disp.screen.return_value.root.create_window.assert_called_once_with( + 0, 0, 1, 1, 0, 0, mock_X.InputOnly, mock_X.CopyFromParent + ) + assert call_order == ["map", "flush", "listener_start"] + + +def test_stop_destroys_window_after_listener() -> None: + adapter = WaylandXWaylandAdapter() + mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard = _make_mocks() + mock_listener = mock_keyboard.GlobalHotKeys.return_value + + call_order: list[str] = [] + mock_listener.stop.side_effect = lambda: call_order.append("listener_stop") + mock_listener.join.side_effect = lambda: call_order.append("listener_join") + mock_win.destroy.side_effect = lambda: call_order.append("win_destroy") + mock_disp.close.side_effect = lambda: call_order.append("disp_close") + + _start(adapter, mock_display_mod, mock_X, mock_keyboard) + adapter.stop() + + assert call_order == ["listener_stop", "listener_join", "win_destroy", "disp_close"] + + +def test_stop_before_start_is_noop() -> None: + adapter = WaylandXWaylandAdapter() + adapter.stop() + + +def test_register_duplicate_raises_value_error() -> None: + adapter = WaylandXWaylandAdapter() + adapter.register("ctrl+shift+h", lambda: None) + with pytest.raises(ValueError, match="already registered"): + adapter.register("ctrl+shift+h", lambda: None) + + +def test_start_raises_when_display_open_fails() -> None: + adapter = WaylandXWaylandAdapter() + mock_display_mod = MagicMock() + mock_display_mod.Display.side_effect = Exception("no display") + mock_X = MagicMock() + + with ( + patch("nimble.hotkeys.wayland._xlib_display", return_value=mock_display_mod), + patch("nimble.hotkeys.wayland._xlib_x", return_value=mock_X), + patch.dict(os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"}), + ): + with pytest.raises(RuntimeError, match="cannot open X display"): + adapter.start() + + +def test_stop_window_destroy_failure_does_not_raise() -> None: + adapter = WaylandXWaylandAdapter() + mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard = _make_mocks() + mock_win.destroy.side_effect = Exception("oops") + + _start(adapter, mock_display_mod, mock_X, mock_keyboard) + adapter.stop() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +.venv/bin/pytest tests/unit/hotkeys/test_wayland.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'nimble.hotkeys.wayland'` + +- [ ] **Step 3: Implement `nimble/hotkeys/wayland.py`** + +```python +from __future__ import annotations + +import logging +import os +from typing import Any + +from nimble.hotkeys.x11 import X11HotkeyAdapter + +logger = logging.getLogger(__name__) + + +def _xlib_display() -> Any: + from Xlib import display + return display + + +def _xlib_x() -> Any: + from Xlib import X + return X + + +class WaylandXWaylandAdapter(X11HotkeyAdapter): + def __init__(self) -> None: + super().__init__() + self._keepalive_display: Any = None + self._keepalive_win: Any = None + + def start(self) -> None: + display_mod = _xlib_display() + X = _xlib_x() + display_name = os.environ.get("DISPLAY", ":0") + try: + d = display_mod.Display(display_name) + except Exception as exc: + raise RuntimeError( + f"WaylandXWaylandAdapter: cannot open X display {display_name!r}: {exc}" + ) from exc + screen = d.screen() + try: + win = screen.root.create_window( + 0, 0, 1, 1, 0, + 0, + X.InputOnly, + X.CopyFromParent, + ) + win.map() + d.flush() + except Exception as exc: + d.close() + raise RuntimeError( + f"WaylandXWaylandAdapter: cannot create keepalive window: {exc}" + ) from exc + self._keepalive_display = d + self._keepalive_win = win + super().start() + + def stop(self) -> None: + super().stop() + if self._keepalive_win is not None: + try: + self._keepalive_win.destroy() + except Exception: + logger.warning("Failed to destroy keepalive window", exc_info=True) + self._keepalive_win = None + if self._keepalive_display is not None: + try: + self._keepalive_display.close() + except Exception: + logger.warning("Failed to close Xlib display", exc_info=True) + self._keepalive_display = None +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +.venv/bin/pytest tests/unit/hotkeys/test_wayland.py -v +``` + +Expected: `7 passed` + +- [ ] **Step 5: Commit** + +```bash +git add nimble/hotkeys/wayland.py tests/unit/hotkeys/test_wayland.py +git commit -m "feat(hotkeys): add WaylandXWaylandAdapter with X11 keepalive window" +``` + +--- + +## Task 2: Factory update + +**Files:** +- Modify: `nimble/hotkeys/__init__.py` +- Modify: `tests/unit/hotkeys/test_factory.py` + +- [ ] **Step 1: Write the failing factory tests** + +Replace the entire contents of `tests/unit/hotkeys/test_factory.py` with: + +```python +from __future__ import annotations + +import os +import sys +from unittest.mock import patch + +import pytest + +from nimble.hotkeys import get_adapter +from nimble.hotkeys.wayland import WaylandXWaylandAdapter +from nimble.hotkeys.windows import WindowsHotkeyAdapter +from nimble.hotkeys.x11 import X11HotkeyAdapter + + +def test_get_adapter_returns_x11_on_pure_x11() -> None: + with patch.object(sys, "platform", "linux"): + with patch.dict(os.environ, {"DISPLAY": ":0"}, clear=False): + os.environ.pop("WAYLAND_DISPLAY", None) + adapter = get_adapter() + assert isinstance(adapter, X11HotkeyAdapter) + assert not isinstance(adapter, WaylandXWaylandAdapter) + + +def test_get_adapter_returns_wayland_adapter_on_xwayland() -> None: + with patch.object(sys, "platform", "linux"): + with patch.dict( + os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"} + ): + adapter = get_adapter() + assert isinstance(adapter, WaylandXWaylandAdapter) + + +def test_get_adapter_raises_on_pure_wayland_no_display() -> None: + with patch.object(sys, "platform", "linux"): + with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}, clear=False): + os.environ.pop("DISPLAY", None) + with pytest.raises(RuntimeError, match="XWayland"): + get_adapter() + + +def test_get_adapter_returns_windows_on_win32() -> None: + with patch.object(sys, "platform", "win32"): + adapter = get_adapter() + assert isinstance(adapter, WindowsHotkeyAdapter) + + +def test_get_adapter_raises_on_unsupported_platform() -> None: + with patch.object(sys, "platform", "darwin"): + with pytest.raises(RuntimeError, match="Unsupported platform: darwin"): + get_adapter() +``` + +- [ ] **Step 2: Run tests to verify the two new cases fail** + +```bash +.venv/bin/pytest tests/unit/hotkeys/test_factory.py -v +``` + +Expected: `test_get_adapter_returns_wayland_adapter_on_xwayland FAILED` and `test_get_adapter_raises_on_pure_wayland_no_display FAILED` + +- [ ] **Step 3: Update `nimble/hotkeys/__init__.py`** + +```python +from __future__ import annotations + +import os +import sys + +from nimble.hotkeys.base import HotkeyAdapter +from nimble.platform import is_linux, is_windows + + +def get_adapter() -> HotkeyAdapter: + if is_linux(): + wayland = os.environ.get("WAYLAND_DISPLAY") + display = os.environ.get("DISPLAY") + if wayland and not display: + raise RuntimeError( + "Nimble requires XWayland on pure Wayland sessions. " + "Install XWayland or set DISPLAY to your X11 display." + ) + if wayland and display: + from nimble.hotkeys.wayland import WaylandXWaylandAdapter + + return WaylandXWaylandAdapter() + from nimble.hotkeys.x11 import X11HotkeyAdapter + + return X11HotkeyAdapter() + elif is_windows(): + from nimble.hotkeys.windows import WindowsHotkeyAdapter + + return WindowsHotkeyAdapter() + raise RuntimeError(f"Unsupported platform: {sys.platform}") +``` + +- [ ] **Step 4: Run the full test suite** + +```bash +.venv/bin/pytest tests/ -v +``` + +Expected: all tests pass (354 existing + 7 new wayland + 5 factory = 366 total) + +- [ ] **Step 5: Commit** + +```bash +git add nimble/hotkeys/__init__.py tests/unit/hotkeys/test_factory.py +git commit -m "feat(hotkeys): factory detects Wayland+XWayland and returns WaylandXWaylandAdapter" +``` + +--- + +## Task 3: Live smoke test + +- [ ] **Step 1: Stop any running daemon** + +```bash +.venv/bin/nimble stop 2>/dev/null || true +``` + +- [ ] **Step 2: Start daemon and confirm it picks the Wayland adapter** + +```bash +DISPLAY=:0 .venv/bin/nimble start --debug +sleep 1 +.venv/bin/nimble status +``` + +Expected: +``` +Daemon: pid=XXXXX started_at=... daemon_version=1.0.0 + +Skills: + hello_world local ctrl+l loaded +``` + +- [ ] **Step 3: Trigger the hotkey and confirm dispatch in logs** + +```bash +DISPLAY=:0 .venv/bin/python trigger_skill.py ctrl+l +sleep 2 +tail -5 "$(.venv/bin/python -c "from nimble.logging_setup import LOG_PATH; print(LOG_PATH)")" +``` + +Expected log line: `DEBUG nimble.skills.runner: Skill hello_world dispatch completed in XX.Xms` + +Expected on desktop: notification popup "Hello from Nimble! The daemon is working." + +- [ ] **Step 4: Stop the daemon** + +```bash +.venv/bin/nimble stop +``` From fed7c712e805374c44fa8e1431bca0247dc40ae7 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 00:09:23 +0200 Subject: [PATCH 06/30] feat(hotkeys): add WaylandXWaylandAdapter with X11 keepalive window --- nimble/hotkeys/wayland.py | 70 ++++++++++++++++++ tests/unit/hotkeys/test_wayland.py | 112 +++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 nimble/hotkeys/wayland.py create mode 100644 tests/unit/hotkeys/test_wayland.py diff --git a/nimble/hotkeys/wayland.py b/nimble/hotkeys/wayland.py new file mode 100644 index 0000000..7aed52b --- /dev/null +++ b/nimble/hotkeys/wayland.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import logging +import os +from typing import Any + +from nimble.hotkeys.x11 import X11HotkeyAdapter + +logger = logging.getLogger(__name__) + + +def _xlib_display() -> Any: + from Xlib import display + return display + + +def _xlib_x() -> Any: + from Xlib import X + return X + + +class WaylandXWaylandAdapter(X11HotkeyAdapter): + def __init__(self) -> None: + super().__init__() + self._keepalive_display: Any = None + self._keepalive_win: Any = None + + def start(self) -> None: + display_mod = _xlib_display() + X = _xlib_x() + display_name = os.environ.get("DISPLAY", ":0") + try: + d = display_mod.Display(display_name) + except Exception as exc: + raise RuntimeError( + f"WaylandXWaylandAdapter: cannot open X display {display_name!r}: {exc}" + ) from exc + screen = d.screen() + try: + win = screen.root.create_window( + 0, 0, 1, 1, 0, + 0, + X.InputOnly, + X.CopyFromParent, + ) + win.map() + d.flush() + except Exception as exc: + d.close() + raise RuntimeError( + f"WaylandXWaylandAdapter: cannot create keepalive window: {exc}" + ) from exc + self._keepalive_display = d + self._keepalive_win = win + super().start() + + def stop(self) -> None: + super().stop() + if self._keepalive_win is not None: + try: + self._keepalive_win.destroy() + except Exception: + logger.warning("Failed to destroy keepalive window", exc_info=True) + self._keepalive_win = None + if self._keepalive_display is not None: + try: + self._keepalive_display.close() + except Exception: + logger.warning("Failed to close Xlib display", exc_info=True) + self._keepalive_display = None diff --git a/tests/unit/hotkeys/test_wayland.py b/tests/unit/hotkeys/test_wayland.py new file mode 100644 index 0000000..d797556 --- /dev/null +++ b/tests/unit/hotkeys/test_wayland.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import os +from unittest.mock import MagicMock, patch + +import pytest + +from nimble.hotkeys.wayland import WaylandXWaylandAdapter + + +def _make_mocks() -> tuple[MagicMock, MagicMock, MagicMock, MagicMock, MagicMock]: + mock_win = MagicMock() + mock_root = MagicMock() + mock_root.create_window.return_value = mock_win + mock_screen = MagicMock() + mock_screen.root = mock_root + mock_disp = MagicMock() + mock_disp.screen.return_value = mock_screen + mock_display_mod = MagicMock() + mock_display_mod.Display.return_value = mock_disp + mock_X = MagicMock() + mock_X.InputOnly = 2 + mock_X.CopyFromParent = 0 + mock_keyboard = MagicMock() + mock_keyboard.GlobalHotKeys.return_value = MagicMock() + return mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard + + +def _start( + adapter: WaylandXWaylandAdapter, + mock_display_mod: MagicMock, + mock_X: MagicMock, + mock_keyboard: MagicMock, +) -> None: + with ( + patch("nimble.hotkeys.wayland._xlib_display", return_value=mock_display_mod), + patch("nimble.hotkeys.wayland._xlib_x", return_value=mock_X), + patch("nimble.hotkeys.x11._pynput_keyboard", return_value=mock_keyboard), + patch.dict(os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"}), + ): + adapter.start() + + +def test_start_creates_inputonly_window_before_listener() -> None: + adapter = WaylandXWaylandAdapter() + mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard = _make_mocks() + mock_listener = mock_keyboard.GlobalHotKeys.return_value + + call_order: list[str] = [] + mock_win.map.side_effect = lambda: call_order.append("map") + mock_disp.flush.side_effect = lambda: call_order.append("flush") + mock_listener.start.side_effect = lambda: call_order.append("listener_start") + + _start(adapter, mock_display_mod, mock_X, mock_keyboard) + + mock_disp.screen.return_value.root.create_window.assert_called_once_with( + 0, 0, 1, 1, 0, 0, mock_X.InputOnly, mock_X.CopyFromParent + ) + assert call_order == ["map", "flush", "listener_start"] + + +def test_stop_destroys_window_after_listener() -> None: + adapter = WaylandXWaylandAdapter() + mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard = _make_mocks() + mock_listener = mock_keyboard.GlobalHotKeys.return_value + + call_order: list[str] = [] + mock_listener.stop.side_effect = lambda: call_order.append("listener_stop") + mock_listener.join.side_effect = lambda: call_order.append("listener_join") + mock_win.destroy.side_effect = lambda: call_order.append("win_destroy") + mock_disp.close.side_effect = lambda: call_order.append("disp_close") + + _start(adapter, mock_display_mod, mock_X, mock_keyboard) + adapter.stop() + + assert call_order == ["listener_stop", "listener_join", "win_destroy", "disp_close"] + + +def test_stop_before_start_is_noop() -> None: + adapter = WaylandXWaylandAdapter() + adapter.stop() + + +def test_register_duplicate_raises_value_error() -> None: + adapter = WaylandXWaylandAdapter() + adapter.register("ctrl+shift+h", lambda: None) + with pytest.raises(ValueError, match="already registered"): + adapter.register("ctrl+shift+h", lambda: None) + + +def test_start_raises_when_display_open_fails() -> None: + adapter = WaylandXWaylandAdapter() + mock_display_mod = MagicMock() + mock_display_mod.Display.side_effect = Exception("no display") + mock_X = MagicMock() + + with ( + patch("nimble.hotkeys.wayland._xlib_display", return_value=mock_display_mod), + patch("nimble.hotkeys.wayland._xlib_x", return_value=mock_X), + patch.dict(os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"}), + ): + with pytest.raises(RuntimeError, match="cannot open X display"): + adapter.start() + + +def test_stop_window_destroy_failure_does_not_raise() -> None: + adapter = WaylandXWaylandAdapter() + mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard = _make_mocks() + mock_win.destroy.side_effect = Exception("oops") + + _start(adapter, mock_display_mod, mock_X, mock_keyboard) + adapter.stop() From 98af0d92f3603c6ba95a355be73ad1ac4ba726c2 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 00:12:01 +0200 Subject: [PATCH 07/30] fix(hotkeys): clean up keepalive window if pynput listener fails to start --- nimble/hotkeys/wayland.py | 6 +++++- tests/unit/hotkeys/test_wayland.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/nimble/hotkeys/wayland.py b/nimble/hotkeys/wayland.py index 7aed52b..ea5b999 100644 --- a/nimble/hotkeys/wayland.py +++ b/nimble/hotkeys/wayland.py @@ -52,7 +52,11 @@ def start(self) -> None: ) from exc self._keepalive_display = d self._keepalive_win = win - super().start() + try: + super().start() + except Exception: + self.stop() + raise def stop(self) -> None: super().stop() diff --git a/tests/unit/hotkeys/test_wayland.py b/tests/unit/hotkeys/test_wayland.py index d797556..468dfd8 100644 --- a/tests/unit/hotkeys/test_wayland.py +++ b/tests/unit/hotkeys/test_wayland.py @@ -81,6 +81,24 @@ def test_stop_before_start_is_noop() -> None: adapter.stop() +def test_start_cleans_up_if_listener_fails() -> None: + adapter = WaylandXWaylandAdapter() + mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard = _make_mocks() + mock_keyboard.GlobalHotKeys.return_value.start.side_effect = RuntimeError("pynput failed") + + with ( + patch("nimble.hotkeys.wayland._xlib_display", return_value=mock_display_mod), + patch("nimble.hotkeys.wayland._xlib_x", return_value=mock_X), + patch("nimble.hotkeys.x11._pynput_keyboard", return_value=mock_keyboard), + patch.dict(os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"}), + ): + with pytest.raises(RuntimeError, match="pynput failed"): + adapter.start() + + mock_win.destroy.assert_called_once() + mock_disp.close.assert_called_once() + + def test_register_duplicate_raises_value_error() -> None: adapter = WaylandXWaylandAdapter() adapter.register("ctrl+shift+h", lambda: None) From 342b3f46891dddcafcd43abf71c07dc97a1a53d8 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 00:14:17 +0200 Subject: [PATCH 08/30] feat(hotkeys): factory detects Wayland+XWayland and returns WaylandXWaylandAdapter --- nimble/hotkeys/__init__.py | 14 ++++++++++++++ tests/unit/hotkeys/test_factory.py | 28 ++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/nimble/hotkeys/__init__.py b/nimble/hotkeys/__init__.py index 2c5c6b7..2b4d413 100644 --- a/nimble/hotkeys/__init__.py +++ b/nimble/hotkeys/__init__.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +import os import sys from nimble.hotkeys.base import HotkeyAdapter @@ -6,6 +9,17 @@ def get_adapter() -> HotkeyAdapter: if is_linux(): + wayland = os.environ.get("WAYLAND_DISPLAY") + display = os.environ.get("DISPLAY") + if wayland and not display: + raise RuntimeError( + "Nimble requires XWayland on pure Wayland sessions. " + "Install XWayland or set DISPLAY to your X11 display." + ) + if wayland and display: + from nimble.hotkeys.wayland import WaylandXWaylandAdapter + + return WaylandXWaylandAdapter() from nimble.hotkeys.x11 import X11HotkeyAdapter return X11HotkeyAdapter() diff --git a/tests/unit/hotkeys/test_factory.py b/tests/unit/hotkeys/test_factory.py index a97d2b1..c9dfcfd 100644 --- a/tests/unit/hotkeys/test_factory.py +++ b/tests/unit/hotkeys/test_factory.py @@ -1,17 +1,41 @@ +from __future__ import annotations + +import os import sys from unittest.mock import patch import pytest from nimble.hotkeys import get_adapter +from nimble.hotkeys.wayland import WaylandXWaylandAdapter from nimble.hotkeys.windows import WindowsHotkeyAdapter from nimble.hotkeys.x11 import X11HotkeyAdapter -def test_get_adapter_returns_x11_on_linux() -> None: +def test_get_adapter_returns_x11_on_pure_x11() -> None: with patch.object(sys, "platform", "linux"): - adapter = get_adapter() + with patch.dict(os.environ, {"DISPLAY": ":0"}, clear=False): + os.environ.pop("WAYLAND_DISPLAY", None) + adapter = get_adapter() assert isinstance(adapter, X11HotkeyAdapter) + assert not isinstance(adapter, WaylandXWaylandAdapter) + + +def test_get_adapter_returns_wayland_adapter_on_xwayland() -> None: + with patch.object(sys, "platform", "linux"): + with patch.dict( + os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"} + ): + adapter = get_adapter() + assert isinstance(adapter, WaylandXWaylandAdapter) + + +def test_get_adapter_raises_on_pure_wayland_no_display() -> None: + with patch.object(sys, "platform", "linux"): + with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}, clear=False): + os.environ.pop("DISPLAY", None) + with pytest.raises(RuntimeError, match="XWayland"): + get_adapter() def test_get_adapter_returns_windows_on_win32() -> None: From 5276847c5dc83360fec6af18df02ca185e2be520 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 00:17:04 +0200 Subject: [PATCH 09/30] test(hotkeys): fix env isolation in factory tests using patch.dict instead of pop --- tests/unit/hotkeys/test_factory.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/unit/hotkeys/test_factory.py b/tests/unit/hotkeys/test_factory.py index c9dfcfd..b1b3a4a 100644 --- a/tests/unit/hotkeys/test_factory.py +++ b/tests/unit/hotkeys/test_factory.py @@ -14,8 +14,7 @@ def test_get_adapter_returns_x11_on_pure_x11() -> None: with patch.object(sys, "platform", "linux"): - with patch.dict(os.environ, {"DISPLAY": ":0"}, clear=False): - os.environ.pop("WAYLAND_DISPLAY", None) + with patch.dict(os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": ""}, clear=False): adapter = get_adapter() assert isinstance(adapter, X11HotkeyAdapter) assert not isinstance(adapter, WaylandXWaylandAdapter) @@ -32,8 +31,7 @@ def test_get_adapter_returns_wayland_adapter_on_xwayland() -> None: def test_get_adapter_raises_on_pure_wayland_no_display() -> None: with patch.object(sys, "platform", "linux"): - with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}, clear=False): - os.environ.pop("DISPLAY", None) + with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0", "DISPLAY": ""}, clear=False): with pytest.raises(RuntimeError, match="XWayland"): get_adapter() From 53103049b0d1f3e59c21d33dc398fedc189db4d0 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 00:45:42 +0200 Subject: [PATCH 10/30] chore(deps): add evdev as explicit linux dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 783cca0..fdff9fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "pyyaml>=6.0", "plyer>=2.1", "watchdog>=3.0", + "evdev>=1.6.1; sys_platform=='linux'", ] [project.optional-dependencies] From e6521887097a6f147a2979a45c371c8ce0dae381 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 00:49:06 +0200 Subject: [PATCH 11/30] feat(hotkeys): add EvdevAdapter for universal Linux hotkey capture via /dev/input --- nimble/hotkeys/evdev_adapter.py | 203 +++++++++++++++++++++++ tests/unit/hotkeys/test_evdev_adapter.py | 176 ++++++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 nimble/hotkeys/evdev_adapter.py create mode 100644 tests/unit/hotkeys/test_evdev_adapter.py diff --git a/nimble/hotkeys/evdev_adapter.py b/nimble/hotkeys/evdev_adapter.py new file mode 100644 index 0000000..a373731 --- /dev/null +++ b/nimble/hotkeys/evdev_adapter.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import logging +import os +import select +import threading +from collections.abc import Callable +from typing import Any + +from nimble.hotkeys.base import HotkeyAdapter, _MODIFIERS + +logger = logging.getLogger(__name__) + + +def _evdev() -> Any: + import evdev + return evdev + + +def _build_modifier_map() -> dict[int, str]: + ec = _evdev().ecodes + return { + ec.KEY_LEFTCTRL: "ctrl", ec.KEY_RIGHTCTRL: "ctrl", + ec.KEY_LEFTSHIFT: "shift", ec.KEY_RIGHTSHIFT: "shift", + ec.KEY_LEFTALT: "alt", ec.KEY_RIGHTALT: "alt", + ec.KEY_LEFTMETA: "super", ec.KEY_RIGHTMETA: "super", + } + + +def _shortcut_token_to_ecode(token: str) -> int: + ec = _evdev().ecodes + if len(token) == 1 and token.isalpha(): + name = f"KEY_{token.upper()}" + if name in ec.ecodes: + return ec.ecodes[name] + if len(token) == 1 and token.isdigit(): + name = f"KEY_{token}" + if name in ec.ecodes: + return ec.ecodes[name] + if token.startswith("f") and token[1:].isdigit(): + name = f"KEY_{token.upper()}" + if name in ec.ecodes: + return ec.ecodes[name] + _special = { + "space": "KEY_SPACE", "enter": "KEY_ENTER", "return": "KEY_ENTER", + "backspace": "KEY_BACKSPACE", "tab": "KEY_TAB", "esc": "KEY_ESC", + "escape": "KEY_ESC", "delete": "KEY_DELETE", "del": "KEY_DELETE", + "insert": "KEY_INSERT", "home": "KEY_HOME", "end": "KEY_END", + "pageup": "KEY_PAGEUP", "pagedown": "KEY_PAGEDOWN", + "up": "KEY_UP", "down": "KEY_DOWN", "left": "KEY_LEFT", "right": "KEY_RIGHT", + "minus": "KEY_MINUS", "equal": "KEY_EQUAL", "semicolon": "KEY_SEMICOLON", + "comma": "KEY_COMMA", "dot": "KEY_DOT", "period": "KEY_DOT", "slash": "KEY_SLASH", + } + if token in _special: + name = _special[token] + if name in ec.ecodes: + return ec.ecodes[name] + raise ValueError(f"Cannot map shortcut token {token!r} to an evdev key code.") + + +def _parse_shortcut(shortcut: str) -> tuple[frozenset[str], int]: + parts = shortcut.lower().split("+") + mods = frozenset(p for p in parts if p in _MODIFIERS) + non_mods = [p for p in parts if p not in _MODIFIERS] + if len(non_mods) != 1: + raise ValueError( + f"Shortcut {shortcut!r} must have exactly one non-modifier key." + ) + return mods, _shortcut_token_to_ecode(non_mods[0]) + + +class EvdevAdapter(HotkeyAdapter): + def __init__(self) -> None: + self._hotkeys: dict[str, tuple[frozenset[str], int, Callable[[], None]]] = {} + self._thread: threading.Thread | None = None + self._pipe_r: int | None = None + self._pipe_w: int | None = None + self._current_modifiers: set[str] = set() + self._lock = threading.Lock() + + def register(self, shortcut: str, callback: Callable[[], None]) -> None: + if shortcut in self._hotkeys: + raise ValueError(f"Hotkey already registered: {shortcut!r}") + mods, trigger_ecode = _parse_shortcut(shortcut) + self._hotkeys[shortcut] = (mods, trigger_ecode, callback) + + def start(self) -> None: + if self._thread is not None: + raise RuntimeError("EvdevAdapter is already started; call stop() first.") + evdev = _evdev() + devices = self._open_keyboard_devices(evdev) + if not devices: + raise RuntimeError("No keyboard devices found in /dev/input/.") + self._pipe_r, self._pipe_w = os.pipe() + self._current_modifiers = set() + modifier_map = _build_modifier_map() + self._thread = threading.Thread( + target=self._run_loop, + args=(devices, modifier_map), + daemon=True, + name="nimble-evdev-loop", + ) + self._thread.start() + + def stop(self) -> None: + if self._thread is None: + return + if self._pipe_w is not None: + try: + os.write(self._pipe_w, b"\x00") + except OSError: + pass + self._thread.join() + self._thread = None + for fd in (self._pipe_r, self._pipe_w): + if fd is not None: + try: + os.close(fd) + except OSError: + pass + self._pipe_r = None + self._pipe_w = None + + def _open_keyboard_devices(self, evdev: Any) -> list[Any]: + paths = evdev.list_devices() + if not paths: + return [] + devices: list[Any] = [] + for path in paths: + try: + dev = evdev.InputDevice(path) + except PermissionError: + raise RuntimeError( + f"Permission denied opening {path}. " + "Add your user to the 'input' group and log out/in:\n" + " sudo usermod -aG input $USER\n" + "Then log out and log back in (or reboot)." + ) from None + except OSError as exc: + logger.debug("Skipping %s: %s", path, exc) + continue + caps = dev.capabilities() + if evdev.ecodes.EV_KEY in caps: + devices.append(dev) + else: + dev.close() + return devices + + def _run_loop(self, devices: list[Any], modifier_map: dict[int, str]) -> None: + evdev = _evdev() + EV_KEY = evdev.ecodes.EV_KEY + KEY_DOWN = evdev.events.KeyEvent.key_down + KEY_UP = evdev.events.KeyEvent.key_up + pipe_r = self._pipe_r + fds = {dev.fd: dev for dev in devices} + all_fds = list(fds.keys()) + [pipe_r] + try: + while True: + try: + readable, _, _ = select.select(all_fds, [], []) + except (ValueError, OSError): + break + for fd in readable: + if fd == pipe_r: + return + dev = fds[fd] + try: + for event in dev.read(): + if event.type != EV_KEY: + continue + code = event.code + value = event.value + if code in modifier_map: + mod = modifier_map[code] + with self._lock: + if value == KEY_DOWN: + self._current_modifiers.add(mod) + elif value == KEY_UP: + self._current_modifiers.discard(mod) + elif value == KEY_DOWN: + with self._lock: + active_mods = frozenset(self._current_modifiers) + for _shortcut, (req_mods, trigger_ecode, cb) in self._hotkeys.items(): + if code == trigger_ecode and active_mods == req_mods: + threading.Thread( + target=cb, + daemon=True, + name=f"nimble-hotkey-{_shortcut}", + ).start() + except BlockingIOError: + pass + except OSError as exc: + logger.warning("Error reading from %s: %s", dev.path, exc) + all_fds.remove(fd) + del fds[fd] + if not fds: + return + finally: + for dev in devices: + try: + dev.close() + except OSError: + pass diff --git a/tests/unit/hotkeys/test_evdev_adapter.py b/tests/unit/hotkeys/test_evdev_adapter.py new file mode 100644 index 0000000..293ad1a --- /dev/null +++ b/tests/unit/hotkeys/test_evdev_adapter.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import os +import threading +from unittest.mock import MagicMock, patch + +import pytest +from evdev import ecodes + +from nimble.hotkeys.evdev_adapter import EvdevAdapter, _parse_shortcut + + +def _make_mock_evdev(keyboard_paths: tuple[str, ...] = ("/dev/input/event0",)) -> MagicMock: + mock_evdev = MagicMock() + mock_evdev.ecodes.EV_KEY = ecodes.EV_KEY + mock_evdev.list_devices.return_value = list(keyboard_paths) + + def make_device(path: str) -> MagicMock: + dev = MagicMock() + dev.path = path + dev.fd = abs(hash(path)) % 900 + 100 + dev.capabilities.return_value = {ecodes.EV_KEY: []} + return dev + + mock_evdev.InputDevice.side_effect = make_device + return mock_evdev + + +def _build_real_modifier_map() -> dict[int, str]: + return { + ecodes.KEY_LEFTCTRL: "ctrl", ecodes.KEY_RIGHTCTRL: "ctrl", + ecodes.KEY_LEFTSHIFT: "shift", ecodes.KEY_RIGHTSHIFT: "shift", + ecodes.KEY_LEFTALT: "alt", ecodes.KEY_RIGHTALT: "alt", + ecodes.KEY_LEFTMETA: "super", ecodes.KEY_RIGHTMETA: "super", + } + + +def test_parse_shortcut_single_modifier() -> None: + mods, code = _parse_shortcut("ctrl+l") + assert mods == frozenset({"ctrl"}) + assert code == ecodes.KEY_L + + +def test_parse_shortcut_multiple_modifiers() -> None: + mods, code = _parse_shortcut("ctrl+shift+h") + assert mods == frozenset({"ctrl", "shift"}) + assert code == ecodes.KEY_H + + +def test_parse_shortcut_raises_no_trigger_key() -> None: + with pytest.raises(ValueError, match="exactly one non-modifier"): + _parse_shortcut("ctrl+shift") + + +def test_parse_shortcut_raises_unknown_key() -> None: + with pytest.raises(ValueError, match="Cannot map"): + _parse_shortcut("ctrl+notakey") + + +def test_register_raises_on_duplicate() -> None: + adapter = EvdevAdapter() + adapter.register("ctrl+l", lambda: None) + with pytest.raises(ValueError, match="already registered"): + adapter.register("ctrl+l", lambda: None) + + +def test_stop_before_start_is_noop() -> None: + adapter = EvdevAdapter() + adapter.stop() + + +def test_open_keyboard_devices_returns_keyboards() -> None: + mock_evdev = _make_mock_evdev() + adapter = EvdevAdapter() + devices = adapter._open_keyboard_devices(mock_evdev) + assert len(devices) == 1 + + +def test_open_keyboard_devices_skips_non_keyboard_devices() -> None: + mock_evdev = _make_mock_evdev() + non_kb = MagicMock() + non_kb.capabilities.return_value = {} + mock_evdev.InputDevice.side_effect = lambda path: non_kb + adapter = EvdevAdapter() + devices = adapter._open_keyboard_devices(mock_evdev) + assert devices == [] + non_kb.close.assert_called_once() + + +def test_open_keyboard_devices_raises_on_permission_error() -> None: + mock_evdev = _make_mock_evdev() + mock_evdev.InputDevice.side_effect = PermissionError("denied") + adapter = EvdevAdapter() + with pytest.raises(RuntimeError, match="input"): + adapter._open_keyboard_devices(mock_evdev) + + +def test_start_raises_when_no_keyboard_devices() -> None: + mock_evdev = _make_mock_evdev(keyboard_paths=()) + adapter = EvdevAdapter() + with patch("nimble.hotkeys.evdev_adapter._evdev", return_value=mock_evdev): + with pytest.raises(RuntimeError, match="No keyboard devices"): + adapter.start() + + +def test_run_loop_fires_callback_on_matching_hotkey() -> None: + adapter = EvdevAdapter() + fired = threading.Event() + adapter.register("ctrl+l", fired.set) + trigger_code = adapter._hotkeys["ctrl+l"][1] + modifier_map = _build_real_modifier_map() + + pipe_r, pipe_w = os.pipe() + adapter._pipe_r = pipe_r + + ev_ctrl = MagicMock(type=ecodes.EV_KEY, code=ecodes.KEY_LEFTCTRL, value=1) + ev_l = MagicMock(type=ecodes.EV_KEY, code=trigger_code, value=1) + + mock_dev = MagicMock() + mock_dev.fd = 5 + mock_dev.path = "/dev/input/event0" + + read_count = [0] + + def fake_read() -> list: + read_count[0] += 1 + if read_count[0] == 1: + return [ev_ctrl, ev_l] + raise BlockingIOError() + + mock_dev.read.side_effect = fake_read + + select_results = iter([([5], [], []), ([pipe_r], [], [])]) + with patch("select.select", side_effect=lambda *a, **kw: next(select_results)): + adapter._run_loop([mock_dev], modifier_map) + + os.close(pipe_r) + os.close(pipe_w) + fired.wait(timeout=2) + assert fired.is_set() + + +def test_run_loop_does_not_fire_with_wrong_modifiers() -> None: + adapter = EvdevAdapter() + fired = threading.Event() + adapter.register("ctrl+shift+l", fired.set) + trigger_code = adapter._hotkeys["ctrl+shift+l"][1] + modifier_map = _build_real_modifier_map() + + pipe_r, pipe_w = os.pipe() + adapter._pipe_r = pipe_r + + ev_ctrl = MagicMock(type=ecodes.EV_KEY, code=ecodes.KEY_LEFTCTRL, value=1) + ev_l = MagicMock(type=ecodes.EV_KEY, code=trigger_code, value=1) + + mock_dev = MagicMock() + mock_dev.fd = 5 + mock_dev.path = "/dev/input/event0" + + read_count = [0] + + def fake_read() -> list: + read_count[0] += 1 + if read_count[0] == 1: + return [ev_ctrl, ev_l] + raise BlockingIOError() + + mock_dev.read.side_effect = fake_read + + select_results = iter([([5], [], []), ([pipe_r], [], [])]) + with patch("select.select", side_effect=lambda *a, **kw: next(select_results)): + adapter._run_loop([mock_dev], modifier_map) + + os.close(pipe_r) + os.close(pipe_w) + assert not fired.is_set() From 94460e527b2b3f26ecf6d9f061938790374e5302 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 00:53:57 +0200 Subject: [PATCH 12/30] fix(hotkeys): normalize cmd/win aliases, guard register() post-start, fix pipe leak in tests --- nimble/hotkeys/evdev_adapter.py | 4 ++++ tests/unit/hotkeys/test_evdev_adapter.py | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/nimble/hotkeys/evdev_adapter.py b/nimble/hotkeys/evdev_adapter.py index a373731..b31cecf 100644 --- a/nimble/hotkeys/evdev_adapter.py +++ b/nimble/hotkeys/evdev_adapter.py @@ -59,7 +59,9 @@ def _shortcut_token_to_ecode(token: str) -> int: def _parse_shortcut(shortcut: str) -> tuple[frozenset[str], int]: + _aliases = {"cmd": "super", "win": "super"} parts = shortcut.lower().split("+") + parts = [_aliases.get(p, p) for p in parts] mods = frozenset(p for p in parts if p in _MODIFIERS) non_mods = [p for p in parts if p not in _MODIFIERS] if len(non_mods) != 1: @@ -79,6 +81,8 @@ def __init__(self) -> None: self._lock = threading.Lock() def register(self, shortcut: str, callback: Callable[[], None]) -> None: + if self._thread is not None: + raise RuntimeError("Cannot register hotkeys after start(); call stop() first.") if shortcut in self._hotkeys: raise ValueError(f"Hotkey already registered: {shortcut!r}") mods, trigger_ecode = _parse_shortcut(shortcut) diff --git a/tests/unit/hotkeys/test_evdev_adapter.py b/tests/unit/hotkeys/test_evdev_adapter.py index 293ad1a..ed92c30 100644 --- a/tests/unit/hotkeys/test_evdev_adapter.py +++ b/tests/unit/hotkeys/test_evdev_adapter.py @@ -132,10 +132,11 @@ def fake_read() -> list: select_results = iter([([5], [], []), ([pipe_r], [], [])]) with patch("select.select", side_effect=lambda *a, **kw: next(select_results)): - adapter._run_loop([mock_dev], modifier_map) - - os.close(pipe_r) - os.close(pipe_w) + try: + adapter._run_loop([mock_dev], modifier_map) + finally: + os.close(pipe_r) + os.close(pipe_w) fired.wait(timeout=2) assert fired.is_set() @@ -169,8 +170,9 @@ def fake_read() -> list: select_results = iter([([5], [], []), ([pipe_r], [], [])]) with patch("select.select", side_effect=lambda *a, **kw: next(select_results)): - adapter._run_loop([mock_dev], modifier_map) - - os.close(pipe_r) - os.close(pipe_w) + try: + adapter._run_loop([mock_dev], modifier_map) + finally: + os.close(pipe_r) + os.close(pipe_w) assert not fired.is_set() From 9fcea1fa5982da274e2d05b10d73ce4c920821ee Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 00:55:13 +0200 Subject: [PATCH 13/30] feat(hotkeys): factory returns EvdevAdapter for all Wayland sessions --- nimble/hotkeys/__init__.py | 11 +++-------- tests/unit/hotkeys/test_factory.py | 14 +++++++------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/nimble/hotkeys/__init__.py b/nimble/hotkeys/__init__.py index 2b4d413..097e3a5 100644 --- a/nimble/hotkeys/__init__.py +++ b/nimble/hotkeys/__init__.py @@ -11,15 +11,10 @@ def get_adapter() -> HotkeyAdapter: if is_linux(): wayland = os.environ.get("WAYLAND_DISPLAY") display = os.environ.get("DISPLAY") - if wayland and not display: - raise RuntimeError( - "Nimble requires XWayland on pure Wayland sessions. " - "Install XWayland or set DISPLAY to your X11 display." - ) - if wayland and display: - from nimble.hotkeys.wayland import WaylandXWaylandAdapter + if wayland or not display: + from nimble.hotkeys.evdev_adapter import EvdevAdapter - return WaylandXWaylandAdapter() + return EvdevAdapter() from nimble.hotkeys.x11 import X11HotkeyAdapter return X11HotkeyAdapter() diff --git a/tests/unit/hotkeys/test_factory.py b/tests/unit/hotkeys/test_factory.py index b1b3a4a..cad2c00 100644 --- a/tests/unit/hotkeys/test_factory.py +++ b/tests/unit/hotkeys/test_factory.py @@ -7,7 +7,7 @@ import pytest from nimble.hotkeys import get_adapter -from nimble.hotkeys.wayland import WaylandXWaylandAdapter +from nimble.hotkeys.evdev_adapter import EvdevAdapter from nimble.hotkeys.windows import WindowsHotkeyAdapter from nimble.hotkeys.x11 import X11HotkeyAdapter @@ -17,23 +17,23 @@ def test_get_adapter_returns_x11_on_pure_x11() -> None: with patch.dict(os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": ""}, clear=False): adapter = get_adapter() assert isinstance(adapter, X11HotkeyAdapter) - assert not isinstance(adapter, WaylandXWaylandAdapter) + assert not isinstance(adapter, EvdevAdapter) -def test_get_adapter_returns_wayland_adapter_on_xwayland() -> None: +def test_get_adapter_returns_evdev_on_xwayland() -> None: with patch.object(sys, "platform", "linux"): with patch.dict( os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"} ): adapter = get_adapter() - assert isinstance(adapter, WaylandXWaylandAdapter) + assert isinstance(adapter, EvdevAdapter) -def test_get_adapter_raises_on_pure_wayland_no_display() -> None: +def test_get_adapter_returns_evdev_on_pure_wayland() -> None: with patch.object(sys, "platform", "linux"): with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0", "DISPLAY": ""}, clear=False): - with pytest.raises(RuntimeError, match="XWayland"): - get_adapter() + adapter = get_adapter() + assert isinstance(adapter, EvdevAdapter) def test_get_adapter_returns_windows_on_win32() -> None: From be9a4e1c0486fa5d250174574367fc8027fac816 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 00:57:07 +0200 Subject: [PATCH 14/30] test(hotkeys): add headless scenario coverage to factory tests --- tests/unit/hotkeys/test_factory.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/unit/hotkeys/test_factory.py b/tests/unit/hotkeys/test_factory.py index cad2c00..f9cf87a 100644 --- a/tests/unit/hotkeys/test_factory.py +++ b/tests/unit/hotkeys/test_factory.py @@ -46,3 +46,10 @@ def test_get_adapter_raises_on_unsupported_platform() -> None: with patch.object(sys, "platform", "darwin"): with pytest.raises(RuntimeError, match="Unsupported platform: darwin"): get_adapter() + + +def test_get_adapter_returns_evdev_on_headless() -> None: + with patch.object(sys, "platform", "linux"): + with patch.dict(os.environ, {"DISPLAY": "", "WAYLAND_DISPLAY": ""}, clear=False): + adapter = get_adapter() + assert isinstance(adapter, EvdevAdapter) From acdfa3a98299321b8750460a1fbc344aaaccf5c6 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 00:57:49 +0200 Subject: [PATCH 15/30] refactor(hotkeys): remove WaylandXWaylandAdapter superseded by EvdevAdapter --- nimble/hotkeys/wayland.py | 74 ---------------- tests/unit/hotkeys/test_wayland.py | 130 ----------------------------- 2 files changed, 204 deletions(-) delete mode 100644 nimble/hotkeys/wayland.py delete mode 100644 tests/unit/hotkeys/test_wayland.py diff --git a/nimble/hotkeys/wayland.py b/nimble/hotkeys/wayland.py deleted file mode 100644 index ea5b999..0000000 --- a/nimble/hotkeys/wayland.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -import logging -import os -from typing import Any - -from nimble.hotkeys.x11 import X11HotkeyAdapter - -logger = logging.getLogger(__name__) - - -def _xlib_display() -> Any: - from Xlib import display - return display - - -def _xlib_x() -> Any: - from Xlib import X - return X - - -class WaylandXWaylandAdapter(X11HotkeyAdapter): - def __init__(self) -> None: - super().__init__() - self._keepalive_display: Any = None - self._keepalive_win: Any = None - - def start(self) -> None: - display_mod = _xlib_display() - X = _xlib_x() - display_name = os.environ.get("DISPLAY", ":0") - try: - d = display_mod.Display(display_name) - except Exception as exc: - raise RuntimeError( - f"WaylandXWaylandAdapter: cannot open X display {display_name!r}: {exc}" - ) from exc - screen = d.screen() - try: - win = screen.root.create_window( - 0, 0, 1, 1, 0, - 0, - X.InputOnly, - X.CopyFromParent, - ) - win.map() - d.flush() - except Exception as exc: - d.close() - raise RuntimeError( - f"WaylandXWaylandAdapter: cannot create keepalive window: {exc}" - ) from exc - self._keepalive_display = d - self._keepalive_win = win - try: - super().start() - except Exception: - self.stop() - raise - - def stop(self) -> None: - super().stop() - if self._keepalive_win is not None: - try: - self._keepalive_win.destroy() - except Exception: - logger.warning("Failed to destroy keepalive window", exc_info=True) - self._keepalive_win = None - if self._keepalive_display is not None: - try: - self._keepalive_display.close() - except Exception: - logger.warning("Failed to close Xlib display", exc_info=True) - self._keepalive_display = None diff --git a/tests/unit/hotkeys/test_wayland.py b/tests/unit/hotkeys/test_wayland.py deleted file mode 100644 index 468dfd8..0000000 --- a/tests/unit/hotkeys/test_wayland.py +++ /dev/null @@ -1,130 +0,0 @@ -from __future__ import annotations - -import os -from unittest.mock import MagicMock, patch - -import pytest - -from nimble.hotkeys.wayland import WaylandXWaylandAdapter - - -def _make_mocks() -> tuple[MagicMock, MagicMock, MagicMock, MagicMock, MagicMock]: - mock_win = MagicMock() - mock_root = MagicMock() - mock_root.create_window.return_value = mock_win - mock_screen = MagicMock() - mock_screen.root = mock_root - mock_disp = MagicMock() - mock_disp.screen.return_value = mock_screen - mock_display_mod = MagicMock() - mock_display_mod.Display.return_value = mock_disp - mock_X = MagicMock() - mock_X.InputOnly = 2 - mock_X.CopyFromParent = 0 - mock_keyboard = MagicMock() - mock_keyboard.GlobalHotKeys.return_value = MagicMock() - return mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard - - -def _start( - adapter: WaylandXWaylandAdapter, - mock_display_mod: MagicMock, - mock_X: MagicMock, - mock_keyboard: MagicMock, -) -> None: - with ( - patch("nimble.hotkeys.wayland._xlib_display", return_value=mock_display_mod), - patch("nimble.hotkeys.wayland._xlib_x", return_value=mock_X), - patch("nimble.hotkeys.x11._pynput_keyboard", return_value=mock_keyboard), - patch.dict(os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"}), - ): - adapter.start() - - -def test_start_creates_inputonly_window_before_listener() -> None: - adapter = WaylandXWaylandAdapter() - mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard = _make_mocks() - mock_listener = mock_keyboard.GlobalHotKeys.return_value - - call_order: list[str] = [] - mock_win.map.side_effect = lambda: call_order.append("map") - mock_disp.flush.side_effect = lambda: call_order.append("flush") - mock_listener.start.side_effect = lambda: call_order.append("listener_start") - - _start(adapter, mock_display_mod, mock_X, mock_keyboard) - - mock_disp.screen.return_value.root.create_window.assert_called_once_with( - 0, 0, 1, 1, 0, 0, mock_X.InputOnly, mock_X.CopyFromParent - ) - assert call_order == ["map", "flush", "listener_start"] - - -def test_stop_destroys_window_after_listener() -> None: - adapter = WaylandXWaylandAdapter() - mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard = _make_mocks() - mock_listener = mock_keyboard.GlobalHotKeys.return_value - - call_order: list[str] = [] - mock_listener.stop.side_effect = lambda: call_order.append("listener_stop") - mock_listener.join.side_effect = lambda: call_order.append("listener_join") - mock_win.destroy.side_effect = lambda: call_order.append("win_destroy") - mock_disp.close.side_effect = lambda: call_order.append("disp_close") - - _start(adapter, mock_display_mod, mock_X, mock_keyboard) - adapter.stop() - - assert call_order == ["listener_stop", "listener_join", "win_destroy", "disp_close"] - - -def test_stop_before_start_is_noop() -> None: - adapter = WaylandXWaylandAdapter() - adapter.stop() - - -def test_start_cleans_up_if_listener_fails() -> None: - adapter = WaylandXWaylandAdapter() - mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard = _make_mocks() - mock_keyboard.GlobalHotKeys.return_value.start.side_effect = RuntimeError("pynput failed") - - with ( - patch("nimble.hotkeys.wayland._xlib_display", return_value=mock_display_mod), - patch("nimble.hotkeys.wayland._xlib_x", return_value=mock_X), - patch("nimble.hotkeys.x11._pynput_keyboard", return_value=mock_keyboard), - patch.dict(os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"}), - ): - with pytest.raises(RuntimeError, match="pynput failed"): - adapter.start() - - mock_win.destroy.assert_called_once() - mock_disp.close.assert_called_once() - - -def test_register_duplicate_raises_value_error() -> None: - adapter = WaylandXWaylandAdapter() - adapter.register("ctrl+shift+h", lambda: None) - with pytest.raises(ValueError, match="already registered"): - adapter.register("ctrl+shift+h", lambda: None) - - -def test_start_raises_when_display_open_fails() -> None: - adapter = WaylandXWaylandAdapter() - mock_display_mod = MagicMock() - mock_display_mod.Display.side_effect = Exception("no display") - mock_X = MagicMock() - - with ( - patch("nimble.hotkeys.wayland._xlib_display", return_value=mock_display_mod), - patch("nimble.hotkeys.wayland._xlib_x", return_value=mock_X), - patch.dict(os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"}), - ): - with pytest.raises(RuntimeError, match="cannot open X display"): - adapter.start() - - -def test_stop_window_destroy_failure_does_not_raise() -> None: - adapter = WaylandXWaylandAdapter() - mock_win, mock_disp, mock_display_mod, mock_X, mock_keyboard = _make_mocks() - mock_win.destroy.side_effect = Exception("oops") - - _start(adapter, mock_display_mod, mock_X, mock_keyboard) - adapter.stop() From cf2033c3faef7cca12ef3eef7278928ea740849a Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 01:04:36 +0200 Subject: [PATCH 16/30] fix(hotkeys): make register() thread-safe for post-start calls Allow register() to be called after start() by acquiring _lock instead of raising RuntimeError, and snapshot _hotkeys under _lock in _run_loop to prevent dictionary-changed-size errors during concurrent iteration. --- nimble/hotkeys/evdev_adapter.py | 14 +++++++------- tests/unit/hotkeys/test_evdev_adapter.py | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/nimble/hotkeys/evdev_adapter.py b/nimble/hotkeys/evdev_adapter.py index b31cecf..87b82bc 100644 --- a/nimble/hotkeys/evdev_adapter.py +++ b/nimble/hotkeys/evdev_adapter.py @@ -81,12 +81,11 @@ def __init__(self) -> None: self._lock = threading.Lock() def register(self, shortcut: str, callback: Callable[[], None]) -> None: - if self._thread is not None: - raise RuntimeError("Cannot register hotkeys after start(); call stop() first.") - if shortcut in self._hotkeys: - raise ValueError(f"Hotkey already registered: {shortcut!r}") mods, trigger_ecode = _parse_shortcut(shortcut) - self._hotkeys[shortcut] = (mods, trigger_ecode, callback) + with self._lock: + if shortcut in self._hotkeys: + raise ValueError(f"Hotkey already registered: {shortcut!r}") + self._hotkeys[shortcut] = (mods, trigger_ecode, callback) def start(self) -> None: if self._thread is not None: @@ -184,12 +183,13 @@ def _run_loop(self, devices: list[Any], modifier_map: dict[int, str]) -> None: elif value == KEY_DOWN: with self._lock: active_mods = frozenset(self._current_modifiers) - for _shortcut, (req_mods, trigger_ecode, cb) in self._hotkeys.items(): + hotkeys_snapshot = list(self._hotkeys.items()) + for shortcut, (req_mods, trigger_ecode, cb) in hotkeys_snapshot: if code == trigger_ecode and active_mods == req_mods: threading.Thread( target=cb, daemon=True, - name=f"nimble-hotkey-{_shortcut}", + name=f"nimble-hotkey-{shortcut}", ).start() except BlockingIOError: pass diff --git a/tests/unit/hotkeys/test_evdev_adapter.py b/tests/unit/hotkeys/test_evdev_adapter.py index ed92c30..e211edd 100644 --- a/tests/unit/hotkeys/test_evdev_adapter.py +++ b/tests/unit/hotkeys/test_evdev_adapter.py @@ -176,3 +176,27 @@ def fake_read() -> list: os.close(pipe_r) os.close(pipe_w) assert not fired.is_set() + + +def test_register_after_start_succeeds_and_fires() -> None: + mock_evdev = _make_mock_evdev() + mock_evdev.ecodes.ecodes = ecodes.ecodes + mock_evdev.events.KeyEvent.key_down = 1 + mock_evdev.events.KeyEvent.key_up = 0 + stop_event = threading.Event() + + def fake_select(rlist, wlist, xlist, *args, **kwargs): + stop_event.wait(timeout=5) + return ([rlist[-1]], [], []) + + adapter = EvdevAdapter() + fired = threading.Event() + + with patch("nimble.hotkeys.evdev_adapter._evdev", return_value=mock_evdev): + with patch("select.select", side_effect=fake_select): + adapter.start() + adapter.register("ctrl+l", fired.set) + stop_event.set() + adapter.stop() + + assert "ctrl+l" in adapter._hotkeys From d56c1409a6478b0eda5d588198321f97d63bf103 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 12:29:10 +0200 Subject: [PATCH 17/30] feat(skills): add greet (ctrl+shift+g) and quick_clip (alt+q) demo skills --- skills/greet/manifest.yaml | 9 +++++++++ skills/greet/skill.py | 7 +++++++ skills/quick_clip/manifest.yaml | 9 +++++++++ skills/quick_clip/skill.py | 7 +++++++ 4 files changed, 32 insertions(+) create mode 100644 skills/greet/manifest.yaml create mode 100644 skills/greet/skill.py create mode 100644 skills/quick_clip/manifest.yaml create mode 100644 skills/quick_clip/skill.py diff --git a/skills/greet/manifest.yaml b/skills/greet/manifest.yaml new file mode 100644 index 0000000..32372ef --- /dev/null +++ b/skills/greet/manifest.yaml @@ -0,0 +1,9 @@ +name: greet +version: "1.0.0" +api_version: 1 +description: "Greets you with the name of the active application" +entrypoint: skill.py +class_name: GreetSkill +permissions: [] +dependencies: [] +author: "Nimble Demo" diff --git a/skills/greet/skill.py b/skills/greet/skill.py new file mode 100644 index 0000000..ef9658a --- /dev/null +++ b/skills/greet/skill.py @@ -0,0 +1,7 @@ +from __future__ import annotations + + +class GreetSkill: + def run(self, context, tools) -> None: + app = context.active_app or "your app" + tools.popup.show(f"Hello from {app}!") diff --git a/skills/quick_clip/manifest.yaml b/skills/quick_clip/manifest.yaml new file mode 100644 index 0000000..1bf0426 --- /dev/null +++ b/skills/quick_clip/manifest.yaml @@ -0,0 +1,9 @@ +name: quick_clip +version: "1.0.0" +api_version: 1 +description: "Shows current clipboard content in a popup" +entrypoint: skill.py +class_name: QuickClipSkill +permissions: [] +dependencies: [] +author: "Nimble Demo" diff --git a/skills/quick_clip/skill.py b/skills/quick_clip/skill.py new file mode 100644 index 0000000..5f28c80 --- /dev/null +++ b/skills/quick_clip/skill.py @@ -0,0 +1,7 @@ +from __future__ import annotations + + +class QuickClipSkill: + def run(self, context, tools) -> None: + text = (context.clipboard or "").strip() + tools.popup.show(text if text else "(clipboard is empty)") From dc97ebbb19876cc19e13dbf77507712586ba62b1 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 12:47:56 +0200 Subject: [PATCH 18/30] chore(deps): update uv.lock for evdev --- uv.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/uv.lock b/uv.lock index 1878833..9d28ef3 100644 --- a/uv.lock +++ b/uv.lock @@ -304,6 +304,7 @@ name = "nimble" version = "1.0.0" source = { editable = "." } dependencies = [ + { name = "evdev", marker = "sys_platform == 'linux'" }, { name = "plyer" }, { name = "pynput" }, { name = "pyyaml" }, @@ -323,6 +324,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = "==26.3.1" }, + { name = "evdev", marker = "sys_platform == 'linux'", specifier = ">=1.6.1" }, { name = "flake8", marker = "extra == 'dev'", specifier = "==7.3.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.20.1" }, { name = "plyer", specifier = ">=2.1" }, From ede7059549bedc8b5472fbc9721abcf372ada5b5 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 12:47:59 +0200 Subject: [PATCH 19/30] chore: add trigger_skill.py dev helper --- trigger_skill.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100755 trigger_skill.py diff --git a/trigger_skill.py b/trigger_skill.py new file mode 100755 index 0000000..151fbbd --- /dev/null +++ b/trigger_skill.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Simulate a hotkey press to trigger a Nimble skill for testing.""" +import sys +import time +import os + +os.environ.setdefault("DISPLAY", ":0") + +from Xlib import display, X +from Xlib.ext import xtest + +KEYCODES = { + "ctrl": 37, + "shift": 50, + "alt": 64, + "l": 46, + "space": 65, +} + +shortcut = sys.argv[1] if len(sys.argv) > 1 else "ctrl+l" +parts = shortcut.lower().split("+") +codes = [KEYCODES[p] for p in parts if p in KEYCODES] + +if not codes: + print(f"Unknown keys in shortcut: {shortcut}") + sys.exit(1) + +d = display.Display(":0") +for kc in codes: + xtest.fake_input(d, X.KeyPress, kc) + d.flush() + time.sleep(0.05) +for kc in reversed(codes): + xtest.fake_input(d, X.KeyRelease, kc) + d.flush() + time.sleep(0.05) +d.close() +print(f"Triggered: {shortcut}") From cfa8b88ed703be8499a3357199ee96eee5875aec Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 12:48:01 +0200 Subject: [PATCH 20/30] docs: add Wayland setup guide --- docs/wayland-hotkeys.md | 49 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/wayland-hotkeys.md diff --git a/docs/wayland-hotkeys.md b/docs/wayland-hotkeys.md new file mode 100644 index 0000000..f3e5486 --- /dev/null +++ b/docs/wayland-hotkeys.md @@ -0,0 +1,49 @@ +# Running Nimble on Wayland + +Nimble uses the Linux kernel's input layer (`/dev/input/event*`) to capture global hotkeys. +This works on any session type — Wayland-native apps, XWayland, or pure X11 — without any +extra configuration beyond a one-time group membership step. + +## One-time setup + +Keyboard input devices are owned by the `input` group. Add your user once: + +```bash +sudo usermod -aG input $USER +``` + +Then **log out and log back in** (or reboot). The change is permanent — you only do this once. + +Verify it worked: + +```bash +groups | grep input +``` + +If `input` appears in the output, Nimble can read keyboard events. + +## Why this is needed + +On Wayland, there is no display-server-level API that lets unprivileged processes observe +global keypresses (the way X11's RECORD extension worked). The only reliable cross-session +approach is to read directly from the kernel input devices at `/dev/input/event*`. + +These device files are owned by root with group `input`. Granting your user membership in +`input` gives Nimble read access without requiring `sudo` or `setuid`. + +## What happens without it + +If the daemon starts without `input` group access, it exits immediately with: + +``` +RuntimeError: Permission denied opening /dev/input/eventN. +Add your user to the 'input' group and log out/in: + sudo usermod -aG input $USER +``` + +## Security note + +Membership in `input` gives read access to all input devices (keyboard, mouse, touchpad). +Any process running as your user can also read these devices. This is the standard trade-off +for global hotkey daemons on Wayland — the same access level used by tools like `evtest`, +`keyd`, and `interception-tools`. From b466f00819a7eef950cae5cd01e759ba4dc4457f Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Fri, 8 May 2026 12:48:04 +0200 Subject: [PATCH 21/30] chore: gitignore docs/superpowers and x11_keepalive.py --- .gitignore | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 598f04b..c9deefd 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,10 @@ build/ *.egg .nimble -config.yaml \ No newline at end of file +config.yaml + +# Agentic planning artifacts +docs/superpowers/ + +# Superseded X11 keepalive prototype +x11_keepalive.py \ No newline at end of file From 9639c47da42b2fa90a4a9de1c2d8529cbed0da32 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Mon, 11 May 2026 12:00:42 +0200 Subject: [PATCH 22/30] ref: split code lines to col length sub 88 --- nimble/hotkeys/evdev_adapter.py | 3 ++- tests/unit/hotkeys/test_factory.py | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/nimble/hotkeys/evdev_adapter.py b/nimble/hotkeys/evdev_adapter.py index 87b82bc..2e2ca13 100644 --- a/nimble/hotkeys/evdev_adapter.py +++ b/nimble/hotkeys/evdev_adapter.py @@ -49,7 +49,8 @@ def _shortcut_token_to_ecode(token: str) -> int: "pageup": "KEY_PAGEUP", "pagedown": "KEY_PAGEDOWN", "up": "KEY_UP", "down": "KEY_DOWN", "left": "KEY_LEFT", "right": "KEY_RIGHT", "minus": "KEY_MINUS", "equal": "KEY_EQUAL", "semicolon": "KEY_SEMICOLON", - "comma": "KEY_COMMA", "dot": "KEY_DOT", "period": "KEY_DOT", "slash": "KEY_SLASH", + "comma": "KEY_COMMA", "dot": "KEY_DOT", "period": "KEY_DOT", + "slash": "KEY_SLASH", } if token in _special: name = _special[token] diff --git a/tests/unit/hotkeys/test_factory.py b/tests/unit/hotkeys/test_factory.py index f9cf87a..6a45ef3 100644 --- a/tests/unit/hotkeys/test_factory.py +++ b/tests/unit/hotkeys/test_factory.py @@ -14,7 +14,9 @@ def test_get_adapter_returns_x11_on_pure_x11() -> None: with patch.object(sys, "platform", "linux"): - with patch.dict(os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": ""}, clear=False): + with patch.dict(os.environ, + {"DISPLAY": ":0", "WAYLAND_DISPLAY": ""}, + clear=False): adapter = get_adapter() assert isinstance(adapter, X11HotkeyAdapter) assert not isinstance(adapter, EvdevAdapter) @@ -31,7 +33,9 @@ def test_get_adapter_returns_evdev_on_xwayland() -> None: def test_get_adapter_returns_evdev_on_pure_wayland() -> None: with patch.object(sys, "platform", "linux"): - with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0", "DISPLAY": ""}, clear=False): + with patch.dict(os.environ, + {"WAYLAND_DISPLAY": "wayland-0", "DISPLAY": ""}, + clear=False): adapter = get_adapter() assert isinstance(adapter, EvdevAdapter) @@ -50,6 +54,8 @@ def test_get_adapter_raises_on_unsupported_platform() -> None: def test_get_adapter_returns_evdev_on_headless() -> None: with patch.object(sys, "platform", "linux"): - with patch.dict(os.environ, {"DISPLAY": "", "WAYLAND_DISPLAY": ""}, clear=False): + with patch.dict(os.environ, + {"DISPLAY": "", "WAYLAND_DISPLAY": ""}, + clear=False): adapter = get_adapter() assert isinstance(adapter, EvdevAdapter) From c4440c8ad862e6761be25f6f1c97a999e0f1a70f Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Mon, 11 May 2026 12:08:17 +0200 Subject: [PATCH 23/30] fix(lint): resolve E501 line-length violations and update greet author --- nimble/hotkeys/evdev_adapter.py | 10 +++++----- nimble/skills/loader.py | 3 ++- tests/unit/hotkeys/test_evdev_adapter.py | 4 +++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/nimble/hotkeys/evdev_adapter.py b/nimble/hotkeys/evdev_adapter.py index 2e2ca13..3b7d771 100644 --- a/nimble/hotkeys/evdev_adapter.py +++ b/nimble/hotkeys/evdev_adapter.py @@ -50,7 +50,7 @@ def _shortcut_token_to_ecode(token: str) -> int: "up": "KEY_UP", "down": "KEY_DOWN", "left": "KEY_LEFT", "right": "KEY_RIGHT", "minus": "KEY_MINUS", "equal": "KEY_EQUAL", "semicolon": "KEY_SEMICOLON", "comma": "KEY_COMMA", "dot": "KEY_DOT", "period": "KEY_DOT", - "slash": "KEY_SLASH", + "slash": "KEY_SLASH", } if token in _special: name = _special[token] @@ -184,13 +184,13 @@ def _run_loop(self, devices: list[Any], modifier_map: dict[int, str]) -> None: elif value == KEY_DOWN: with self._lock: active_mods = frozenset(self._current_modifiers) - hotkeys_snapshot = list(self._hotkeys.items()) - for shortcut, (req_mods, trigger_ecode, cb) in hotkeys_snapshot: - if code == trigger_ecode and active_mods == req_mods: + _items = list(self._hotkeys.items()) + for _key, (req_mods, ecode, cb) in _items: + if code == ecode and active_mods == req_mods: threading.Thread( target=cb, daemon=True, - name=f"nimble-hotkey-{shortcut}", + name=f"nimble-hotkey-{_key}", ).start() except BlockingIOError: pass diff --git a/nimble/skills/loader.py b/nimble/skills/loader.py index 0723e5c..c2d9f55 100644 --- a/nimble/skills/loader.py +++ b/nimble/skills/loader.py @@ -19,7 +19,8 @@ def validate_skill_paths( skill_path.relative_to(base_root) except ValueError: raise ConfigError( - f"Skill '{config.name}': path '{config.path}' escapes the repository root" + f"Skill '{config.name}': path '{config.path}'" + " escapes the repository root" ) if not skill_path.exists(): raise ConfigError( diff --git a/tests/unit/hotkeys/test_evdev_adapter.py b/tests/unit/hotkeys/test_evdev_adapter.py index e211edd..61c3c75 100644 --- a/tests/unit/hotkeys/test_evdev_adapter.py +++ b/tests/unit/hotkeys/test_evdev_adapter.py @@ -10,7 +10,9 @@ from nimble.hotkeys.evdev_adapter import EvdevAdapter, _parse_shortcut -def _make_mock_evdev(keyboard_paths: tuple[str, ...] = ("/dev/input/event0",)) -> MagicMock: +def _make_mock_evdev( + keyboard_paths: tuple[str, ...] = ("/dev/input/event0",), +) -> MagicMock: mock_evdev = MagicMock() mock_evdev.ecodes.EV_KEY = ecodes.EV_KEY mock_evdev.list_devices.return_value = list(keyboard_paths) From 3c347710880b16a97df043acfebe0862cb553ab9 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Mon, 11 May 2026 12:09:44 +0200 Subject: [PATCH 24/30] doc: added author to Greet Skill --- skills/greet/manifest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/greet/manifest.yaml b/skills/greet/manifest.yaml index 32372ef..4204556 100644 --- a/skills/greet/manifest.yaml +++ b/skills/greet/manifest.yaml @@ -6,4 +6,4 @@ entrypoint: skill.py class_name: GreetSkill permissions: [] dependencies: [] -author: "Nimble Demo" +author: "Kotmin" From 58ca4cd72b849b08883c92b08a4e73792a0bb34b Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Mon, 11 May 2026 12:13:42 +0200 Subject: [PATCH 25/30] fix(types): cast evdev ecodes lookups to int to satisfy mypy no-any-return --- nimble/hotkeys/evdev_adapter.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nimble/hotkeys/evdev_adapter.py b/nimble/hotkeys/evdev_adapter.py index 3b7d771..5767759 100644 --- a/nimble/hotkeys/evdev_adapter.py +++ b/nimble/hotkeys/evdev_adapter.py @@ -32,15 +32,15 @@ def _shortcut_token_to_ecode(token: str) -> int: if len(token) == 1 and token.isalpha(): name = f"KEY_{token.upper()}" if name in ec.ecodes: - return ec.ecodes[name] + return int(ec.ecodes[name]) if len(token) == 1 and token.isdigit(): name = f"KEY_{token}" if name in ec.ecodes: - return ec.ecodes[name] + return int(ec.ecodes[name]) if token.startswith("f") and token[1:].isdigit(): name = f"KEY_{token.upper()}" if name in ec.ecodes: - return ec.ecodes[name] + return int(ec.ecodes[name]) _special = { "space": "KEY_SPACE", "enter": "KEY_ENTER", "return": "KEY_ENTER", "backspace": "KEY_BACKSPACE", "tab": "KEY_TAB", "esc": "KEY_ESC", @@ -55,7 +55,7 @@ def _shortcut_token_to_ecode(token: str) -> int: if token in _special: name = _special[token] if name in ec.ecodes: - return ec.ecodes[name] + return int(ec.ecodes[name]) raise ValueError(f"Cannot map shortcut token {token!r} to an evdev key code.") From 39384a1687f6ecab25fc03d304fb9c603cf373f1 Mon Sep 17 00:00:00 2001 From: Bernard van der Esch Date: Tue, 12 May 2026 15:25:08 +0200 Subject: [PATCH 26/30] fix(hotkeys): prefer X11HotkeyAdapter when DISPLAY is set On Wayland+XWayland sessions (both WAYLAND_DISPLAY and DISPLAY set), route to X11HotkeyAdapter instead of EvdevAdapter so the daemon starts without requiring input group membership. EvdevAdapter is now only selected for pure Wayland (no DISPLAY) and headless sessions. Co-Authored-By: Claude Sonnet 4.6 --- .../spec-fix-adapter-xwayland-routing.md | 18 ++++++++++++++++++ nimble/hotkeys/__init__.py | 11 +++++------ tests/unit/hotkeys/test_factory.py | 4 ++-- 3 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 docs/bmad_output/implementation-artifacts/spec-fix-adapter-xwayland-routing.md diff --git a/docs/bmad_output/implementation-artifacts/spec-fix-adapter-xwayland-routing.md b/docs/bmad_output/implementation-artifacts/spec-fix-adapter-xwayland-routing.md new file mode 100644 index 0000000..ee2d156 --- /dev/null +++ b/docs/bmad_output/implementation-artifacts/spec-fix-adapter-xwayland-routing.md @@ -0,0 +1,18 @@ +--- +title: 'Fix hotkey adapter routing: prefer X11 when DISPLAY is set' +type: 'bugfix' +created: '2026-05-12' +status: 'done' +route: 'one-shot' +--- + +## Intent + +**Problem:** On Wayland sessions with XWayland running (both `WAYLAND_DISPLAY` and `DISPLAY` set), `get_adapter()` routed to `EvdevAdapter`, which requires the user to be in the `input` group. Users not in that group got a silent empty device list and a startup failure. + +**Approach:** Prioritise `DISPLAY` in the routing decision. When `DISPLAY` is set (X11 or XWayland), use `X11HotkeyAdapter` (pynput). Fall back to `EvdevAdapter` only for pure Wayland (no `DISPLAY`) or headless sessions. + +## Suggested Review Order + +- [`nimble/hotkeys/__init__.py`](../../nimble/hotkeys/__init__.py) — routing condition, the core change +- [`tests/unit/hotkeys/test_factory.py:25`](../../tests/unit/hotkeys/test_factory.py) — renamed + updated XWayland test assertion diff --git a/nimble/hotkeys/__init__.py b/nimble/hotkeys/__init__.py index 097e3a5..f9f177e 100644 --- a/nimble/hotkeys/__init__.py +++ b/nimble/hotkeys/__init__.py @@ -9,15 +9,14 @@ def get_adapter() -> HotkeyAdapter: if is_linux(): - wayland = os.environ.get("WAYLAND_DISPLAY") display = os.environ.get("DISPLAY") - if wayland or not display: - from nimble.hotkeys.evdev_adapter import EvdevAdapter + if display: + from nimble.hotkeys.x11 import X11HotkeyAdapter - return EvdevAdapter() - from nimble.hotkeys.x11 import X11HotkeyAdapter + return X11HotkeyAdapter() + from nimble.hotkeys.evdev_adapter import EvdevAdapter - return X11HotkeyAdapter() + return EvdevAdapter() elif is_windows(): from nimble.hotkeys.windows import WindowsHotkeyAdapter diff --git a/tests/unit/hotkeys/test_factory.py b/tests/unit/hotkeys/test_factory.py index 6a45ef3..408fba5 100644 --- a/tests/unit/hotkeys/test_factory.py +++ b/tests/unit/hotkeys/test_factory.py @@ -22,13 +22,13 @@ def test_get_adapter_returns_x11_on_pure_x11() -> None: assert not isinstance(adapter, EvdevAdapter) -def test_get_adapter_returns_evdev_on_xwayland() -> None: +def test_get_adapter_returns_x11_on_xwayland() -> None: with patch.object(sys, "platform", "linux"): with patch.dict( os.environ, {"DISPLAY": ":0", "WAYLAND_DISPLAY": "wayland-0"} ): adapter = get_adapter() - assert isinstance(adapter, EvdevAdapter) + assert isinstance(adapter, X11HotkeyAdapter) def test_get_adapter_returns_evdev_on_pure_wayland() -> None: From 2a7b6fcc8599ca2cc4ae399e12862eed92bcea02 Mon Sep 17 00:00:00 2001 From: Bernard van der Esch Date: Tue, 12 May 2026 16:09:47 +0200 Subject: [PATCH 27/30] feat: load .env from repo root and add anthropic/openai dependencies - _load_dotenv() reads KEY=VALUE pairs from .env at daemon startup, making them available to skill workers via inherited os.environ - Existing env vars are never overridden (.env provides defaults only) - anthropic and openai added as core dependencies so skills can use tools.ai without declaring them in manifest.yaml Co-Authored-By: Claude Sonnet 4.6 --- nimble/daemon.py | 20 ++ pyproject.toml | 2 + tests/unit/test_daemon.py | 89 +++++++++ uv.lock | 405 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 516 insertions(+) diff --git a/nimble/daemon.py b/nimble/daemon.py index 064e9d5..f9c398f 100644 --- a/nimble/daemon.py +++ b/nimble/daemon.py @@ -28,6 +28,25 @@ logger = logging.getLogger(__name__) +def _load_dotenv(dotenv_path: Path) -> None: + if not dotenv_path.exists(): + return + with dotenv_path.open() as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"): + value = value[1:-1] + if key and key not in os.environ: + os.environ[key] = value + + def _build_skill_states(registry: SkillRegistry) -> list[SkillState]: return [ SkillState( @@ -51,6 +70,7 @@ def _state_signature( def run(repo_root: Path, debug: bool = False) -> None: + _load_dotenv(repo_root / ".env") configure_logging(LOG_PATH, debug) notifier = Notifier() diff --git a/pyproject.toml b/pyproject.toml index 783cca0..778755b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ dependencies = [ "pyyaml>=6.0", "plyer>=2.1", "watchdog>=3.0", + "anthropic>=0.40.0", + "openai>=1.0.0", ] [project.optional-dependencies] diff --git a/tests/unit/test_daemon.py b/tests/unit/test_daemon.py index be030d3..f552ff2 100644 --- a/tests/unit/test_daemon.py +++ b/tests/unit/test_daemon.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from collections.abc import Callable from pathlib import Path from unittest.mock import MagicMock, patch @@ -14,6 +15,94 @@ from tests.conftest import FakeNotifier +# --- _load_dotenv tests --- + + +def test_load_dotenv_sets_variables(tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text("FOO=bar\nBAZ=qux\n") + env_backup = dict(os.environ) + try: + os.environ.pop("FOO", None) + os.environ.pop("BAZ", None) + daemon_module._load_dotenv(env_file) + assert os.environ.get("FOO") == "bar" + assert os.environ.get("BAZ") == "qux" + finally: + for key in ("FOO", "BAZ"): + os.environ.pop(key, None) + os.environ.update(env_backup) + + +def test_load_dotenv_skips_missing_file(tmp_path: Path) -> None: + daemon_module._load_dotenv(tmp_path / ".env") # must not raise + + +def test_load_dotenv_does_not_override_existing(tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text("MY_KEY=from_dotenv\n") + os.environ["MY_KEY"] = "from_env" + try: + daemon_module._load_dotenv(env_file) + assert os.environ["MY_KEY"] == "from_env" + finally: + del os.environ["MY_KEY"] + + +def test_load_dotenv_skips_comments_and_blank_lines(tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text("# comment\n\nVALID_KEY=hello\n") + os.environ.pop("VALID_KEY", None) + try: + daemon_module._load_dotenv(env_file) + assert os.environ.get("VALID_KEY") == "hello" + finally: + os.environ.pop("VALID_KEY", None) + + +def test_load_dotenv_strips_quotes(tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text('QUOTED="hello world"\nSINGLE=\'hi there\'\n') + for key in ("QUOTED", "SINGLE"): + os.environ.pop(key, None) + try: + daemon_module._load_dotenv(env_file) + assert os.environ.get("QUOTED") == "hello world" + assert os.environ.get("SINGLE") == "hi there" + finally: + for key in ("QUOTED", "SINGLE"): + os.environ.pop(key, None) + + +def test_run_loads_dotenv_from_repo_root(tmp_path: Path) -> None: + env_file = tmp_path / ".env" + env_file.write_text("NIMBLE_TEST_DOTENV_KEY=loaded\n") + os.environ.pop("NIMBLE_TEST_DOTENV_KEY", None) + mock_stop_event = MagicMock() + mock_stop_event.wait.return_value = None + try: + with ( + patch("nimble.daemon.configure_logging"), + patch("nimble.daemon.get_adapter"), + patch("nimble.daemon.load_config") as mock_load_config, + patch("nimble.daemon.validate_skill_paths", return_value=[]), + patch("nimble.daemon.SkillRunner"), + patch("nimble.daemon.ConfigWatcher"), + patch("nimble.daemon.write_pid"), + patch("nimble.daemon.remove_pid"), + patch("nimble.daemon.write_state"), + patch("nimble.daemon.remove_state"), + patch("nimble.daemon.Notifier"), + patch("threading.Event", return_value=mock_stop_event), + patch("nimble.daemon.threading.Thread"), + ): + mock_load_config.return_value.skills = [] + daemon_module.run(tmp_path) + assert os.environ.get("NIMBLE_TEST_DOTENV_KEY") == "loaded" + finally: + os.environ.pop("NIMBLE_TEST_DOTENV_KEY", None) + + def test_dispatch_fires_notification_on_skill_error() -> None: notifier = FakeNotifier() error = SkillError( diff --git a/uv.lock b/uv.lock index 1878833..321e76a 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anthropic" +version = "0.101.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/cb/9d0123243e749ac3a579972b2c398971bce1dc57bcc4efb08066df610360/anthropic-0.101.0.tar.gz", hash = "sha256:1116a6a87c55757e0fbe3e1ba40804fbd04de7963601a6dd6b539a889f18de3e", size = 758603, upload-time = "2026-05-11T15:46:33.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/b2/74ff06762d005ecf1658929a292df0acb786d025f6a6c54fcb30e2dc7761/anthropic-0.101.0-py3-none-any.whl", hash = "sha256:cc3cc6576989471e2aa9132258034ad0ff0d8fe500b04ac499e4e46ed68c5ed0", size = 753594, upload-time = "2026-05-11T15:46:32.216Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + [[package]] name = "black" version = "26.3.1" @@ -55,6 +97,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + [[package]] name = "click" version = "8.3.2" @@ -76,6 +127,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "evdev" version = "1.9.3" @@ -108,6 +177,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/b1/efac073e0c297ecf2fb33c346989a529d4e19164f1759102dee5953ee17e/idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3", size = 198272, upload-time = "2026-05-10T20:32:15.935Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -117,6 +232,109 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jiter" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/2e/a9959997739c403378d0a4a3a1c4ed80b60aeace216c4d37b303a9fc60a4/jiter-0.14.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:02f36a5c700f105ac04a6556fe664a59037a2c200db3b7e88784fac2ddf02531", size = 316927, upload-time = "2026-04-10T14:25:40.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/72/b6de8a531e0adbadd839bec301165feb1fccf00e9ff55073ba2dd20f0043/jiter-0.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41eab6c09ceffb6f0fe25e214b3068146edb1eda3649ca2aee2a061029c7ba2e", size = 321181, upload-time = "2026-04-10T14:25:42.621Z" }, + { url = "https://files.pythonhosted.org/packages/db/d8/2040b9efa13c917f855c40890ae4119fe02c25b7c7677d5b4fa820a851fc/jiter-0.14.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cf4d4c109641f9cfaf4a7b6aebd51654e405cd00fa9ebbf87163b8b97b325aa", size = 347387, upload-time = "2026-04-10T14:25:44.212Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/655c0ad5ce6a8e90f9068c175b8a236877d753e460762b3183c136db1c5b/jiter-0.14.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b80c7b41a628e6be2213ad0ece763c5f88aa5ee003fa394d58acaaee1f4b8342", size = 373083, upload-time = "2026-04-10T14:25:45.55Z" }, + { url = "https://files.pythonhosted.org/packages/f1/66/549c40fa068f08710b7570869c306a051eb67a29758bd64f4114f730554c/jiter-0.14.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb3dbf7cc0d4dbe73cce307ebe7eefa7f73a7d3d854dd119ea0c243f03e40927", size = 463639, upload-time = "2026-04-10T14:25:47.452Z" }, + { url = "https://files.pythonhosted.org/packages/25/2f/97a32a05fed14ed58a18e181fdfb619e05163f3726b54ee6080ec0539c09/jiter-0.14.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7054adcdeb06b46efd17b5734f75817a44a2d06d3748e36c3a023a1bb52af9ec", size = 380735, upload-time = "2026-04-10T14:25:49.305Z" }, + { url = "https://files.pythonhosted.org/packages/2a/3b/4347e1d6c2a973d653bbb7a2d671a2d2426e54b52ba735b8ff0d0a29b75c/jiter-0.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d597cd1bf6790376f3fffc7c708766e57301d99a19314824ea0ccc9c3c70e1e2", size = 358632, upload-time = "2026-04-10T14:25:50.931Z" }, + { url = "https://files.pythonhosted.org/packages/ef/24/ca452fbf2ea33548ed30ce68a39a50442d3f7c9bf0704a7af958a930c057/jiter-0.14.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:df63a14878da754427926281626fd3ee249424a186e25a274e78176d42945264", size = 359969, upload-time = "2026-04-10T14:25:52.381Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a3/94470a0d199287caabeb4da2bb2ae5f6d17f3cf05dfc975d7cb064d58e0f/jiter-0.14.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ea73187627bcc5810e085df715e8a99da8bdfd96a7eb36b4b4df700ba6d4c9c", size = 397529, upload-time = "2026-04-10T14:25:53.801Z" }, + { url = "https://files.pythonhosted.org/packages/cf/71/6768edc09d7c45c39f093feb3de105fa718a3e982b5208b8a2ed6382b44b/jiter-0.14.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9f541eaf7bb8382367a1a23d6fc3d6aad57f8dd8c18c3c17f838bee20f217220", size = 522342, upload-time = "2026-04-10T14:25:55.396Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6b/5c2e17559a0f4e96e934479f7137df46c939e983fa05244e674815befb73/jiter-0.14.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:107465250de4fce00fdb47166bcd51df8e634e049541174fe3c71848e44f52ce", size = 556784, upload-time = "2026-04-10T14:25:56.927Z" }, + { url = "https://files.pythonhosted.org/packages/b1/83/c25f3556a60fc74d11199100f1b6cc0c006b815c8494dea8ca16fe398732/jiter-0.14.0-cp310-cp310-win32.whl", hash = "sha256:ffb2a08a406465bb076b7cc1df41d833106d3cf7905076cc73f0cb90078c7d10", size = 208439, upload-time = "2026-04-10T14:25:58.796Z" }, + { url = "https://files.pythonhosted.org/packages/2e/99/781a1b413f0989b7f2ea203b094b331685f1a35e52e0a45e5d000ecaab27/jiter-0.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb8b682d10cb0cce7ff4c1af7244af7022c9b01ae16d46c357bdd0df13afb25d", size = 204558, upload-time = "2026-04-10T14:26:00.208Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896, upload-time = "2026-04-10T14:26:01.986Z" }, + { url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085, upload-time = "2026-04-10T14:26:03.364Z" }, + { url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393, upload-time = "2026-04-10T14:26:05.314Z" }, + { url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937, upload-time = "2026-04-10T14:26:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646, upload-time = "2026-04-10T14:26:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225, upload-time = "2026-04-10T14:26:10.161Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682, upload-time = "2026-04-10T14:26:11.574Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973, upload-time = "2026-04-10T14:26:13.316Z" }, + { url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568, upload-time = "2026-04-10T14:26:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535, upload-time = "2026-04-10T14:26:16.956Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709, upload-time = "2026-04-10T14:26:18.5Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660, upload-time = "2026-04-10T14:26:20.511Z" }, + { url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659, upload-time = "2026-04-10T14:26:22.152Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772, upload-time = "2026-04-10T14:26:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" }, + { url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" }, + { url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" }, + { url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" }, + { url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" }, + { url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" }, + { url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" }, + { url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" }, + { url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" }, + { url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" }, + { url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" }, + { url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" }, + { url = "https://files.pythonhosted.org/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" }, + { url = "https://files.pythonhosted.org/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" }, + { url = "https://files.pythonhosted.org/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" }, + { url = "https://files.pythonhosted.org/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" }, + { url = "https://files.pythonhosted.org/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" }, + { url = "https://files.pythonhosted.org/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" }, + { url = "https://files.pythonhosted.org/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" }, + { url = "https://files.pythonhosted.org/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" }, + { url = "https://files.pythonhosted.org/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" }, + { url = "https://files.pythonhosted.org/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" }, + { url = "https://files.pythonhosted.org/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" }, + { url = "https://files.pythonhosted.org/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, + { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, + { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, + { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, + { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, + { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, + { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, + { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, + { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, + { url = "https://files.pythonhosted.org/packages/32/a1/ef34ca2cab2962598591636a1804b93645821201cc0095d4a93a9a329c9d/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211", size = 311366, upload-time = "2026-04-10T14:28:27.943Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/520576a532a6b8a6f42747afed289c8448c879a34d7802fe2c832d4fd38f/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b", size = 309873, upload-time = "2026-04-10T14:28:29.688Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7c/c16db114ea1f2f532f198aa8dc39585026af45af362c69a0492f31bc4821/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea", size = 344816, upload-time = "2026-04-10T14:28:31.348Z" }, + { url = "https://files.pythonhosted.org/packages/99/8f/15e7741ff19e9bcd4d753f7ff22f988fd54592f134ca13701c13ea8c20e0/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577", size = 351445, upload-time = "2026-04-10T14:28:33.093Z" }, + { url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" }, + { url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" }, + { url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" }, +] + [[package]] name = "librt" version = "0.9.0" @@ -304,6 +522,8 @@ name = "nimble" version = "1.0.0" source = { editable = "." } dependencies = [ + { name = "anthropic" }, + { name = "openai" }, { name = "plyer" }, { name = "pynput" }, { name = "pyyaml" }, @@ -322,9 +542,11 @@ dev = [ [package.metadata] requires-dist = [ + { name = "anthropic", specifier = ">=0.40.0" }, { name = "black", marker = "extra == 'dev'", specifier = "==26.3.1" }, { name = "flake8", marker = "extra == 'dev'", specifier = "==7.3.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = "==1.20.1" }, + { name = "openai", specifier = ">=1.0.0" }, { name = "plyer", specifier = ">=2.1" }, { name = "pynput", specifier = ">=1.8.1" }, { name = "pytest", marker = "extra == 'dev'" }, @@ -335,6 +557,25 @@ requires-dist = [ ] provides-extras = ["dev"] +[[package]] +name = "openai" +version = "2.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/a1/4d5e84cf51720fc1526cc49e10ac1961abcccb55b0efb3d970db1e9a2728/openai-2.36.0.tar.gz", hash = "sha256:139dea0edd2f1b30c33d46ae1a6929e03906254140318e4608e98fe8c566f2e7", size = 753003, upload-time = "2026-05-07T17:33:17.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/1c/5d43735b2553baae2a5e899dcbcd0670a86930d993184d72ca909bf11c9b/openai-2.36.0-py3-none-any.whl", hash = "sha256:143f6194b548dbc2c921af1f1b03b9f14c85fed8a75b5b516f5bcc11a2a50c63", size = 1302361, upload-time = "2026-05-07T17:33:15.063Z" }, +] + [[package]] name = "packaging" version = "26.1" @@ -389,6 +630,137 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + [[package]] name = "pyflakes" version = "3.4.0" @@ -680,6 +1052,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -734,6 +1115,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + [[package]] name = "typer" version = "0.24.1" @@ -767,6 +1160,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "watchdog" version = "6.0.0" From d451d00315a1dc67b4897c4450bf71824c2895f4 Mon Sep 17 00:00:00 2001 From: Bernard van der Esch Date: Tue, 12 May 2026 16:19:57 +0200 Subject: [PATCH 28/30] mypy related changes --- nimble/tools/ai.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/nimble/tools/ai.py b/nimble/tools/ai.py index 9bd571b..6b51fc1 100644 --- a/nimble/tools/ai.py +++ b/nimble/tools/ai.py @@ -32,32 +32,39 @@ def ask(self, text: str, prompt: str | None = None) -> str: def _ask_anthropic(self, text: str, prompt: str | None, api_key: str) -> str: try: import anthropic + from anthropic.types import MessageParam, TextBlock except ImportError: raise RuntimeError( "anthropic package not installed; add 'anthropic' to your" " skill's manifest.yaml dependencies" ) client = anthropic.Anthropic(api_key=api_key) - kwargs: dict[str, object] = { - "model": self._config.model, # type: ignore[union-attr] - "max_tokens": 1024, - "messages": [{"role": "user", "content": text}], - } + messages: list[MessageParam] = [{"role": "user", "content": text}] + model: str = self._config.model # type: ignore[union-attr] if prompt is not None: - kwargs["system"] = prompt - response = client.messages.create(**kwargs) - return str(response.content[0].text) + response = client.messages.create( + model=model, max_tokens=1024, messages=messages, system=prompt + ) + else: + response = client.messages.create( + model=model, max_tokens=1024, messages=messages + ) + for block in response.content: + if isinstance(block, TextBlock): + return block.text + raise RuntimeError("Anthropic response contained no text block") def _ask_openai(self, text: str, prompt: str | None, api_key: str) -> str: try: import openai + from openai.types.chat import ChatCompletionMessageParam except ImportError: raise RuntimeError( "openai package not installed; add 'openai' to your" " skill's manifest.yaml dependencies" ) client = openai.OpenAI(api_key=api_key) - messages: list[dict[str, str]] = [] + messages: list[ChatCompletionMessageParam] = [] if prompt is not None: messages.append({"role": "system", "content": prompt}) messages.append({"role": "user", "content": text}) From 9c407ba9efe67fbd85e9cbc8348172dfce382349 Mon Sep 17 00:00:00 2001 From: Kotmin <70173732+Kotmin@users.noreply.github.com> Date: Thu, 7 May 2026 23:22:42 +0200 Subject: [PATCH 29/30] chore(config): ignore CLAUDE.md from version control --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f3ac0db..6e98f39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea .cursor .claude +CLAUDE.md _bmad # Local configuration — copy config.yaml.example to config.yaml to get started From c793677b3c74aa8bdd432f783a9226b8a21a76a0 Mon Sep 17 00:00:00 2001 From: Bernard van der Esch Date: Sun, 17 May 2026 11:07:27 +0200 Subject: [PATCH 30/30] flake8 and mypy --- nimble/tools/ai.py | 27 +++++++++++++++-------- tests/unit/tools/test_ai.py | 44 +++++++++++++++++++++++++++---------- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/nimble/tools/ai.py b/nimble/tools/ai.py index 4e930ae..d13da84 100644 --- a/nimble/tools/ai.py +++ b/nimble/tools/ai.py @@ -37,16 +37,23 @@ def _ask_anthropic(self, text: str, system_prompt: str | None, api_key: str) -> "anthropic package not installed; add 'anthropic' to your" " skill's manifest.yaml dependencies" ) + from anthropic.types import MessageParam, TextBlock + client = anthropic.Anthropic(api_key=api_key) - kwargs: dict[str, object] = { - "model": self._config.model, # type: ignore[union-attr] - "max_tokens": 1024, - "messages": [{"role": "user", "content": text}], - } + messages: list[MessageParam] = [{"role": "user", "content": text}] + model: str = self._config.model # type: ignore[union-attr] if system_prompt is not None: - kwargs["system"] = system_prompt - response = client.messages.create(**kwargs) - return str(response.content[0].text) + response = client.messages.create( + model=model, max_tokens=1024, messages=messages, system=system_prompt + ) + else: + response = client.messages.create( + model=model, max_tokens=1024, messages=messages + ) + for block in response.content: + if isinstance(block, TextBlock): + return block.text + raise RuntimeError("Anthropic response contained no text block") def _ask_openai(self, text: str, system_prompt: str | None, api_key: str) -> str: try: @@ -56,8 +63,10 @@ def _ask_openai(self, text: str, system_prompt: str | None, api_key: str) -> str "openai package not installed; add 'openai' to your" " skill's manifest.yaml dependencies" ) + from openai.types.chat import ChatCompletionMessageParam + client = openai.OpenAI(api_key=api_key) - messages: list[dict[str, str]] = [] + messages: list[ChatCompletionMessageParam] = [] if system_prompt is not None: messages.append({"role": "system", "content": system_prompt}) messages.append({"role": "user", "content": text}) diff --git a/tests/unit/tools/test_ai.py b/tests/unit/tools/test_ai.py index afa6ebe..5a4eb23 100644 --- a/tests/unit/tools/test_ai.py +++ b/tests/unit/tools/test_ai.py @@ -8,17 +8,22 @@ from nimble.tools.ai import AiTool -def _make_anthropic_mock(response_text: str) -> MagicMock: +def _make_anthropic_mock(response_text: str) -> tuple[MagicMock, MagicMock]: mock_response = MagicMock() mock_response.content = [MagicMock(text=response_text)] mock_client = MagicMock() mock_client.messages.create.return_value = mock_response mock_anthropic = MagicMock() mock_anthropic.Anthropic.return_value = mock_client - return mock_anthropic + mock_types = MagicMock() + mock_types.TextBlock = MagicMock # isinstance(MagicMock(), MagicMock) is True + mock_types.MessageParam = dict -def _make_openai_mock(response_text: str) -> MagicMock: + return mock_anthropic, mock_types + + +def _make_openai_mock(response_text: str) -> tuple[MagicMock, MagicMock]: mock_message = MagicMock() mock_message.content = response_text mock_choice = MagicMock() @@ -29,7 +34,10 @@ def _make_openai_mock(response_text: str) -> MagicMock: mock_client.chat.completions.create.return_value = mock_response mock_openai = MagicMock() mock_openai.OpenAI.return_value = mock_client - return mock_openai + + mock_types_chat = MagicMock() + + return mock_openai, mock_types_chat def test_ask_anthropic_returns_text() -> None: @@ -37,11 +45,14 @@ def test_ask_anthropic_returns_text() -> None: provider="anthropic", model="claude-sonnet-4-6", api_key_env="TEST_KEY" ) tool = AiTool(cfg) - mock_anthropic = _make_anthropic_mock("answer text") + mock_anthropic, mock_types = _make_anthropic_mock("answer text") with ( patch.dict("os.environ", {"TEST_KEY": "sk-fake"}), - patch.dict("sys.modules", {"anthropic": mock_anthropic}), + patch.dict( + "sys.modules", + {"anthropic": mock_anthropic, "anthropic.types": mock_types}, + ), ): result = tool.ask("hello") assert result == "answer text" @@ -50,11 +61,14 @@ def test_ask_anthropic_returns_text() -> None: def test_ask_openai_returns_text() -> None: cfg = AiConfig(provider="openai", model="gpt-4o", api_key_env="TEST_KEY") tool = AiTool(cfg) - mock_openai = _make_openai_mock("answer text") + mock_openai, mock_types_chat = _make_openai_mock("answer text") with ( patch.dict("os.environ", {"TEST_KEY": "sk-fake"}), - patch.dict("sys.modules", {"openai": mock_openai}), + patch.dict( + "sys.modules", + {"openai": mock_openai, "openai.types.chat": mock_types_chat}, + ), ): result = tool.ask("hello") assert result == "answer text" @@ -65,12 +79,15 @@ def test_ask_with_system_prompt_anthropic() -> None: provider="anthropic", model="claude-sonnet-4-6", api_key_env="TEST_KEY" ) tool = AiTool(cfg) - mock_anthropic = _make_anthropic_mock("ok") + mock_anthropic, mock_types = _make_anthropic_mock("ok") mock_client = mock_anthropic.Anthropic.return_value with ( patch.dict("os.environ", {"TEST_KEY": "sk-fake"}), - patch.dict("sys.modules", {"anthropic": mock_anthropic}), + patch.dict( + "sys.modules", + {"anthropic": mock_anthropic, "anthropic.types": mock_types}, + ), ): tool.ask("hello", system_prompt="You are helpful") @@ -81,12 +98,15 @@ def test_ask_with_system_prompt_anthropic() -> None: def test_ask_with_system_prompt_openai() -> None: cfg = AiConfig(provider="openai", model="gpt-4o", api_key_env="TEST_KEY") tool = AiTool(cfg) - mock_openai = _make_openai_mock("ok") + mock_openai, mock_types_chat = _make_openai_mock("ok") mock_client = mock_openai.OpenAI.return_value with ( patch.dict("os.environ", {"TEST_KEY": "sk-fake"}), - patch.dict("sys.modules", {"openai": mock_openai}), + patch.dict( + "sys.modules", + {"openai": mock_openai, "openai.types.chat": mock_types_chat}, + ), ): tool.ask("hello", system_prompt="You are helpful")