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.
- takopi/__init__.py +1 -0
- takopi/api.py +116 -0
- takopi/backends.py +25 -0
- takopi/backends_helpers.py +14 -0
- takopi/cli/__init__.py +228 -0
- takopi/cli/config.py +320 -0
- takopi/cli/doctor.py +173 -0
- takopi/cli/init.py +113 -0
- takopi/cli/onboarding_cmd.py +126 -0
- takopi/cli/plugins.py +196 -0
- takopi/cli/run.py +419 -0
- takopi/cli/topic.py +355 -0
- takopi/commands.py +134 -0
- takopi/config.py +142 -0
- takopi/config_migrations.py +124 -0
- takopi/config_watch.py +146 -0
- takopi/context.py +9 -0
- takopi/directives.py +146 -0
- takopi/engines.py +53 -0
- takopi/events.py +170 -0
- takopi/ids.py +17 -0
- takopi/lockfile.py +158 -0
- takopi/logging.py +283 -0
- takopi/markdown.py +298 -0
- takopi/model.py +77 -0
- takopi/plugins.py +312 -0
- takopi/presenter.py +25 -0
- takopi/progress.py +99 -0
- takopi/router.py +113 -0
- takopi/runner.py +712 -0
- takopi/runner_bridge.py +619 -0
- takopi/runners/__init__.py +1 -0
- takopi/runners/claude.py +483 -0
- takopi/runners/codex.py +656 -0
- takopi/runners/mock.py +221 -0
- takopi/runners/opencode.py +505 -0
- takopi/runners/pi.py +523 -0
- takopi/runners/run_options.py +39 -0
- takopi/runners/tool_actions.py +90 -0
- takopi/runtime_loader.py +207 -0
- takopi/scheduler.py +159 -0
- takopi/schemas/__init__.py +1 -0
- takopi/schemas/claude.py +238 -0
- takopi/schemas/codex.py +169 -0
- takopi/schemas/opencode.py +51 -0
- takopi/schemas/pi.py +117 -0
- takopi/settings.py +360 -0
- takopi/telegram/__init__.py +20 -0
- takopi/telegram/api_models.py +37 -0
- takopi/telegram/api_schemas.py +152 -0
- takopi/telegram/backend.py +163 -0
- takopi/telegram/bridge.py +425 -0
- takopi/telegram/chat_prefs.py +242 -0
- takopi/telegram/chat_sessions.py +112 -0
- takopi/telegram/client.py +409 -0
- takopi/telegram/client_api.py +539 -0
- takopi/telegram/commands/__init__.py +12 -0
- takopi/telegram/commands/agent.py +196 -0
- takopi/telegram/commands/cancel.py +116 -0
- takopi/telegram/commands/dispatch.py +111 -0
- takopi/telegram/commands/executor.py +449 -0
- takopi/telegram/commands/file_transfer.py +586 -0
- takopi/telegram/commands/handlers.py +45 -0
- takopi/telegram/commands/media.py +143 -0
- takopi/telegram/commands/menu.py +139 -0
- takopi/telegram/commands/model.py +215 -0
- takopi/telegram/commands/overrides.py +159 -0
- takopi/telegram/commands/parse.py +30 -0
- takopi/telegram/commands/plan.py +16 -0
- takopi/telegram/commands/reasoning.py +234 -0
- takopi/telegram/commands/reply.py +23 -0
- takopi/telegram/commands/topics.py +332 -0
- takopi/telegram/commands/trigger.py +143 -0
- takopi/telegram/context.py +140 -0
- takopi/telegram/engine_defaults.py +86 -0
- takopi/telegram/engine_overrides.py +105 -0
- takopi/telegram/files.py +178 -0
- takopi/telegram/loop.py +1822 -0
- takopi/telegram/onboarding.py +1088 -0
- takopi/telegram/outbox.py +177 -0
- takopi/telegram/parsing.py +239 -0
- takopi/telegram/render.py +198 -0
- takopi/telegram/state_store.py +88 -0
- takopi/telegram/topic_state.py +334 -0
- takopi/telegram/topics.py +256 -0
- takopi/telegram/trigger_mode.py +68 -0
- takopi/telegram/types.py +63 -0
- takopi/telegram/voice.py +110 -0
- takopi/transport.py +53 -0
- takopi/transport_runtime.py +323 -0
- takopi/transports.py +76 -0
- takopi/utils/__init__.py +1 -0
- takopi/utils/git.py +87 -0
- takopi/utils/json_state.py +21 -0
- takopi/utils/paths.py +47 -0
- takopi/utils/streams.py +44 -0
- takopi/utils/subprocess.py +86 -0
- takopi/worktrees.py +135 -0
- yee88-0.1.0.dist-info/METADATA +116 -0
- yee88-0.1.0.dist-info/RECORD +103 -0
- yee88-0.1.0.dist-info/WHEEL +4 -0
- yee88-0.1.0.dist-info/entry_points.txt +11 -0
- yee88-0.1.0.dist-info/licenses/LICENSE +21 -0
takopi/cli/topic.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""CLI command to create and bind a Telegram topic from the command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ..config import ConfigError, HOME_CONFIG_PATH, load_or_init_config, write_config
|
|
11
|
+
from ..config_migrations import migrate_config
|
|
12
|
+
from ..engines import list_backend_ids
|
|
13
|
+
from ..ids import RESERVED_CHAT_COMMANDS, RESERVED_CLI_COMMANDS
|
|
14
|
+
from ..settings import load_settings, validate_settings_data
|
|
15
|
+
from ..telegram.client import TelegramClient
|
|
16
|
+
from ..telegram.topic_state import TopicStateStore, resolve_state_path
|
|
17
|
+
from ..context import RunContext
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
from ..utils.git import resolve_default_base, resolve_main_worktree_root
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_current_branch(cwd: Path) -> str | None:
|
|
24
|
+
"""Get current git branch name."""
|
|
25
|
+
import subprocess
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
result = subprocess.run(
|
|
29
|
+
["git", "branch", "--show-current"],
|
|
30
|
+
cwd=cwd,
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
check=False,
|
|
34
|
+
)
|
|
35
|
+
if result.returncode == 0:
|
|
36
|
+
branch = result.stdout.strip()
|
|
37
|
+
return branch if branch else None
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
pass
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_project_root(cwd: Path) -> Path:
|
|
44
|
+
"""Get git project root, handling worktrees."""
|
|
45
|
+
import subprocess
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
50
|
+
cwd=cwd,
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
check=False,
|
|
54
|
+
)
|
|
55
|
+
if result.returncode == 0:
|
|
56
|
+
common_dir = result.stdout.strip()
|
|
57
|
+
if common_dir:
|
|
58
|
+
# Check if bare repo
|
|
59
|
+
bare_result = subprocess.run(
|
|
60
|
+
["git", "rev-parse", "--is-bare-repository"],
|
|
61
|
+
cwd=cwd,
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
check=False,
|
|
65
|
+
)
|
|
66
|
+
if bare_result.stdout.strip() == "true":
|
|
67
|
+
return cwd
|
|
68
|
+
return Path(common_dir).parent
|
|
69
|
+
except FileNotFoundError:
|
|
70
|
+
pass
|
|
71
|
+
return cwd
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check_alias_conflict(alias: str) -> str | None:
|
|
75
|
+
"""Check if project alias conflicts with engine IDs or reserved commands.
|
|
76
|
+
|
|
77
|
+
Returns conflict reason if conflicts, None otherwise.
|
|
78
|
+
"""
|
|
79
|
+
reserved = RESERVED_CLI_COMMANDS | RESERVED_CHAT_COMMANDS
|
|
80
|
+
engine_ids = set(list_backend_ids())
|
|
81
|
+
|
|
82
|
+
alias_lower = alias.lower()
|
|
83
|
+
if alias_lower in engine_ids:
|
|
84
|
+
return f"engine ID '{alias_lower}'"
|
|
85
|
+
if alias_lower in reserved:
|
|
86
|
+
return f"reserved command '{alias_lower}'"
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _generate_topic_title(project: str, branch: str | None) -> str:
|
|
91
|
+
"""Generate topic title like 'project @branch'."""
|
|
92
|
+
if branch:
|
|
93
|
+
return f"{project} @{branch}"
|
|
94
|
+
return project
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _create_topic(
|
|
98
|
+
*,
|
|
99
|
+
bot_token: str,
|
|
100
|
+
chat_id: int,
|
|
101
|
+
project: str,
|
|
102
|
+
branch: str | None,
|
|
103
|
+
config_path: Path,
|
|
104
|
+
) -> tuple[int, str] | None:
|
|
105
|
+
"""Create forum topic and update state file.
|
|
106
|
+
|
|
107
|
+
Returns (thread_id, title) on success, None on failure.
|
|
108
|
+
"""
|
|
109
|
+
title = _generate_topic_title(project, branch)
|
|
110
|
+
|
|
111
|
+
client = TelegramClient(bot_token)
|
|
112
|
+
try:
|
|
113
|
+
# Create the forum topic
|
|
114
|
+
result = await client.create_forum_topic(chat_id, title)
|
|
115
|
+
if result is None:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
thread_id = result.message_thread_id
|
|
119
|
+
|
|
120
|
+
# Update state file
|
|
121
|
+
state_path = resolve_state_path(config_path)
|
|
122
|
+
store = TopicStateStore(state_path)
|
|
123
|
+
|
|
124
|
+
context = RunContext(project=project.lower(), branch=branch)
|
|
125
|
+
await store.set_context(chat_id, thread_id, context, topic_title=title)
|
|
126
|
+
|
|
127
|
+
# Send confirmation message to the new topic
|
|
128
|
+
bound_text = f"topic bound to `{project}"
|
|
129
|
+
if branch:
|
|
130
|
+
bound_text += f" @{branch}"
|
|
131
|
+
bound_text += "`"
|
|
132
|
+
|
|
133
|
+
await client.send_message(
|
|
134
|
+
chat_id=chat_id,
|
|
135
|
+
text=bound_text,
|
|
136
|
+
message_thread_id=thread_id,
|
|
137
|
+
parse_mode="Markdown",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return thread_id, title
|
|
141
|
+
finally:
|
|
142
|
+
await client.close()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def _delete_topic(
|
|
146
|
+
*,
|
|
147
|
+
bot_token: str,
|
|
148
|
+
chat_id: int,
|
|
149
|
+
project: str,
|
|
150
|
+
branch: str | None,
|
|
151
|
+
config_path: Path,
|
|
152
|
+
) -> bool:
|
|
153
|
+
"""Delete topic binding from state file.
|
|
154
|
+
|
|
155
|
+
Note: Telegram API doesn't support deleting forum topics, only closing them.
|
|
156
|
+
We remove the binding from state file so Takopi won't recognize it.
|
|
157
|
+
"""
|
|
158
|
+
state_path = resolve_state_path(config_path)
|
|
159
|
+
store = TopicStateStore(state_path)
|
|
160
|
+
|
|
161
|
+
# Find thread by context
|
|
162
|
+
context = RunContext(project=project.lower(), branch=branch)
|
|
163
|
+
thread_id = await store.find_thread_for_context(chat_id, context)
|
|
164
|
+
|
|
165
|
+
if thread_id is None:
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
# Delete from state
|
|
169
|
+
await store.delete_thread(chat_id, thread_id)
|
|
170
|
+
|
|
171
|
+
# Try to close the topic via API (best effort)
|
|
172
|
+
client = TelegramClient(bot_token)
|
|
173
|
+
try:
|
|
174
|
+
# Note: There's no deleteForumTopic, but we can try to close it
|
|
175
|
+
# or at least send a message indicating it's been unbound
|
|
176
|
+
await client.send_message(
|
|
177
|
+
chat_id=chat_id,
|
|
178
|
+
text=f"topic unbound from `{project}{' @' + branch if branch else ''}`",
|
|
179
|
+
message_thread_id=thread_id,
|
|
180
|
+
parse_mode="Markdown",
|
|
181
|
+
)
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
finally:
|
|
185
|
+
await client.close()
|
|
186
|
+
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _ensure_project(
|
|
191
|
+
project: str,
|
|
192
|
+
project_root: Path,
|
|
193
|
+
config_path: Path,
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Ensure project is registered in config, auto-init if needed."""
|
|
196
|
+
config, cfg_path = load_or_init_config()
|
|
197
|
+
|
|
198
|
+
if cfg_path.exists():
|
|
199
|
+
applied = migrate_config(config, config_path=cfg_path)
|
|
200
|
+
if applied:
|
|
201
|
+
write_config(config, cfg_path)
|
|
202
|
+
|
|
203
|
+
projects = config.setdefault("projects", {})
|
|
204
|
+
if not isinstance(projects, dict):
|
|
205
|
+
raise ConfigError(f"Invalid `projects` in {cfg_path}; expected a table.")
|
|
206
|
+
|
|
207
|
+
# Check if project already exists
|
|
208
|
+
if project in projects:
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
# Auto-init project
|
|
212
|
+
worktree_base = resolve_default_base(project_root)
|
|
213
|
+
|
|
214
|
+
entry: dict[str, object] = {
|
|
215
|
+
"path": str(project_root),
|
|
216
|
+
"worktrees_dir": ".worktrees",
|
|
217
|
+
}
|
|
218
|
+
if worktree_base:
|
|
219
|
+
entry["worktree_base"] = worktree_base
|
|
220
|
+
|
|
221
|
+
projects[project] = entry
|
|
222
|
+
write_config(config, cfg_path)
|
|
223
|
+
typer.echo(f"auto-registered project '{project}'")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def run_topic(
|
|
227
|
+
*,
|
|
228
|
+
project: str | None,
|
|
229
|
+
branch: str | None,
|
|
230
|
+
delete: bool,
|
|
231
|
+
config_path: Path | None,
|
|
232
|
+
) -> None:
|
|
233
|
+
"""Create or delete a Telegram topic bound to project/branch."""
|
|
234
|
+
cwd = Path.cwd()
|
|
235
|
+
|
|
236
|
+
# Resolve project root
|
|
237
|
+
project_root = _get_project_root(cwd)
|
|
238
|
+
|
|
239
|
+
# Load settings first to check existing projects
|
|
240
|
+
cfg_path = config_path or HOME_CONFIG_PATH
|
|
241
|
+
try:
|
|
242
|
+
settings, cfg_path = load_settings(cfg_path)
|
|
243
|
+
except ConfigError as e:
|
|
244
|
+
typer.echo(f"error: {e}", err=True)
|
|
245
|
+
raise typer.Exit(code=1) from None
|
|
246
|
+
|
|
247
|
+
# Default project name from directory
|
|
248
|
+
if project is None:
|
|
249
|
+
project = project_root.name.lower()
|
|
250
|
+
if project.endswith(".git"):
|
|
251
|
+
project = project[:-4]
|
|
252
|
+
|
|
253
|
+
project_key = project.lower()
|
|
254
|
+
|
|
255
|
+
# Check if project already exists in config
|
|
256
|
+
project_exists = project_key in settings.projects or project in settings.projects
|
|
257
|
+
|
|
258
|
+
# Check for alias conflicts only for NEW projects
|
|
259
|
+
if not project_exists:
|
|
260
|
+
conflict_reason = _check_alias_conflict(project)
|
|
261
|
+
if conflict_reason:
|
|
262
|
+
typer.echo(
|
|
263
|
+
f"error: project alias '{project}' conflicts with {conflict_reason}.\n"
|
|
264
|
+
f"please specify a different alias: takopi topic init <alias>",
|
|
265
|
+
err=True,
|
|
266
|
+
)
|
|
267
|
+
raise typer.Exit(code=1)
|
|
268
|
+
|
|
269
|
+
# Default branch from current git branch
|
|
270
|
+
if branch is None:
|
|
271
|
+
branch = _get_current_branch(cwd)
|
|
272
|
+
|
|
273
|
+
# Auto-init project if not exists (only for create mode)
|
|
274
|
+
if not delete and not project_exists:
|
|
275
|
+
try:
|
|
276
|
+
_ensure_project(project_key, project_root, cfg_path)
|
|
277
|
+
# Reload settings after auto-init
|
|
278
|
+
settings, cfg_path = load_settings(cfg_path)
|
|
279
|
+
except ConfigError as e:
|
|
280
|
+
typer.echo(f"warning: failed to auto-init project: {e}", err=True)
|
|
281
|
+
|
|
282
|
+
# Check project exists in config (use settings.projects directly to avoid validation of ALL projects)
|
|
283
|
+
if project_key not in settings.projects and project not in settings.projects:
|
|
284
|
+
typer.echo(
|
|
285
|
+
f"error: project '{project}' not found in config. "
|
|
286
|
+
f"Run `takopi init {project}` first.",
|
|
287
|
+
err=True,
|
|
288
|
+
)
|
|
289
|
+
raise typer.Exit(code=1)
|
|
290
|
+
|
|
291
|
+
# Get telegram config
|
|
292
|
+
if settings.transport != "telegram":
|
|
293
|
+
typer.echo("error: only telegram transport is supported", err=True)
|
|
294
|
+
raise typer.Exit(code=1)
|
|
295
|
+
|
|
296
|
+
tg = settings.transports.telegram
|
|
297
|
+
bot_token = tg.bot_token
|
|
298
|
+
chat_id = tg.chat_id
|
|
299
|
+
|
|
300
|
+
# Check topics enabled
|
|
301
|
+
if not tg.topics.enabled:
|
|
302
|
+
typer.echo(
|
|
303
|
+
"error: topics not enabled. "
|
|
304
|
+
"Run `takopi config set transports.telegram.topics.enabled true`",
|
|
305
|
+
err=True,
|
|
306
|
+
)
|
|
307
|
+
raise typer.Exit(code=1)
|
|
308
|
+
|
|
309
|
+
typer.echo(f"project: {project}")
|
|
310
|
+
typer.echo(f"branch: {branch or '<none>'}")
|
|
311
|
+
typer.echo(f"chat_id: {chat_id}")
|
|
312
|
+
typer.echo("")
|
|
313
|
+
|
|
314
|
+
if delete:
|
|
315
|
+
# Delete mode
|
|
316
|
+
typer.echo("deleting topic binding...")
|
|
317
|
+
result = asyncio.run(
|
|
318
|
+
_delete_topic(
|
|
319
|
+
bot_token=bot_token,
|
|
320
|
+
chat_id=chat_id,
|
|
321
|
+
project=project,
|
|
322
|
+
branch=branch,
|
|
323
|
+
config_path=cfg_path,
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if not result:
|
|
328
|
+
typer.echo(f"error: no topic found for {project}{' @' + branch if branch else ''}", err=True)
|
|
329
|
+
raise typer.Exit(code=1)
|
|
330
|
+
|
|
331
|
+
typer.echo(f"deleted topic binding for: {project}{' @' + branch if branch else ''}")
|
|
332
|
+
typer.echo("")
|
|
333
|
+
typer.echo("done! the topic has been unbound from takopi.")
|
|
334
|
+
typer.echo("note: the telegram topic still exists but won't be managed by takopi.")
|
|
335
|
+
else:
|
|
336
|
+
# Create mode
|
|
337
|
+
typer.echo("creating topic...")
|
|
338
|
+
result = asyncio.run(
|
|
339
|
+
_create_topic(
|
|
340
|
+
bot_token=bot_token,
|
|
341
|
+
chat_id=chat_id,
|
|
342
|
+
project=project,
|
|
343
|
+
branch=branch,
|
|
344
|
+
config_path=cfg_path,
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
if result is None:
|
|
349
|
+
typer.echo("error: failed to create topic", err=True)
|
|
350
|
+
raise typer.Exit(code=1)
|
|
351
|
+
|
|
352
|
+
thread_id, title = result
|
|
353
|
+
typer.echo(f"created topic: {title} (thread_id: {thread_id})")
|
|
354
|
+
typer.echo("")
|
|
355
|
+
typer.echo("done! check telegram for the new topic.")
|
takopi/commands.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal, Protocol, overload, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from .config import ConfigError
|
|
9
|
+
from .context import RunContext
|
|
10
|
+
from .ids import RESERVED_COMMAND_IDS
|
|
11
|
+
from .model import EngineId
|
|
12
|
+
from .plugins import COMMAND_GROUP, list_ids, load_plugin_backend
|
|
13
|
+
from .transport import MessageRef, RenderedMessage
|
|
14
|
+
from .transport_runtime import TransportRuntime
|
|
15
|
+
|
|
16
|
+
RunMode = Literal["emit", "capture"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class RunRequest:
|
|
21
|
+
prompt: str
|
|
22
|
+
engine: EngineId | None = None
|
|
23
|
+
context: RunContext | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class RunResult:
|
|
28
|
+
engine: EngineId
|
|
29
|
+
message: RenderedMessage | None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CommandExecutor(Protocol):
|
|
33
|
+
async def send(
|
|
34
|
+
self,
|
|
35
|
+
message: RenderedMessage | str,
|
|
36
|
+
*,
|
|
37
|
+
reply_to: MessageRef | None = None,
|
|
38
|
+
notify: bool = True,
|
|
39
|
+
) -> MessageRef | None: ...
|
|
40
|
+
|
|
41
|
+
async def run_one(
|
|
42
|
+
self, request: RunRequest, *, mode: RunMode = "emit"
|
|
43
|
+
) -> RunResult: ...
|
|
44
|
+
|
|
45
|
+
async def run_many(
|
|
46
|
+
self,
|
|
47
|
+
requests: Sequence[RunRequest],
|
|
48
|
+
*,
|
|
49
|
+
mode: RunMode = "emit",
|
|
50
|
+
parallel: bool = False,
|
|
51
|
+
) -> list[RunResult]: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class CommandContext:
|
|
56
|
+
command: str
|
|
57
|
+
text: str
|
|
58
|
+
args_text: str
|
|
59
|
+
args: tuple[str, ...]
|
|
60
|
+
message: MessageRef
|
|
61
|
+
reply_to: MessageRef | None
|
|
62
|
+
reply_text: str | None
|
|
63
|
+
config_path: Path | None
|
|
64
|
+
plugin_config: dict[str, Any]
|
|
65
|
+
runtime: TransportRuntime
|
|
66
|
+
executor: CommandExecutor
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class CommandResult:
|
|
71
|
+
text: str
|
|
72
|
+
notify: bool = True
|
|
73
|
+
reply_to: MessageRef | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@runtime_checkable
|
|
77
|
+
class CommandBackend(Protocol):
|
|
78
|
+
id: str
|
|
79
|
+
description: str
|
|
80
|
+
|
|
81
|
+
async def handle(self, ctx: CommandContext) -> CommandResult | None: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _validate_command_backend(backend: object, ep) -> None:
|
|
85
|
+
if not isinstance(backend, CommandBackend):
|
|
86
|
+
raise TypeError(f"{ep.value} is not a CommandBackend")
|
|
87
|
+
if backend.id != ep.name:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"{ep.value} command id {backend.id!r} does not match entrypoint {ep.name!r}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@overload
|
|
94
|
+
def get_command(
|
|
95
|
+
command_id: str,
|
|
96
|
+
*,
|
|
97
|
+
allowlist: Iterable[str] | None = None,
|
|
98
|
+
required: Literal[True] = True,
|
|
99
|
+
) -> CommandBackend: ...
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@overload
|
|
103
|
+
def get_command(
|
|
104
|
+
command_id: str,
|
|
105
|
+
*,
|
|
106
|
+
allowlist: Iterable[str] | None = None,
|
|
107
|
+
required: Literal[False],
|
|
108
|
+
) -> CommandBackend | None: ...
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_command(
|
|
112
|
+
command_id: str,
|
|
113
|
+
*,
|
|
114
|
+
allowlist: Iterable[str] | None = None,
|
|
115
|
+
required: bool = True,
|
|
116
|
+
) -> CommandBackend | None:
|
|
117
|
+
if command_id.lower() in RESERVED_COMMAND_IDS:
|
|
118
|
+
raise ConfigError(f"Command id {command_id!r} is reserved.")
|
|
119
|
+
return load_plugin_backend(
|
|
120
|
+
COMMAND_GROUP,
|
|
121
|
+
command_id,
|
|
122
|
+
allowlist=allowlist,
|
|
123
|
+
validator=_validate_command_backend,
|
|
124
|
+
kind_label="command",
|
|
125
|
+
required=required,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def list_command_ids(*, allowlist: Iterable[str] | None = None) -> list[str]:
|
|
130
|
+
return list_ids(
|
|
131
|
+
COMMAND_GROUP,
|
|
132
|
+
allowlist=allowlist,
|
|
133
|
+
reserved_ids=RESERVED_COMMAND_IDS,
|
|
134
|
+
)
|
takopi/config.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import tempfile
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import tomli_w
|
|
11
|
+
|
|
12
|
+
HOME_CONFIG_PATH = Path.home() / ".yee88" / "yee88.toml"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ensure_table(
|
|
20
|
+
config: dict[str, Any],
|
|
21
|
+
key: str,
|
|
22
|
+
*,
|
|
23
|
+
config_path: Path,
|
|
24
|
+
label: str | None = None,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
value = config.get(key)
|
|
27
|
+
if value is None:
|
|
28
|
+
table: dict[str, Any] = {}
|
|
29
|
+
config[key] = table
|
|
30
|
+
return table
|
|
31
|
+
if not isinstance(value, dict):
|
|
32
|
+
name = label or key
|
|
33
|
+
raise ConfigError(f"Invalid `{name}` in {config_path}; expected a table.")
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_config(cfg_path: Path) -> dict:
|
|
38
|
+
if cfg_path.exists() and not cfg_path.is_file():
|
|
39
|
+
raise ConfigError(f"Config path {cfg_path} exists but is not a file.") from None
|
|
40
|
+
try:
|
|
41
|
+
raw = cfg_path.read_text(encoding="utf-8")
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
raise ConfigError(f"Missing config file {cfg_path}.") from None
|
|
44
|
+
except OSError as e:
|
|
45
|
+
raise ConfigError(f"Failed to read config file {cfg_path}: {e}") from e
|
|
46
|
+
try:
|
|
47
|
+
return tomllib.loads(raw)
|
|
48
|
+
except tomllib.TOMLDecodeError as e:
|
|
49
|
+
raise ConfigError(f"Malformed TOML in {cfg_path}: {e}") from None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_or_init_config(path: str | Path | None = None) -> tuple[dict, Path]:
|
|
53
|
+
cfg_path = Path(path).expanduser() if path else HOME_CONFIG_PATH
|
|
54
|
+
if cfg_path.exists() and not cfg_path.is_file():
|
|
55
|
+
raise ConfigError(f"Config path {cfg_path} exists but is not a file.") from None
|
|
56
|
+
if not cfg_path.exists():
|
|
57
|
+
return {}, cfg_path
|
|
58
|
+
return read_config(cfg_path), cfg_path
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class ProjectConfig:
|
|
63
|
+
alias: str
|
|
64
|
+
path: Path
|
|
65
|
+
worktrees_dir: Path
|
|
66
|
+
default_engine: str | None = None
|
|
67
|
+
worktree_base: str | None = None
|
|
68
|
+
chat_id: int | None = None
|
|
69
|
+
system_prompt: str | None = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def worktrees_root(self) -> Path:
|
|
73
|
+
if self.worktrees_dir.is_absolute():
|
|
74
|
+
return self.worktrees_dir
|
|
75
|
+
return self.path / self.worktrees_dir
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class ProjectsConfig:
|
|
80
|
+
projects: dict[str, ProjectConfig]
|
|
81
|
+
default_project: str | None = None
|
|
82
|
+
global_system_prompt: str | None = None
|
|
83
|
+
chat_map: dict[int, str] = field(default_factory=dict)
|
|
84
|
+
|
|
85
|
+
def resolve(self, alias: str | None) -> ProjectConfig | None:
|
|
86
|
+
if alias is None:
|
|
87
|
+
if self.default_project is None:
|
|
88
|
+
return None
|
|
89
|
+
return self.projects.get(self.default_project)
|
|
90
|
+
return self.projects.get(alias.lower())
|
|
91
|
+
|
|
92
|
+
def resolve_system_prompt(self, alias: str | None) -> str | None:
|
|
93
|
+
project = self.resolve(alias)
|
|
94
|
+
if project is not None and project.system_prompt is not None:
|
|
95
|
+
return project.system_prompt
|
|
96
|
+
return self.global_system_prompt
|
|
97
|
+
|
|
98
|
+
def project_for_chat(self, chat_id: int | None) -> str | None:
|
|
99
|
+
if chat_id is None:
|
|
100
|
+
return None
|
|
101
|
+
return self.chat_map.get(chat_id)
|
|
102
|
+
|
|
103
|
+
def project_chat_ids(self) -> tuple[int, ...]:
|
|
104
|
+
return tuple(self.chat_map.keys())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def dump_toml(config: dict[str, Any]) -> str:
|
|
108
|
+
try:
|
|
109
|
+
dumped = tomli_w.dumps(config)
|
|
110
|
+
except (TypeError, ValueError) as e:
|
|
111
|
+
raise ConfigError(f"Unsupported config value: {e}") from None
|
|
112
|
+
return dumped
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def write_config(config: dict[str, Any], path: Path) -> None:
|
|
116
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
payload = dump_toml(config)
|
|
118
|
+
tmp_path: Path | None = None
|
|
119
|
+
try:
|
|
120
|
+
with tempfile.NamedTemporaryFile(
|
|
121
|
+
"w",
|
|
122
|
+
encoding="utf-8",
|
|
123
|
+
dir=path.parent,
|
|
124
|
+
prefix=f".{path.name}.",
|
|
125
|
+
suffix=".tmp",
|
|
126
|
+
delete=False,
|
|
127
|
+
) as tmp:
|
|
128
|
+
tmp.write(payload)
|
|
129
|
+
tmp.flush()
|
|
130
|
+
os.fsync(tmp.fileno())
|
|
131
|
+
tmp_path = Path(tmp.name)
|
|
132
|
+
os.replace(tmp_path, path)
|
|
133
|
+
except OSError as e:
|
|
134
|
+
raise ConfigError(f"Failed to write config file {path}: {e}") from e
|
|
135
|
+
finally:
|
|
136
|
+
if tmp_path is not None:
|
|
137
|
+
try:
|
|
138
|
+
tmp_path.unlink()
|
|
139
|
+
except FileNotFoundError:
|
|
140
|
+
pass
|
|
141
|
+
except OSError:
|
|
142
|
+
pass
|