Skip to content
Merged
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
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,16 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk

## Configuration

| Variable | Default | Description |
|----------|---------|-------------|
| `BUB_MODEL` | `openrouter:qwen/qwen3-coder-next` | Model identifier |
| `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) |
| `BUB_API_BASE` | — | Custom provider endpoint |
| `BUB_API_FORMAT` | `completion` | `completion`, `responses`, or `messages` |
| `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
| `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
| `BUB_MAX_TOKENS` | `1024` | Max tokens per model call |
| `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |
| Variable | Default | Description |
| --------------------------- | ---------------------------- | ---------------------------------------------------- |
| `BUB_MODEL` | `openrouter:openrouter/free` | Model identifier |
| `BUB_API_KEY` | — | Provider key (optional with `bub login openai`) |
| `BUB_API_BASE` | — | Custom provider endpoint |
| `BUB_API_FORMAT` | `completion` | `completion`, `responses`, or `messages` |
| `BUB_CLIENT_ARGS` | — | JSON object forwarded to the underlying model client |
| `BUB_MAX_STEPS` | `50` | Max tool-use loop iterations |
| `BUB_MAX_TOKENS` | `1024` | Max tokens per model call |
| `BUB_MODEL_TIMEOUT_SECONDS` | — | Model call timeout (seconds) |

## Background

Expand Down
6 changes: 3 additions & 3 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
# Agent runtime
# ---------------------------------------------------------------------------
# Republic model format: provider:model_id
# Default in code is `openrouter:qwen/qwen3-coder-next`.
# BUB_MODEL=openrouter:qwen/qwen3-coder-next
# Default in code is `openrouter:openrouter/free`.
# BUB_MODEL=openrouter:openrouter/free
# BUB_MAX_STEPS=50
# BUB_MAX_TOKENS=1024
# BUB_MODEL_TIMEOUT_SECONDS=300
Expand Down Expand Up @@ -58,6 +58,6 @@
# ---------------------------------------------------------------------------
# Example minimal OpenRouter setup
# ---------------------------------------------------------------------------
# BUB_MODEL=openrouter:qwen/qwen3-coder-next
# BUB_MODEL=openrouter:openrouter/free
# BUB_API_KEY=sk-or-...
# BUB_CLIENT_ARGS={"extra_headers":{"HTTP-Referer":"https://openclaw.ai","X-Title":"OpenClaw"}}
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ dependencies = [
"pydantic-settings>=2.0.0",
"pyyaml>=6.0.0",
"pluggy>=1.6.0",
"inquirer-textual>=0.5.1",
"typer>=0.9.0",
"republic>=0.5.4",
"any-llm-sdk[anthropic]",
Expand Down
31 changes: 31 additions & 0 deletions src/bub/builtin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,23 @@

import typer

from bub import __version__, configure
from bub.builtin.auth import app as login_app # noqa: F401
from bub.channels.message import ChannelMessage
from bub.envelope import field_of
from bub.framework import BubFramework

ONBOARD_BANNER = r"""
███████████ █████
▒▒███▒▒▒▒▒███ ▒▒███
▒███ ▒███ █████ ████ ▒███████
▒██████████ ▒▒███ ▒███ ▒███▒▒███
▒███▒▒▒▒▒███ ▒███ ▒███ ▒███ ▒███
▒███ ▒███ ▒███ ▒███ ▒███ ▒███
███████████ ▒▒████████ ████████
▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒ v{version}
""".strip("\n")


def run(
ctx: typer.Context,
Expand Down Expand Up @@ -92,6 +104,25 @@ def chat(
asyncio.run(manager.listen_and_run())


def onboard(ctx: typer.Context) -> None:
"""Interactively collect plugin configuration and write it to Bub's config file."""

framework = ctx.ensure_object(BubFramework)
typer.echo(ONBOARD_BANNER.format(version=__version__))
typer.echo("\nWelcome to Bub! Let's get you set up.\n")

try:
config_data = framework.collect_onboard_config()
configure.save(framework.config_file, config_data)
except (typer.Abort, typer.Exit):
raise
except Exception as exc:
typer.secho(f"Onboarding failed: {exc}", err=True, fg="red")
raise typer.Exit(1) from exc

typer.echo(f"Saved config to {framework.config_file}")


@lru_cache(maxsize=1)
def _find_uv() -> str:
import shutil
Expand Down
102 changes: 102 additions & 0 deletions src/bub/builtin/hook_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
from republic import AsyncStreamEvents, TapeContext
from republic.tape import TapeStore

from bub import inquirer as bub_inquirer
from bub.builtin.agent import Agent
from bub.builtin.context import default_tape_context
from bub.builtin.settings import DEFAULT_MODEL
from bub.channels.base import Channel
from bub.channels.message import ChannelMessage, MediaItem
from bub.envelope import content_of, field_of
Expand All @@ -18,6 +20,19 @@
from bub.types import Envelope, MessageHandler, State

AGENTS_FILE_NAME = "AGENTS.md"
MODEL_PROVIDER_CHOICES: tuple[str, ...] = (
"openrouter",
"openai",
"anthropic",
"gemini",
"azure",
"bedrock",
"ollama",
"groq",
"mistral",
"deepseek",
)
API_FORMAT_CHOICES: tuple[str, ...] = ("completion", "responses", "messages")
DEFAULT_SYSTEM_PROMPT = """\
<general_instruct>
Call tools or skills to finish the task.
Expand Down Expand Up @@ -55,6 +70,37 @@ def _get_agent(self) -> Agent:
self._agent = Agent(self.framework)
return self._agent

@staticmethod
async def _discard_message(_: ChannelMessage) -> None:
return

@staticmethod
def _split_model_identifier(model: str) -> tuple[str, str]:
provider, separator, model_name = model.partition(":")
if separator and provider and model_name:
return provider.strip(), model_name.strip()
default_provider, _, default_model_name = DEFAULT_MODEL.partition(":")
fallback_model_name = model.strip() or default_model_name
return default_provider, fallback_model_name

@staticmethod
def _provider_choices(current_provider: str) -> list[str]:
choices = list(MODEL_PROVIDER_CHOICES)
if current_provider and current_provider not in choices:
choices.append(current_provider)
choices.append("custom")
return choices

def _channel_choices(self) -> list[str]:
return [c for c in self.framework.get_channels(self._discard_message) if c != "cli"]

@staticmethod
def _default_enabled_channels(current_value: object, available_channels: list[str]) -> list[str]:
if isinstance(current_value, str) and current_value.strip() and current_value.strip().lower() != "all":
selected = [name.strip() for name in current_value.split(",") if name.strip() in available_channels]
return selected
return available_channels

@hookimpl
def resolve_session(self, message: ChannelMessage) -> str:
session_id = field_of(message, "session_id")
Expand Down Expand Up @@ -124,13 +170,69 @@ def register_cli_commands(self, app: typer.Typer) -> None:

app.command("run")(cli.run)
app.command("chat")(cli.chat)
app.command("onboard")(cli.onboard)
app.add_typer(cli.login_app)
app.command("hooks", hidden=True)(cli.list_hooks)
app.command("gateway")(cli.gateway)
app.command("install")(cli.install)
app.command("uninstall")(cli.uninstall)
app.command("update")(cli.update)

@hookimpl
def onboard_config(self, current_config: dict[str, object]) -> dict[str, object] | None:
current_model = current_config.get("model")
model_default = str(current_model) if isinstance(current_model, str) and current_model else DEFAULT_MODEL
provider_default, model_name_default = self._split_model_identifier(model_default)

provider = bub_inquirer.ask_fuzzy(
"LLM provider",
choices=self._provider_choices(provider_default),
default=provider_default,
)
if provider == "custom":
provider = bub_inquirer.ask_text("Custom provider", default=provider_default) or provider_default

model_name = bub_inquirer.ask_text("LLM model", default=model_name_default)
if not model_name:
model_name = model_name_default
model = f"{provider}:{model_name}"

api_key = bub_inquirer.ask_secret("API key (optional)")

current_api_base = current_config.get("api_base")
api_base_default = str(current_api_base) if isinstance(current_api_base, str) else ""
api_base = bub_inquirer.ask_text("API base (optional)", default=api_base_default)

current_api_format = current_config.get("api_format")
api_format_default = (
str(current_api_format)
if isinstance(current_api_format, str) and current_api_format in API_FORMAT_CHOICES
else API_FORMAT_CHOICES[0]
)
api_format = bub_inquirer.ask_select("API format", choices=list(API_FORMAT_CHOICES), default=api_format_default)

available_channels = self._channel_choices()
default_channels = self._default_enabled_channels(current_config.get("enabled_channels"), available_channels)
enabled_channels = bub_inquirer.ask_checkbox(
"Channels",
choices=available_channels,
enabled=default_channels,
validate=lambda values: True if values else "Select at least one channel.",
)

stream_output = bub_inquirer.ask_confirm("Stream output", default=bool(current_config.get("stream_output")))
config: dict[str, object] = {
"model": model,
"api_format": api_format,
"enabled_channels": ",".join(enabled_channels),
"stream_output": stream_output,
}
if api_key:
config["api_key"] = api_key
if api_base:
config["api_base"] = api_base
return config

def _read_agents_file(self, state: State) -> str:
workspace = state.get("_runtime_workspace", str(Path.cwd()))
prompt_path = Path(workspace) / AGENTS_FILE_NAME
Expand Down
2 changes: 1 addition & 1 deletion src/bub/builtin/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from bub import Settings, config, ensure_config

DEFAULT_MODEL = "openrouter:qwen/qwen3-coder-next"
DEFAULT_MODEL = "openrouter:openrouter/free"
DEFAULT_MAX_TOKENS = 1024


Expand Down
51 changes: 51 additions & 0 deletions src/bub/configure.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,42 @@ def load(config_file: Path) -> dict[str, Any]:
"""Load config from a file."""
import yaml

_global_config.clear()
_config_data.clear()
if config_file.exists():
with config_file.open() as f:
_config_data.update(yaml.safe_load(f) or {})
return _config_data


def merge(base: dict[str, Any], *updates: dict[str, Any]) -> dict[str, Any]:
"""Update base in place with config updates, preferring incoming values on conflict."""

for update in updates:
_merge_into(base, update, path=())
return base


def validate(config_data: dict[str, Any]) -> dict[str, Any]:
"""Validate config data against all registered config classes."""

for section, config_classes in CONFIG_MAP.items():
section_data = config_data if section == ROOT else config_data.get(section, {})
for config_cls in config_classes:
config_cls.model_validate(section_data)
return config_data


def save(config_file: Path, config_data: dict[str, Any]) -> None:
"""Validate and persist config data to a YAML file."""
import yaml

validated = validate(config_data)
config_file.parent.mkdir(parents=True, exist_ok=True)
with config_file.open("w", encoding="utf-8") as f:
yaml.safe_dump(validated, f, sort_keys=False)


def ensure_config[C: BaseSettings](config_cls: type[C]) -> C:
"""No-op function to ensure a config class is registered and can be imported."""
section = getattr(config_cls, "__config_name__", ROOT)
Expand All @@ -64,3 +93,25 @@ def ensure_config[C: BaseSettings](config_cls: type[C]) -> C:
instance = config_cls.model_validate(section_data)
instances.append(instance)
return instance


def _copy_dict(data: dict[str, Any]) -> dict[str, Any]:
copied: dict[str, Any] = {}
for key, value in data.items():
if isinstance(value, dict):
copied[key] = _copy_dict(value)
else:
copied[key] = value
return copied


def _merge_into(target: dict[str, Any], incoming: dict[str, Any], path: tuple[str, ...]) -> None:
for key, value in incoming.items():
existing = target.get(key)
if key not in target:
target[key] = _copy_dict(value) if isinstance(value, dict) else value
continue
if isinstance(existing, dict) and isinstance(value, dict):
_merge_into(existing, value, path=(*path, key))
continue
target[key] = _copy_dict(value) if isinstance(value, dict) else value
24 changes: 22 additions & 2 deletions src/bub/framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from bub import configure
from bub.envelope import content_of, field_of, unpack_batch
from bub.hook_runtime import HookRuntime
from bub.hook_runtime import _SKIP_VALUE, HookRuntime
from bub.hookspecs import BUB_HOOK_NAMESPACE, BubHookSpecs
from bub.types import Envelope, MessageHandler, OutboundChannelRouter, TurnResult

Expand All @@ -40,12 +40,13 @@ class BubFramework:

def __init__(self, config_file: Path = DEFAULT_CONFIG_FILE) -> None:
self.workspace = Path.cwd().resolve()
self.config_file = config_file.resolve()
self._plugin_manager = pluggy.PluginManager(BUB_HOOK_NAMESPACE)
self._plugin_manager.add_hookspecs(BubHookSpecs)
self._hook_runtime = HookRuntime(self._plugin_manager)
self._plugin_status: dict[str, PluginStatus] = {}
self._outbound_router: OutboundChannelRouter | None = None
configure.load(config_file)
configure.load(self.config_file)

def _load_builtin_hooks(self) -> None:
from bub.builtin.hook_impl import BuiltinImpl
Expand Down Expand Up @@ -264,3 +265,22 @@ def get_system_prompt(self, prompt: str | list[dict], state: dict[str, Any]) ->

def build_tape_context(self) -> TapeContext:
return self._hook_runtime.call_first_sync("build_tape_context")

def collect_onboard_config(self) -> dict[str, Any]:
current_config: dict[str, Any] = {}

for impl in self._hook_runtime._iter_hookimpls("onboard_config"):
result = self._hook_runtime._invoke_impl_sync(
hook_name="onboard_config",
impl=impl,
call_kwargs={"current_config": current_config},
kwargs={"current_config": current_config},
)
if result is _SKIP_VALUE:
continue
if result is None:
continue
if not isinstance(result, dict):
raise TypeError("hook.onboard_config must return dict or None")
configure.merge(current_config, result)
return configure.validate(current_config)
Loading
Loading