feat: onboarding
This commit is contained in:
@@ -74,6 +74,7 @@ uv run takopi
|
|||||||
| `--final-notify` / `--no-final-notify` | `--final-notify` | Send final response as new message (vs. edit) |
|
| `--final-notify` / `--no-final-notify` | `--final-notify` | Send final response as new message (vs. edit) |
|
||||||
| `--debug` / `--no-debug` | `--no-debug` | Enable verbose logging |
|
| `--debug` / `--no-debug` | `--no-debug` | Enable verbose logging |
|
||||||
| `--profile NAME` | (codex default) | Codex profile name |
|
| `--profile NAME` | (codex default) | Codex profile name |
|
||||||
|
| `--setup-demo` | off | Render all onboarding guide variants and exit |
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from .config import ConfigError, load_telegram_config
|
|||||||
from .exec_render import ExecProgressRenderer, render_event_cli
|
from .exec_render import ExecProgressRenderer, render_event_cli
|
||||||
from .logging import setup_logging
|
from .logging import setup_logging
|
||||||
from .rendering import render_markdown
|
from .rendering import render_markdown
|
||||||
|
from .onboarding import check_setup, demo_results, render_setup_guide
|
||||||
from .telegram_client import TelegramClient
|
from .telegram_client import TelegramClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -677,8 +678,24 @@ def run(
|
|||||||
"--profile",
|
"--profile",
|
||||||
help="Codex profile name to pass to `codex --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:
|
) -> None:
|
||||||
setup_logging(debug=debug)
|
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:
|
try:
|
||||||
cfg = _parse_bridge_config(
|
cfg = _parse_bridge_config(
|
||||||
final_notify=final_notify,
|
final_notify=final_notify,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user