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.
Files changed (103) hide show
  1. yee88/__init__.py +1 -0
  2. yee88/api.py +116 -0
  3. yee88/backends.py +25 -0
  4. yee88/backends_helpers.py +14 -0
  5. yee88/cli/__init__.py +228 -0
  6. yee88/cli/config.py +320 -0
  7. yee88/cli/doctor.py +173 -0
  8. yee88/cli/init.py +113 -0
  9. yee88/cli/onboarding_cmd.py +126 -0
  10. yee88/cli/plugins.py +196 -0
  11. yee88/cli/run.py +419 -0
  12. yee88/cli/topic.py +355 -0
  13. yee88/commands.py +134 -0
  14. yee88/config.py +142 -0
  15. yee88/config_migrations.py +124 -0
  16. yee88/config_watch.py +146 -0
  17. yee88/context.py +9 -0
  18. yee88/directives.py +146 -0
  19. yee88/engines.py +53 -0
  20. yee88/events.py +170 -0
  21. yee88/ids.py +17 -0
  22. yee88/lockfile.py +158 -0
  23. yee88/logging.py +283 -0
  24. yee88/markdown.py +298 -0
  25. yee88/model.py +77 -0
  26. yee88/plugins.py +312 -0
  27. yee88/presenter.py +25 -0
  28. yee88/progress.py +99 -0
  29. yee88/router.py +113 -0
  30. yee88/runner.py +712 -0
  31. yee88/runner_bridge.py +619 -0
  32. yee88/runners/__init__.py +1 -0
  33. yee88/runners/claude.py +483 -0
  34. yee88/runners/codex.py +656 -0
  35. yee88/runners/mock.py +221 -0
  36. yee88/runners/opencode.py +505 -0
  37. yee88/runners/pi.py +523 -0
  38. yee88/runners/run_options.py +39 -0
  39. yee88/runners/tool_actions.py +90 -0
  40. yee88/runtime_loader.py +207 -0
  41. yee88/scheduler.py +159 -0
  42. yee88/schemas/__init__.py +1 -0
  43. yee88/schemas/claude.py +238 -0
  44. yee88/schemas/codex.py +169 -0
  45. yee88/schemas/opencode.py +51 -0
  46. yee88/schemas/pi.py +117 -0
  47. yee88/settings.py +360 -0
  48. yee88/telegram/__init__.py +20 -0
  49. yee88/telegram/api_models.py +37 -0
  50. yee88/telegram/api_schemas.py +152 -0
  51. yee88/telegram/backend.py +163 -0
  52. yee88/telegram/bridge.py +425 -0
  53. yee88/telegram/chat_prefs.py +242 -0
  54. yee88/telegram/chat_sessions.py +112 -0
  55. yee88/telegram/client.py +409 -0
  56. yee88/telegram/client_api.py +539 -0
  57. yee88/telegram/commands/__init__.py +12 -0
  58. yee88/telegram/commands/agent.py +196 -0
  59. yee88/telegram/commands/cancel.py +116 -0
  60. yee88/telegram/commands/dispatch.py +111 -0
  61. yee88/telegram/commands/executor.py +449 -0
  62. yee88/telegram/commands/file_transfer.py +586 -0
  63. yee88/telegram/commands/handlers.py +45 -0
  64. yee88/telegram/commands/media.py +143 -0
  65. yee88/telegram/commands/menu.py +139 -0
  66. yee88/telegram/commands/model.py +215 -0
  67. yee88/telegram/commands/overrides.py +159 -0
  68. yee88/telegram/commands/parse.py +30 -0
  69. yee88/telegram/commands/plan.py +16 -0
  70. yee88/telegram/commands/reasoning.py +234 -0
  71. yee88/telegram/commands/reply.py +23 -0
  72. yee88/telegram/commands/topics.py +332 -0
  73. yee88/telegram/commands/trigger.py +143 -0
  74. yee88/telegram/context.py +140 -0
  75. yee88/telegram/engine_defaults.py +86 -0
  76. yee88/telegram/engine_overrides.py +105 -0
  77. yee88/telegram/files.py +178 -0
  78. yee88/telegram/loop.py +1822 -0
  79. yee88/telegram/onboarding.py +1088 -0
  80. yee88/telegram/outbox.py +177 -0
  81. yee88/telegram/parsing.py +239 -0
  82. yee88/telegram/render.py +198 -0
  83. yee88/telegram/state_store.py +88 -0
  84. yee88/telegram/topic_state.py +334 -0
  85. yee88/telegram/topics.py +256 -0
  86. yee88/telegram/trigger_mode.py +68 -0
  87. yee88/telegram/types.py +63 -0
  88. yee88/telegram/voice.py +110 -0
  89. yee88/transport.py +53 -0
  90. yee88/transport_runtime.py +323 -0
  91. yee88/transports.py +76 -0
  92. yee88/utils/__init__.py +1 -0
  93. yee88/utils/git.py +87 -0
  94. yee88/utils/json_state.py +21 -0
  95. yee88/utils/paths.py +47 -0
  96. yee88/utils/streams.py +44 -0
  97. yee88/utils/subprocess.py +86 -0
  98. yee88/worktrees.py +135 -0
  99. yee88-0.3.0.dist-info/METADATA +116 -0
  100. yee88-0.3.0.dist-info/RECORD +103 -0
  101. yee88-0.3.0.dist-info/WHEEL +4 -0
  102. yee88-0.3.0.dist-info/entry_points.txt +11 -0
  103. yee88-0.3.0.dist-info/licenses/LICENSE +21 -0
yee88/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: yee88 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 `yee88 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 `yee88 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 yee88.")
334
+ typer.echo("note: the telegram topic still exists but won't be managed by yee88.")
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.")
yee88/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
+ )
yee88/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