yee88 0.3.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.
- yee88/__init__.py +1 -0
- yee88/api.py +116 -0
- yee88/backends.py +25 -0
- yee88/backends_helpers.py +14 -0
- yee88/cli/__init__.py +228 -0
- yee88/cli/config.py +320 -0
- yee88/cli/doctor.py +173 -0
- yee88/cli/init.py +113 -0
- yee88/cli/onboarding_cmd.py +126 -0
- yee88/cli/plugins.py +196 -0
- yee88/cli/run.py +419 -0
- yee88/cli/topic.py +355 -0
- yee88/commands.py +134 -0
- yee88/config.py +142 -0
- yee88/config_migrations.py +124 -0
- yee88/config_watch.py +146 -0
- yee88/context.py +9 -0
- yee88/directives.py +146 -0
- yee88/engines.py +53 -0
- yee88/events.py +170 -0
- yee88/ids.py +17 -0
- yee88/lockfile.py +158 -0
- yee88/logging.py +283 -0
- yee88/markdown.py +298 -0
- yee88/model.py +77 -0
- yee88/plugins.py +312 -0
- yee88/presenter.py +25 -0
- yee88/progress.py +99 -0
- yee88/router.py +113 -0
- yee88/runner.py +712 -0
- yee88/runner_bridge.py +619 -0
- yee88/runners/__init__.py +1 -0
- yee88/runners/claude.py +483 -0
- yee88/runners/codex.py +656 -0
- yee88/runners/mock.py +221 -0
- yee88/runners/opencode.py +505 -0
- yee88/runners/pi.py +523 -0
- yee88/runners/run_options.py +39 -0
- yee88/runners/tool_actions.py +90 -0
- yee88/runtime_loader.py +207 -0
- yee88/scheduler.py +159 -0
- yee88/schemas/__init__.py +1 -0
- yee88/schemas/claude.py +238 -0
- yee88/schemas/codex.py +169 -0
- yee88/schemas/opencode.py +51 -0
- yee88/schemas/pi.py +117 -0
- yee88/settings.py +360 -0
- yee88/telegram/__init__.py +20 -0
- yee88/telegram/api_models.py +37 -0
- yee88/telegram/api_schemas.py +152 -0
- yee88/telegram/backend.py +163 -0
- yee88/telegram/bridge.py +425 -0
- yee88/telegram/chat_prefs.py +242 -0
- yee88/telegram/chat_sessions.py +112 -0
- yee88/telegram/client.py +409 -0
- yee88/telegram/client_api.py +539 -0
- yee88/telegram/commands/__init__.py +12 -0
- yee88/telegram/commands/agent.py +196 -0
- yee88/telegram/commands/cancel.py +116 -0
- yee88/telegram/commands/dispatch.py +111 -0
- yee88/telegram/commands/executor.py +449 -0
- yee88/telegram/commands/file_transfer.py +586 -0
- yee88/telegram/commands/handlers.py +45 -0
- yee88/telegram/commands/media.py +143 -0
- yee88/telegram/commands/menu.py +139 -0
- yee88/telegram/commands/model.py +215 -0
- yee88/telegram/commands/overrides.py +159 -0
- yee88/telegram/commands/parse.py +30 -0
- yee88/telegram/commands/plan.py +16 -0
- yee88/telegram/commands/reasoning.py +234 -0
- yee88/telegram/commands/reply.py +23 -0
- yee88/telegram/commands/topics.py +332 -0
- yee88/telegram/commands/trigger.py +143 -0
- yee88/telegram/context.py +140 -0
- yee88/telegram/engine_defaults.py +86 -0
- yee88/telegram/engine_overrides.py +105 -0
- yee88/telegram/files.py +178 -0
- yee88/telegram/loop.py +1822 -0
- yee88/telegram/onboarding.py +1088 -0
- yee88/telegram/outbox.py +177 -0
- yee88/telegram/parsing.py +239 -0
- yee88/telegram/render.py +198 -0
- yee88/telegram/state_store.py +88 -0
- yee88/telegram/topic_state.py +334 -0
- yee88/telegram/topics.py +256 -0
- yee88/telegram/trigger_mode.py +68 -0
- yee88/telegram/types.py +63 -0
- yee88/telegram/voice.py +110 -0
- yee88/transport.py +53 -0
- yee88/transport_runtime.py +323 -0
- yee88/transports.py +76 -0
- yee88/utils/__init__.py +1 -0
- yee88/utils/git.py +87 -0
- yee88/utils/json_state.py +21 -0
- yee88/utils/paths.py +47 -0
- yee88/utils/streams.py +44 -0
- yee88/utils/subprocess.py +86 -0
- yee88/worktrees.py +135 -0
- yee88-0.3.0.dist-info/METADATA +116 -0
- yee88-0.3.0.dist-info/RECORD +103 -0
- yee88-0.3.0.dist-info/WHEEL +4 -0
- yee88-0.3.0.dist-info/entry_points.txt +11 -0
- yee88-0.3.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 yee88 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 = "yee88 @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 yee88?",
|
|
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("yee88 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 yee88...", 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"
|