yee88 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. takopi/__init__.py +1 -0
  2. takopi/api.py +116 -0
  3. takopi/backends.py +25 -0
  4. takopi/backends_helpers.py +14 -0
  5. takopi/cli/__init__.py +228 -0
  6. takopi/cli/config.py +320 -0
  7. takopi/cli/doctor.py +173 -0
  8. takopi/cli/init.py +113 -0
  9. takopi/cli/onboarding_cmd.py +126 -0
  10. takopi/cli/plugins.py +196 -0
  11. takopi/cli/run.py +419 -0
  12. takopi/cli/topic.py +355 -0
  13. takopi/commands.py +134 -0
  14. takopi/config.py +142 -0
  15. takopi/config_migrations.py +124 -0
  16. takopi/config_watch.py +146 -0
  17. takopi/context.py +9 -0
  18. takopi/directives.py +146 -0
  19. takopi/engines.py +53 -0
  20. takopi/events.py +170 -0
  21. takopi/ids.py +17 -0
  22. takopi/lockfile.py +158 -0
  23. takopi/logging.py +283 -0
  24. takopi/markdown.py +298 -0
  25. takopi/model.py +77 -0
  26. takopi/plugins.py +312 -0
  27. takopi/presenter.py +25 -0
  28. takopi/progress.py +99 -0
  29. takopi/router.py +113 -0
  30. takopi/runner.py +712 -0
  31. takopi/runner_bridge.py +619 -0
  32. takopi/runners/__init__.py +1 -0
  33. takopi/runners/claude.py +483 -0
  34. takopi/runners/codex.py +656 -0
  35. takopi/runners/mock.py +221 -0
  36. takopi/runners/opencode.py +505 -0
  37. takopi/runners/pi.py +523 -0
  38. takopi/runners/run_options.py +39 -0
  39. takopi/runners/tool_actions.py +90 -0
  40. takopi/runtime_loader.py +207 -0
  41. takopi/scheduler.py +159 -0
  42. takopi/schemas/__init__.py +1 -0
  43. takopi/schemas/claude.py +238 -0
  44. takopi/schemas/codex.py +169 -0
  45. takopi/schemas/opencode.py +51 -0
  46. takopi/schemas/pi.py +117 -0
  47. takopi/settings.py +360 -0
  48. takopi/telegram/__init__.py +20 -0
  49. takopi/telegram/api_models.py +37 -0
  50. takopi/telegram/api_schemas.py +152 -0
  51. takopi/telegram/backend.py +163 -0
  52. takopi/telegram/bridge.py +425 -0
  53. takopi/telegram/chat_prefs.py +242 -0
  54. takopi/telegram/chat_sessions.py +112 -0
  55. takopi/telegram/client.py +409 -0
  56. takopi/telegram/client_api.py +539 -0
  57. takopi/telegram/commands/__init__.py +12 -0
  58. takopi/telegram/commands/agent.py +196 -0
  59. takopi/telegram/commands/cancel.py +116 -0
  60. takopi/telegram/commands/dispatch.py +111 -0
  61. takopi/telegram/commands/executor.py +449 -0
  62. takopi/telegram/commands/file_transfer.py +586 -0
  63. takopi/telegram/commands/handlers.py +45 -0
  64. takopi/telegram/commands/media.py +143 -0
  65. takopi/telegram/commands/menu.py +139 -0
  66. takopi/telegram/commands/model.py +215 -0
  67. takopi/telegram/commands/overrides.py +159 -0
  68. takopi/telegram/commands/parse.py +30 -0
  69. takopi/telegram/commands/plan.py +16 -0
  70. takopi/telegram/commands/reasoning.py +234 -0
  71. takopi/telegram/commands/reply.py +23 -0
  72. takopi/telegram/commands/topics.py +332 -0
  73. takopi/telegram/commands/trigger.py +143 -0
  74. takopi/telegram/context.py +140 -0
  75. takopi/telegram/engine_defaults.py +86 -0
  76. takopi/telegram/engine_overrides.py +105 -0
  77. takopi/telegram/files.py +178 -0
  78. takopi/telegram/loop.py +1822 -0
  79. takopi/telegram/onboarding.py +1088 -0
  80. takopi/telegram/outbox.py +177 -0
  81. takopi/telegram/parsing.py +239 -0
  82. takopi/telegram/render.py +198 -0
  83. takopi/telegram/state_store.py +88 -0
  84. takopi/telegram/topic_state.py +334 -0
  85. takopi/telegram/topics.py +256 -0
  86. takopi/telegram/trigger_mode.py +68 -0
  87. takopi/telegram/types.py +63 -0
  88. takopi/telegram/voice.py +110 -0
  89. takopi/transport.py +53 -0
  90. takopi/transport_runtime.py +323 -0
  91. takopi/transports.py +76 -0
  92. takopi/utils/__init__.py +1 -0
  93. takopi/utils/git.py +87 -0
  94. takopi/utils/json_state.py +21 -0
  95. takopi/utils/paths.py +47 -0
  96. takopi/utils/streams.py +44 -0
  97. takopi/utils/subprocess.py +86 -0
  98. takopi/worktrees.py +135 -0
  99. yee88-0.1.0.dist-info/METADATA +116 -0
  100. yee88-0.1.0.dist-info/RECORD +103 -0
  101. yee88-0.1.0.dist-info/WHEEL +4 -0
  102. yee88-0.1.0.dist-info/entry_points.txt +11 -0
  103. yee88-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1088 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from contextlib import contextmanager
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Literal, Protocol, cast
9
+
10
+ import anyio
11
+ import questionary
12
+ from prompt_toolkit import PromptSession
13
+ from prompt_toolkit.formatted_text import to_formatted_text
14
+ from prompt_toolkit.key_binding import KeyBindings
15
+ from prompt_toolkit.keys import Keys
16
+ from questionary.constants import DEFAULT_QUESTION_PREFIX
17
+ from questionary.question import Question
18
+ from questionary.styles import merge_styles_default
19
+ from rich import box
20
+ from rich.columns import Columns
21
+ from rich.console import Console, Group
22
+ from rich.panel import Panel
23
+ from rich.table import Table
24
+ from rich.text import Text
25
+
26
+ from ..backends import EngineBackend, SetupIssue
27
+ from ..backends_helpers import install_issue
28
+ from ..config import (
29
+ ConfigError,
30
+ ensure_table,
31
+ read_config,
32
+ write_config,
33
+ )
34
+ from ..engines import list_backends
35
+ from ..logging import suppress_logs
36
+ from ..settings import (
37
+ HOME_CONFIG_PATH,
38
+ TelegramTopicsSettings,
39
+ load_settings,
40
+ require_telegram,
41
+ )
42
+ from ..transports import SetupResult
43
+ from .api_models import User
44
+ from .client import TelegramClient, TelegramRetryAfter
45
+ from .topics import _validate_topics_setup_for
46
+
47
+ __all__ = [
48
+ "ChatInfo",
49
+ "check_setup",
50
+ "debug_onboarding_paths",
51
+ "interactive_setup",
52
+ "mask_token",
53
+ "get_bot_info",
54
+ "wait_for_chat",
55
+ ]
56
+
57
+ TopicScope = Literal["auto", "main", "projects", "all"]
58
+ SessionMode = Literal["chat", "stateless"]
59
+ Persona = Literal["workspace", "assistant", "handoff"]
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class ChatInfo:
64
+ chat_id: int
65
+ username: str | None
66
+ title: str | None
67
+ first_name: str | None
68
+ last_name: str | None
69
+ chat_type: str | None
70
+
71
+ @property
72
+ def is_group(self) -> bool:
73
+ return self.chat_type in {"group", "supergroup"}
74
+
75
+ @property
76
+ def display(self) -> str:
77
+ if self.is_group:
78
+ if self.title:
79
+ return f'group "{self.title}"'
80
+ return "group chat"
81
+ if self.chat_type == "channel":
82
+ if self.title:
83
+ return f'channel "{self.title}"'
84
+ return "channel"
85
+ if self.username:
86
+ return f"@{self.username}"
87
+ full_name = " ".join(part for part in [self.first_name, self.last_name] if part)
88
+ return full_name or "private chat"
89
+
90
+ @property
91
+ def kind(self) -> str:
92
+ if self.chat_type in {None, "private"}:
93
+ return "private chat"
94
+ if self.chat_type in {"group", "supergroup"}:
95
+ if self.title:
96
+ return f'{self.chat_type} "{self.title}"'
97
+ return self.chat_type
98
+ if self.chat_type == "channel":
99
+ if self.title:
100
+ return f'channel "{self.title}"'
101
+ return "channel"
102
+ if self.chat_type:
103
+ return self.chat_type
104
+ return "unknown chat"
105
+
106
+
107
+ @dataclass(slots=True)
108
+ class OnboardingState:
109
+ config_path: Path
110
+ force: bool
111
+
112
+ token: str | None = None
113
+ bot_username: str | None = None
114
+ bot_name: str | None = None
115
+ chat: ChatInfo | None = None
116
+ persona: Persona | None = None
117
+
118
+ session_mode: SessionMode | None = None
119
+ topics_enabled: bool = False
120
+ topics_scope: TopicScope = "auto"
121
+ show_resume_line: bool | None = None
122
+ default_engine: str | None = None
123
+
124
+ @property
125
+ def is_stateful(self) -> bool:
126
+ return self.session_mode == "chat" or self.topics_enabled
127
+
128
+ @property
129
+ def bot_ref(self) -> str:
130
+ if self.bot_username:
131
+ return f"@{self.bot_username}"
132
+ if self.bot_name:
133
+ return self.bot_name
134
+ return "your bot"
135
+
136
+
137
+ class OnboardingCancelled(Exception):
138
+ pass
139
+
140
+
141
+ def require_value(value: Any) -> Any:
142
+ if value is None:
143
+ raise OnboardingCancelled()
144
+ return value
145
+
146
+
147
+ class UI(Protocol):
148
+ def panel(
149
+ self,
150
+ title: str | None,
151
+ body: str,
152
+ *,
153
+ border_style: str = "yellow",
154
+ ) -> None: ...
155
+
156
+ def step(self, title: str, *, number: int) -> None: ...
157
+ def print(self, text: object = "", *, markup: bool | None = None) -> None: ...
158
+ async def confirm(self, prompt: str, default: bool = True) -> bool | None: ...
159
+ async def select(
160
+ self, prompt: str, choices: list[tuple[str, Any]]
161
+ ) -> Any | None: ...
162
+ async def password(self, prompt: str) -> str | None: ...
163
+
164
+
165
+ class Services(Protocol):
166
+ async def get_bot_info(self, token: str) -> User | None: ...
167
+ async def wait_for_chat(self, token: str) -> ChatInfo: ...
168
+
169
+ async def validate_topics(
170
+ self, token: str, chat_id: int, scope: TopicScope
171
+ ) -> ConfigError | None: ...
172
+
173
+ def list_engines(self) -> list[tuple[str, bool, str | None]]: ...
174
+ def read_config(self, path: Path) -> dict[str, Any]: ...
175
+ def write_config(self, path: Path, data: dict[str, Any]) -> None: ...
176
+
177
+
178
+ def display_path(path: Path) -> str:
179
+ home = Path.home()
180
+ try:
181
+ return f"~/{path.relative_to(home)}"
182
+ except ValueError:
183
+ return str(path)
184
+
185
+
186
+ _CREATE_CONFIG_TITLE = "create a config"
187
+ _CONFIGURE_TELEGRAM_TITLE = "configure telegram"
188
+
189
+
190
+ def config_issue(path: Path, *, title: str) -> SetupIssue:
191
+ return SetupIssue(title, (f" {display_path(path)}",))
192
+
193
+
194
+ def check_setup(
195
+ backend: EngineBackend,
196
+ *,
197
+ transport_override: str | None = None,
198
+ ) -> SetupResult:
199
+ issues: list[SetupIssue] = []
200
+ config_path = HOME_CONFIG_PATH
201
+ cmd = backend.cli_cmd or backend.id
202
+ backend_issues: list[SetupIssue] = []
203
+ if shutil.which(cmd) is None:
204
+ backend_issues.append(install_issue(cmd, backend.install_cmd))
205
+
206
+ try:
207
+ settings, config_path = load_settings()
208
+ if transport_override:
209
+ settings = settings.model_copy(update={"transport": transport_override})
210
+ try:
211
+ require_telegram(settings, config_path)
212
+ except ConfigError:
213
+ issues.append(config_issue(config_path, title=_CONFIGURE_TELEGRAM_TITLE))
214
+ except ConfigError:
215
+ issues.extend(backend_issues)
216
+ title = (
217
+ _CONFIGURE_TELEGRAM_TITLE
218
+ if config_path.exists() and config_path.is_file()
219
+ else _CREATE_CONFIG_TITLE
220
+ )
221
+ issues.append(config_issue(config_path, title=title))
222
+ return SetupResult(issues=issues, config_path=config_path)
223
+
224
+ issues.extend(backend_issues)
225
+ return SetupResult(issues=issues, config_path=config_path)
226
+
227
+
228
+ def mask_token(token: str) -> str:
229
+ token = token.strip()
230
+ if len(token) <= 12:
231
+ return "*" * len(token)
232
+ return f"{token[:9]}...{token[-5:]}"
233
+
234
+
235
+ async def get_bot_info(
236
+ token: str,
237
+ *,
238
+ sleep: Callable[[float], Awaitable[None]] | None = None,
239
+ ) -> User | None:
240
+ if sleep is None:
241
+ sleep = anyio.sleep
242
+ bot = TelegramClient(token)
243
+ try:
244
+ for _ in range(3):
245
+ try:
246
+ return await bot.get_me()
247
+ except TelegramRetryAfter as exc:
248
+ await sleep(exc.retry_after)
249
+ return None
250
+ finally:
251
+ await bot.close()
252
+
253
+
254
+ async def wait_for_chat(
255
+ token: str,
256
+ *,
257
+ sleep: Callable[[float], Awaitable[None]] | None = None,
258
+ ) -> ChatInfo:
259
+ if sleep is None:
260
+ sleep = anyio.sleep
261
+ bot = TelegramClient(token)
262
+ try:
263
+ offset: int | None = None
264
+ allowed_updates = ["message"]
265
+ drained = await bot.get_updates(
266
+ offset=None, timeout_s=0, allowed_updates=allowed_updates
267
+ )
268
+ if drained:
269
+ offset = drained[-1].update_id + 1
270
+ while True:
271
+ updates = await bot.get_updates(
272
+ offset=offset, timeout_s=50, allowed_updates=allowed_updates
273
+ )
274
+ if updates is None:
275
+ await sleep(1)
276
+ continue
277
+ if not updates:
278
+ continue
279
+ update = updates[-1]
280
+ offset = update.update_id + 1
281
+ msg = update.message
282
+ if msg is None:
283
+ continue
284
+ sender = msg.from_
285
+ if sender is not None and sender.is_bot is True:
286
+ continue
287
+ chat = msg.chat
288
+ if chat is None:
289
+ continue
290
+ chat_id = chat.id
291
+ return ChatInfo(
292
+ chat_id=chat_id,
293
+ username=chat.username,
294
+ title=chat.title,
295
+ first_name=chat.first_name,
296
+ last_name=chat.last_name,
297
+ chat_type=chat.type,
298
+ )
299
+ finally:
300
+ await bot.close()
301
+
302
+
303
+ def render_engine_table(ui: UI, rows: list[tuple[str, bool, str | None]]) -> None:
304
+ table = Table(show_header=True, header_style="bold", box=box.SIMPLE)
305
+ table.add_column("engine")
306
+ table.add_column("status")
307
+ table.add_column("install command")
308
+ for engine_id, installed, install_cmd in rows:
309
+ status = "[green]✓ installed[/]" if installed else "[dim]✗ not found[/]"
310
+ table.add_row(
311
+ engine_id,
312
+ status,
313
+ "" if installed else (install_cmd or "-"),
314
+ )
315
+ ui.print(table)
316
+
317
+
318
+ def append_dialogue(
319
+ text: Text,
320
+ speaker: str,
321
+ message: str,
322
+ *,
323
+ speaker_style: str,
324
+ message_style: str | None = None,
325
+ ) -> None:
326
+ text.append(f"[{speaker}] ", style=speaker_style)
327
+ text.append(message, style=message_style)
328
+ text.append("\n")
329
+
330
+
331
+ def render_private_chat_instructions(bot_ref: str) -> Text:
332
+ return Text.assemble(
333
+ f" 1. open a chat with {bot_ref}\n",
334
+ " 2. send /start\n",
335
+ )
336
+
337
+
338
+ def render_topics_group_instructions(bot_ref: str) -> Text:
339
+ return Text.assemble(
340
+ " set up a topics group:\n",
341
+ " 1. create a group and enable topics (settings → topics)\n",
342
+ f' 2. add {bot_ref} as admin with "manage topics"\n',
343
+ " 3. send any message in the group\n",
344
+ )
345
+
346
+
347
+ def render_generic_capture_prompt(bot_ref: str) -> Text:
348
+ return Text.assemble(
349
+ f" send /start to {bot_ref} in the chat you want takopi to use "
350
+ "(private chat or group)"
351
+ )
352
+
353
+
354
+ def render_botfather_instructions() -> Text:
355
+ return Text.assemble(
356
+ " 1. open telegram and message @BotFather\n",
357
+ " 2. send /newbot and follow the prompts\n",
358
+ " 3. copy the token (looks like 123456789:ABCdef...)",
359
+ )
360
+
361
+
362
+ def render_topics_validation_warning(issue: ConfigError) -> Text:
363
+ return Text.assemble(
364
+ ("warning: ", "yellow"),
365
+ f"topics validation failed: {issue}\n",
366
+ ' ensure the bot is admin with "manage topics" permission.',
367
+ )
368
+
369
+
370
+ def render_config_malformed_warning(error: ConfigError) -> Text:
371
+ return Text.assemble(("warning: ", "yellow"), f"config is malformed: {error}")
372
+
373
+
374
+ def render_backup_failed_warning(error: OSError) -> Text:
375
+ return Text.assemble(("warning: ", "yellow"), f"failed to back up config: {error}")
376
+
377
+
378
+ def render_persona_tabs() -> Table:
379
+ active_label = "happian @memory-box"
380
+ inactive_label = "takopi @master"
381
+ grid = Table.grid(padding=(0, 2))
382
+ grid.pad_edge = False
383
+ grid.add_column()
384
+ grid.add_column()
385
+ grid.add_row(Text(active_label, style="cyan"), Text(inactive_label, style="dim"))
386
+ grid.add_row(Text("─" * len(active_label), style="cyan"), Text(""))
387
+ return grid
388
+
389
+
390
+ def render_workspace_preview() -> Text:
391
+ return Text.assemble(
392
+ ("[bot] ", "bold magenta"),
393
+ ("topic bound to @memory-box\n", "dim"),
394
+ ("[you] ", "bold cyan"),
395
+ "store artifacts forever\n",
396
+ ("[bot] ", "bold magenta"),
397
+ ("done · codex · 10s\n", "dim"),
398
+ ("[you] ", "bold cyan"),
399
+ "also freeze them\n",
400
+ ("[bot] ", "bold magenta"),
401
+ ("done · codex · 6s\n", "dim"),
402
+ ("[you] ", "bold cyan"),
403
+ "automatically adjust size\n",
404
+ ("[bot] ", "bold magenta"),
405
+ ("done · codex · 6s", "dim"),
406
+ )
407
+
408
+
409
+ def render_assistant_preview() -> Text:
410
+ return Text.assemble(
411
+ ("[you] ", "bold cyan"),
412
+ "make happy wings fit\n",
413
+ ("[bot] ", "bold magenta"),
414
+ ("done · codex · 8s\n", "dim"),
415
+ ("[you] ", "bold cyan"),
416
+ "carry heavy creatures\n",
417
+ ("[bot] ", "bold magenta"),
418
+ ("done · codex · 12s\n", "dim"),
419
+ ("[you] ", "bold cyan"),
420
+ ("/new", "green"),
421
+ (" ← start fresh\n", "yellow"),
422
+ ("[you] ", "bold cyan"),
423
+ "add flower pin\n",
424
+ ("[bot] ", "bold magenta"),
425
+ ("done · codex · 6s\n", "dim"),
426
+ ("[you] ", "bold cyan"),
427
+ "make wearer appear as flower\n",
428
+ ("[bot] ", "bold magenta"),
429
+ ("done · codex · 4s", "dim"),
430
+ )
431
+
432
+
433
+ def render_handoff_preview() -> Text:
434
+ return Text.assemble(
435
+ ("[you] ", "bold cyan"),
436
+ "make it go back in time\n",
437
+ ("[bot] ", "bold magenta"),
438
+ ("done · codex · 8s\n", "dim"),
439
+ (" codex resume ", "dim"),
440
+ ("abc123 ", "cyan"),
441
+ ("← reply\n", "yellow"),
442
+ ("[you] ", "bold cyan"),
443
+ "add reconciliation ribbon\n",
444
+ ("[bot] ", "bold magenta"),
445
+ ("done · codex · 3s\n", "dim"),
446
+ (" codex resume ", "dim"),
447
+ ("def456\n", "blue"),
448
+ ("[you] ", "bold cyan"),
449
+ ("(reply) ", "green"),
450
+ "more than once\n",
451
+ ("[bot] ", "bold magenta"),
452
+ ("done · codex · 8s\n", "dim"),
453
+ (" codex resume ", "dim"),
454
+ ("abc123", "cyan"),
455
+ )
456
+
457
+
458
+ def render_persona_preview(ui: UI) -> None:
459
+ panel_width = 40
460
+ workspace_layout = Group(
461
+ render_persona_tabs(),
462
+ render_workspace_preview(),
463
+ )
464
+ assistant_panel = Panel(
465
+ render_assistant_preview(),
466
+ title=Text("assistant", style="bold"),
467
+ subtitle="ongoing chat (recommended)",
468
+ border_style="green",
469
+ box=box.ROUNDED,
470
+ padding=(0, 1),
471
+ width=panel_width,
472
+ )
473
+ handoff_panel = Panel(
474
+ render_handoff_preview(),
475
+ title=Text("handoff", style="bold"),
476
+ subtitle="reply · terminal resume",
477
+ border_style="magenta",
478
+ box=box.ROUNDED,
479
+ padding=(0, 1),
480
+ width=panel_width,
481
+ )
482
+ workspace_panel = Panel(
483
+ workspace_layout,
484
+ title=Text("workspace", style="bold"),
485
+ subtitle="project/branch workspaces",
486
+ border_style="cyan",
487
+ box=box.ROUNDED,
488
+ padding=(0, 1),
489
+ width=panel_width,
490
+ )
491
+ ui.print(
492
+ Columns(
493
+ [assistant_panel, workspace_panel, handoff_panel],
494
+ expand=False,
495
+ equal=True,
496
+ padding=(0, 2),
497
+ ),
498
+ markup=False,
499
+ )
500
+
501
+
502
+ async def prompt_persona(ui: UI) -> Persona | None:
503
+ render_persona_preview(ui)
504
+ ui.print("")
505
+ return cast(
506
+ Persona,
507
+ await ui.select(
508
+ "how will you use takopi?",
509
+ choices=[
510
+ ("assistant (ongoing chat, /new to reset)", "assistant"),
511
+ ("workspace (projects + branches, i'll set those up)", "workspace"),
512
+ ("handoff (reply to continue, terminal resume)", "handoff"),
513
+ ],
514
+ ),
515
+ )
516
+
517
+
518
+ async def validate_topics_onboarding(
519
+ token: str,
520
+ chat_id: int,
521
+ scope: TopicScope,
522
+ project_chat_ids: tuple[int, ...],
523
+ ) -> ConfigError | None:
524
+ bot = TelegramClient(token)
525
+ try:
526
+ settings = TelegramTopicsSettings(enabled=True, scope=scope)
527
+ await _validate_topics_setup_for(
528
+ bot=bot,
529
+ topics=settings,
530
+ chat_id=chat_id,
531
+ project_chat_ids=project_chat_ids,
532
+ )
533
+ return None
534
+ except ConfigError as exc:
535
+ return exc
536
+ except Exception as exc: # noqa: BLE001
537
+ return ConfigError(f"topics validation failed: {exc}")
538
+ finally:
539
+ await bot.close()
540
+
541
+
542
+ @contextmanager
543
+ def suppress_logging():
544
+ with suppress_logs():
545
+ yield
546
+
547
+
548
+ async def confirm_prompt(message: str, *, default: bool = True) -> bool | None:
549
+ merged_style = merge_styles_default([None])
550
+ status = {"answer": None, "complete": False}
551
+
552
+ def get_prompt_tokens():
553
+ tokens = [
554
+ ("class:qmark", DEFAULT_QUESTION_PREFIX),
555
+ ("class:question", f" {message} "),
556
+ ]
557
+ if not status["complete"]:
558
+ tokens.append(("class:instruction", "(yes/no) "))
559
+ if status["answer"] is not None:
560
+ tokens.append(("class:answer", "yes" if status["answer"] else "no"))
561
+ return to_formatted_text(tokens)
562
+
563
+ def exit_with_result(event):
564
+ status["complete"] = True
565
+ event.app.exit(result=status["answer"])
566
+
567
+ bindings = KeyBindings()
568
+
569
+ @bindings.add(Keys.ControlQ, eager=True)
570
+ @bindings.add(Keys.ControlC, eager=True)
571
+ def _(event):
572
+ event.app.exit(exception=KeyboardInterrupt, style="class:aborting")
573
+
574
+ @bindings.add("n")
575
+ @bindings.add("N")
576
+ def key_n(event):
577
+ status["answer"] = False
578
+ exit_with_result(event)
579
+
580
+ @bindings.add("y")
581
+ @bindings.add("Y")
582
+ def key_y(event):
583
+ status["answer"] = True
584
+ exit_with_result(event)
585
+
586
+ @bindings.add(Keys.ControlH)
587
+ def key_backspace(event):
588
+ status["answer"] = None
589
+
590
+ @bindings.add(Keys.ControlM, eager=True)
591
+ def set_answer(event):
592
+ if status["answer"] is None:
593
+ status["answer"] = default
594
+ exit_with_result(event)
595
+
596
+ @bindings.add(Keys.Any)
597
+ def other(_event):
598
+ return None
599
+
600
+ question = Question(
601
+ PromptSession(get_prompt_tokens, key_bindings=bindings, style=merged_style).app
602
+ )
603
+ return await question.ask_async()
604
+
605
+
606
+ class InteractiveUI:
607
+ def __init__(self, console: Console) -> None:
608
+ self._console = console
609
+
610
+ def panel(
611
+ self,
612
+ title: str | None,
613
+ body: str,
614
+ *,
615
+ border_style: str = "yellow",
616
+ ) -> None:
617
+ panel = Panel(
618
+ body,
619
+ title=title,
620
+ border_style=border_style,
621
+ padding=(1, 2),
622
+ expand=False,
623
+ )
624
+ self._console.print(panel)
625
+
626
+ def step(self, title: str, *, number: int) -> None:
627
+ self._console.print("")
628
+ self._console.print(Text(f"step {number}: {title}", style="bold yellow"))
629
+ self._console.print("")
630
+
631
+ def print(self, text: object = "", *, markup: bool | None = None) -> None:
632
+ if markup is None:
633
+ self._console.print(text)
634
+ return
635
+ self._console.print(text, markup=markup)
636
+
637
+ async def confirm(self, prompt: str, default: bool = True) -> bool | None:
638
+ return await confirm_prompt(prompt, default=default)
639
+
640
+ async def select(self, prompt: str, choices: list[tuple[str, Any]]) -> Any | None:
641
+ return await questionary.select(
642
+ prompt,
643
+ choices=[
644
+ questionary.Choice(label, value=value) for label, value in choices
645
+ ],
646
+ instruction="(use arrow keys)",
647
+ ).ask_async()
648
+
649
+ async def password(self, prompt: str) -> str | None:
650
+ return await questionary.password(prompt).ask_async()
651
+
652
+
653
+ class LiveServices:
654
+ async def get_bot_info(self, token: str) -> User | None:
655
+ return await get_bot_info(token)
656
+
657
+ async def wait_for_chat(self, token: str) -> ChatInfo:
658
+ return await wait_for_chat(token)
659
+
660
+ async def validate_topics(
661
+ self, token: str, chat_id: int, scope: TopicScope
662
+ ) -> ConfigError | None:
663
+ return await validate_topics_onboarding(token, chat_id, scope, ())
664
+
665
+ def list_engines(self) -> list[tuple[str, bool, str | None]]:
666
+ rows: list[tuple[str, bool, str | None]] = []
667
+ for backend in list_backends():
668
+ cmd = backend.cli_cmd or backend.id
669
+ installed = shutil.which(cmd) is not None
670
+ rows.append((backend.id, installed, backend.install_cmd))
671
+ return rows
672
+
673
+ def read_config(self, path: Path) -> dict[str, Any]:
674
+ return read_config(path)
675
+
676
+ def write_config(self, path: Path, data: dict[str, Any]) -> None:
677
+ write_config(data, path)
678
+
679
+
680
+ async def prompt_token(ui: UI, svc: Services) -> tuple[str, User]:
681
+ while True:
682
+ ui.print("")
683
+ token = require_value(await ui.password("paste your bot token:"))
684
+ token = token.strip()
685
+ if not token:
686
+ ui.print(" token cannot be empty")
687
+ continue
688
+ ui.print(" validating...")
689
+ info = await svc.get_bot_info(token)
690
+ if info:
691
+ if info.username:
692
+ ui.print(f" connected to @{info.username}")
693
+ else:
694
+ name = info.first_name or "your bot"
695
+ ui.print(f" connected to {name}")
696
+ return token, info
697
+ ui.print(" failed to connect, check the token and try again")
698
+ ui.print("")
699
+ retry = await ui.confirm("try again?", default=True)
700
+ if not retry:
701
+ raise OnboardingCancelled()
702
+
703
+
704
+ def build_transport_patch(state: OnboardingState, *, bot_token: str) -> dict[str, Any]:
705
+ if state.chat is None:
706
+ raise RuntimeError("onboarding state missing chat")
707
+ if state.session_mode is None:
708
+ raise RuntimeError("onboarding state missing session mode")
709
+ if state.show_resume_line is None:
710
+ raise RuntimeError("onboarding state missing resume choice")
711
+ return {
712
+ "bot_token": bot_token,
713
+ "chat_id": state.chat.chat_id,
714
+ "session_mode": state.session_mode,
715
+ "show_resume_line": state.show_resume_line,
716
+ "topics": {
717
+ "enabled": state.topics_enabled,
718
+ "scope": state.topics_scope,
719
+ },
720
+ }
721
+
722
+
723
+ def build_config_patch(state: OnboardingState, *, bot_token: str) -> dict[str, Any]:
724
+ patch: dict[str, Any] = {
725
+ "transport": "telegram",
726
+ "transports": {"telegram": build_transport_patch(state, bot_token=bot_token)},
727
+ }
728
+ if state.default_engine is not None:
729
+ patch["default_engine"] = state.default_engine
730
+ return patch
731
+
732
+
733
+ def merge_config(
734
+ existing: dict[str, Any],
735
+ patch: dict[str, Any],
736
+ *,
737
+ config_path: Path,
738
+ ) -> dict[str, Any]:
739
+ merged = dict(existing)
740
+ if "default_engine" in patch:
741
+ merged["default_engine"] = patch["default_engine"]
742
+ merged["transport"] = patch["transport"]
743
+ transports = ensure_table(merged, "transports", config_path=config_path)
744
+ telegram = ensure_table(
745
+ transports,
746
+ "telegram",
747
+ config_path=config_path,
748
+ label="transports.telegram",
749
+ )
750
+ telegram_patch = patch["transports"]["telegram"]
751
+ telegram["bot_token"] = telegram_patch["bot_token"]
752
+ telegram["chat_id"] = telegram_patch["chat_id"]
753
+ telegram["session_mode"] = telegram_patch["session_mode"]
754
+ telegram["show_resume_line"] = telegram_patch["show_resume_line"]
755
+ topics = ensure_table(
756
+ telegram,
757
+ "topics",
758
+ config_path=config_path,
759
+ label="transports.telegram.topics",
760
+ )
761
+ topics_patch = telegram_patch["topics"]
762
+ topics["enabled"] = topics_patch["enabled"]
763
+ topics["scope"] = topics_patch["scope"]
764
+ merged.pop("bot_token", None)
765
+ merged.pop("chat_id", None)
766
+ return merged
767
+
768
+
769
+ async def capture_chat(
770
+ ui: UI,
771
+ svc: Services,
772
+ state: OnboardingState,
773
+ *,
774
+ prompt: Text | None = None,
775
+ ) -> None:
776
+ if state.token is None:
777
+ raise RuntimeError("onboarding state missing token")
778
+ if prompt is not None:
779
+ ui.print(prompt, markup=False)
780
+ ui.print(" waiting for message...")
781
+ try:
782
+ chat = await svc.wait_for_chat(state.token)
783
+ except KeyboardInterrupt as exc:
784
+ ui.print(" cancelled")
785
+ raise OnboardingCancelled() from exc
786
+ if chat is None:
787
+ ui.print(" cancelled")
788
+ raise OnboardingCancelled()
789
+ if chat.is_group or chat.chat_type == "channel":
790
+ ui.print(f" got chat_id {chat.chat_id} for {chat.kind}")
791
+ else:
792
+ ui.print(f" got chat_id {chat.chat_id} for {chat.display} ({chat.kind})")
793
+ state.chat = chat
794
+
795
+
796
+ async def step_token_and_bot(ui: UI, svc: Services, state: OnboardingState) -> None:
797
+ have_token = require_value(
798
+ await ui.confirm("do you already have a bot token from @BotFather?")
799
+ )
800
+ if not have_token:
801
+ ui.print(render_botfather_instructions(), markup=False)
802
+ else:
803
+ ui.print(" token looks like 123456789:ABCdef...")
804
+ token, info = await prompt_token(ui, svc)
805
+ state.token = token
806
+ state.bot_username = info.username
807
+ state.bot_name = info.first_name
808
+
809
+
810
+ async def step_persona(ui: UI, _svc: Services, state: OnboardingState) -> None:
811
+ persona = await prompt_persona(ui)
812
+ state.persona = require_value(persona)
813
+ if state.persona == "workspace":
814
+ state.session_mode = "chat"
815
+ state.topics_enabled = True
816
+ state.topics_scope = "auto"
817
+ state.show_resume_line = False
818
+ return
819
+ if state.persona == "assistant":
820
+ state.session_mode = "chat"
821
+ state.topics_enabled = False
822
+ state.topics_scope = "auto"
823
+ state.show_resume_line = False
824
+ return
825
+ state.session_mode = "stateless"
826
+ state.topics_enabled = False
827
+ state.topics_scope = "auto"
828
+ state.show_resume_line = True
829
+
830
+
831
+ async def step_capture_chat(ui: UI, svc: Services, state: OnboardingState) -> None:
832
+ if state.persona is None:
833
+ raise RuntimeError("onboarding state missing persona")
834
+ if state.persona == "workspace":
835
+ await capture_chat(
836
+ ui,
837
+ svc,
838
+ state,
839
+ prompt=render_topics_group_instructions(state.bot_ref),
840
+ )
841
+ if state.token is None:
842
+ raise RuntimeError("onboarding state missing token")
843
+ if state.chat is None:
844
+ raise RuntimeError("onboarding state missing chat")
845
+ while True:
846
+ ui.print(" validating topics setup...")
847
+ issue = await svc.validate_topics(
848
+ state.token,
849
+ state.chat.chat_id,
850
+ state.topics_scope,
851
+ )
852
+ if issue is None:
853
+ break
854
+ ui.print(render_topics_validation_warning(issue), markup=False)
855
+ ui.print("")
856
+ choice = await ui.select(
857
+ "how to proceed?",
858
+ choices=[
859
+ ("retry validation", "retry"),
860
+ ("switch to assistant mode", "assistant"),
861
+ ],
862
+ )
863
+ if choice is None:
864
+ raise OnboardingCancelled()
865
+ if choice == "assistant":
866
+ state.persona = "assistant"
867
+ state.topics_enabled = False
868
+ state.topics_scope = "auto"
869
+ break
870
+ return
871
+ await capture_chat(
872
+ ui,
873
+ svc,
874
+ state,
875
+ prompt=render_private_chat_instructions(state.bot_ref),
876
+ )
877
+
878
+
879
+ async def step_default_engine(ui: UI, svc: Services, state: OnboardingState) -> None:
880
+ ui.print("takopi runs these engines on your computer. switch anytime with /agent.")
881
+ rows = svc.list_engines()
882
+ render_engine_table(ui, rows)
883
+ installed_ids = [engine_id for engine_id, installed, _ in rows if installed]
884
+
885
+ if installed_ids:
886
+ ui.print("")
887
+ default_engine = await ui.select(
888
+ "choose default engine:",
889
+ choices=[(engine_id, engine_id) for engine_id in installed_ids],
890
+ )
891
+ state.default_engine = require_value(default_engine)
892
+ return
893
+
894
+ ui.print("no engines found. install one and rerun --onboard.")
895
+ ui.print("")
896
+ save_anyway = await ui.confirm("save config anyway?", default=False)
897
+ if not save_anyway:
898
+ raise OnboardingCancelled()
899
+
900
+
901
+ async def step_save_config(ui: UI, svc: Services, state: OnboardingState) -> None:
902
+ save = await ui.confirm(
903
+ f"save config to {display_path(state.config_path)}?",
904
+ default=True,
905
+ )
906
+ if not save:
907
+ raise OnboardingCancelled()
908
+
909
+ raw_config: dict[str, Any] = {}
910
+ if state.config_path.exists():
911
+ try:
912
+ raw_config = svc.read_config(state.config_path)
913
+ except ConfigError as exc:
914
+ ui.print(render_config_malformed_warning(exc), markup=False)
915
+ backup = state.config_path.with_suffix(".toml.bak")
916
+ try:
917
+ shutil.copyfile(state.config_path, backup)
918
+ except OSError as copy_exc:
919
+ ui.print(render_backup_failed_warning(copy_exc), markup=False)
920
+ else:
921
+ ui.print(f" backed up to {display_path(backup)}")
922
+ raw_config = {}
923
+ if state.token is None:
924
+ raise RuntimeError("onboarding state missing token")
925
+ patch = build_config_patch(state, bot_token=state.token)
926
+ merged = merge_config(raw_config, patch, config_path=state.config_path)
927
+ svc.write_config(state.config_path, merged)
928
+ ui.print("")
929
+ ui.print(Text("✓ setup complete. starting takopi...", style="green"))
930
+
931
+
932
+ def always_true(_state: OnboardingState) -> bool:
933
+ return True
934
+
935
+
936
+ @dataclass(frozen=True, slots=True)
937
+ class OnboardingStep:
938
+ title: str | None
939
+ number: int | None
940
+ run: Callable[[UI, Services, OnboardingState], Awaitable[None]]
941
+ applies: Callable[[OnboardingState], bool] = always_true
942
+
943
+
944
+ STEPS: list[OnboardingStep] = [
945
+ OnboardingStep("bot token", 1, step_token_and_bot),
946
+ OnboardingStep("pick your workflow", 2, step_persona),
947
+ OnboardingStep("connect chat", 3, step_capture_chat),
948
+ OnboardingStep("default engine", 4, step_default_engine),
949
+ OnboardingStep("save config", 5, step_save_config),
950
+ ]
951
+
952
+
953
+ async def run_onboarding(ui: UI, svc: Services, state: OnboardingState) -> bool:
954
+ try:
955
+ for step in STEPS:
956
+ if not step.applies(state):
957
+ continue
958
+ if step.title and step.number is not None:
959
+ ui.step(step.title, number=step.number)
960
+ await step.run(ui, svc, state)
961
+ except OnboardingCancelled:
962
+ return False
963
+ return True
964
+
965
+
966
+ async def capture_chat_id(*, token: str | None = None) -> ChatInfo | None:
967
+ ui = InteractiveUI(Console())
968
+ svc = LiveServices()
969
+ state = OnboardingState(config_path=HOME_CONFIG_PATH, force=False)
970
+ with suppress_logging():
971
+ try:
972
+ if token is not None:
973
+ token = token.strip()
974
+ if not token:
975
+ ui.print(" token cannot be empty")
976
+ return None
977
+ ui.print(" validating...")
978
+ info = await svc.get_bot_info(token)
979
+ if not info:
980
+ ui.print(" failed to connect, check the token and try again")
981
+ return None
982
+ state.token = token
983
+ state.bot_username = info.username
984
+ state.bot_name = info.first_name
985
+ else:
986
+ token, info = await prompt_token(ui, svc)
987
+ state.token = token
988
+ state.bot_username = info.username
989
+ state.bot_name = info.first_name
990
+
991
+ await capture_chat(
992
+ ui,
993
+ svc,
994
+ state,
995
+ prompt=render_generic_capture_prompt(state.bot_ref),
996
+ )
997
+ return state.chat
998
+ except OnboardingCancelled:
999
+ return None
1000
+
1001
+
1002
+ async def interactive_setup(*, force: bool) -> bool:
1003
+ ui = InteractiveUI(Console())
1004
+ svc = LiveServices()
1005
+ state = OnboardingState(config_path=HOME_CONFIG_PATH, force=force)
1006
+
1007
+ if state.config_path.exists() and not force:
1008
+ ui.print(
1009
+ f"config already exists at {display_path(state.config_path)}. "
1010
+ "use --onboard to reconfigure."
1011
+ )
1012
+ return True
1013
+
1014
+ if state.config_path.exists() and force:
1015
+ overwrite = await ui.confirm(
1016
+ f"update existing config at {display_path(state.config_path)}?",
1017
+ default=False,
1018
+ )
1019
+ if not overwrite:
1020
+ return False
1021
+
1022
+ with suppress_logging():
1023
+ return await run_onboarding(ui, svc, state)
1024
+
1025
+
1026
+ def debug_onboarding_paths(console: Console | None = None) -> None:
1027
+ console = console or Console()
1028
+ table = Table(show_header=True, header_style="bold", box=box.SIMPLE)
1029
+ table.add_column("#", justify="right", style="dim")
1030
+ table.add_column("persona")
1031
+ table.add_column("session")
1032
+ table.add_column("topics")
1033
+ table.add_column("resume footer")
1034
+ table.add_column("topics check")
1035
+ table.add_column("engines")
1036
+ table.add_column("save anyway")
1037
+ table.add_column("save config")
1038
+ table.add_column("outcome")
1039
+
1040
+ engine_paths: list[tuple[bool, bool | None, tuple[bool | None, ...]]] = [
1041
+ (True, None, (True, False)),
1042
+ (False, False, (None,)),
1043
+ (False, True, (True, False)),
1044
+ ]
1045
+
1046
+ path_count = 0
1047
+ personas = {
1048
+ "workspace": ("chat", True, "hide"),
1049
+ "assistant": ("chat", False, "hide"),
1050
+ "handoff": ("stateless", False, "show (fixed)"),
1051
+ }
1052
+ for persona, (session_mode, topics_enabled, resume_label) in personas.items():
1053
+ topics_label = "on" if topics_enabled else "off"
1054
+ topics_check = "run" if topics_enabled else "skip"
1055
+ for agents_found, save_anyway, save_configs in engine_paths:
1056
+ for save_config in save_configs:
1057
+ path_count += 1
1058
+ agents_label = "found" if agents_found else "none"
1059
+ save_anyway_label = format_bool(save_anyway)
1060
+ save_config_label = format_bool(save_config)
1061
+ outcome = "saved" if save_config else "exit"
1062
+ table.add_row(
1063
+ str(path_count),
1064
+ persona,
1065
+ session_mode,
1066
+ topics_label,
1067
+ resume_label,
1068
+ topics_check,
1069
+ agents_label,
1070
+ save_anyway_label,
1071
+ save_config_label,
1072
+ outcome,
1073
+ )
1074
+
1075
+ console.print(f"onboarding paths ({path_count})", markup=False)
1076
+ console.print(
1077
+ "assumes config is missing or --onboard was confirmed; "
1078
+ "cancellations/timeouts are omitted.",
1079
+ markup=False,
1080
+ )
1081
+ console.print("")
1082
+ console.print(table)
1083
+
1084
+
1085
+ def format_bool(value: bool | None) -> str:
1086
+ if value is None:
1087
+ return "n/a"
1088
+ return "yes" if value else "no"