diff --git a/README.md b/README.md index 20d0d5b..3fe3532 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Below is the list of packages currently included in this repository. | Package | Bub Plugin Entry Point | Description | | ------------------------------------------------------------------------------------ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | [`packages/bub-codex`](./packages/bub-codex/README.md) | `codex` | Provides a `run_model` hook that delegates model execution to the Codex CLI. | +| [`packages/bub-cursor`](./packages/bub-cursor/README.md) | `cursor` | Provides a `run_model` hook that delegates model execution to the Cursor CLI, plus `bub login cursor`. | | [`packages/bub-tg-feed`](./packages/bub-tg-feed/README.md) | `tg-feed` | Provides an AMQP-based channel adapter for Telegram feed messages. | | [`packages/bub-schedule`](./packages/bub-schedule/README.md) | `schedule` | Provides scheduling channel/tools backed by APScheduler with a JSON job store. | | [`packages/bub-tapestore-sqlalchemy`](./packages/bub-tapestore-sqlalchemy/README.md) | `tapestore-sqlalchemy` | Provides a SQLAlchemy-backed tape store for Bub conversation history. | diff --git a/packages/bub-cursor/README.md b/packages/bub-cursor/README.md new file mode 100644 index 0000000..15f0647 --- /dev/null +++ b/packages/bub-cursor/README.md @@ -0,0 +1,94 @@ +# bub-cursor + +Cursor CLI-backed model plugin for `bub`. + +## What It Provides + +- Bub plugin entry point: `cursor` +- A `run_model` hook implementation that invokes the Cursor CLI +- `bub login cursor`, which delegates to Cursor CLI login +- Session continuation via ` --resume=` +- JSON output parsing from ` -p ... --output-format json` +- Optional temporary skill wiring from installed Bub `skills` into workspace `.agents/skills` + +## Installation + +```bash +uv pip install "git+https://github.com/bubbuild/bub-contrib.git#subdirectory=packages/bub-cursor" +``` + +You can also install it with Bub: + +```bash +bub install bub-cursor@main +``` + +## Prerequisites + +- Cursor CLI must be installed and available in `PATH`. +- Cursor CLI must have authentication available through either saved browser login + or `CURSOR_API_KEY`. + +Depending on how Cursor CLI was installed, the executable may be named +`cursor-agent` or `agent`. Homebrew installs `cursor-agent`; the curl installer +usually installs `agent`. + +Verify the CLI with whichever command exists: + +```bash +cursor-agent --version +# or +agent --version +``` + +Browser login is available through Cursor CLI: + +```bash +cursor-agent login +# or +agent login +``` + +or through Bub: + +```bash +bub login cursor +``` + +CLI path resolution uses this order: `BUB_CURSOR_CLI_PATH`, `cursor-agent`, +then `agent`. + +## Configuration + +The plugin reads environment variables with prefix `BUB_CURSOR_`: + +- `BUB_CURSOR_MODEL`: optional model name passed as `--model `. +- `BUB_CURSOR_CLI_PATH`: Cursor CLI executable path. When unset, the plugin + tries `cursor-agent`, then `agent`. +- `BUB_CURSOR_TIMEOUT_SECONDS`: subprocess timeout. Defaults to `300`. + +Cursor CLI also reads its own authentication environment variables, such as +`CURSOR_API_KEY`, directly. +When neither saved Cursor login nor `CURSOR_API_KEY` is available, the plugin +raises `No Cursor authentication found. Run \`bub login cursor\` first or set \`CURSOR_API_KEY\`.` + +## Runtime Behavior + +- Workspace resolution: + - Uses `state["_runtime_workspace"]` when present + - Falls back to current working directory +- Command shape: + - ` -p --output-format json` + - ` --resume= -p --output-format json` +- The plugin stores Cursor session IDs in `/.bub-cursor-threads.json`. +- Cursor CLI stdout is parsed as JSON; the `result` field is returned as model output. + +## Skill Integration + +- During invocation, the plugin scans `skills` for directories containing `SKILL.md`. +- It creates symlinks under `/.agents/skills/`. +- Symlinks created by this plugin invocation are removed after the run. + +## Notes + +Cursor CLI non-interactive mode can modify files in the selected workspace. diff --git a/packages/bub-cursor/pyproject.toml b/packages/bub-cursor/pyproject.toml new file mode 100644 index 0000000..c767687 --- /dev/null +++ b/packages/bub-cursor/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "bub-cursor" +version = "0.1.0" +description = "Cursor-backed run_model plugin for Bub" +readme = "README.md" +authors = [ + { name = "tssujt" } +] +requires-python = ">=3.12" +dependencies = [ + "pydantic-settings>=2.13.1", +] + +[project.entry-points.bub] +cursor = "bub_cursor.plugin" + +[build-system] +requires = ["uv_build>=0.10.4,<0.11.0"] +build-backend = "uv_build" diff --git a/packages/bub-cursor/src/bub_cursor/__init__.py b/packages/bub-cursor/src/bub_cursor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/bub-cursor/src/bub_cursor/auth.py b/packages/bub-cursor/src/bub_cursor/auth.py new file mode 100644 index 0000000..222d132 --- /dev/null +++ b/packages/bub-cursor/src/bub_cursor/auth.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import asyncio +import os +import shutil + +CURSOR_API_KEY_ENV = "CURSOR_API_KEY" +CURSOR_CLI_CANDIDATES = ("cursor-agent", "agent") +CURSOR_AUTH_ERROR_MESSAGE = ( + "No Cursor authentication found. Run `bub login cursor` first or set " + "`CURSOR_API_KEY`." +) + + +class CursorLoginError(RuntimeError): + """Raised when Cursor CLI login cannot be started or completed.""" + + +def resolve_cursor_cli_path(cli_path: str | None = None) -> str: + if cli_path and cli_path.strip(): + return cli_path + for candidate in CURSOR_CLI_CANDIDATES: + if resolved := shutil.which(candidate): + return resolved + return CURSOR_CLI_CANDIDATES[0] + + +async def run_cursor_login(cli_path: str) -> int: + try: + process = await asyncio.create_subprocess_exec(cli_path, "login") + except FileNotFoundError as exc: + raise CursorLoginError(f"Cursor CLI not found: {cli_path}") from exc + return await process.wait() + + +def has_cursor_api_key() -> bool: + return bool(os.getenv(CURSOR_API_KEY_ENV, "").strip()) + + +async def has_cursor_cli_login(cli_path: str, *, timeout_seconds: float = 10.0) -> bool: + try: + process = await asyncio.create_subprocess_exec( + cli_path, + "status", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError as exc: + raise CursorLoginError(f"Cursor CLI not found: {cli_path}") from exc + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=timeout_seconds, + ) + except TimeoutError: + process.kill() + await process.communicate() + return False + output = f"{stdout.decode(errors='ignore')}\n{stderr.decode(errors='ignore')}" + return process.returncode == 0 and "logged in as" in output.lower() + + +async def ensure_cursor_authenticated(cli_path: str) -> None: + if has_cursor_api_key(): + return + if await has_cursor_cli_login(cli_path): + return + raise RuntimeError(CURSOR_AUTH_ERROR_MESSAGE) + + +__all__ = [ + "CURSOR_AUTH_ERROR_MESSAGE", + "CURSOR_CLI_CANDIDATES", + "CursorLoginError", + "ensure_cursor_authenticated", + "has_cursor_api_key", + "has_cursor_cli_login", + "resolve_cursor_cli_path", + "run_cursor_login", +] diff --git a/packages/bub-cursor/src/bub_cursor/plugin.py b/packages/bub-cursor/src/bub_cursor/plugin.py new file mode 100644 index 0000000..d071aef --- /dev/null +++ b/packages/bub-cursor/src/bub_cursor/plugin.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import asyncio +import contextlib +import json +import os +from pathlib import Path +from typing import TYPE_CHECKING, Annotated, Any, cast + +import bub +import typer +from bub import BubFramework, hookimpl +from bub.builtin.auth import app as auth_app +from bub.types import State +from pydantic import Field +from pydantic_settings import SettingsConfigDict + +from bub_cursor.auth import ( + CursorLoginError, + ensure_cursor_authenticated, + resolve_cursor_cli_path, + run_cursor_login, +) +from bub_cursor.utils import with_bub_skills + +if TYPE_CHECKING: + from bub.builtin.agent import Agent + +THREADS_FILE = ".bub-cursor-threads.json" +CliPathOption = Annotated[ + str | None, + typer.Option( + "--cli-path", + help="Cursor CLI executable path. Defaults to BUB_CURSOR_CLI_PATH or auto-detection.", + ), +] + + +@bub.config(name="cursor") +class CursorSettings(bub.Settings): + """Configuration for the Cursor Bub plugin.""" + + model_config = SettingsConfigDict( + env_prefix="BUB_CURSOR_", + env_file=".env", + extra="ignore", + ) + + model: str | None = None + cli_path: str | None = None + timeout_seconds: float = Field(default=300.0, gt=0) + + +def _settings() -> CursorSettings: + return bub.ensure_config(CursorSettings) + + +def workspace_from_state(state: State) -> Path: + raw = state.get("_runtime_workspace") + if isinstance(raw, str) and raw.strip(): + return Path(raw).expanduser().resolve() + return Path.cwd().resolve() + + +def _load_thread_id(session_id: str, state: State) -> str | None: + threads_file = workspace_from_state(state) / THREADS_FILE + with contextlib.suppress(FileNotFoundError, json.JSONDecodeError): + with threads_file.open() as f: + threads = json.load(f) + thread_id = threads.get(session_id) + if isinstance(thread_id, str) and thread_id: + return thread_id + return None + + +def _save_thread_id(session_id: str, thread_id: str, state: State) -> None: + threads_file = workspace_from_state(state) / THREADS_FILE + if threads_file.exists(): + with threads_file.open() as f: + threads = json.load(f) + else: + threads = {} + threads[session_id] = thread_id + with threads_file.open("w") as f: + json.dump(threads, f, indent=2) + + +def _runtime_agent_from_state(state: State) -> Agent | None: + agent = state.get("_runtime_agent") + if agent is None: + return None + return cast("Agent", agent) + + +def _prompt_to_text(prompt: str | list[dict[str, Any]]) -> str: + if isinstance(prompt, str): + return prompt + return "\n".join( + str(part.get("text", "")) + for part in prompt + if isinstance(part, dict) and part.get("type") == "text" + ).strip() + + +async def _run_internal_command( + prompt: str, session_id: str, state: State +) -> str | None: + if not prompt.strip().startswith(","): + return None + agent = _runtime_agent_from_state(state) + if agent is None: + return None + return await agent.run(session_id=session_id, prompt=prompt, state=state) + + +@auth_app.command(name="cursor") +def cursor_login(cli_path: CliPathOption = None) -> None: + """Login with Cursor CLI browser authentication.""" + + settings = _settings() + command = resolve_cursor_cli_path(cli_path or settings.cli_path) + try: + exit_code = asyncio.run(run_cursor_login(command)) + except CursorLoginError as exc: + typer.echo(str(exc), err=True) + raise typer.Exit(1) from exc + + if exit_code != 0: + raise typer.Exit(exit_code) + + typer.echo("login: ok") + + +def _cursor_command( + *, + prompt: str, + thread_id: str | None, + settings: CursorSettings, +) -> list[str]: + command = [resolve_cursor_cli_path(settings.cli_path)] + if thread_id: + command.append(f"--resume={thread_id}") + command.extend(["-p", prompt, "--output-format", "json"]) + if settings.model: + command.extend(["--model", settings.model]) + return command + + +def _result_from_stdout(stdout_text: str, session_id: str, state: State) -> str: + try: + data = json.loads(stdout_text) + except json.JSONDecodeError: + return stdout_text + + if thread_id := data.get("session_id"): + if isinstance(thread_id, str) and thread_id: + _save_thread_id(session_id, thread_id, state) + + result = data.get("result") + if isinstance(result, str): + return result + if data.get("is_error"): + return json.dumps(data, ensure_ascii=False) + return stdout_text + + +@hookimpl +async def run_model( + prompt: str | list[dict[str, Any]], session_id: str, state: State +) -> str: + prompt_text = _prompt_to_text(prompt) + internal_command_result = await _run_internal_command( + prompt_text, session_id, state + ) + if internal_command_result is not None: + return internal_command_result + + workspace = workspace_from_state(state) + settings = _settings() + cli_path = resolve_cursor_cli_path(settings.cli_path) + await ensure_cursor_authenticated(cli_path) + command = _cursor_command( + prompt=prompt_text, + thread_id=_load_thread_id(session_id, state), + settings=settings, + ) + with with_bub_skills(workspace): + process = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(workspace), + env=os.environ.copy(), + ) + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=settings.timeout_seconds, + ) + except TimeoutError: + process.kill() + await process.communicate() + return f"Cursor process timed out after {settings.timeout_seconds:g} seconds." + + stdout_text = stdout.decode() if stdout else "" + stderr_text = stderr.decode() if stderr else "" + + if process.returncode != 0: + parts = [f"Cursor process exited with code {process.returncode}."] + if stderr_text.strip(): + parts.append(stderr_text) + if stdout_text.strip(): + parts.append(stdout_text) + return "\n\n".join(parts) + + return _result_from_stdout(stdout_text, session_id, state) + + +class CursorPlugin: + def __init__(self, framework: BubFramework) -> None: + self.framework = framework + + +__all__ = [ + "CursorPlugin", + "CursorSettings", + "run_model", + "workspace_from_state", +] diff --git a/packages/bub-cursor/src/bub_cursor/py.typed b/packages/bub-cursor/src/bub_cursor/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/packages/bub-cursor/src/bub_cursor/py.typed @@ -0,0 +1 @@ + diff --git a/packages/bub-cursor/src/bub_cursor/utils.py b/packages/bub-cursor/src/bub_cursor/utils.py new file mode 100644 index 0000000..43fb2b8 --- /dev/null +++ b/packages/bub-cursor/src/bub_cursor/utils.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import contextlib +import importlib +from collections.abc import Generator +from pathlib import Path + +SKILL_TARGET_DIR = ".agents/skills" + + +def _copy_bub_skills(workspace: Path) -> list[Path]: + bub_skill_paths = importlib.import_module("skills").__path__ + workspace.joinpath(SKILL_TARGET_DIR).mkdir(parents=True, exist_ok=True) + collected_symlinks: list[Path] = [] + for skill_root in bub_skill_paths: + for skill_dir in Path(skill_root).iterdir(): + if skill_dir.joinpath("SKILL.md").is_file(): + symlink_path = workspace / SKILL_TARGET_DIR / skill_dir.name + if not symlink_path.exists(): + symlink_path.symlink_to(skill_dir, target_is_directory=True) + collected_symlinks.append(symlink_path) + return collected_symlinks + + +def _safe_copy_bub_skills(workspace: Path) -> list[Path]: + with contextlib.suppress(ModuleNotFoundError): + return _copy_bub_skills(workspace) + return [] + + +@contextlib.contextmanager +def with_bub_skills(workspace: Path) -> Generator[None, None, None]: + """Temporarily expose installed Bub packaged skills under workspace .agents/skills.""" + + skills = _safe_copy_bub_skills(workspace) + try: + yield + finally: + for skill in skills: + with contextlib.suppress(OSError): + skill.unlink() diff --git a/packages/bub-cursor/tests/test_auth.py b/packages/bub-cursor/tests/test_auth.py new file mode 100644 index 0000000..b47b09f --- /dev/null +++ b/packages/bub-cursor/tests/test_auth.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import asyncio +import shutil + +import pytest + +from bub_cursor import auth + + +def test_resolve_cursor_cli_path_prefers_explicit_path() -> None: + assert auth.resolve_cursor_cli_path("/custom/cursor-agent") == "/custom/cursor-agent" + + +def test_resolve_cursor_cli_path_prefers_cursor_agent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fake_which(command: str) -> str | None: + return f"/bin/{command}" if command in {"cursor-agent", "agent"} else None + + monkeypatch.setattr(shutil, "which", fake_which) + + assert auth.resolve_cursor_cli_path() == "/bin/cursor-agent" + + +def test_resolve_cursor_cli_path_falls_back_to_agent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fake_which(command: str) -> str | None: + return "/bin/agent" if command == "agent" else None + + monkeypatch.setattr(shutil, "which", fake_which) + + assert auth.resolve_cursor_cli_path() == "/bin/agent" + + +def test_resolve_cursor_cli_path_returns_cursor_agent_when_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(shutil, "which", lambda command: None) + + assert auth.resolve_cursor_cli_path() == "cursor-agent" + + +def test_run_cursor_login_spawns_agent_login(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[object, ...]] = [] + + class FakeProcess: + async def wait(self) -> int: + return 0 + + async def fake_create_subprocess_exec(*args): + calls.append(args) + return FakeProcess() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + + result = asyncio.run(auth.run_cursor_login("cursor-agent")) + + assert result == 0 + assert calls == [("cursor-agent", "login")] + + +def test_run_cursor_login_wraps_missing_cli(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_create_subprocess_exec(*args): + raise FileNotFoundError + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + + with pytest.raises(auth.CursorLoginError, match="Cursor CLI not found"): + asyncio.run(auth.run_cursor_login("missing-agent")) + + +def test_ensure_cursor_authenticated_uses_cursor_api_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("CURSOR_API_KEY", "cursor-api-key") + + async def fake_has_cursor_cli_login(cli_path: str) -> bool: + raise AssertionError("status should not be called when CURSOR_API_KEY is set") + + monkeypatch.setattr(auth, "has_cursor_cli_login", fake_has_cursor_cli_login) + + asyncio.run(auth.ensure_cursor_authenticated("agent")) + + +def test_ensure_cursor_authenticated_uses_cli_status( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("CURSOR_API_KEY", raising=False) + calls: list[str] = [] + + async def fake_has_cursor_cli_login(cli_path: str) -> bool: + calls.append(cli_path) + return True + + monkeypatch.setattr(auth, "has_cursor_cli_login", fake_has_cursor_cli_login) + + asyncio.run(auth.ensure_cursor_authenticated("cursor-agent")) + + assert calls == ["cursor-agent"] + + +def test_ensure_cursor_authenticated_raises_when_status_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("CURSOR_API_KEY", raising=False) + + async def fake_has_cursor_cli_login(cli_path: str) -> bool: + return False + + monkeypatch.setattr(auth, "has_cursor_cli_login", fake_has_cursor_cli_login) + + with pytest.raises(RuntimeError, match="bub login cursor"): + asyncio.run(auth.ensure_cursor_authenticated("agent")) + + +def test_has_cursor_cli_login_runs_agent_status(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[tuple[object, ...]] = [] + + class FakeProcess: + returncode = 0 + + async def communicate(self) -> tuple[bytes, bytes]: + return (b"Logged in as user@example.com", b"") + + async def fake_create_subprocess_exec(*args, **kwargs): + calls.append(args) + return FakeProcess() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + + result = asyncio.run(auth.has_cursor_cli_login("cursor-agent")) + + assert result is True + assert calls == [("cursor-agent", "status")] + + +def test_has_cursor_cli_login_requires_logged_in_as_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FakeProcess: + returncode = 0 + + async def communicate(self) -> tuple[bytes, bytes]: + return (b"Authenticated", b"") + + async def fake_create_subprocess_exec(*args, **kwargs): + return FakeProcess() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + + result = asyncio.run(auth.has_cursor_cli_login("cursor-agent")) + + assert result is False diff --git a/packages/bub-cursor/tests/test_plugin.py b/packages/bub-cursor/tests/test_plugin.py new file mode 100644 index 0000000..860b05a --- /dev/null +++ b/packages/bub-cursor/tests/test_plugin.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +import asyncio +import contextlib +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from bub.builtin.auth import app as auth_app +from bub_cursor import plugin + + +class FakeAgent: + def __init__(self) -> None: + self.calls: list[tuple[str, str, dict[str, object]]] = [] + + async def run( + self, + *, + session_id: str, + prompt: str, + state: dict[str, object], + ) -> str: + self.calls.append((session_id, prompt, state)) + return "internal-command-result" + + +async def _async_noop() -> None: + return None + + +@pytest.fixture(autouse=True) +def clear_cursor_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("BUB_CURSOR_CLI_PATH", raising=False) + monkeypatch.delenv("BUB_CURSOR_MODEL", raising=False) + monkeypatch.delenv("BUB_CURSOR_TIMEOUT_SECONDS", raising=False) + + +def test_run_model_delegates_internal_commands_to_runtime_agent() -> None: + state: dict[str, object] = {"_runtime_agent": FakeAgent()} + + result = asyncio.run(plugin.run_model(",help", session_id="session-1", state=state)) + + agent = state["_runtime_agent"] + assert result == "internal-command-result" + assert isinstance(agent, FakeAgent) + assert agent.calls == [("session-1", ",help", state)] + + +def test_run_model_uses_cursor_cli( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] + + class FakeProcess: + returncode = 0 + + async def communicate(self) -> tuple[bytes, bytes]: + return ( + json.dumps( + { + "type": "result", + "subtype": "success", + "is_error": False, + "result": "cursor-output", + "session_id": "cursor-thread-1", + } + ).encode(), + b"", + ) + + async def fake_create_subprocess_exec(*args, **kwargs): + calls.append((args, kwargs)) + return FakeProcess() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr(plugin, "with_bub_skills", lambda workspace: contextlib.nullcontext()) + monkeypatch.setattr(plugin, "ensure_cursor_authenticated", lambda cli_path: _async_noop()) + monkeypatch.setattr(plugin, "resolve_cursor_cli_path", lambda cli_path=None: cli_path or "cursor-agent") + monkeypatch.setattr( + plugin, + "_settings", + lambda: plugin.CursorSettings( + model="cursor-model", + cli_path="cursor-agent", + timeout_seconds=12, + ), + ) + + state = {"_runtime_workspace": str(tmp_path)} + result = asyncio.run(plugin.run_model("hello", session_id="session-1", state=state)) + + assert result == "cursor-output" + assert calls + args, kwargs = calls[0] + assert args == ( + "cursor-agent", + "-p", + "hello", + "--output-format", + "json", + "--model", + "cursor-model", + ) + assert kwargs["cwd"] == str(tmp_path) + assert kwargs["stdout"] == asyncio.subprocess.PIPE + assert kwargs["stderr"] == asyncio.subprocess.PIPE + assert json.loads((tmp_path / plugin.THREADS_FILE).read_text()) == { + "session-1": "cursor-thread-1" + } + + +def test_run_model_resumes_previous_cursor_session( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + (tmp_path / plugin.THREADS_FILE).write_text( + json.dumps({"session-1": "cursor-thread-1"}) + ) + calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] + + class FakeProcess: + returncode = 0 + + async def communicate(self) -> tuple[bytes, bytes]: + return (b'{"result": "ok"}', b"") + + async def fake_create_subprocess_exec(*args, **kwargs): + calls.append((args, kwargs)) + return FakeProcess() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr(plugin, "with_bub_skills", lambda workspace: contextlib.nullcontext()) + monkeypatch.setattr(plugin, "ensure_cursor_authenticated", lambda cli_path: _async_noop()) + monkeypatch.setattr(plugin, "resolve_cursor_cli_path", lambda cli_path=None: cli_path or "cursor-agent") + monkeypatch.setattr(plugin, "_settings", lambda: plugin.CursorSettings()) + + state = {"_runtime_workspace": str(tmp_path)} + result = asyncio.run(plugin.run_model("hello", session_id="session-1", state=state)) + + assert result == "ok" + args, _ = calls[0] + assert args[:2] == ("cursor-agent", "--resume=cursor-thread-1") + + +def test_run_model_returns_process_error( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + class FakeProcess: + returncode = 2 + + async def communicate(self) -> tuple[bytes, bytes]: + return (b"stdout text", b"stderr text") + + async def fake_create_subprocess_exec(*args, **kwargs): + return FakeProcess() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr(plugin, "with_bub_skills", lambda workspace: contextlib.nullcontext()) + monkeypatch.setattr(plugin, "ensure_cursor_authenticated", lambda cli_path: _async_noop()) + monkeypatch.setattr(plugin, "resolve_cursor_cli_path", lambda cli_path=None: cli_path or "cursor-agent") + monkeypatch.setattr(plugin, "_settings", lambda: plugin.CursorSettings()) + + state = {"_runtime_workspace": str(tmp_path)} + result = asyncio.run(plugin.run_model("hello", session_id="session-1", state=state)) + + assert "Cursor process exited with code 2." in result + assert "stderr text" in result + assert "stdout text" in result + + +def test_run_model_returns_plain_stdout_when_json_parse_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + class FakeProcess: + returncode = 0 + + async def communicate(self) -> tuple[bytes, bytes]: + return (b"plain output", b"") + + async def fake_create_subprocess_exec(*args, **kwargs): + return FakeProcess() + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr(plugin, "with_bub_skills", lambda workspace: contextlib.nullcontext()) + monkeypatch.setattr(plugin, "ensure_cursor_authenticated", lambda cli_path: _async_noop()) + monkeypatch.setattr(plugin, "resolve_cursor_cli_path", lambda cli_path=None: cli_path or "cursor-agent") + monkeypatch.setattr(plugin, "_settings", lambda: plugin.CursorSettings()) + + state = {"_runtime_workspace": str(tmp_path)} + result = asyncio.run(plugin.run_model("hello", session_id="session-1", state=state)) + + assert result == "plain output" + + +def test_run_model_wraps_cursor_cli_with_bub_skills( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + events: list[tuple[str, Path]] = [] + + class FakeProcess: + returncode = 0 + + async def communicate(self) -> tuple[bytes, bytes]: + events.append(("communicate", tmp_path)) + return (b'{"result": "ok"}', b"") + + async def fake_create_subprocess_exec(*args, **kwargs): + events.append(("spawn", Path(str(kwargs["cwd"])))) + return FakeProcess() + + @contextlib.contextmanager + def fake_with_bub_skills(workspace: Path): + events.append(("enter", workspace)) + try: + yield + finally: + events.append(("exit", workspace)) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr(plugin, "with_bub_skills", fake_with_bub_skills) + monkeypatch.setattr(plugin, "ensure_cursor_authenticated", lambda cli_path: _async_noop()) + monkeypatch.setattr(plugin, "resolve_cursor_cli_path", lambda cli_path=None: cli_path or "cursor-agent") + monkeypatch.setattr(plugin, "_settings", lambda: plugin.CursorSettings()) + + state = {"_runtime_workspace": str(tmp_path)} + result = asyncio.run(plugin.run_model("hello", session_id="session-1", state=state)) + + assert result == "ok" + assert events == [ + ("enter", tmp_path), + ("spawn", tmp_path), + ("communicate", tmp_path), + ("exit", tmp_path), + ] + + +def test_run_model_checks_cursor_auth_before_spawning( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + calls: list[str] = [] + + async def fake_ensure_cursor_authenticated(cli_path: str) -> None: + calls.append(cli_path) + raise RuntimeError("No Cursor authentication found. Run `bub login cursor` first.") + + async def fake_create_subprocess_exec(*args, **kwargs): + raise AssertionError("Cursor CLI should not be spawned when auth is missing") + + monkeypatch.setattr(plugin, "ensure_cursor_authenticated", fake_ensure_cursor_authenticated) + monkeypatch.setattr(plugin, "resolve_cursor_cli_path", lambda cli_path=None: cli_path or "cursor-agent") + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec) + monkeypatch.setattr( + plugin, + "_settings", + lambda: plugin.CursorSettings(cli_path="cursor-agent"), + ) + + state = {"_runtime_workspace": str(tmp_path)} + with pytest.raises(RuntimeError, match="bub login cursor"): + asyncio.run(plugin.run_model("hello", session_id="session-1", state=state)) + + assert calls == ["cursor-agent"] + + +def test_cursor_login_command_runs_agent_login(monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[str] = [] + + async def fake_run_cursor_login(cli_path: str) -> int: + calls.append(cli_path) + return 0 + + monkeypatch.setattr(plugin, "run_cursor_login", fake_run_cursor_login) + monkeypatch.setattr(plugin, "resolve_cursor_cli_path", lambda cli_path=None: cli_path or "cursor-agent") + monkeypatch.setattr( + plugin, + "_settings", + lambda: plugin.CursorSettings(cli_path="cursor-agent"), + ) + + result = CliRunner().invoke(auth_app, ["cursor"]) + + assert result.exit_code == 0 + assert calls == ["cursor-agent"] + assert "login: ok" in result.stdout + + +def test_cursor_login_command_accepts_cli_path_override( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + + async def fake_run_cursor_login(cli_path: str) -> int: + calls.append(cli_path) + return 0 + + monkeypatch.setattr(plugin, "run_cursor_login", fake_run_cursor_login) + monkeypatch.setattr(plugin, "resolve_cursor_cli_path", lambda cli_path=None: cli_path or "cursor-agent") + + result = CliRunner().invoke(auth_app, ["cursor", "--cli-path", "custom-agent"]) + + assert result.exit_code == 0 + assert calls == ["custom-agent"] + + +def test_cursor_login_command_returns_cli_exit_code( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fake_run_cursor_login(cli_path: str) -> int: + return 3 + + monkeypatch.setattr(plugin, "run_cursor_login", fake_run_cursor_login) + monkeypatch.setattr(plugin, "resolve_cursor_cli_path", lambda cli_path=None: cli_path or "cursor-agent") + + result = CliRunner().invoke(auth_app, ["cursor", "--cli-path", "agent"]) + + assert result.exit_code == 3 diff --git a/packages/bub-cursor/tests/test_utils.py b/packages/bub-cursor/tests/test_utils.py new file mode 100644 index 0000000..4fc14a5 --- /dev/null +++ b/packages/bub-cursor/tests/test_utils.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import sys +from types import ModuleType + +from bub_cursor import utils + + +def test_with_bub_skills_links_agents_skill_dir(tmp_path, monkeypatch) -> None: + skill_root = tmp_path / "installed-skills" + skill_dir = skill_root / "example-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("---\nname: example-skill\n---\n") + + skills_module = ModuleType("skills") + skills_module.__path__ = [str(skill_root)] + monkeypatch.setitem(sys.modules, "skills", skills_module) + + workspace = tmp_path / "workspace" + with utils.with_bub_skills(workspace): + agents_link = workspace / ".agents/skills/example-skill" + assert agents_link.is_symlink() + assert agents_link.resolve() == skill_dir + assert not (workspace / ".cursor/skills/example-skill").exists() + + assert not (workspace / ".agents/skills/example-skill").exists() + assert not (workspace / ".cursor/skills/example-skill").exists() diff --git a/pyproject.toml b/pyproject.toml index a781c9d..ee756a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.12" dependencies = [ "bub", "bub-codex", + "bub-cursor", "bub-discord", "bub-dingtalk", "bub-extism", @@ -36,6 +37,7 @@ members = ["packages/*"] bub = { git = "https://github.com/bubbuild/bub.git" } bub-tg-feed = { workspace = true } bub-codex = { workspace = true } +bub-cursor = { workspace = true } bub-discord = { workspace = true } bub-dingtalk = { workspace = true } bub-extism = { workspace = true } diff --git a/uv.lock b/uv.lock index 8bca3de..0e7c2fe 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,7 @@ resolution-markers = [ members = [ "bub-codex", "bub-contrib", + "bub-cursor", "bub-dingtalk", "bub-discord", "bub-extism", @@ -388,6 +389,7 @@ source = { virtual = "." } dependencies = [ { name = "bub" }, { name = "bub-codex" }, + { name = "bub-cursor" }, { name = "bub-dingtalk" }, { name = "bub-discord" }, { name = "bub-extism" }, @@ -421,6 +423,7 @@ test = [ requires-dist = [ { name = "bub", git = "https://github.com/bubbuild/bub.git" }, { name = "bub-codex", editable = "packages/bub-codex" }, + { name = "bub-cursor", editable = "packages/bub-cursor" }, { name = "bub-dingtalk", editable = "packages/bub-dingtalk" }, { name = "bub-discord", editable = "packages/bub-discord" }, { name = "bub-extism", editable = "packages/bub-extism" }, @@ -450,6 +453,17 @@ test = [ { name = "pytest-asyncio", specifier = ">=0.21.0" }, ] +[[package]] +name = "bub-cursor" +version = "0.1.0" +source = { editable = "packages/bub-cursor" } +dependencies = [ + { name = "pydantic-settings" }, +] + +[package.metadata] +requires-dist = [{ name = "pydantic-settings", specifier = ">=2.13.1" }] + [[package]] name = "bub-dingtalk" version = "0.1.0" @@ -1365,9 +1379,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, - { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, - { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, @@ -1375,9 +1387,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, - { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, @@ -1385,9 +1395,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, @@ -1395,18 +1403,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, - { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, - { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, - { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, @@ -1414,9 +1418,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, - { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" },