From db4429af4d5c30b4585618604e39e0dc5373560e Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:11:16 +0400 Subject: [PATCH] feat: onboarding --- readme.md | 1 + src/takopi/exec_bridge.py | 17 ++++ src/takopi/onboarding.py | 164 ++++++++++++++++++++++++++++++++++++++ tests/test_onboarding.py | 47 +++++++++++ 4 files changed, 229 insertions(+) create mode 100644 src/takopi/onboarding.py create mode 100644 tests/test_onboarding.py diff --git a/readme.md b/readme.md index 4e3eb37..3cd25cc 100644 --- a/readme.md +++ b/readme.md @@ -74,6 +74,7 @@ uv run takopi | `--final-notify` / `--no-final-notify` | `--final-notify` | Send final response as new message (vs. edit) | | `--debug` / `--no-debug` | `--no-debug` | Enable verbose logging | | `--profile NAME` | (codex default) | Codex profile name | +| `--setup-demo` | off | Render all onboarding guide variants and exit | ## Usage diff --git a/src/takopi/exec_bridge.py b/src/takopi/exec_bridge.py index c8c8721..c561972 100644 --- a/src/takopi/exec_bridge.py +++ b/src/takopi/exec_bridge.py @@ -21,6 +21,7 @@ from .config import ConfigError, load_telegram_config from .exec_render import ExecProgressRenderer, render_event_cli from .logging import setup_logging from .rendering import render_markdown +from .onboarding import check_setup, demo_results, render_setup_guide from .telegram_client import TelegramClient logger = logging.getLogger(__name__) @@ -677,8 +678,24 @@ def run( "--profile", help="Codex profile name to pass to `codex --profile`.", ), + setup_demo: bool = typer.Option( + False, + "--setup-demo", + help="Render all onboarding guide variants and exit.", + ), ) -> None: setup_logging(debug=debug) + if setup_demo: + for idx, (label, result) in enumerate(demo_results()): + if idx: + typer.echo("", err=True) + typer.echo(f"[setup demo] {label}", err=True) + render_setup_guide(result) + raise typer.Exit(code=0) + setup = check_setup() + if not setup.ok: + render_setup_guide(setup) + raise typer.Exit(code=1) try: cfg = _parse_bridge_config( final_notify=final_notify, diff --git a/src/takopi/onboarding.py b/src/takopi/onboarding.py new file mode 100644 index 0000000..34517bf --- /dev/null +++ b/src/takopi/onboarding.py @@ -0,0 +1,164 @@ +"""First-run setup validation and onboarding.""" + +from __future__ import annotations + +import shutil +from dataclasses import dataclass +from pathlib import Path + +from rich.console import Console +from rich.panel import Panel + +from .config import ConfigError, load_telegram_config +from .constants import HOME_CONFIG_PATH + +_OCTOPUS = "\N{OCTOPUS}" + + +@dataclass +class SetupResult: + """Collected setup issues.""" + + missing_codex: bool = False + missing_or_invalid_config: bool = False + config_path: Path | None = None + + @property + def ok(self) -> bool: + return not (self.missing_codex or self.missing_or_invalid_config) + + +def check_setup() -> SetupResult: + """Check all prerequisites and return collected issues.""" + result = SetupResult() + + if not shutil.which("codex"): + result.missing_codex = True + + try: + config, config_path = load_telegram_config() + result.config_path = config_path + except ConfigError: + result.missing_or_invalid_config = True + result.config_path = HOME_CONFIG_PATH + return result + + token = config.get("bot_token") + if not isinstance(token, str) or not token.strip(): + result.missing_or_invalid_config = True + + chat_id_value = config.get("chat_id") + if ( + chat_id_value is None + or isinstance(chat_id_value, bool) + or not isinstance(chat_id_value, int) + ): + result.missing_or_invalid_config = True + + return result + + +def _config_path_display(path: Path) -> str: + """Format path for display, using ~ for home directory.""" + home = Path.home() + try: + return f"~/{path.relative_to(home)}" + except ValueError: + return str(path) + + +def _step_marker(step: int) -> str: + return f"{step}." + + +def render_setup_guide(result: SetupResult) -> None: + """Render a friendly setup guide panel to stderr.""" + console = Console(stderr=True) + parts: list[str] = [] + step = 0 + needs_credentials_help = False + + if result.missing_codex: + step += 1 + parts.append( + f"[bold yellow]{_step_marker(step)}[/] [bold]Install the Codex CLI[/]" + ) + parts.append("") + parts.append(" [dim]$[/] npm install -g @openai/codex") + parts.append("") + + config_display = ( + _config_path_display(result.config_path) + if result.config_path + else _config_path_display(HOME_CONFIG_PATH) + ) + + if result.missing_or_invalid_config: + step += 1 + parts.append(f"[bold yellow]{_step_marker(step)}[/] [bold]Create a config[/]") + parts.append("") + parts.append(f" [dim]{config_display}[/]") + parts.append("") + parts.append(' [cyan]bot_token[/] = [green]"123456789:ABCdef..."[/]') + parts.append(" [cyan]chat_id[/] = [green]123456789[/]") + parts.append("") + needs_credentials_help = True + + if needs_credentials_help: + needs_token_help = True + needs_chat_id_help = True + + parts.append("[dim]" + ("-" * 56) + "[/]") + parts.append("") + parts.append("[bold]Getting your Telegram credentials:[/]") + parts.append("") + if needs_token_help: + parts.append( + " [cyan]bot_token[/] create a bot with [link=https://t.me/BotFather]@BotFather[/]" + ) + if needs_chat_id_help: + parts.append( + " [cyan]chat_id[/] get from [link=https://t.me/myidbot]@myidbot[/]" + ) + + while parts and not parts[-1].strip(): + parts.pop() + + panel = Panel( + "\n".join(parts), + title="[bold]Welcome to Takopi![/]", + subtitle=f"{_OCTOPUS} setup required", + border_style="yellow", + padding=(1, 2), + expand=False, + ) + console.print(panel) + + +def demo_results() -> list[tuple[str, SetupResult]]: + """Return sample setup results for previewing all onboarding modes.""" + config_path = HOME_CONFIG_PATH + return [ + ( + "fresh-install", + SetupResult( + missing_codex=True, + missing_or_invalid_config=True, + config_path=config_path, + ), + ), + ( + "missing-codex", + SetupResult( + missing_codex=True, + config_path=config_path, + ), + ), + ( + "missing-or-invalid-config", + SetupResult( + missing_or_invalid_config=True, + config_path=config_path, + ), + ), + ] diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py new file mode 100644 index 0000000..df795a8 --- /dev/null +++ b/tests/test_onboarding.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from pathlib import Path + +from takopi import onboarding + + +def test_check_setup_marks_missing_codex(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(onboarding.shutil, "which", lambda _name: None) + monkeypatch.setattr( + onboarding, + "load_telegram_config", + lambda: ({"bot_token": "token", "chat_id": 123}, tmp_path / "takopi.toml"), + ) + + result = onboarding.check_setup() + + assert result.missing_codex is True + assert result.missing_or_invalid_config is False + assert result.ok is False + + +def test_check_setup_marks_missing_config(monkeypatch) -> None: + monkeypatch.setattr(onboarding.shutil, "which", lambda _name: "/usr/bin/codex") + + def _raise() -> None: + raise onboarding.ConfigError("Missing config file") + + monkeypatch.setattr(onboarding, "load_telegram_config", _raise) + + result = onboarding.check_setup() + + assert result.missing_or_invalid_config is True + assert result.config_path == onboarding.HOME_CONFIG_PATH + + +def test_check_setup_marks_invalid_chat_id(monkeypatch, tmp_path: Path) -> None: + monkeypatch.setattr(onboarding.shutil, "which", lambda _name: "/usr/bin/codex") + monkeypatch.setattr( + onboarding, + "load_telegram_config", + lambda: ({"bot_token": "token", "chat_id": "123"}, tmp_path / "takopi.toml"), + ) + + result = onboarding.check_setup() + + assert result.missing_or_invalid_config is True