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
yee88/cli/doctor.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
import anyio
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from ..config import ConfigError
|
|
14
|
+
from ..engines import list_backend_ids
|
|
15
|
+
from ..ids import RESERVED_CHAT_COMMANDS
|
|
16
|
+
from ..runtime_loader import resolve_plugins_allowlist
|
|
17
|
+
from ..settings import TakopiSettings, TelegramTopicsSettings
|
|
18
|
+
from ..telegram.client import TelegramClient
|
|
19
|
+
from ..telegram.topics import _validate_topics_setup_for
|
|
20
|
+
|
|
21
|
+
DoctorStatus = Literal["ok", "warning", "error"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class DoctorCheck:
|
|
26
|
+
label: str
|
|
27
|
+
status: DoctorStatus
|
|
28
|
+
detail: str | None = None
|
|
29
|
+
|
|
30
|
+
def render(self) -> str:
|
|
31
|
+
if self.detail:
|
|
32
|
+
return f"- {self.label}: {self.status} ({self.detail})"
|
|
33
|
+
return f"- {self.label}: {self.status}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _doctor_file_checks(settings: TakopiSettings) -> list[DoctorCheck]:
|
|
37
|
+
files = settings.transports.telegram.files
|
|
38
|
+
if not files.enabled:
|
|
39
|
+
return [DoctorCheck("file transfer", "ok", "disabled")]
|
|
40
|
+
if files.allowed_user_ids:
|
|
41
|
+
count = len(files.allowed_user_ids)
|
|
42
|
+
detail = f"restricted to {count} user id(s)"
|
|
43
|
+
return [DoctorCheck("file transfer", "ok", detail)]
|
|
44
|
+
return [DoctorCheck("file transfer", "warning", "enabled for all users")]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _doctor_voice_checks(settings: TakopiSettings) -> list[DoctorCheck]:
|
|
48
|
+
if not settings.transports.telegram.voice_transcription:
|
|
49
|
+
return [DoctorCheck("voice transcription", "ok", "disabled")]
|
|
50
|
+
api_key = settings.transports.telegram.voice_transcription_api_key
|
|
51
|
+
if api_key:
|
|
52
|
+
return [
|
|
53
|
+
DoctorCheck("voice transcription", "ok", "voice_transcription_api_key set")
|
|
54
|
+
]
|
|
55
|
+
if os.environ.get("OPENAI_API_KEY"):
|
|
56
|
+
return [DoctorCheck("voice transcription", "ok", "OPENAI_API_KEY set")]
|
|
57
|
+
return [DoctorCheck("voice transcription", "error", "API key not set")]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _doctor_telegram_checks(
|
|
61
|
+
token: str,
|
|
62
|
+
chat_id: int,
|
|
63
|
+
topics: TelegramTopicsSettings,
|
|
64
|
+
project_chat_ids: tuple[int, ...],
|
|
65
|
+
) -> list[DoctorCheck]:
|
|
66
|
+
checks: list[DoctorCheck] = []
|
|
67
|
+
client_factory = _resolve_cli_attr("TelegramClient") or TelegramClient
|
|
68
|
+
validate_topics = (
|
|
69
|
+
_resolve_cli_attr("_validate_topics_setup_for") or _validate_topics_setup_for
|
|
70
|
+
)
|
|
71
|
+
bot = client_factory(token)
|
|
72
|
+
try:
|
|
73
|
+
me = await bot.get_me()
|
|
74
|
+
if me is None:
|
|
75
|
+
checks.append(
|
|
76
|
+
DoctorCheck("telegram token", "error", "failed to fetch bot info")
|
|
77
|
+
)
|
|
78
|
+
checks.append(DoctorCheck("chat_id", "error", "skipped (token invalid)"))
|
|
79
|
+
if topics.enabled:
|
|
80
|
+
checks.append(DoctorCheck("topics", "error", "skipped (token invalid)"))
|
|
81
|
+
else:
|
|
82
|
+
checks.append(DoctorCheck("topics", "ok", "disabled"))
|
|
83
|
+
return checks
|
|
84
|
+
bot_label = f"@{me.username}" if me.username else f"id={me.id}"
|
|
85
|
+
checks.append(DoctorCheck("telegram token", "ok", bot_label))
|
|
86
|
+
chat = await bot.get_chat(chat_id)
|
|
87
|
+
if chat is None:
|
|
88
|
+
checks.append(DoctorCheck("chat_id", "error", f"unreachable ({chat_id})"))
|
|
89
|
+
else:
|
|
90
|
+
checks.append(DoctorCheck("chat_id", "ok", f"{chat.type} ({chat_id})"))
|
|
91
|
+
if topics.enabled:
|
|
92
|
+
try:
|
|
93
|
+
await validate_topics(
|
|
94
|
+
bot=bot,
|
|
95
|
+
topics=topics,
|
|
96
|
+
chat_id=chat_id,
|
|
97
|
+
project_chat_ids=project_chat_ids,
|
|
98
|
+
)
|
|
99
|
+
checks.append(DoctorCheck("topics", "ok", f"scope={topics.scope}"))
|
|
100
|
+
except ConfigError as exc:
|
|
101
|
+
checks.append(DoctorCheck("topics", "error", str(exc)))
|
|
102
|
+
else:
|
|
103
|
+
checks.append(DoctorCheck("topics", "ok", "disabled"))
|
|
104
|
+
except Exception as exc: # noqa: BLE001
|
|
105
|
+
checks.append(DoctorCheck("telegram", "error", str(exc)))
|
|
106
|
+
finally:
|
|
107
|
+
await bot.close()
|
|
108
|
+
return checks
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def run_doctor(
|
|
112
|
+
*,
|
|
113
|
+
load_settings_fn: Callable[[], tuple[TakopiSettings, Path]],
|
|
114
|
+
telegram_checks: Callable[
|
|
115
|
+
[str, int, TelegramTopicsSettings, tuple[int, ...]],
|
|
116
|
+
Awaitable[list[DoctorCheck]],
|
|
117
|
+
],
|
|
118
|
+
file_checks: Callable[[TakopiSettings], list[DoctorCheck]],
|
|
119
|
+
voice_checks: Callable[[TakopiSettings], list[DoctorCheck]],
|
|
120
|
+
) -> None:
|
|
121
|
+
try:
|
|
122
|
+
settings, config_path = load_settings_fn()
|
|
123
|
+
except ConfigError as exc:
|
|
124
|
+
typer.echo(f"error: {exc}", err=True)
|
|
125
|
+
raise typer.Exit(code=1) from exc
|
|
126
|
+
|
|
127
|
+
if settings.transport != "telegram":
|
|
128
|
+
typer.echo(
|
|
129
|
+
"error: yee88 doctor currently supports the telegram transport only.",
|
|
130
|
+
err=True,
|
|
131
|
+
)
|
|
132
|
+
raise typer.Exit(code=1)
|
|
133
|
+
|
|
134
|
+
allowlist = resolve_plugins_allowlist(settings)
|
|
135
|
+
engine_ids = list_backend_ids(allowlist=allowlist)
|
|
136
|
+
try:
|
|
137
|
+
projects_cfg = settings.to_projects_config(
|
|
138
|
+
config_path=config_path,
|
|
139
|
+
engine_ids=engine_ids,
|
|
140
|
+
reserved=RESERVED_CHAT_COMMANDS,
|
|
141
|
+
)
|
|
142
|
+
except ConfigError as exc:
|
|
143
|
+
typer.echo(f"error: {exc}", err=True)
|
|
144
|
+
raise typer.Exit(code=1) from exc
|
|
145
|
+
|
|
146
|
+
tg = settings.transports.telegram
|
|
147
|
+
project_chat_ids = projects_cfg.project_chat_ids()
|
|
148
|
+
telegram_checks_result = anyio.run(
|
|
149
|
+
telegram_checks,
|
|
150
|
+
tg.bot_token,
|
|
151
|
+
tg.chat_id,
|
|
152
|
+
tg.topics,
|
|
153
|
+
project_chat_ids,
|
|
154
|
+
)
|
|
155
|
+
if telegram_checks_result is None:
|
|
156
|
+
telegram_checks_result = []
|
|
157
|
+
checks = [
|
|
158
|
+
*telegram_checks_result,
|
|
159
|
+
*file_checks(settings),
|
|
160
|
+
*voice_checks(settings),
|
|
161
|
+
]
|
|
162
|
+
typer.echo("yee88 doctor")
|
|
163
|
+
for check in checks:
|
|
164
|
+
typer.echo(check.render())
|
|
165
|
+
if any(check.status == "error" for check in checks):
|
|
166
|
+
raise typer.Exit(code=1)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _resolve_cli_attr(name: str) -> object | None:
|
|
170
|
+
cli_module = sys.modules.get("yee88.cli")
|
|
171
|
+
if cli_module is None:
|
|
172
|
+
return None
|
|
173
|
+
return getattr(cli_module, name, None)
|
yee88/cli/init.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ..config import ConfigError, write_config
|
|
9
|
+
from ..config_migrations import migrate_config
|
|
10
|
+
from ..ids import RESERVED_CHAT_COMMANDS
|
|
11
|
+
from ..settings import TakopiSettings, validate_settings_data
|
|
12
|
+
from .config import _config_path_display
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _prompt_alias(value: str | None, *, default_alias: str | None = None) -> str:
|
|
16
|
+
if value is not None:
|
|
17
|
+
alias = value
|
|
18
|
+
elif default_alias:
|
|
19
|
+
alias = typer.prompt("project alias", default=default_alias)
|
|
20
|
+
else:
|
|
21
|
+
alias = typer.prompt("project alias")
|
|
22
|
+
alias = alias.strip()
|
|
23
|
+
if not alias:
|
|
24
|
+
typer.echo("error: project alias cannot be empty", err=True)
|
|
25
|
+
raise typer.Exit(code=1)
|
|
26
|
+
return alias
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _default_alias_from_path(path: Path) -> str | None:
|
|
30
|
+
name = path.name
|
|
31
|
+
if not name:
|
|
32
|
+
return None
|
|
33
|
+
name = name.removesuffix(".git")
|
|
34
|
+
return name or None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _ensure_projects_table(config: dict, config_path: Path) -> dict:
|
|
38
|
+
projects = config.setdefault("projects", {})
|
|
39
|
+
if not isinstance(projects, dict):
|
|
40
|
+
raise ConfigError(f"Invalid `projects` in {config_path}; expected a table.")
|
|
41
|
+
return projects
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_init(
|
|
45
|
+
*,
|
|
46
|
+
alias: str | None,
|
|
47
|
+
default: bool,
|
|
48
|
+
load_or_init_config_fn: Callable[[], tuple[dict, Path]],
|
|
49
|
+
resolve_main_worktree_root_fn: Callable[[Path], Path | None],
|
|
50
|
+
resolve_default_base_fn: Callable[[Path], str | None],
|
|
51
|
+
list_backend_ids_fn: Callable[..., list[str]],
|
|
52
|
+
resolve_plugins_allowlist_fn: Callable[[TakopiSettings], list[str] | None],
|
|
53
|
+
) -> None:
|
|
54
|
+
config, config_path = load_or_init_config_fn()
|
|
55
|
+
if config_path.exists():
|
|
56
|
+
applied = migrate_config(config, config_path=config_path)
|
|
57
|
+
if applied:
|
|
58
|
+
write_config(config, config_path)
|
|
59
|
+
|
|
60
|
+
cwd = Path.cwd()
|
|
61
|
+
project_path = resolve_main_worktree_root_fn(cwd) or cwd
|
|
62
|
+
default_alias = _default_alias_from_path(project_path)
|
|
63
|
+
alias = _prompt_alias(alias, default_alias=default_alias)
|
|
64
|
+
|
|
65
|
+
settings = validate_settings_data(config, config_path=config_path)
|
|
66
|
+
allowlist = resolve_plugins_allowlist_fn(settings)
|
|
67
|
+
engine_ids = list_backend_ids_fn(allowlist=allowlist)
|
|
68
|
+
projects_cfg = settings.to_projects_config(
|
|
69
|
+
config_path=config_path,
|
|
70
|
+
engine_ids=engine_ids,
|
|
71
|
+
reserved=RESERVED_CHAT_COMMANDS,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
alias_key = alias.lower()
|
|
75
|
+
if alias_key in {engine.lower() for engine in engine_ids}:
|
|
76
|
+
raise ConfigError(
|
|
77
|
+
f"Invalid project alias {alias!r}; aliases must not match engine ids."
|
|
78
|
+
)
|
|
79
|
+
if alias_key in RESERVED_CHAT_COMMANDS:
|
|
80
|
+
raise ConfigError(
|
|
81
|
+
f"Invalid project alias {alias!r}; aliases must not match reserved commands."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
existing = projects_cfg.projects.get(alias_key)
|
|
85
|
+
if existing is not None:
|
|
86
|
+
overwrite = typer.confirm(
|
|
87
|
+
f"project {existing.alias!r} already exists, overwrite?",
|
|
88
|
+
default=False,
|
|
89
|
+
)
|
|
90
|
+
if not overwrite:
|
|
91
|
+
raise typer.Exit(code=1)
|
|
92
|
+
|
|
93
|
+
projects = _ensure_projects_table(config, config_path)
|
|
94
|
+
if existing is not None and existing.alias in projects:
|
|
95
|
+
projects.pop(existing.alias, None)
|
|
96
|
+
|
|
97
|
+
default_engine = settings.default_engine
|
|
98
|
+
worktree_base = resolve_default_base_fn(project_path)
|
|
99
|
+
|
|
100
|
+
entry: dict[str, object] = {
|
|
101
|
+
"path": str(project_path),
|
|
102
|
+
"worktrees_dir": ".worktrees",
|
|
103
|
+
"default_engine": default_engine,
|
|
104
|
+
}
|
|
105
|
+
if worktree_base:
|
|
106
|
+
entry["worktree_base"] = worktree_base
|
|
107
|
+
|
|
108
|
+
projects[alias] = entry
|
|
109
|
+
if default:
|
|
110
|
+
config["default_project"] = alias
|
|
111
|
+
|
|
112
|
+
write_config(config, config_path)
|
|
113
|
+
typer.echo(f"saved project {alias!r} to {_config_path_display(config_path)}")
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from functools import partial
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
import anyio
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from ..config import ConfigError, load_or_init_config, write_config
|
|
13
|
+
from ..config_migrations import migrate_config
|
|
14
|
+
from ..logging import setup_logging
|
|
15
|
+
from ..settings import TakopiSettings
|
|
16
|
+
from ..telegram import onboarding
|
|
17
|
+
from .init import _ensure_projects_table
|
|
18
|
+
from .run import _load_settings_optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def chat_id(
|
|
22
|
+
token: str | None = typer.Option(
|
|
23
|
+
None,
|
|
24
|
+
"--token",
|
|
25
|
+
help="Telegram bot token (defaults to config if available).",
|
|
26
|
+
),
|
|
27
|
+
project: str | None = typer.Option(
|
|
28
|
+
None,
|
|
29
|
+
"--project",
|
|
30
|
+
help="Project alias to print a chat_id snippet for.",
|
|
31
|
+
),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Capture a Telegram chat id and exit."""
|
|
34
|
+
setup_logging_fn = cast(
|
|
35
|
+
Callable[..., None],
|
|
36
|
+
_resolve_cli_attr("setup_logging") or setup_logging,
|
|
37
|
+
)
|
|
38
|
+
load_settings_optional_fn = cast(
|
|
39
|
+
Callable[[], tuple[TakopiSettings | None, Path | None]],
|
|
40
|
+
_resolve_cli_attr("_load_settings_optional") or _load_settings_optional,
|
|
41
|
+
)
|
|
42
|
+
onboarding_mod = cast(
|
|
43
|
+
Any,
|
|
44
|
+
_resolve_cli_attr("onboarding") or onboarding,
|
|
45
|
+
)
|
|
46
|
+
load_or_init_config_fn = cast(
|
|
47
|
+
Callable[[], tuple[dict, Path]],
|
|
48
|
+
_resolve_cli_attr("load_or_init_config") or load_or_init_config,
|
|
49
|
+
)
|
|
50
|
+
ensure_projects_table_fn = cast(
|
|
51
|
+
Callable[[dict, Path], dict],
|
|
52
|
+
_resolve_cli_attr("_ensure_projects_table") or _ensure_projects_table,
|
|
53
|
+
)
|
|
54
|
+
migrate_config_fn = cast(
|
|
55
|
+
Callable[..., object],
|
|
56
|
+
_resolve_cli_attr("migrate_config") or migrate_config,
|
|
57
|
+
)
|
|
58
|
+
write_config_fn = cast(
|
|
59
|
+
Callable[[dict, Path], None],
|
|
60
|
+
_resolve_cli_attr("write_config") or write_config,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
setup_logging_fn(debug=False, cache_logger_on_first_use=False)
|
|
64
|
+
if token is None:
|
|
65
|
+
settings, _ = load_settings_optional_fn()
|
|
66
|
+
if settings is not None:
|
|
67
|
+
tg = settings.transports.telegram
|
|
68
|
+
token = tg.bot_token or None
|
|
69
|
+
chat = anyio.run(partial(onboarding_mod.capture_chat_id, token=token))
|
|
70
|
+
if chat is None:
|
|
71
|
+
raise typer.Exit(code=1)
|
|
72
|
+
if project:
|
|
73
|
+
project = project.strip()
|
|
74
|
+
if not project:
|
|
75
|
+
raise ConfigError("Invalid `--project`; expected a non-empty string.")
|
|
76
|
+
|
|
77
|
+
config, config_path = load_or_init_config_fn()
|
|
78
|
+
if config_path.exists():
|
|
79
|
+
applied = migrate_config_fn(config, config_path=config_path)
|
|
80
|
+
if applied:
|
|
81
|
+
write_config_fn(config, config_path)
|
|
82
|
+
|
|
83
|
+
projects = ensure_projects_table_fn(config, config_path)
|
|
84
|
+
entry = projects.get(project)
|
|
85
|
+
if entry is None:
|
|
86
|
+
lowered = project.lower()
|
|
87
|
+
for key, value in projects.items():
|
|
88
|
+
if isinstance(key, str) and key.lower() == lowered:
|
|
89
|
+
entry = value
|
|
90
|
+
project = key
|
|
91
|
+
break
|
|
92
|
+
if entry is None:
|
|
93
|
+
raise ConfigError(
|
|
94
|
+
f"Unknown project {project!r}; run `yee88 init {project}` first."
|
|
95
|
+
)
|
|
96
|
+
if not isinstance(entry, dict):
|
|
97
|
+
raise ConfigError(
|
|
98
|
+
f"Invalid `projects.{project}` in {config_path}; expected a table."
|
|
99
|
+
)
|
|
100
|
+
entry["chat_id"] = chat.chat_id
|
|
101
|
+
write_config_fn(config, config_path)
|
|
102
|
+
typer.echo(f"updated projects.{project}.chat_id = {chat.chat_id}")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
typer.echo(f"chat_id = {chat.chat_id}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def onboarding_paths() -> None:
|
|
109
|
+
"""Print all possible onboarding paths."""
|
|
110
|
+
setup_logging_fn = cast(
|
|
111
|
+
Callable[..., None],
|
|
112
|
+
_resolve_cli_attr("setup_logging") or setup_logging,
|
|
113
|
+
)
|
|
114
|
+
onboarding_mod = cast(
|
|
115
|
+
Any,
|
|
116
|
+
_resolve_cli_attr("onboarding") or onboarding,
|
|
117
|
+
)
|
|
118
|
+
setup_logging_fn(debug=False, cache_logger_on_first_use=False)
|
|
119
|
+
onboarding_mod.debug_onboarding_paths()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _resolve_cli_attr(name: str) -> object | None:
|
|
123
|
+
cli_module = sys.modules.get("yee88.cli")
|
|
124
|
+
if cli_module is None:
|
|
125
|
+
return None
|
|
126
|
+
return getattr(cli_module, name, None)
|
yee88/cli/plugins.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from importlib.metadata import EntryPoint
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from ..commands import get_command
|
|
12
|
+
from ..config import ConfigError
|
|
13
|
+
from ..engines import get_backend
|
|
14
|
+
from ..ids import RESERVED_COMMAND_IDS, RESERVED_ENGINE_IDS
|
|
15
|
+
from ..plugins import (
|
|
16
|
+
COMMAND_GROUP,
|
|
17
|
+
ENGINE_GROUP,
|
|
18
|
+
PluginLoadError,
|
|
19
|
+
TRANSPORT_GROUP,
|
|
20
|
+
entrypoint_distribution_name,
|
|
21
|
+
get_load_errors,
|
|
22
|
+
is_entrypoint_allowed,
|
|
23
|
+
list_entrypoints,
|
|
24
|
+
normalize_allowlist,
|
|
25
|
+
)
|
|
26
|
+
from ..runtime_loader import resolve_plugins_allowlist
|
|
27
|
+
from ..settings import TakopiSettings, load_settings_if_exists
|
|
28
|
+
from ..transports import get_transport
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_settings_optional() -> tuple[TakopiSettings | None, Path | None]:
|
|
32
|
+
try:
|
|
33
|
+
loaded = load_settings_if_exists()
|
|
34
|
+
except ConfigError:
|
|
35
|
+
return None, None
|
|
36
|
+
if loaded is None:
|
|
37
|
+
return None, None
|
|
38
|
+
return loaded
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _print_entrypoints(
|
|
42
|
+
label: str,
|
|
43
|
+
entrypoints: list[EntryPoint],
|
|
44
|
+
*,
|
|
45
|
+
allowlist: set[str] | None,
|
|
46
|
+
entrypoint_distribution_name_fn: Callable[[EntryPoint], str | None],
|
|
47
|
+
is_entrypoint_allowed_fn: Callable[[EntryPoint, set[str] | None], bool],
|
|
48
|
+
) -> None:
|
|
49
|
+
typer.echo(f"{label}:")
|
|
50
|
+
if not entrypoints:
|
|
51
|
+
typer.echo(" (none)")
|
|
52
|
+
return
|
|
53
|
+
for ep in entrypoints:
|
|
54
|
+
dist = entrypoint_distribution_name_fn(ep) or "unknown"
|
|
55
|
+
status = ""
|
|
56
|
+
if allowlist is not None:
|
|
57
|
+
allowed = is_entrypoint_allowed_fn(ep, allowlist)
|
|
58
|
+
status = " enabled" if allowed else " disabled"
|
|
59
|
+
typer.echo(f" {ep.name} ({dist}){status}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def plugins_cmd(
|
|
63
|
+
load: bool = typer.Option(
|
|
64
|
+
False,
|
|
65
|
+
"--load/--no-load",
|
|
66
|
+
help="Load plugins to validate and surface import errors.",
|
|
67
|
+
),
|
|
68
|
+
) -> None:
|
|
69
|
+
"""List discovered plugins and optionally validate them."""
|
|
70
|
+
load_settings_optional = cast(
|
|
71
|
+
Callable[[], tuple[TakopiSettings | None, Path | None]],
|
|
72
|
+
_resolve_cli_attr("_load_settings_optional") or _load_settings_optional,
|
|
73
|
+
)
|
|
74
|
+
resolve_plugins_allowlist_fn = cast(
|
|
75
|
+
Callable[[TakopiSettings | None], list[str] | None],
|
|
76
|
+
_resolve_cli_attr("resolve_plugins_allowlist") or resolve_plugins_allowlist,
|
|
77
|
+
)
|
|
78
|
+
list_entrypoints_fn = cast(
|
|
79
|
+
Callable[..., list[EntryPoint]],
|
|
80
|
+
_resolve_cli_attr("list_entrypoints") or list_entrypoints,
|
|
81
|
+
)
|
|
82
|
+
get_backend_fn = cast(
|
|
83
|
+
Callable[..., object],
|
|
84
|
+
_resolve_cli_attr("get_backend") or get_backend,
|
|
85
|
+
)
|
|
86
|
+
get_transport_fn = cast(
|
|
87
|
+
Callable[..., object],
|
|
88
|
+
_resolve_cli_attr("get_transport") or get_transport,
|
|
89
|
+
)
|
|
90
|
+
get_command_fn = cast(
|
|
91
|
+
Callable[..., object],
|
|
92
|
+
_resolve_cli_attr("get_command") or get_command,
|
|
93
|
+
)
|
|
94
|
+
get_load_errors_fn = cast(
|
|
95
|
+
Callable[[], tuple[PluginLoadError, ...]],
|
|
96
|
+
_resolve_cli_attr("get_load_errors") or get_load_errors,
|
|
97
|
+
)
|
|
98
|
+
entrypoint_distribution_name_fn = cast(
|
|
99
|
+
Callable[[EntryPoint], str | None],
|
|
100
|
+
_resolve_cli_attr("entrypoint_distribution_name")
|
|
101
|
+
or entrypoint_distribution_name,
|
|
102
|
+
)
|
|
103
|
+
is_entrypoint_allowed_fn = cast(
|
|
104
|
+
Callable[[EntryPoint, set[str] | None], bool],
|
|
105
|
+
_resolve_cli_attr("is_entrypoint_allowed") or is_entrypoint_allowed,
|
|
106
|
+
)
|
|
107
|
+
normalize_allowlist_fn = cast(
|
|
108
|
+
Callable[[list[str] | None], set[str] | None],
|
|
109
|
+
_resolve_cli_attr("normalize_allowlist") or normalize_allowlist,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
settings_hint, _ = load_settings_optional()
|
|
113
|
+
allowlist = resolve_plugins_allowlist_fn(settings_hint)
|
|
114
|
+
|
|
115
|
+
allowlist_set = normalize_allowlist_fn(allowlist)
|
|
116
|
+
engine_eps = list_entrypoints_fn(
|
|
117
|
+
ENGINE_GROUP,
|
|
118
|
+
reserved_ids=RESERVED_ENGINE_IDS,
|
|
119
|
+
)
|
|
120
|
+
transport_eps = list_entrypoints_fn(TRANSPORT_GROUP)
|
|
121
|
+
command_eps = list_entrypoints_fn(
|
|
122
|
+
COMMAND_GROUP,
|
|
123
|
+
reserved_ids=RESERVED_COMMAND_IDS,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
_print_entrypoints(
|
|
127
|
+
"engine backends",
|
|
128
|
+
engine_eps,
|
|
129
|
+
allowlist=allowlist_set,
|
|
130
|
+
entrypoint_distribution_name_fn=entrypoint_distribution_name_fn,
|
|
131
|
+
is_entrypoint_allowed_fn=is_entrypoint_allowed_fn,
|
|
132
|
+
)
|
|
133
|
+
_print_entrypoints(
|
|
134
|
+
"transport backends",
|
|
135
|
+
transport_eps,
|
|
136
|
+
allowlist=allowlist_set,
|
|
137
|
+
entrypoint_distribution_name_fn=entrypoint_distribution_name_fn,
|
|
138
|
+
is_entrypoint_allowed_fn=is_entrypoint_allowed_fn,
|
|
139
|
+
)
|
|
140
|
+
_print_entrypoints(
|
|
141
|
+
"command backends",
|
|
142
|
+
command_eps,
|
|
143
|
+
allowlist=allowlist_set,
|
|
144
|
+
entrypoint_distribution_name_fn=entrypoint_distribution_name_fn,
|
|
145
|
+
is_entrypoint_allowed_fn=is_entrypoint_allowed_fn,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if load:
|
|
149
|
+
for ep in engine_eps:
|
|
150
|
+
if allowlist_set is not None and not is_entrypoint_allowed_fn(
|
|
151
|
+
ep, allowlist_set
|
|
152
|
+
):
|
|
153
|
+
continue
|
|
154
|
+
try:
|
|
155
|
+
get_backend_fn(ep.name, allowlist=allowlist)
|
|
156
|
+
except ConfigError:
|
|
157
|
+
continue
|
|
158
|
+
for ep in transport_eps:
|
|
159
|
+
if allowlist_set is not None and not is_entrypoint_allowed_fn(
|
|
160
|
+
ep, allowlist_set
|
|
161
|
+
):
|
|
162
|
+
continue
|
|
163
|
+
try:
|
|
164
|
+
get_transport_fn(ep.name, allowlist=allowlist)
|
|
165
|
+
except ConfigError:
|
|
166
|
+
continue
|
|
167
|
+
for ep in command_eps:
|
|
168
|
+
if allowlist_set is not None and not is_entrypoint_allowed_fn(
|
|
169
|
+
ep, allowlist_set
|
|
170
|
+
):
|
|
171
|
+
continue
|
|
172
|
+
try:
|
|
173
|
+
get_command_fn(ep.name, allowlist=allowlist)
|
|
174
|
+
except ConfigError:
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
errors = get_load_errors_fn()
|
|
178
|
+
if errors:
|
|
179
|
+
typer.echo("errors:")
|
|
180
|
+
for err in errors:
|
|
181
|
+
group = err.group
|
|
182
|
+
if group == ENGINE_GROUP:
|
|
183
|
+
group = "engine"
|
|
184
|
+
elif group == TRANSPORT_GROUP:
|
|
185
|
+
group = "transport"
|
|
186
|
+
elif group == COMMAND_GROUP:
|
|
187
|
+
group = "command"
|
|
188
|
+
dist = err.distribution or "unknown"
|
|
189
|
+
typer.echo(f" {group} {err.name} ({dist}): {err.error}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _resolve_cli_attr(name: str) -> object | None:
|
|
193
|
+
cli_module = sys.modules.get("yee88.cli")
|
|
194
|
+
if cli_module is None:
|
|
195
|
+
return None
|
|
196
|
+
return getattr(cli_module, name, None)
|