Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
94 changes: 94 additions & 0 deletions packages/bub-cursor/README.md
Original file line number Diff line number Diff line change
@@ -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 `<cursor-cli> --resume=<session_id>`
- JSON output parsing from `<cursor-cli> -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 <value>`.
- `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:
- `<cursor-cli> -p <prompt> --output-format json`
- `<cursor-cli> --resume=<session_id> -p <prompt> --output-format json`
- The plugin stores Cursor session IDs in `<workspace>/.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 `<workspace>/.agents/skills/<skill_name>`.
- Symlinks created by this plugin invocation are removed after the run.

## Notes

Cursor CLI non-interactive mode can modify files in the selected workspace.
19 changes: 19 additions & 0 deletions packages/bub-cursor/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Empty file.
80 changes: 80 additions & 0 deletions packages/bub-cursor/src/bub_cursor/auth.py
Original file line number Diff line number Diff line change
@@ -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",
]
Loading