Skip to content

Commit 774057d

Browse files
authored
feat: new bub command: onboard (#183)
* feat: new bub command: onboard Signed-off-by: Frost Ming <me@frostming.com> * feat: replace questionary with inquirer-textual for improved CLI prompts Signed-off-by: Frost Ming <me@frostming.com> * feat: implement inquirer utility functions for improved CLI interaction Signed-off-by: Frost Ming <me@frostming.com>
1 parent 1c19f4a commit 774057d

19 files changed

Lines changed: 884 additions & 42 deletions

File tree

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -106,16 +106,16 @@ Lines starting with `,` enter internal command mode (`,help`, `,skill name=my-sk
106106

107107
## Configuration
108108

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

120120
## Background
121121

env.example

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
# Agent runtime
66
# ---------------------------------------------------------------------------
77
# Republic model format: provider:model_id
8-
# Default in code is `openrouter:qwen/qwen3-coder-next`.
9-
# BUB_MODEL=openrouter:qwen/qwen3-coder-next
8+
# Default in code is `openrouter:openrouter/free`.
9+
# BUB_MODEL=openrouter:openrouter/free
1010
# BUB_MAX_STEPS=50
1111
# BUB_MAX_TOKENS=1024
1212
# BUB_MODEL_TIMEOUT_SECONDS=300
@@ -58,6 +58,6 @@
5858
# ---------------------------------------------------------------------------
5959
# Example minimal OpenRouter setup
6060
# ---------------------------------------------------------------------------
61-
# BUB_MODEL=openrouter:qwen/qwen3-coder-next
61+
# BUB_MODEL=openrouter:openrouter/free
6262
# BUB_API_KEY=sk-or-...
6363
# BUB_CLIENT_ARGS={"extra_headers":{"HTTP-Referer":"https://openclaw.ai","X-Title":"OpenClaw"}}

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ dependencies = [
2424
"pydantic-settings>=2.0.0",
2525
"pyyaml>=6.0.0",
2626
"pluggy>=1.6.0",
27+
"inquirer-textual>=0.5.1",
2728
"typer>=0.9.0",
2829
"republic>=0.5.4",
2930
"any-llm-sdk[anthropic]",

src/bub/builtin/cli.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,23 @@
1616

1717
import typer
1818

19+
from bub import __version__, configure
1920
from bub.builtin.auth import app as login_app # noqa: F401
2021
from bub.channels.message import ChannelMessage
2122
from bub.envelope import field_of
2223
from bub.framework import BubFramework
2324

25+
ONBOARD_BANNER = r"""
26+
███████████ █████
27+
▒▒███▒▒▒▒▒███ ▒▒███
28+
▒███ ▒███ █████ ████ ▒███████
29+
▒██████████ ▒▒███ ▒███ ▒███▒▒███
30+
▒███▒▒▒▒▒███ ▒███ ▒███ ▒███ ▒███
31+
▒███ ▒███ ▒███ ▒███ ▒███ ▒███
32+
███████████ ▒▒████████ ████████
33+
▒▒▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒ v{version}
34+
""".strip("\n")
35+
2436

2537
def run(
2638
ctx: typer.Context,
@@ -92,6 +104,25 @@ def chat(
92104
asyncio.run(manager.listen_and_run())
93105

94106

107+
def onboard(ctx: typer.Context) -> None:
108+
"""Interactively collect plugin configuration and write it to Bub's config file."""
109+
110+
framework = ctx.ensure_object(BubFramework)
111+
typer.echo(ONBOARD_BANNER.format(version=__version__))
112+
typer.echo("\nWelcome to Bub! Let's get you set up.\n")
113+
114+
try:
115+
config_data = framework.collect_onboard_config()
116+
configure.save(framework.config_file, config_data)
117+
except (typer.Abort, typer.Exit):
118+
raise
119+
except Exception as exc:
120+
typer.secho(f"Onboarding failed: {exc}", err=True, fg="red")
121+
raise typer.Exit(1) from exc
122+
123+
typer.echo(f"Saved config to {framework.config_file}")
124+
125+
95126
@lru_cache(maxsize=1)
96127
def _find_uv() -> str:
97128
import shutil

src/bub/builtin/hook_impl.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
from republic import AsyncStreamEvents, TapeContext
99
from republic.tape import TapeStore
1010

11+
from bub import inquirer as bub_inquirer
1112
from bub.builtin.agent import Agent
1213
from bub.builtin.context import default_tape_context
14+
from bub.builtin.settings import DEFAULT_MODEL
1315
from bub.channels.base import Channel
1416
from bub.channels.message import ChannelMessage, MediaItem
1517
from bub.envelope import content_of, field_of
@@ -18,6 +20,19 @@
1820
from bub.types import Envelope, MessageHandler, State
1921

2022
AGENTS_FILE_NAME = "AGENTS.md"
23+
MODEL_PROVIDER_CHOICES: tuple[str, ...] = (
24+
"openrouter",
25+
"openai",
26+
"anthropic",
27+
"gemini",
28+
"azure",
29+
"bedrock",
30+
"ollama",
31+
"groq",
32+
"mistral",
33+
"deepseek",
34+
)
35+
API_FORMAT_CHOICES: tuple[str, ...] = ("completion", "responses", "messages")
2136
DEFAULT_SYSTEM_PROMPT = """\
2237
<general_instruct>
2338
Call tools or skills to finish the task.
@@ -55,6 +70,37 @@ def _get_agent(self) -> Agent:
5570
self._agent = Agent(self.framework)
5671
return self._agent
5772

73+
@staticmethod
74+
async def _discard_message(_: ChannelMessage) -> None:
75+
return
76+
77+
@staticmethod
78+
def _split_model_identifier(model: str) -> tuple[str, str]:
79+
provider, separator, model_name = model.partition(":")
80+
if separator and provider and model_name:
81+
return provider.strip(), model_name.strip()
82+
default_provider, _, default_model_name = DEFAULT_MODEL.partition(":")
83+
fallback_model_name = model.strip() or default_model_name
84+
return default_provider, fallback_model_name
85+
86+
@staticmethod
87+
def _provider_choices(current_provider: str) -> list[str]:
88+
choices = list(MODEL_PROVIDER_CHOICES)
89+
if current_provider and current_provider not in choices:
90+
choices.append(current_provider)
91+
choices.append("custom")
92+
return choices
93+
94+
def _channel_choices(self) -> list[str]:
95+
return [c for c in self.framework.get_channels(self._discard_message) if c != "cli"]
96+
97+
@staticmethod
98+
def _default_enabled_channels(current_value: object, available_channels: list[str]) -> list[str]:
99+
if isinstance(current_value, str) and current_value.strip() and current_value.strip().lower() != "all":
100+
selected = [name.strip() for name in current_value.split(",") if name.strip() in available_channels]
101+
return selected
102+
return available_channels
103+
58104
@hookimpl
59105
def resolve_session(self, message: ChannelMessage) -> str:
60106
session_id = field_of(message, "session_id")
@@ -124,13 +170,69 @@ def register_cli_commands(self, app: typer.Typer) -> None:
124170

125171
app.command("run")(cli.run)
126172
app.command("chat")(cli.chat)
173+
app.command("onboard")(cli.onboard)
127174
app.add_typer(cli.login_app)
128175
app.command("hooks", hidden=True)(cli.list_hooks)
129176
app.command("gateway")(cli.gateway)
130177
app.command("install")(cli.install)
131178
app.command("uninstall")(cli.uninstall)
132179
app.command("update")(cli.update)
133180

181+
@hookimpl
182+
def onboard_config(self, current_config: dict[str, object]) -> dict[str, object] | None:
183+
current_model = current_config.get("model")
184+
model_default = str(current_model) if isinstance(current_model, str) and current_model else DEFAULT_MODEL
185+
provider_default, model_name_default = self._split_model_identifier(model_default)
186+
187+
provider = bub_inquirer.ask_fuzzy(
188+
"LLM provider",
189+
choices=self._provider_choices(provider_default),
190+
default=provider_default,
191+
)
192+
if provider == "custom":
193+
provider = bub_inquirer.ask_text("Custom provider", default=provider_default) or provider_default
194+
195+
model_name = bub_inquirer.ask_text("LLM model", default=model_name_default)
196+
if not model_name:
197+
model_name = model_name_default
198+
model = f"{provider}:{model_name}"
199+
200+
api_key = bub_inquirer.ask_secret("API key (optional)")
201+
202+
current_api_base = current_config.get("api_base")
203+
api_base_default = str(current_api_base) if isinstance(current_api_base, str) else ""
204+
api_base = bub_inquirer.ask_text("API base (optional)", default=api_base_default)
205+
206+
current_api_format = current_config.get("api_format")
207+
api_format_default = (
208+
str(current_api_format)
209+
if isinstance(current_api_format, str) and current_api_format in API_FORMAT_CHOICES
210+
else API_FORMAT_CHOICES[0]
211+
)
212+
api_format = bub_inquirer.ask_select("API format", choices=list(API_FORMAT_CHOICES), default=api_format_default)
213+
214+
available_channels = self._channel_choices()
215+
default_channels = self._default_enabled_channels(current_config.get("enabled_channels"), available_channels)
216+
enabled_channels = bub_inquirer.ask_checkbox(
217+
"Channels",
218+
choices=available_channels,
219+
enabled=default_channels,
220+
validate=lambda values: True if values else "Select at least one channel.",
221+
)
222+
223+
stream_output = bub_inquirer.ask_confirm("Stream output", default=bool(current_config.get("stream_output")))
224+
config: dict[str, object] = {
225+
"model": model,
226+
"api_format": api_format,
227+
"enabled_channels": ",".join(enabled_channels),
228+
"stream_output": stream_output,
229+
}
230+
if api_key:
231+
config["api_key"] = api_key
232+
if api_base:
233+
config["api_base"] = api_base
234+
return config
235+
134236
def _read_agents_file(self, state: State) -> str:
135237
workspace = state.get("_runtime_workspace", str(Path.cwd()))
136238
prompt_path = Path(workspace) / AGENTS_FILE_NAME

src/bub/builtin/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from bub import Settings, config, ensure_config
1313

14-
DEFAULT_MODEL = "openrouter:qwen/qwen3-coder-next"
14+
DEFAULT_MODEL = "openrouter:openrouter/free"
1515
DEFAULT_MAX_TOKENS = 1024
1616

1717

src/bub/configure.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,42 @@ def load(config_file: Path) -> dict[str, Any]:
4242
"""Load config from a file."""
4343
import yaml
4444

45+
_global_config.clear()
4546
_config_data.clear()
4647
if config_file.exists():
4748
with config_file.open() as f:
4849
_config_data.update(yaml.safe_load(f) or {})
4950
return _config_data
5051

5152

53+
def merge(base: dict[str, Any], *updates: dict[str, Any]) -> dict[str, Any]:
54+
"""Update base in place with config updates, preferring incoming values on conflict."""
55+
56+
for update in updates:
57+
_merge_into(base, update, path=())
58+
return base
59+
60+
61+
def validate(config_data: dict[str, Any]) -> dict[str, Any]:
62+
"""Validate config data against all registered config classes."""
63+
64+
for section, config_classes in CONFIG_MAP.items():
65+
section_data = config_data if section == ROOT else config_data.get(section, {})
66+
for config_cls in config_classes:
67+
config_cls.model_validate(section_data)
68+
return config_data
69+
70+
71+
def save(config_file: Path, config_data: dict[str, Any]) -> None:
72+
"""Validate and persist config data to a YAML file."""
73+
import yaml
74+
75+
validated = validate(config_data)
76+
config_file.parent.mkdir(parents=True, exist_ok=True)
77+
with config_file.open("w", encoding="utf-8") as f:
78+
yaml.safe_dump(validated, f, sort_keys=False)
79+
80+
5281
def ensure_config[C: BaseSettings](config_cls: type[C]) -> C:
5382
"""No-op function to ensure a config class is registered and can be imported."""
5483
section = getattr(config_cls, "__config_name__", ROOT)
@@ -64,3 +93,25 @@ def ensure_config[C: BaseSettings](config_cls: type[C]) -> C:
6493
instance = config_cls.model_validate(section_data)
6594
instances.append(instance)
6695
return instance
96+
97+
98+
def _copy_dict(data: dict[str, Any]) -> dict[str, Any]:
99+
copied: dict[str, Any] = {}
100+
for key, value in data.items():
101+
if isinstance(value, dict):
102+
copied[key] = _copy_dict(value)
103+
else:
104+
copied[key] = value
105+
return copied
106+
107+
108+
def _merge_into(target: dict[str, Any], incoming: dict[str, Any], path: tuple[str, ...]) -> None:
109+
for key, value in incoming.items():
110+
existing = target.get(key)
111+
if key not in target:
112+
target[key] = _copy_dict(value) if isinstance(value, dict) else value
113+
continue
114+
if isinstance(existing, dict) and isinstance(value, dict):
115+
_merge_into(existing, value, path=(*path, key))
116+
continue
117+
target[key] = _copy_dict(value) if isinstance(value, dict) else value

src/bub/framework.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from bub import configure
1818
from bub.envelope import content_of, field_of, unpack_batch
19-
from bub.hook_runtime import HookRuntime
19+
from bub.hook_runtime import _SKIP_VALUE, HookRuntime
2020
from bub.hookspecs import BUB_HOOK_NAMESPACE, BubHookSpecs
2121
from bub.types import Envelope, MessageHandler, OutboundChannelRouter, TurnResult
2222

@@ -40,12 +40,13 @@ class BubFramework:
4040

4141
def __init__(self, config_file: Path = DEFAULT_CONFIG_FILE) -> None:
4242
self.workspace = Path.cwd().resolve()
43+
self.config_file = config_file.resolve()
4344
self._plugin_manager = pluggy.PluginManager(BUB_HOOK_NAMESPACE)
4445
self._plugin_manager.add_hookspecs(BubHookSpecs)
4546
self._hook_runtime = HookRuntime(self._plugin_manager)
4647
self._plugin_status: dict[str, PluginStatus] = {}
4748
self._outbound_router: OutboundChannelRouter | None = None
48-
configure.load(config_file)
49+
configure.load(self.config_file)
4950

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

265266
def build_tape_context(self) -> TapeContext:
266267
return self._hook_runtime.call_first_sync("build_tape_context")
268+
269+
def collect_onboard_config(self) -> dict[str, Any]:
270+
current_config: dict[str, Any] = {}
271+
272+
for impl in self._hook_runtime._iter_hookimpls("onboard_config"):
273+
result = self._hook_runtime._invoke_impl_sync(
274+
hook_name="onboard_config",
275+
impl=impl,
276+
call_kwargs={"current_config": current_config},
277+
kwargs={"current_config": current_config},
278+
)
279+
if result is _SKIP_VALUE:
280+
continue
281+
if result is None:
282+
continue
283+
if not isinstance(result, dict):
284+
raise TypeError("hook.onboard_config must return dict or None")
285+
configure.merge(current_config, result)
286+
return configure.validate(current_config)

0 commit comments

Comments
 (0)