feat: onboarding

This commit is contained in:
banteg
2025-12-29 19:11:16 +04:00
parent d6f392c01e
commit db4429af4d
4 changed files with 229 additions and 0 deletions
+1
View File
@@ -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
+17
View File
@@ -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,
+164
View File
@@ -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,
),
),
]
+47
View File
@@ -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