diff --git a/README.md b/README.md index 8c5d4b7..a5e1e14 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,16 @@ BUB_RUNTIME_ENABLED=0 uv run bub run ",help" BUB_API_KEY=your_key uv run bub run "Summarize this repository" ``` +```bash +# OpenAI Codex OAuth (no provider API key required) +uv run bub login openai +BUB_MODEL=openai:gpt-5-codex uv run bub chat +``` + ## CLI Commands - `bub run MESSAGE`: execute one inbound turn and print outbound messages +- `bub login openai`: persist OpenAI Codex OAuth credentials for later runs - `bub hooks`: print hook-to-plugin bindings - `bub install PLUGIN_SPEC`: install plugin from PyPI or `owner/repo` (GitHub shorthand) @@ -84,7 +91,7 @@ Implement hooks with `@hookimpl` following `BubHookSpecs`. - `BUB_RUNTIME_ENABLED`: `auto` (default), `1`, `0` - `BUB_MODEL`: default `openrouter:qwen/qwen3-coder-next` -- `BUB_API_KEY`: runtime provider key +- `BUB_API_KEY`: runtime provider key; optional when using `openai:*` models with `bub login openai` - `BUB_API_BASE`: optional provider base URL - `BUB_RUNTIME_MAX_STEPS`: default `8` - `BUB_RUNTIME_MAX_TOKENS`: default `1024` diff --git a/docs/cli.md b/docs/cli.md index 31ed6a3..3c18048 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,6 +1,6 @@ # CLI -`bub` currently exposes four builtin commands: `run`, `gateway`, `chat`, and the hidden compatibility command `message`. +`bub` currently exposes five builtin commands: `run`, `gateway`, `chat`, `login`, and the hidden compatibility command `message`. ## `bub run` @@ -67,6 +67,26 @@ uv run bub chat uv run bub chat --chat-id local --session-id cli:local ``` +## `bub login` + +Authenticate with OpenAI Codex OAuth and persist the resulting credentials under `CODEX_HOME` (default `~/.codex`). + +```bash +uv run bub login openai +``` + +Manual callback mode is useful when the local redirect server is unavailable: + +```bash +uv run bub login openai --manual --no-browser +``` + +After login, you can use an OpenAI model without setting `BUB_API_KEY`: + +```bash +BUB_MODEL=openai:gpt-5-codex uv run bub chat +``` + ## Notes - `--workspace` is parsed before the subcommand, for example `uv run bub --workspace /repo chat`. diff --git a/pyproject.toml b/pyproject.toml index cbb19af..c687089 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ "pyyaml>=6.0.0", "pluggy>=1.6.0", "typer>=0.9.0", - "republic>=0.5.3", + "republic>=0.5.4", "any-llm-sdk[anthropic]", "rich>=13.0.0", "prompt-toolkit>=3.0.0", diff --git a/src/bub/builtin/cli.py b/src/bub/builtin/cli.py index 2eab2ed..04397c0 100644 --- a/src/bub/builtin/cli.py +++ b/src/bub/builtin/cli.py @@ -4,14 +4,17 @@ from __future__ import annotations import asyncio +import os +from pathlib import Path import typer +from republic.auth.openai_codex import CodexOAuthLoginError, OpenAICodexOAuthTokens, login_openai_codex_oauth from bub.channels.message import ChannelMessage from bub.envelope import field_of from bub.framework import BubFramework -app = typer.Typer() +DEFAULT_CODEX_REDIRECT_URI = "http://localhost:1455/auth/callback" def run( @@ -82,3 +85,58 @@ def chat( raise typer.Exit(1) channel.set_metadata(chat_id=chat_id, session_id=session_id) # type: ignore[attr-defined] asyncio.run(manager.listen_and_run()) + + +def _prompt_for_codex_redirect(authorize_url: str) -> str: + typer.echo("Open this URL in your browser and complete the Codex sign-in flow:\n") + typer.echo(authorize_url) + typer.echo("\nPaste the full callback URL or the authorization code.") + return str(typer.prompt("callback")).strip() + + +def _resolve_codex_home(codex_home: Path | None) -> Path: + if codex_home is not None: + return codex_home.expanduser() + return Path(os.getenv("CODEX_HOME", "~/.codex")).expanduser() + + +def _render_codex_login_result(tokens: OpenAICodexOAuthTokens, auth_path: Path) -> None: + typer.echo("login: ok") + typer.echo(f"account_id: {tokens.account_id or '-'}") + typer.echo(f"auth_file: {auth_path}") + typer.echo("usage: set BUB_MODEL=openai:gpt-5-codex and omit BUB_API_KEY") + + +def login( + provider: str = typer.Argument(..., help="Authentication provider"), + codex_home: Path | None = typer.Option(None, "--codex-home", help="Directory to store Codex OAuth credentials"), + open_browser: bool = typer.Option(True, "--browser/--no-browser", help="Open the OAuth URL in a browser"), + manual: bool = typer.Option( + False, + "--manual", + help="Paste the callback URL or code instead of waiting for a local callback server", + ), + timeout_seconds: float = typer.Option(300.0, "--timeout", help="OAuth wait timeout in seconds"), +) -> None: + """Authenticate with a provider and persist the resulting credentials.""" + + if provider != "openai": + typer.echo(f"Unsupported auth provider: {provider}", err=True) + raise typer.Exit(1) + + resolved_codex_home = _resolve_codex_home(codex_home) + prompt_for_redirect = _prompt_for_codex_redirect if manual or not open_browser else None + + try: + tokens = login_openai_codex_oauth( + codex_home=resolved_codex_home, + prompt_for_redirect=prompt_for_redirect, + open_browser=open_browser, + redirect_uri=DEFAULT_CODEX_REDIRECT_URI, + timeout_seconds=timeout_seconds, + ) + except CodexOAuthLoginError as exc: + typer.echo(f"Codex login failed: {exc}", err=True) + raise typer.Exit(1) from exc + + _render_codex_login_result(tokens, resolved_codex_home / "auth.json") diff --git a/src/bub/builtin/hook_impl.py b/src/bub/builtin/hook_impl.py index 1ffa39f..f10faf3 100644 --- a/src/bub/builtin/hook_impl.py +++ b/src/bub/builtin/hook_impl.py @@ -92,6 +92,7 @@ def register_cli_commands(self, app: typer.Typer) -> None: app.command("run")(cli.run) app.command("chat")(cli.chat) + app.command("login")(cli.login) app.command("hooks", hidden=True)(cli.list_hooks) app.command("message", hidden=True)(app.command("gateway")(cli.gateway)) diff --git a/tests/test_builtin_agent.py b/tests/test_builtin_agent.py new file mode 100644 index 0000000..957514f --- /dev/null +++ b/tests/test_builtin_agent.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Any + +import republic.auth.openai_codex as openai_codex + +import bub.builtin.agent as agent_module +from bub.builtin.settings import AgentSettings + + +def test_build_llm_passes_codex_resolver_to_republic(monkeypatch) -> None: + captured: dict[str, Any] = {} + resolver = object() + + class FakeLLM: + def __init__(self, *args: object, **kwargs: object) -> None: + captured["args"] = args + captured["kwargs"] = kwargs + + monkeypatch.setattr(agent_module, "LLM", FakeLLM) + monkeypatch.setattr(openai_codex, "openai_codex_oauth_resolver", lambda: resolver) + monkeypatch.setattr(agent_module, "default_tape_context", lambda: "ctx") + + settings = AgentSettings(model="openai:gpt-5-codex", api_key=None, api_base=None) + tape_store = object() + + agent_module._build_llm(settings, tape_store) + + assert captured["args"] == ("openai:gpt-5-codex",) + assert captured["kwargs"]["api_key"] is None + assert captured["kwargs"]["api_base"] is None + assert captured["kwargs"]["api_key_resolver"] is resolver + assert captured["kwargs"]["tape_store"] is tape_store + assert captured["kwargs"]["context"] == "ctx" diff --git a/tests/test_builtin_cli.py b/tests/test_builtin_cli.py new file mode 100644 index 0000000..2049c09 --- /dev/null +++ b/tests/test_builtin_cli.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from pathlib import Path + +from typer.testing import CliRunner + +import bub.builtin.cli as cli +from bub.framework import BubFramework + + +def _create_app() -> object: + framework = BubFramework() + framework.load_hooks() + return framework.create_cli_app() + + +def test_login_openai_runs_oauth_flow_and_prints_usage_hint( + tmp_path: Path, + monkeypatch, +) -> None: + captured: dict[str, object] = {} + + def fake_login_openai_codex_oauth(**kwargs: object) -> cli.OpenAICodexOAuthTokens: + captured.update(kwargs) + prompt_for_redirect = kwargs["prompt_for_redirect"] + assert callable(prompt_for_redirect) + callback = prompt_for_redirect("https://auth.openai.com/authorize") + assert callback == "http://localhost:1455/auth/callback?code=test" + return cli.OpenAICodexOAuthTokens( + access_token="access", # noqa: S106 + refresh_token="refresh", # noqa: S106 + expires_at=123, + account_id="acct_123", + ) + + monkeypatch.setattr(cli, "login_openai_codex_oauth", fake_login_openai_codex_oauth) + monkeypatch.setattr(cli.typer, "prompt", lambda message: "http://localhost:1455/auth/callback?code=test") + + result = CliRunner().invoke( + _create_app(), + ["login", "openai", "--manual", "--no-browser", "--codex-home", str(tmp_path)], + ) + + assert result.exit_code == 0 + assert captured["codex_home"] == tmp_path + assert captured["open_browser"] is False + assert captured["redirect_uri"] == cli.DEFAULT_CODEX_REDIRECT_URI + assert captured["timeout_seconds"] == 300.0 + assert "login: ok" in result.stdout + assert "account_id: acct_123" in result.stdout + assert f"auth_file: {tmp_path / 'auth.json'}" in result.stdout + assert "BUB_MODEL=openai:gpt-5-codex" in result.stdout + + +def test_login_openai_surfaces_oauth_errors(monkeypatch) -> None: + def fake_login_openai_codex_oauth(**kwargs: object) -> cli.OpenAICodexOAuthTokens: + raise cli.CodexOAuthLoginError("bad redirect") + + monkeypatch.setattr(cli, "login_openai_codex_oauth", fake_login_openai_codex_oauth) + + result = CliRunner().invoke(_create_app(), ["login", "openai", "--manual"]) + + assert result.exit_code == 1 + assert "Codex login failed: bad redirect" in result.stderr + + +def test_login_rejects_unsupported_provider() -> None: + result = CliRunner().invoke(_create_app(), ["login", "anthropic"]) + + assert result.exit_code == 1 + assert "Unsupported auth provider: anthropic" in result.stderr diff --git a/tests/test_framework.py b/tests/test_framework.py index 51118ce..281ff2a 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -94,7 +94,7 @@ def system_prompt(self, prompt: str, state: dict[str, str]) -> str | None: assert prompt == "low\n\nhigh" -def test_builtin_cli_exposes_gateway_and_keeps_message_hidden_alias() -> None: +def test_builtin_cli_exposes_login_and_keeps_message_hidden_alias() -> None: framework = BubFramework() framework.load_hooks() app = framework.create_cli_app() @@ -104,6 +104,7 @@ def test_builtin_cli_exposes_gateway_and_keeps_message_hidden_alias() -> None: alias_result = runner.invoke(app, ["message", "--help"]) assert help_result.exit_code == 0 + assert "login" in help_result.stdout assert "gateway" in help_result.stdout assert "│ message" not in help_result.stdout assert alias_result.exit_code == 0 diff --git a/uv.lock b/uv.lock index 2800c3f..f8d49f9 100644 --- a/uv.lock +++ b/uv.lock @@ -253,7 +253,7 @@ requires-dist = [ { name = "python-telegram-bot", specifier = ">=21.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, { name = "rapidfuzz", specifier = ">=3.14.3" }, - { name = "republic", specifier = ">=0.5.3" }, + { name = "republic", specifier = ">=0.5.4" }, { name = "rich", specifier = ">=13.0.0" }, { name = "typer", specifier = ">=0.9.0" }, ]