feat: transport registry and onboarding updates (#69)

This commit is contained in:
banteg
2026-01-08 12:55:15 +04:00
committed by GitHub
parent c0579a4ebd
commit 75d669bdd7
11 changed files with 442 additions and 110 deletions
+15 -1
View File
@@ -77,12 +77,25 @@ Defines `Transport`, `MessageRef`, `RenderedMessage`, and `SendOptions`.
Defines a renderer that converts `ProgressState` into `RenderedMessage` outputs.
### `transports.py` - Transport registry
Defines the transport backend protocol, registry helpers, and built-in transport registration.
### `config_migrations.py` - Config migrations
Applies one-time edits to on-disk config (e.g., legacy Telegram key migration) before
`TakopiSettings` validation runs.
### `telegram/backend.py` - Telegram transport backend
Adapter that validates Telegram config, runs onboarding, and builds/runs the Telegram bridge.
### `cli.py` - CLI entry point
| Component | Purpose |
|-----------|---------|
| `run()` / `main()` | Typer CLI entry points |
| `_parse_bridge_config()` | Reads config + builds `TelegramBridgeConfig` + `ExecBridgeConfig` |
| `_run_auto_router()` | Loads settings, resolves transport + engine, builds router, delegates to transport backend |
### `progress.py` - Progress tracking
@@ -261,6 +274,7 @@ Environment flags:
- `PI_CODING_AGENT_DIR` (override Pi agent session directory base path)
CLI flag: `--debug` enables debug logging (overrides `TAKOPI_LOG_LEVEL`).
CLI flag: `--transport <id>` overrides the configured transport backend.
### `telegram/onboarding.py` - Setup validation
+5 -1
View File
@@ -20,7 +20,7 @@ All config lives in `~/.takopi/takopi.toml`.
```toml
default_engine = "codex" # optional
default_project = "z80" # optional
transport = "telegram" # required
transport = "telegram" # optional, defaults to "telegram"
[transports.telegram]
bot_token = "..." # required
@@ -33,6 +33,9 @@ default_engine = "codex" # optional, per-project override
worktree_base = "master" # optional, base for new branches
```
Legacy config note: top-level `bot_token` / `chat_id` are auto-migrated into
`[transports.telegram]` on startup.
Note on `worktrees_dir`:
- The default `.worktrees` lives inside the repo root. You'll see it as an
@@ -49,6 +52,7 @@ Validation rules:
- `default_project` must match a configured project alias.
- Project aliases cannot collide with engine ids or reserved commands (`/cancel`).
- `default_engine` and per-project `default_engine` must be valid engine ids.
- `transport` defaults to `"telegram"` when omitted; override per-run with `--transport`.
## `takopi init`
+9 -1
View File
@@ -53,6 +53,7 @@ global config `~/.takopi/takopi.toml`
```toml
default_engine = "codex"
# optional, defaults to "telegram"
transport = "telegram"
[transports.telegram]
@@ -81,7 +82,7 @@ provider = "openai"
extra_args = ["--no-color"]
```
note: configs with top-level `bot_token` / `chat_id` must be migrated to `[transports.telegram]`.
note: configs with top-level `bot_token` / `chat_id` are migrated to `[transports.telegram]` on startup.
## projects
@@ -122,6 +123,13 @@ takopi opencode
takopi pi
```
list available transports (and override in a run):
```sh
takopi transports
takopi --transport telegram
```
resume lines always route to the matching engine; subcommands only override the default for new threads.
send a message to the bot.
+107 -93
View File
@@ -6,7 +6,6 @@ import sys
from collections.abc import Callable
from pathlib import Path
import anyio
import typer
from . import __version__
@@ -16,7 +15,6 @@ from .engines import get_backend, list_backends
from .lockfile import LockError, LockHandle, acquire_lock, token_fingerprint
from .logging import get_logger, setup_logging
from .router import AutoRouter, RunnerEntry
from .runner_bridge import ExecBridgeConfig
from .settings import (
TakopiSettings,
load_settings,
@@ -24,14 +22,7 @@ from .settings import (
require_telegram,
validate_settings_data,
)
from .telegram.bridge import (
TelegramBridgeConfig,
TelegramPresenter,
TelegramTransport,
run_main_loop,
)
from .telegram.client import TelegramClient
from .telegram.onboarding import SetupResult, check_setup, interactive_setup
from .transports import SetupResult, get_transport, list_transports
from .utils.git import resolve_default_base, resolve_main_worktree_root
logger = get_logger(__name__)
@@ -47,6 +38,22 @@ def _version_callback(value: bool) -> None:
_print_version_and_exit()
def _resolve_transport_id(override: str | None) -> str:
if override is not None:
value = override.strip()
if not value:
raise ConfigError("Invalid `--transport`; expected a non-empty string.")
return value
try:
config, _ = load_or_init_config()
except ConfigError:
return "telegram"
raw = config.get("transport")
if not isinstance(raw, str) or not raw.strip():
return "telegram"
return raw.strip()
def load_and_validate_config(
path: str | Path | None = None,
) -> tuple[TakopiSettings, Path, str, int]:
@@ -55,11 +62,12 @@ def load_and_validate_config(
return settings, config_path, token, chat_id
def acquire_config_lock(config_path: Path, token: str) -> LockHandle:
def acquire_config_lock(config_path: Path, token: str | None) -> LockHandle:
fingerprint = token_fingerprint(token) if token else None
try:
return acquire_lock(
config_path=config_path,
token_fingerprint=token_fingerprint(token),
token_fingerprint=fingerprint,
)
except LockError as exc:
lines = str(exc).splitlines()
@@ -75,7 +83,10 @@ def acquire_config_lock(config_path: Path, token: str) -> LockHandle:
def _default_engine_for_setup(override: str | None) -> str:
if override:
return override
loaded = load_settings_if_exists()
try:
loaded = load_settings_if_exists()
except ConfigError:
return "codex"
if loaded is None:
return "codex"
settings, config_path = loaded
@@ -173,72 +184,6 @@ def _build_router(
return AutoRouter(entries=entries, default_engine=default_engine)
def _parse_bridge_config(
*,
final_notify: bool,
default_engine_override: str | None,
settings: TakopiSettings,
config_path: Path,
token: str,
chat_id: int,
) -> TelegramBridgeConfig:
startup_pwd = os.getcwd()
backends = list_backends()
projects = settings.to_projects_config(
config_path=config_path,
engine_ids=[backend.id for backend in backends],
reserved=("cancel",),
)
default_engine = _resolve_default_engine(
override=default_engine_override,
settings=settings,
config_path=config_path,
backends=backends,
)
router = _build_router(
settings=settings,
config_path=config_path,
backends=backends,
default_engine=default_engine,
)
available_engines = [entry.engine for entry in router.available_entries]
missing_engines = [entry.engine for entry in router.entries if not entry.available]
engine_list = ", ".join(available_engines) if available_engines else "none"
if missing_engines:
engine_list = f"{engine_list} (not installed: {', '.join(missing_engines)})"
project_aliases = sorted(
{project.alias for project in projects.projects.values()},
key=str.lower,
)
project_list = ", ".join(project_aliases) if project_aliases else "none"
startup_msg = (
f"\N{OCTOPUS} **takopi is ready**\n\n"
f"default: `{router.default_engine}` \n"
f"agents: `{engine_list}` \n"
f"projects: `{project_list}` \n"
f"working in: `{startup_pwd}`"
)
bot = TelegramClient(token)
transport = TelegramTransport(bot)
presenter = TelegramPresenter()
exec_cfg = ExecBridgeConfig(
transport=transport,
presenter=presenter,
final_notify=final_notify,
)
return TelegramBridgeConfig(
bot=bot,
router=router,
chat_id=chat_id,
startup_msg=startup_msg,
exec_cfg=exec_cfg,
projects=projects,
)
def _config_path_display(path: Path) -> str:
home = Path.home()
try:
@@ -259,12 +204,16 @@ def _setup_needs_config(setup: SetupResult) -> bool:
def _fail_missing_config(path: Path) -> None:
display = _config_path_display(path)
typer.echo(f"error: missing takopi config at {display}", err=True)
if path.exists():
typer.echo(f"error: invalid takopi config at {display}", err=True)
else:
typer.echo(f"error: missing takopi config at {display}", err=True)
def _run_auto_router(
*,
default_engine_override: str | None,
transport_override: str | None,
final_notify: bool,
debug: bool,
onboard: bool,
@@ -273,7 +222,9 @@ def _run_auto_router(
lock_handle: LockHandle | None = None
try:
default_engine = _default_engine_for_setup(default_engine_override)
backend = get_backend(default_engine)
engine_backend = get_backend(default_engine)
transport_id = _resolve_transport_id(transport_override)
transport_backend = get_transport(transport_id)
except ConfigError as e:
typer.echo(f"error: {e}", err=True)
raise typer.Exit(code=1)
@@ -281,17 +232,37 @@ def _run_auto_router(
if not _should_run_interactive():
typer.echo("error: --onboard requires a TTY", err=True)
raise typer.Exit(code=1)
if not interactive_setup(force=True):
if not transport_backend.interactive_setup(force=True):
raise typer.Exit(code=1)
default_engine = _default_engine_for_setup(default_engine_override)
backend = get_backend(default_engine)
setup = check_setup(backend)
engine_backend = get_backend(default_engine)
setup = transport_backend.check_setup(
engine_backend,
transport_override=transport_override,
)
if not setup.ok:
if _setup_needs_config(setup) and _should_run_interactive():
if interactive_setup(force=False):
if setup.config_path.exists():
display = _config_path_display(setup.config_path)
run_onboard = typer.confirm(
f"config at {display} is missing/invalid for "
f"{transport_backend.id}, run onboarding now?",
default=False,
)
if run_onboard and transport_backend.interactive_setup(force=True):
default_engine = _default_engine_for_setup(default_engine_override)
engine_backend = get_backend(default_engine)
setup = transport_backend.check_setup(
engine_backend,
transport_override=transport_override,
)
elif transport_backend.interactive_setup(force=False):
default_engine = _default_engine_for_setup(default_engine_override)
backend = get_backend(default_engine)
setup = check_setup(backend)
engine_backend = get_backend(default_engine)
setup = transport_backend.check_setup(
engine_backend,
transport_override=transport_override,
)
if not setup.ok:
if _setup_needs_config(setup):
_fail_missing_config(setup.config_path)
@@ -300,17 +271,40 @@ def _run_auto_router(
typer.echo(f"error: {first.title}", err=True)
raise typer.Exit(code=1)
try:
settings, config_path, token, chat_id = load_and_validate_config()
lock_handle = acquire_config_lock(config_path, token)
cfg = _parse_bridge_config(
settings, config_path = load_settings()
if transport_override and transport_override != settings.transport:
settings = settings.model_copy(update={"transport": transport_override})
backends = list_backends()
projects = settings.to_projects_config(
config_path=config_path,
engine_ids=[backend.id for backend in backends],
reserved=("cancel",),
)
default_engine = _resolve_default_engine(
override=default_engine_override,
settings=settings,
config_path=config_path,
backends=backends,
)
router = _build_router(
settings=settings,
config_path=config_path,
backends=backends,
default_engine=default_engine,
)
lock_token = transport_backend.lock_token(
settings=settings,
config_path=config_path,
)
lock_handle = acquire_config_lock(config_path, lock_token)
transport_backend.build_and_run(
final_notify=final_notify,
default_engine_override=default_engine_override,
settings=settings,
config_path=config_path,
token=token,
chat_id=chat_id,
router=router,
projects=projects,
)
anyio.run(run_main_loop, cfg)
except ConfigError as e:
typer.echo(f"error: {e}", err=True)
raise typer.Exit(code=1)
@@ -423,6 +417,13 @@ def init(
typer.echo(f"saved project {alias!r} to {_config_path_display(config_path)}")
def transports_cmd() -> None:
"""List available transport backends."""
ids = list_transports()
for transport_id in ids:
typer.echo(transport_id)
app = typer.Typer(
add_completion=False,
invoke_without_command=True,
@@ -431,6 +432,7 @@ app = typer.Typer(
app.command(name="init")(init)
app.command(name="transports")(transports_cmd)
@app.callback()
@@ -453,6 +455,11 @@ def app_main(
"--onboard/--no-onboard",
help="Run the interactive setup wizard before starting.",
),
transport: str | None = typer.Option(
None,
"--transport",
help="Override the transport backend id.",
),
debug: bool = typer.Option(
False,
"--debug/--no-debug",
@@ -463,6 +470,7 @@ def app_main(
if ctx.invoked_subcommand is None:
_run_auto_router(
default_engine_override=None,
transport_override=transport,
final_notify=final_notify,
debug=debug,
onboard=onboard,
@@ -482,6 +490,11 @@ def make_engine_cmd(engine_id: str) -> Callable[..., None]:
"--onboard/--no-onboard",
help="Run the interactive setup wizard before starting.",
),
transport: str | None = typer.Option(
None,
"--transport",
help="Override the transport backend id.",
),
debug: bool = typer.Option(
False,
"--debug/--no-debug",
@@ -490,6 +503,7 @@ def make_engine_cmd(engine_id: str) -> Callable[..., None]:
) -> None:
_run_auto_router(
default_engine_override=engine_id,
transport_override=transport,
final_notify=final_notify,
debug=debug,
onboard=onboard,
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from .config import ConfigError
from .config_store import read_raw_toml, write_raw_toml
from .logging import get_logger
logger = get_logger(__name__)
def _ensure_table(
config: dict[str, Any],
key: str,
*,
config_path: Path,
label: str | None = None,
) -> dict[str, Any]:
value = config.get(key)
if value is None:
table: dict[str, Any] = {}
config[key] = table
return table
if not isinstance(value, dict):
name = label or key
raise ConfigError(f"Invalid `{name}` in {config_path}; expected a table.")
return value
def _migrate_legacy_telegram(config: dict[str, Any], *, config_path: Path) -> bool:
has_legacy = "bot_token" in config or "chat_id" in config
if not has_legacy:
return False
transports = _ensure_table(config, "transports", config_path=config_path)
telegram = transports.get("telegram")
if telegram is None:
telegram = {}
transports["telegram"] = telegram
if not isinstance(telegram, dict):
raise ConfigError(
f"Invalid `transports.telegram` in {config_path}; expected a table."
)
if "bot_token" in config and "bot_token" not in telegram:
telegram["bot_token"] = config["bot_token"]
if "chat_id" in config and "chat_id" not in telegram:
telegram["chat_id"] = config["chat_id"]
config.pop("bot_token", None)
config.pop("chat_id", None)
config.setdefault("transport", "telegram")
return True
def migrate_config(config: dict[str, Any], *, config_path: Path) -> list[str]:
applied: list[str] = []
if _migrate_legacy_telegram(config, config_path=config_path):
applied.append("legacy-telegram")
return applied
def migrate_config_file(path: Path) -> list[str]:
config = read_raw_toml(path)
applied = migrate_config(config, config_path=path)
if applied:
write_raw_toml(config, path)
for migration in applied:
logger.info(
"config.migrated",
migration=migration,
path=str(path),
)
return applied
+3
View File
@@ -17,6 +17,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic_settings.sources import TomlConfigSettingsSource
from .config import ConfigError, ProjectConfig, ProjectsConfig, HOME_CONFIG_PATH
from .config_migrations import migrate_config_file
class TelegramTransportSettings(BaseModel):
@@ -263,6 +264,7 @@ class TakopiSettings(BaseSettings):
def load_settings(path: str | Path | None = None) -> tuple[TakopiSettings, Path]:
cfg_path = _resolve_config_path(path)
_ensure_config_file(cfg_path)
migrate_config_file(cfg_path)
return _load_settings_from_path(cfg_path), cfg_path
@@ -275,6 +277,7 @@ def load_settings_if_exists(
raise ConfigError(
f"Config path {cfg_path} exists but is not a file."
) from None
migrate_config_file(cfg_path)
return _load_settings_from_path(cfg_path), cfg_path
return None
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
import os
from pathlib import Path
import anyio
from ..backends import EngineBackend
from ..config import ProjectsConfig
from ..router import AutoRouter
from ..runner_bridge import ExecBridgeConfig
from ..settings import TakopiSettings, require_telegram
from ..transports import SetupResult, TransportBackend
from .bridge import (
TelegramBridgeConfig,
TelegramPresenter,
TelegramTransport,
run_main_loop,
)
from .client import TelegramClient
from .onboarding import check_setup, interactive_setup
def _build_startup_message(
router: AutoRouter,
projects: ProjectsConfig,
*,
startup_pwd: str,
) -> str:
available_engines = [entry.engine for entry in router.available_entries]
missing_engines = [entry.engine for entry in router.entries if not entry.available]
engine_list = ", ".join(available_engines) if available_engines else "none"
if missing_engines:
engine_list = f"{engine_list} (not installed: {', '.join(missing_engines)})"
project_aliases = sorted(
{project.alias for project in projects.projects.values()},
key=str.lower,
)
project_list = ", ".join(project_aliases) if project_aliases else "none"
return (
f"\N{OCTOPUS} **takopi is ready**\n\n"
f"default: `{router.default_engine}` \n"
f"agents: `{engine_list}` \n"
f"projects: `{project_list}` \n"
f"working in: `{startup_pwd}`"
)
class TelegramBackend(TransportBackend):
id = "telegram"
description = "Telegram bot"
def check_setup(
self,
engine_backend: EngineBackend,
*,
transport_override: str | None = None,
) -> SetupResult:
return check_setup(engine_backend, transport_override=transport_override)
def interactive_setup(self, *, force: bool) -> bool:
return interactive_setup(force=force)
def lock_token(self, *, settings: TakopiSettings, config_path: Path) -> str | None:
token, _ = require_telegram(settings, config_path)
return token
def build_and_run(
self,
*,
settings: TakopiSettings,
config_path: Path,
router: AutoRouter,
projects: ProjectsConfig,
final_notify: bool,
default_engine_override: str | None,
) -> None:
token, chat_id = require_telegram(settings, config_path)
startup_msg = _build_startup_message(
router,
projects,
startup_pwd=os.getcwd(),
)
bot = TelegramClient(token)
transport = TelegramTransport(bot)
presenter = TelegramPresenter()
exec_cfg = ExecBridgeConfig(
transport=transport,
presenter=presenter,
final_notify=final_notify,
)
cfg = TelegramBridgeConfig(
bot=bot,
router=router,
chat_id=chat_id,
startup_msg=startup_msg,
exec_cfg=exec_cfg,
projects=projects,
)
anyio.run(run_main_loop, cfg)
telegram_backend = TelegramBackend()
+8 -11
View File
@@ -27,19 +27,10 @@ from ..config_store import read_raw_toml, write_raw_toml
from ..engines import list_backends
from ..logging import suppress_logs
from ..settings import HOME_CONFIG_PATH, load_settings, require_telegram
from ..transports import SetupResult
from .client import TelegramClient, TelegramRetryAfter
@dataclass(slots=True)
class SetupResult:
issues: list[SetupIssue]
config_path: Path = HOME_CONFIG_PATH
@property
def ok(self) -> bool:
return not self.issues
@dataclass(frozen=True, slots=True)
class ChatInfo:
chat_id: int
@@ -81,7 +72,11 @@ def config_issue(path: Path) -> SetupIssue:
return SetupIssue("create a config", (f" {_display_path(path)}",))
def check_setup(backend: EngineBackend) -> SetupResult:
def check_setup(
backend: EngineBackend,
*,
transport_override: str | None = None,
) -> SetupResult:
issues: list[SetupIssue] = []
config_path = HOME_CONFIG_PATH
cmd = backend.cli_cmd or backend.id
@@ -91,6 +86,8 @@ def check_setup(backend: EngineBackend) -> SetupResult:
try:
settings, config_path = load_settings()
if transport_override:
settings = settings.model_copy(update={"transport": transport_override})
try:
require_telegram(settings, config_path)
except ConfigError:
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
from .backends import EngineBackend, SetupIssue
from .config import ConfigError, ProjectsConfig
from .router import AutoRouter
from .settings import TakopiSettings
@dataclass(frozen=True, slots=True)
class SetupResult:
issues: list[SetupIssue]
config_path: Path
@property
def ok(self) -> bool:
return not self.issues
class TransportBackend(Protocol):
id: str
description: str
def check_setup(
self,
engine_backend: EngineBackend,
*,
transport_override: str | None = None,
) -> SetupResult: ...
def interactive_setup(self, *, force: bool) -> bool: ...
def lock_token(
self, *, settings: TakopiSettings, config_path: Path
) -> str | None: ...
def build_and_run(
self,
*,
settings: TakopiSettings,
config_path: Path,
router: AutoRouter,
projects: ProjectsConfig,
final_notify: bool,
default_engine_override: str | None,
) -> None: ...
_registry: dict[str, TransportBackend] = {}
_builtins_loaded = False
def register_transport(backend: TransportBackend) -> None:
existing = _registry.get(backend.id)
if existing is not None and existing is not backend:
raise ConfigError(f"Transport {backend.id!r} is already registered.")
_registry[backend.id] = backend
def register_builtin_transports() -> None:
global _builtins_loaded
if _builtins_loaded:
return
from .telegram.backend import telegram_backend
register_transport(telegram_backend)
_builtins_loaded = True
def get_transport(transport_id: str) -> TransportBackend:
register_builtin_transports()
try:
return _registry[transport_id]
except KeyError:
available = ", ".join(sorted(_registry))
raise ConfigError(
f"Unknown transport {transport_id!r}. Available: {available}."
) from None
def list_transports() -> list[str]:
register_builtin_transports()
return sorted(_registry)
+12 -3
View File
@@ -5,6 +5,7 @@ from pathlib import Path
import pytest
from takopi.config import ConfigError
from takopi.config_store import read_raw_toml
from takopi.settings import (
TakopiSettings,
load_settings,
@@ -58,12 +59,20 @@ def test_env_overrides_toml(tmp_path: Path, monkeypatch) -> None:
assert settings.default_engine == "claude"
def test_legacy_keys_rejected(tmp_path: Path) -> None:
def test_legacy_keys_migrated(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
config_path.write_text('bot_token = "token"\nchat_id = 123\n', encoding="utf-8")
with pytest.raises(ConfigError, match="transports\\.telegram"):
load_settings(config_path)
settings, loaded_path = load_settings(config_path)
assert loaded_path == config_path
assert settings.transports.telegram.chat_id == 123
raw = read_raw_toml(config_path)
assert "bot_token" not in raw
assert "chat_id" not in raw
assert raw["transports"]["telegram"]["bot_token"] == "token"
assert raw["transports"]["telegram"]["chat_id"] == 123
assert raw["transport"] == "telegram"
def test_validate_settings_data_rejects_invalid_bot_token_type(tmp_path: Path) -> None:
+19
View File
@@ -0,0 +1,19 @@
import pytest
from takopi import transports
from takopi.config import ConfigError
def test_transport_registry_lists_telegram() -> None:
ids = transports.list_transports()
assert "telegram" in ids
def test_transport_registry_gets_telegram() -> None:
backend = transports.get_transport("telegram")
assert backend.id == "telegram"
def test_transport_registry_unknown() -> None:
with pytest.raises(ConfigError, match="Unknown transport"):
transports.get_transport("nope")