Coverage for src / openenv / bots / manager.py: 97.09%
765 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 13:36 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 13:36 +0000
1"""Interactive bot catalog management."""
3from __future__ import annotations
5from collections.abc import Iterable
6from copy import deepcopy
7import shutil
8import re
9from getpass import getpass
10from dataclasses import dataclass, field
11from pathlib import Path, PurePosixPath
13from openenv.core.skills import (
14 MANDATORY_SKILL_SOURCES,
15 build_catalog_skill,
16 catalog_skill_specs,
17 is_mandatory_skill,
18 merge_mandatory_skill_sources,
19)
20from openenv.core.errors import OpenEnvError
21from openenv.core.models import (
22 AccessConfig,
23 AgentConfig,
24 Manifest,
25 OpenClawConfig,
26 ProjectConfig,
27 RuntimeConfig,
28 SandboxConfig,
29 SkillConfig,
30)
31from openenv.core.utils import slugify_name
32from openenv.core.utils import stable_json_dumps
33from openenv.docker.builder import default_image_tag
34from openenv.docker.compose import (
35 ALL_BOTS_GATEWAY_CONTAINER_ROOT,
36 ALL_BOTS_GATEWAY_STATE_DIR,
37 AllBotsComposeSpec,
38 all_bots_compose_filename,
39 all_bots_env_filename,
40 default_compose_filename,
41 default_env_filename,
42 gateway_container_name,
43 materialize_runtime_mount_tree,
44 prepare_runtime_env_values,
45 render_compose,
46 render_all_bots_compose,
47 render_all_bots_env_file,
48 render_env_file,
49 write_compose,
50 write_env_file,
51)
52from openenv.docker.dockerfile import render_dockerfile
53from openenv.docker.runtime import (
54 CapturedSkill,
55 fetch_container_logs,
56 list_running_container_names,
57 snapshot_installed_skills,
58)
59from openenv.envfiles.project_env import get_project_env_value, write_project_env_value
60from openenv.envfiles.secret_env import (
61 load_secret_values,
62 secret_env_path,
63 write_secret_env,
64)
65from openenv.integrations.openrouter import improve_markdown_documents_with_openrouter
66from openenv.manifests.loader import load_manifest
67from openenv.manifests.lockfile import (
68 build_lockfile,
69 dump_lockfile,
70 load_lockfile,
71 write_lockfile,
72)
73from openenv.manifests.writer import render_manifest
76DEFAULT_SYSTEM_PACKAGES = ["git", "curl", "chromium"]
77DEFAULT_LANGUAGE = "pl"
78MANDATORY_SKILLS_LABEL = ", ".join(MANDATORY_SKILL_SOURCES)
79MANIFEST_FILENAME = "openclawenv.toml"
80LEGACY_MANIFEST_FILENAME = "openenv.toml"
81LOCKFILE_FILENAME = "openclawenv.lock"
82LEGACY_LOCKFILE_FILENAME = "openenv.lock"
83MAIN_OPENCLAW_AGENT_ID = "main"
84SHARED_AGENT_STATE_FILENAMES = ("auth-profiles.json", "auth.json", "models.json")
85ENV_PLACEHOLDER_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
86CATALOG_SKILL_PLACEHOLDER_MARKER = "This skill is referenced from an external catalog."
87AGENT_DOC_FILENAMES = {
88 "agents_md": "AGENTS.md",
89 "soul_md": "SOUL.md",
90 "user_md": "USER.md",
91 "identity_md": "IDENTITY.md",
92 "tools_md": "TOOLS.md",
93 "memory_seed": "memory.md",
94}
95LANGUAGE_ALIASES = {
96 "": DEFAULT_LANGUAGE,
97 "1": "pl",
98 "2": "en",
99 "pl": "pl",
100 "polski": "pl",
101 "polish": "pl",
102 "en": "en",
103 "eng": "en",
104 "english": "en",
105}
106YES_WORDS = {
107 "pl": {"t", "tak", "y", "yes"},
108 "en": {"y", "yes"},
109}
110MESSAGES = {
111 "pl": {
112 "language_title": "OpenClawenv - Wybierz jezyk / Choose language",
113 "language_option_pl": "1. Polski",
114 "language_option_en": "2. English",
115 "language_prompt": "Wybierz jezyk / Choose language [1-2, domyslnie/default 1]: ",
116 "language_invalid": "Nieznany wybor / Unknown choice. Wpisz / Enter 1/2 albo / or pl/en.",
117 "menu_title": "OpenClawenv - Interaktywne menu",
118 "menu_list": "1. Wylistuj boty",
119 "menu_add": "2. Dodaj nowego bota",
120 "menu_edit": "3. Edytuj bota",
121 "menu_delete": "4. Usun bota",
122 "menu_running": "5. Wylistuj dzialajace boty",
123 "menu_exit": "6. Zakoncz",
124 "menu_prompt": "Wybierz opcje [1-6]: ",
125 "menu_unknown": "Nieznana opcja. Wybierz 1, 2, 3, 4, 5 lub 6.",
126 "menu_exit_message": "Zamykam menu OpenClawenv.",
127 "no_bots": "Brak zarejestrowanych botow.",
128 "bots_header": "Zarejestrowane boty:",
129 "bots_generate_stack": "A. Generuj wspolny stack dla wszystkich botow",
130 "role_label": "Rola",
131 "manifest_label": "Manifest",
132 "compose_label": "docker-compose",
133 "container_label": "Kontener",
134 "browse_prompt": (
135 "Podaj numer bota, wpisz A aby wygenerowac wspolny stack, "
136 "albo Enter, aby wrocic: "
137 ),
138 "running_failed": "Nie udalo sie odczytac dzialajacych botow: {error}",
139 "running_no_bots": "Brak dzialajacych botow uruchomionych z katalogu bots.",
140 "running_header": "Dzialajace boty:",
141 "running_prompt": (
142 "Podaj numer dzialajacego bota, aby przejsc do akcji, albo Enter, aby wrocic: "
143 ),
144 "running_actions_title": "Dzialajacy bot `{display_name}`",
145 "running_actions_logs": "1. Podglad logow",
146 "running_actions_snapshot": "2. Stworz snapshot skilli",
147 "running_actions_back": "3. Wroc",
148 "running_actions_prompt": "Wybierz opcje [1-3]: ",
149 "running_actions_unknown": "Nieznana opcja. Wybierz 1, 2 lub 3.",
150 "logs_failed": "Nie udalo sie pobrac logow: {error}",
151 "logs_header": "Logi dla `{display_name}`:",
152 "logs_empty": "Brak logow do wyswietlenia.",
153 "snapshot_failed": "Nie udalo sie stworzyc snapshota: {error}",
154 "snapshot_no_changes": "Snapshot nie wykryl nowych zmian w skillach.",
155 "snapshot_manifest": "Zaktualizowano manifest: {path}",
156 "snapshot_lockfile": "Zaktualizowano lockfile: {path}",
157 "snapshot_added_skill": "Dodano skill: {name}",
158 "snapshot_hydrated_skill": "Uzupelniono skill z kontenera: {name}",
159 "bot_actions_title": "Bot `{display_name}`",
160 "bot_actions_generate": "1. Generuj Dockerfile + docker-compose",
161 "bot_actions_improve_docs": "2. Popraw dokumenty *.md przez OpenRouter",
162 "bot_actions_back": "3. Wroc",
163 "bot_actions_prompt": "Wybierz opcje [1-3]: ",
164 "bot_actions_unknown": "Nieznana opcja. Wybierz 1, 2 lub 3.",
165 "generate_failed": "Nie udalo sie wygenerowac artefaktow: {error}",
166 "generated_lockfile": "Wygenerowano lockfile: {path}",
167 "generated_dockerfile": "Wygenerowano Dockerfile: {path}",
168 "generated_compose": "Wygenerowano docker-compose: {path}",
169 "generated_env": "Wygenerowano plik sekretow: {path}",
170 "generate_all_failed": "Nie udalo sie wygenerowac wspolnego stacku: {error}",
171 "generated_all_compose": "Wygenerowano wspolny stack: {path}",
172 "generated_all_prepared": "Przygotowano artefakty dla {count} botow.",
173 "edit_docs_prompt": (
174 "Opisz, co poprawic w dokumentach *.md [Enter = popraw spojność i jakosc]: "
175 ),
176 "openrouter_key_missing": (
177 "Brak OPENROUTER_API_KEY w zmiennych systemowych i w pliku .env projektu."
178 ),
179 "openrouter_key_prompt": "Podaj OPENROUTER_API_KEY: ",
180 "openrouter_key_saved": "Zapisano OPENROUTER_API_KEY w {path}",
181 "edit_docs_failed": "Nie udalo sie poprawic dokumentow: {error}",
182 "edit_docs_done": "OpenRouter zakonczyl poprawianie dokumentow: {summary}",
183 "edit_docs_updated_file": "Zaktualizowano: {path}",
184 "add_title": "Dodawanie nowego bota",
185 "prompt_name": "Jak ma sie nazywac bot? ",
186 "prompt_role": "Jaka ma byc rola bota? ",
187 "prompt_skills": (
188 "Jakie dodatkowe skille ma miec bot? Podaj referencje po przecinku "
189 "(np. kralsamwise/kdp-publisher). Obowiazkowe skille: "
190 f"{MANDATORY_SKILLS_LABEL}: "
191 ),
192 "prompt_system_packages": (
193 "Jakie dodatkowe pakiety systemowe zainstalowac w kontenerze? "
194 "(po przecinku, Enter jesli brak): "
195 ),
196 "prompt_python_packages": (
197 "Jakie dodatkowe pakiety Python zainstalowac? "
198 "(np. requests==2.32.3, Enter jesli brak): "
199 ),
200 "prompt_node_packages": (
201 "Jakie dodatkowe pakiety Node.js zainstalowac globalnie? "
202 "(np. typescript@5.8.3, @scope/pkg@1.2.3, Enter jesli brak): "
203 ),
204 "prompt_secrets": (
205 "Jakie sekrety / dane logowania sa potrzebne? "
206 "(nazwy zmiennych env, np. OPENAI_API_KEY, DB_PASSWORD): "
207 ),
208 "prompt_websites": (
209 "Jakie linki do witryn / endpointow powinien znac bot? "
210 "(po przecinku, Enter jesli brak): "
211 ),
212 "prompt_databases": (
213 "Jakie bazy danych lub polaczenia uprzywilejowane powinny byc opisane? "
214 "(po przecinku, Enter jesli brak): "
215 ),
216 "prompt_access_notes": (
217 "Dodatkowe notatki o poziomach dostepu lub ograniczeniach "
218 "(po przecinku, Enter jesli brak): "
219 ),
220 "create_failed": "Nie udalo sie utworzyc bota: {error}",
221 "created": "Utworzono bota `{display_name}` w {path}",
222 "edit_no_bots": "Brak botow do edycji.",
223 "edit_select": "Podaj numer bota do edycji: ",
224 "edit_title": "Edycja bota `{display_name}`",
225 "update_failed": "Nie udalo sie zaktualizowac bota: {error}",
226 "updated": "Zaktualizowano bota `{display_name}` w {path}",
227 "delete_no_bots": "Brak botow do usuniecia.",
228 "delete_select": "Podaj numer bota do usuniecia: ",
229 "delete_confirm": (
230 "Czy na pewno usunac bota `{display_name}` i caly katalog `{slug}`? [t/N]: "
231 ),
232 "delete_cancelled": "Usuwanie anulowane.",
233 "delete_failed": "Nie udalo sie usunac bota: {error}",
234 "deleted": "Usunieto bota `{display_name}`.",
235 "required_field": "To pole jest wymagane.",
236 "invalid_number": "Podano niepoprawny numer.",
237 "out_of_range": "Numer bota jest poza zakresem.",
238 },
239 "en": {
240 "language_title": "OpenClawenv - Wybierz jezyk / Choose language",
241 "language_option_pl": "1. Polski",
242 "language_option_en": "2. English",
243 "language_prompt": "Wybierz jezyk / Choose language [1-2, domyslnie/default 1]: ",
244 "language_invalid": "Nieznany wybor / Unknown choice. Wpisz / Enter 1/2 albo / or pl/en.",
245 "menu_title": "OpenClawenv - Interactive menu",
246 "menu_list": "1. List bots",
247 "menu_add": "2. Add a new bot",
248 "menu_edit": "3. Edit a bot",
249 "menu_delete": "4. Delete a bot",
250 "menu_running": "5. List running bots",
251 "menu_exit": "6. Exit",
252 "menu_prompt": "Choose an option [1-6]: ",
253 "menu_unknown": "Unknown option. Choose 1, 2, 3, 4, 5, or 6.",
254 "menu_exit_message": "Closing the OpenClawenv menu.",
255 "no_bots": "No registered bots.",
256 "bots_header": "Registered bots:",
257 "bots_generate_stack": "A. Generate a shared stack for all bots",
258 "role_label": "Role",
259 "manifest_label": "Manifest",
260 "compose_label": "docker-compose",
261 "container_label": "Container",
262 "browse_prompt": (
263 "Enter a bot number, press A to generate the shared stack, "
264 "or press Enter to go back: "
265 ),
266 "running_failed": "Failed to inspect running bots: {error}",
267 "running_no_bots": "No running bots launched from the bots directory were found.",
268 "running_header": "Running bots:",
269 "running_prompt": (
270 "Enter a running bot number to open actions, or press Enter to go back: "
271 ),
272 "running_actions_title": "Running bot `{display_name}`",
273 "running_actions_logs": "1. View logs",
274 "running_actions_snapshot": "2. Create a skill snapshot",
275 "running_actions_back": "3. Back",
276 "running_actions_prompt": "Choose an option [1-3]: ",
277 "running_actions_unknown": "Unknown option. Choose 1, 2, or 3.",
278 "logs_failed": "Failed to fetch logs: {error}",
279 "logs_header": "Logs for `{display_name}`:",
280 "logs_empty": "No logs available.",
281 "snapshot_failed": "Failed to create a snapshot: {error}",
282 "snapshot_no_changes": "The snapshot did not detect any new skill changes.",
283 "snapshot_manifest": "Updated manifest: {path}",
284 "snapshot_lockfile": "Updated lockfile: {path}",
285 "snapshot_added_skill": "Added skill: {name}",
286 "snapshot_hydrated_skill": "Hydrated skill from container: {name}",
287 "bot_actions_title": "Bot `{display_name}`",
288 "bot_actions_generate": "1. Generate Dockerfile + docker-compose",
289 "bot_actions_improve_docs": "2. Improve *.md documents via OpenRouter",
290 "bot_actions_back": "3. Back",
291 "bot_actions_prompt": "Choose an option [1-3]: ",
292 "bot_actions_unknown": "Unknown option. Choose 1, 2, or 3.",
293 "generate_failed": "Failed to generate artifacts: {error}",
294 "generated_lockfile": "Generated lockfile: {path}",
295 "generated_dockerfile": "Generated Dockerfile: {path}",
296 "generated_compose": "Generated docker-compose: {path}",
297 "generated_env": "Generated secrets env file: {path}",
298 "generate_all_failed": "Failed to generate the shared stack: {error}",
299 "generated_all_compose": "Generated shared stack: {path}",
300 "generated_all_prepared": "Prepared artifacts for {count} bot(s).",
301 "edit_docs_prompt": (
302 "Describe what should be improved in the *.md documents "
303 "[Enter = improve overall consistency and quality]: "
304 ),
305 "openrouter_key_missing": (
306 "OPENROUTER_API_KEY was not found in the system environment or project .env."
307 ),
308 "openrouter_key_prompt": "Enter OPENROUTER_API_KEY: ",
309 "openrouter_key_saved": "Saved OPENROUTER_API_KEY to {path}",
310 "edit_docs_failed": "Failed to improve documents: {error}",
311 "edit_docs_done": "OpenRouter finished improving the documents: {summary}",
312 "edit_docs_updated_file": "Updated: {path}",
313 "add_title": "Adding a new bot",
314 "prompt_name": "What should the bot be called? ",
315 "prompt_role": "What should the bot role be? ",
316 "prompt_skills": (
317 "Which additional skills should it have? Provide comma-separated references "
318 "(for example kralsamwise/kdp-publisher). Mandatory skills: "
319 f"{MANDATORY_SKILLS_LABEL}: "
320 ),
321 "prompt_system_packages": (
322 "Which extra system packages should be installed in the container? "
323 "(comma-separated, press Enter if none): "
324 ),
325 "prompt_python_packages": (
326 "Which extra Python packages should be installed? "
327 "(for example requests==2.32.3, press Enter if none): "
328 ),
329 "prompt_node_packages": (
330 "Which extra Node.js packages should be installed globally? "
331 "(for example typescript@5.8.3, @scope/pkg@1.2.3, press Enter if none): "
332 ),
333 "prompt_secrets": (
334 "Which secrets / credentials are required? "
335 "(env variable names such as OPENAI_API_KEY, DB_PASSWORD): "
336 ),
337 "prompt_websites": (
338 "Which websites / endpoints should the bot know about? "
339 "(comma-separated, press Enter if none): "
340 ),
341 "prompt_databases": (
342 "Which databases or privileged connections should be described? "
343 "(comma-separated, press Enter if none): "
344 ),
345 "prompt_access_notes": (
346 "Additional access-level notes or restrictions "
347 "(comma-separated, press Enter if none): "
348 ),
349 "create_failed": "Failed to create bot: {error}",
350 "created": "Created bot `{display_name}` at {path}",
351 "edit_no_bots": "There are no bots to edit.",
352 "edit_select": "Enter the bot number to edit: ",
353 "edit_title": "Editing bot `{display_name}`",
354 "update_failed": "Failed to update bot: {error}",
355 "updated": "Updated bot `{display_name}` at {path}",
356 "delete_no_bots": "There are no bots to delete.",
357 "delete_select": "Enter the bot number to delete: ",
358 "delete_confirm": (
359 "Are you sure you want to delete bot `{display_name}` and the entire `{slug}` directory? [y/N]: "
360 ),
361 "delete_cancelled": "Deletion cancelled.",
362 "delete_failed": "Failed to delete bot: {error}",
363 "deleted": "Deleted bot `{display_name}`.",
364 "required_field": "This field is required.",
365 "invalid_number": "The provided number is invalid.",
366 "out_of_range": "The bot number is out of range.",
367 },
368}
371@dataclass(slots=True)
372class BotAnswers:
373 """Normalized answers collected from the interactive bot creation or edit flow."""
375 display_name: str
376 role: str
377 skill_sources: list[str]
378 system_packages: list[str]
379 python_packages: list[str]
380 secret_names: list[str]
381 websites: list[str]
382 databases: list[str]
383 access_notes: list[str]
384 node_packages: list[str] = field(default_factory=list)
387@dataclass(slots=True)
388class BotRecord:
389 """Managed bot discovered from the local `bots/` catalog."""
391 slug: str
392 manifest_path: Path
393 manifest: Manifest
395 @property
396 def display_name(self) -> str:
397 """Return the human-facing bot name stored in the OpenClaw configuration."""
398 return self.manifest.openclaw.agent_name
400 @property
401 def role(self) -> str:
402 """Return the role/description that summarizes what the bot is expected to do."""
403 return self.manifest.project.description
406@dataclass(slots=True)
407class GeneratedArtifacts:
408 """Paths and metadata produced when rendering one bot build bundle."""
410 bot: BotRecord
411 lock_path: Path
412 dockerfile_path: Path
413 compose_path: Path
414 env_path: Path
415 image_tag: str
418@dataclass(slots=True)
419class AllBotsStackArtifacts:
420 """Result of generating the shared compose stack for every managed bot."""
422 stack_path: Path
423 bot_artifacts: list[GeneratedArtifacts]
426@dataclass(slots=True)
427class DocumentImprovementResult:
428 """Summary of markdown documents updated by the OpenRouter improvement flow."""
430 bot: BotRecord
431 summary: str
432 updated_paths: list[Path]
435@dataclass(slots=True)
436class RunningBotRecord:
437 """Managed bot enriched with runtime details about its running Docker container."""
439 bot: BotRecord
440 compose_path: Path
441 container_name: str
443 @property
444 def display_name(self) -> str:
445 """Return the display name of the running bot."""
446 return self.bot.display_name
448 @property
449 def slug(self) -> str:
450 """Return the managed slug of the running bot."""
451 return self.bot.slug
454@dataclass(slots=True)
455class SkillSnapshotResult:
456 """Result of reconciling installed runtime skills back into the bot manifest."""
458 bot: BotRecord
459 manifest_path: Path
460 lock_path: Path | None
461 added_skill_names: list[str]
462 hydrated_skill_names: list[str]
465def bots_root(root: str | Path) -> Path:
466 """Return the canonical directory that stores all managed bot folders."""
467 return Path(root).resolve() / "bots"
470def all_bots_compose_path(root: str | Path) -> Path:
471 """Return the shared compose path for all managed bots."""
472 return bots_root(root) / all_bots_compose_filename()
475def _resolve_bot_manifest_path(bot_dir: Path) -> Path | None:
476 """Return the preferred manifest path for one bot, with legacy fallback support."""
477 for candidate in (bot_dir / MANIFEST_FILENAME, bot_dir / LEGACY_MANIFEST_FILENAME):
478 if candidate.exists():
479 return candidate
480 return None
483def _preferred_lockfile_path(bot_dir: Path) -> Path:
484 """Return the preferred lockfile path for a bot directory."""
485 preferred = bot_dir / LOCKFILE_FILENAME
486 legacy = bot_dir / LEGACY_LOCKFILE_FILENAME
487 if preferred.exists() or not legacy.exists():
488 return preferred
489 return legacy
492def discover_bots(root: str | Path) -> list[BotRecord]:
493 """Discover managed bot manifests."""
494 records: list[BotRecord] = []
495 root_path = bots_root(root)
496 if not root_path.exists():
497 return records
498 for bot_dir in sorted(path for path in root_path.iterdir() if path.is_dir()):
499 manifest_path = _resolve_bot_manifest_path(bot_dir)
500 if manifest_path is None:
501 continue
502 try:
503 manifest, _ = load_manifest(manifest_path)
504 except OpenEnvError:
505 continue
506 records.append(
507 BotRecord(
508 slug=manifest_path.parent.name,
509 manifest_path=manifest_path,
510 manifest=manifest,
511 )
512 )
513 return records
516def create_bot(root: str | Path, answers: BotAnswers) -> BotRecord:
517 """Create a new managed bot manifest from interactive answers."""
518 slug = slugify_name(answers.display_name)
519 bot_dir = bots_root(root) / slug
520 if bot_dir.exists():
521 raise OpenEnvError(f"Bot `{slug}` already exists.")
522 bot_dir.mkdir(parents=True, exist_ok=False)
523 manifest = build_bot_manifest(answers)
524 _write_agent_docs(bot_dir, manifest.agent)
525 manifest_path = bot_dir / MANIFEST_FILENAME
526 manifest_path.write_text(render_manifest(manifest), encoding="utf-8")
527 write_secret_env(
528 secret_env_path(bot_dir),
529 answers.secret_names,
530 display_name=answers.display_name,
531 )
532 return load_bot(root, slug)
535def update_bot(root: str | Path, existing_slug: str, answers: BotAnswers) -> BotRecord:
536 """Update an existing managed bot manifest."""
537 current_slug = slugify_name(existing_slug)
538 current_dir = bots_root(root) / current_slug
539 if not current_dir.exists():
540 raise OpenEnvError(f"Bot `{existing_slug}` does not exist.")
542 new_slug = slugify_name(answers.display_name)
543 target_dir = bots_root(root) / new_slug
544 if new_slug != current_slug and target_dir.exists():
545 raise OpenEnvError(f"Bot `{new_slug}` already exists.")
547 existing_manifest = load_bot(root, current_slug).manifest
548 existing_secret_values = load_secret_values(secret_env_path(current_dir))
549 manifest = build_bot_manifest(answers)
550 manifest.openclaw.channels = deepcopy(existing_manifest.openclaw.channels)
551 if new_slug != current_slug:
552 current_dir.rename(target_dir)
553 else:
554 target_dir = current_dir
556 _write_agent_docs(target_dir, manifest.agent)
557 manifest_path = target_dir / MANIFEST_FILENAME
558 manifest_path.write_text(render_manifest(manifest), encoding="utf-8")
559 legacy_manifest_path = target_dir / LEGACY_MANIFEST_FILENAME
560 if legacy_manifest_path.exists():
561 legacy_manifest_path.unlink()
562 write_secret_env(
563 secret_env_path(target_dir),
564 answers.secret_names,
565 existing_values=existing_secret_values,
566 display_name=answers.display_name,
567 )
568 return load_bot(root, new_slug)
571def delete_bot(root: str | Path, slug: str) -> None:
572 """Delete all managed data for a bot."""
573 target = bots_root(root) / slugify_name(slug)
574 if not target.exists():
575 raise OpenEnvError(f"Bot `{slug}` does not exist.")
576 shutil.rmtree(target, ignore_errors=False)
579def load_bot(root: str | Path, slug: str) -> BotRecord:
580 """Load a single managed bot by slug."""
581 bot_dir = bots_root(root) / slugify_name(slug)
582 manifest_path = _resolve_bot_manifest_path(bot_dir)
583 if manifest_path is None:
584 raise OpenEnvError(f"Bot `{slug}` does not exist.")
585 manifest, _ = load_manifest(manifest_path)
586 return BotRecord(
587 slug=manifest_path.parent.name,
588 manifest_path=manifest_path,
589 manifest=manifest,
590 )
593def discover_running_bots(root: str | Path) -> list[RunningBotRecord]:
594 """Discover managed bots that currently have running Docker containers."""
595 running_containers = list_running_container_names()
596 records: list[RunningBotRecord] = []
597 for bot in discover_bots(root):
598 compose_path = _compose_path_for_bot(bot)
599 if not compose_path.exists(): 599 ↛ 600line 599 didn't jump to line 600 because the condition on line 599 was never true
600 continue
601 container_name = _container_name_for_bot(bot)
602 if container_name not in running_containers:
603 continue
604 records.append(
605 RunningBotRecord(
606 bot=bot,
607 compose_path=compose_path,
608 container_name=container_name,
609 )
610 )
611 return records
614def preview_running_bot_logs(root: str | Path, slug: str, *, tail: int = 120) -> str:
615 """Fetch recent logs for a running managed bot."""
616 running_bot = _load_running_bot(root, slug)
617 return fetch_container_logs(running_bot.container_name, tail=tail)
620def create_skill_snapshot(root: str | Path, slug: str) -> SkillSnapshotResult:
621 """Snapshot installed skills from a running bot and update the manifest."""
622 running_bot = _load_running_bot(root, slug)
623 manifest = running_bot.bot.manifest
624 captured_skills = snapshot_installed_skills(
625 running_bot.container_name,
626 workspace=manifest.openclaw.workspace,
627 )
628 added_skill_names, hydrated_skill_names = _apply_skill_snapshot(
629 manifest,
630 captured_skills,
631 )
633 if not added_skill_names and not hydrated_skill_names: 633 ↛ 634line 633 didn't jump to line 634 because the condition on line 633 was never true
634 return SkillSnapshotResult(
635 bot=running_bot.bot,
636 manifest_path=running_bot.bot.manifest_path,
637 lock_path=None,
638 added_skill_names=[],
639 hydrated_skill_names=[],
640 )
642 rendered_manifest = render_manifest(manifest)
643 running_bot.bot.manifest_path.write_text(rendered_manifest, encoding="utf-8")
645 lock_path = _preferred_lockfile_path(running_bot.bot.manifest_path.parent)
646 updated_lock_path: Path | None = None
647 if lock_path.exists(): 647 ↛ 660line 647 didn't jump to line 660 because the condition on line 647 was always true
648 existing_lock = load_lockfile(lock_path)
649 lockfile = build_lockfile(
650 manifest,
651 rendered_manifest,
652 resolver=lambda _: {
653 "digest": existing_lock.base_image["digest"],
654 "resolved_reference": existing_lock.base_image["resolved_reference"],
655 },
656 )
657 write_lockfile(lock_path, lockfile)
658 updated_lock_path = lock_path
660 return SkillSnapshotResult(
661 bot=load_bot(root, slug),
662 manifest_path=running_bot.bot.manifest_path,
663 lock_path=updated_lock_path,
664 added_skill_names=added_skill_names,
665 hydrated_skill_names=hydrated_skill_names,
666 )
669def generate_bot_artifacts(root: str | Path, slug: str) -> GeneratedArtifacts:
670 """Generate lockfile, Dockerfile, compose, and env bundle for a bot."""
671 bot = load_bot(root, slug)
672 manifest, raw_manifest_text = load_manifest(bot.manifest_path)
673 lockfile = build_lockfile(manifest, raw_manifest_text)
675 lock_path = _preferred_lockfile_path(bot.manifest_path.parent)
676 write_lockfile(lock_path, lockfile)
677 raw_lock_text = dump_lockfile(lockfile)
679 dockerfile_path = bot.manifest_path.with_name("Dockerfile")
680 dockerfile_path.write_text(
681 render_dockerfile(
682 manifest,
683 lockfile,
684 raw_manifest_text=raw_manifest_text,
685 raw_lock_text=raw_lock_text,
686 ),
687 encoding="utf-8",
688 )
690 image_tag = default_image_tag(manifest.project.name, manifest.project.version)
691 compose_path = bot.manifest_path.parent / default_compose_filename(
692 manifest.openclaw.agent_name
693 )
694 write_compose(compose_path, render_compose(manifest, image_tag))
696 env_path = bot.manifest_path.parent / default_env_filename(manifest.openclaw.agent_name)
697 sidecar_env_path = secret_env_path(bot.manifest_path.parent)
698 existing_values = load_secret_values(env_path)
699 if sidecar_env_path.exists():
700 existing_values.update(load_secret_values(sidecar_env_path))
701 write_env_file(
702 env_path,
703 render_env_file(manifest, image_tag, existing_values=existing_values),
704 )
705 materialize_runtime_mount_tree(
706 bot.manifest_path.parent,
707 manifest,
708 lockfile,
709 raw_manifest_text=raw_manifest_text,
710 raw_lock_text=raw_lock_text,
711 )
713 return GeneratedArtifacts(
714 bot=bot,
715 lock_path=lock_path,
716 dockerfile_path=dockerfile_path,
717 compose_path=compose_path,
718 env_path=env_path,
719 image_tag=image_tag,
720 )
723def generate_all_bots_stack(root: str | Path) -> AllBotsStackArtifacts:
724 """Generate a shared compose stack with one gateway and all managed bots."""
725 bots = discover_bots(root)
726 if not bots:
727 raise OpenEnvError("No managed bots were found.")
728 bot_artifacts = [generate_bot_artifacts(root, bot.slug) for bot in bots]
729 specs = [
730 AllBotsComposeSpec(
731 slug=artifact.bot.slug,
732 manifest=artifact.bot.manifest,
733 image_tag=artifact.image_tag,
734 )
735 for artifact in bot_artifacts
736 ]
737 required_shared_env_names = _materialize_all_bots_runtime(root, bot_artifacts)
738 shared_env_path = bots_root(root) / all_bots_env_filename()
739 shared_env_values = prepare_runtime_env_values(load_secret_values(shared_env_path))
740 _merge_required_shared_env_values(
741 shared_env_values,
742 bot_artifacts=bot_artifacts,
743 required_env_names=required_shared_env_names,
744 )
745 write_env_file(shared_env_path, render_all_bots_env_file(existing_values=shared_env_values))
746 stack_path = all_bots_compose_path(root)
747 write_compose(stack_path, render_all_bots_compose(specs))
748 return AllBotsStackArtifacts(
749 stack_path=stack_path,
750 bot_artifacts=bot_artifacts,
751 )
754def _materialize_all_bots_runtime(
755 root: str | Path,
756 bot_artifacts: list[GeneratedArtifacts],
757) -> set[str]:
758 """Write the shared state/workspace tree consumed by the all-bots gateway."""
759 shared_root = bots_root(root) / ".all-bots"
760 shared_state_root = shared_root / ".openclaw"
761 shared_workspace_root = shared_root / "workspace"
762 shared_root.mkdir(parents=True, exist_ok=True)
763 shared_state_root.mkdir(parents=True, exist_ok=True)
764 shared_workspace_root.mkdir(parents=True, exist_ok=True)
766 shared_state_dir = ALL_BOTS_GATEWAY_STATE_DIR
767 shared_workspace_prefix = PurePosixPath(ALL_BOTS_GATEWAY_CONTAINER_ROOT) / "workspace"
768 shared_agent_entries: list[dict[str, object]] = []
769 shared_channels: dict[str, object] = {}
770 seen_agent_ids: set[str] = set()
772 for artifact in bot_artifacts:
773 manifest, _ = load_manifest(artifact.bot.manifest_path)
774 if manifest.openclaw.agent_id in seen_agent_ids: 774 ↛ 775line 774 didn't jump to line 775 because the condition on line 774 was never true
775 raise OpenEnvError(
776 "Shared gateway generation requires unique openclaw.agent_id values; "
777 f"duplicate detected: {manifest.openclaw.agent_id}"
778 )
779 seen_agent_ids.add(manifest.openclaw.agent_id)
781 shared_workspace = str(shared_workspace_prefix / artifact.bot.slug)
782 _write_shared_bot_workspace(
783 manifest,
784 shared_state_root=shared_state_root,
785 shared_workspace_root=shared_workspace_root / artifact.bot.slug,
786 shared_state_dir=shared_state_dir,
787 shared_workspace=shared_workspace,
788 )
789 _sync_shared_agent_state_from_main(
790 shared_state_root,
791 agent_id=manifest.openclaw.agent_id,
792 )
793 _merge_shared_channel_configs(
794 shared_channels,
795 manifest.openclaw.channels,
796 agent_id=manifest.openclaw.agent_id,
797 )
798 shared_agent_entries.append(
799 manifest.openclaw.agent_definition(
800 artifact.image_tag,
801 workspace=shared_workspace,
802 state_dir=shared_state_dir,
803 )
804 )
806 shared_config = {
807 "gateway": {
808 "mode": "local",
809 "bind": "lan",
810 "auth": {
811 "mode": "token",
812 "token": "${OPENCLAW_GATEWAY_TOKEN}",
813 },
814 },
815 "agents": {
816 "defaults": {
817 "workspace": str(shared_workspace_prefix),
818 },
819 "list": shared_agent_entries,
820 },
821 }
822 if shared_channels:
823 shared_config["channels"] = shared_channels
824 (shared_state_root / "openclaw.json").write_text(
825 stable_json_dumps(shared_config, indent=2) + "\n",
826 encoding="utf-8",
827 )
828 return _collect_env_placeholders(shared_config)
831def _merge_required_shared_env_values(
832 shared_env_values: dict[str, str],
833 *,
834 bot_artifacts: list[GeneratedArtifacts],
835 required_env_names: set[str],
836) -> None:
837 """Populate the shared env file with values referenced by the shared config."""
838 missing_names = {
839 name for name in required_env_names if not shared_env_values.get(name, "").strip()
840 }
841 if not missing_names:
842 return
844 for artifact in bot_artifacts: 844 ↛ exitline 844 didn't return from function '_merge_required_shared_env_values' because the loop on line 844 didn't complete
845 candidate_sources = [
846 load_secret_values(secret_env_path(artifact.bot.manifest_path.parent)),
847 ]
848 if artifact.env_path.exists(): 848 ↛ 850line 848 didn't jump to line 850 because the condition on line 848 was always true
849 candidate_sources.append(load_secret_values(artifact.env_path))
850 for source in candidate_sources: 850 ↛ 844line 850 didn't jump to line 844 because the loop on line 850 didn't complete
851 for name in sorted(missing_names):
852 value = source.get(name, "")
853 if not value.strip(): 853 ↛ 854line 853 didn't jump to line 854 because the condition on line 853 was never true
854 continue
855 shared_env_values[name] = value
856 missing_names = {
857 name for name in missing_names if not shared_env_values.get(name, "").strip()
858 }
859 if not missing_names: 859 ↛ 850line 859 didn't jump to line 850 because the condition on line 859 was always true
860 return
863def _collect_env_placeholders(value: object) -> set[str]:
864 """Collect `${VAR}` placeholders recursively from JSON-like config values."""
865 if isinstance(value, str):
866 return set(ENV_PLACEHOLDER_PATTERN.findall(value))
867 if isinstance(value, dict):
868 placeholders: set[str] = set()
869 for nested in value.values():
870 placeholders.update(_collect_env_placeholders(nested))
871 return placeholders
872 if isinstance(value, list):
873 placeholders: set[str] = set()
874 for nested in value:
875 placeholders.update(_collect_env_placeholders(nested))
876 return placeholders
877 return set()
880def _write_shared_bot_workspace(
881 manifest: Manifest,
882 *,
883 shared_state_root: Path,
884 shared_workspace_root: Path,
885 shared_state_dir: str,
886 shared_workspace: str,
887) -> None:
888 """Write one bot's runtime workspace into the shared all-bots tree."""
889 files = manifest.workspace_files(workspace=shared_workspace, state_dir=shared_state_dir)
890 placeholder_paths = {
891 str(PurePosixPath(shared_workspace) / "skills" / skill_name / "SKILL.md")
892 for skill_name, _ in catalog_skill_specs(manifest.skills)
893 }
894 state_dir = PurePosixPath(shared_state_dir)
895 workspace_dir = PurePosixPath(shared_workspace)
896 shared_workspace_root.mkdir(parents=True, exist_ok=True)
897 _map_shared_host_path(
898 manifest.openclaw.agent_dir(state_dir=shared_state_dir),
899 state_dir=state_dir,
900 workspace_dir=workspace_dir,
901 shared_state_root=shared_state_root,
902 shared_workspace_root=shared_workspace_root,
903 ).mkdir(parents=True, exist_ok=True)
904 for container_path, content in sorted(files.items()):
905 host_path = _map_shared_host_path(
906 container_path,
907 state_dir=state_dir,
908 workspace_dir=workspace_dir,
909 shared_state_root=shared_state_root,
910 shared_workspace_root=shared_workspace_root,
911 )
912 host_path.parent.mkdir(parents=True, exist_ok=True)
913 if (
914 container_path in placeholder_paths
915 and host_path.exists()
916 and CATALOG_SKILL_PLACEHOLDER_MARKER
917 not in host_path.read_text(encoding="utf-8")
918 ):
919 continue
920 host_path.write_text(content, encoding="utf-8")
923def _sync_shared_agent_state_from_main(shared_state_root: Path, *, agent_id: str) -> None:
924 """Seed per-agent auth/model files from the main agent without overwriting custom state."""
925 if agent_id == MAIN_OPENCLAW_AGENT_ID: 925 ↛ 926line 925 didn't jump to line 926 because the condition on line 925 was never true
926 return
927 main_agent_root = shared_state_root / "agents" / MAIN_OPENCLAW_AGENT_ID / "agent"
928 if not main_agent_root.exists():
929 return
930 target_agent_root = shared_state_root / "agents" / agent_id / "agent"
931 target_agent_root.mkdir(parents=True, exist_ok=True)
932 for filename in SHARED_AGENT_STATE_FILENAMES:
933 source_path = main_agent_root / filename
934 target_path = target_agent_root / filename
935 if source_path.exists() and not target_path.exists():
936 shutil.copy2(source_path, target_path)
939def _merge_shared_channel_configs(
940 shared_channels: dict[str, object],
941 incoming_channels: dict[str, object],
942 *,
943 agent_id: str,
944) -> None:
945 """Merge channel config into the shared gateway, rejecting incompatible duplicates."""
946 for channel_id, channel_config in incoming_channels.items():
947 if channel_id not in shared_channels:
948 shared_channels[channel_id] = deepcopy(channel_config)
949 continue
950 if shared_channels[channel_id] != channel_config: 950 ↛ 951line 950 didn't jump to line 951 because the condition on line 950 was never true
951 raise OpenEnvError(
952 "Shared gateway generation requires consistent openclaw.channels configs; "
953 f"conflict detected for channel `{channel_id}` while processing agent "
954 f"`{agent_id}`."
955 )
958def _map_shared_host_path(
959 container_path: str,
960 *,
961 state_dir: PurePosixPath,
962 workspace_dir: PurePosixPath,
963 shared_state_root: Path,
964 shared_workspace_root: Path,
965) -> Path:
966 """Map one container path into the shared all-bots host tree."""
967 container = PurePosixPath(container_path)
968 try:
969 relative = container.relative_to(workspace_dir)
970 except ValueError:
971 relative = container.relative_to(state_dir)
972 root = shared_state_root
973 else:
974 root = shared_workspace_root
975 return root.joinpath(*relative.parts) if relative.parts else root
978def improve_bot_markdown_documents(
979 root: str | Path,
980 slug: str,
981 *,
982 instruction: str,
983 api_key: str,
984) -> DocumentImprovementResult:
985 """Improve bot markdown documents via OpenRouter tool calling."""
986 bot = _ensure_bot_agent_documents_materialized(load_bot(root, slug))
987 updated_paths: list[Path] = []
989 def write_document(relative_path: str, content: str) -> None:
990 """Persist one markdown update produced by OpenRouter into the bot directory."""
991 target = bot.manifest_path.parent / relative_path
992 target.write_text(_normalize_markdown_content(content), encoding="utf-8")
993 updated_paths.append(target)
995 summary = improve_markdown_documents_with_openrouter(
996 api_key=api_key,
997 bot_name=bot.display_name,
998 context_payload=_bot_document_context(bot),
999 instruction=instruction,
1000 write_document=write_document,
1001 )
1002 return DocumentImprovementResult(
1003 bot=load_bot(root, bot.slug),
1004 summary=summary,
1005 updated_paths=_unique_paths(updated_paths),
1006 )
1009def build_bot_manifest(answers: BotAnswers) -> Manifest:
1010 """Build a manifest from bot creation answers."""
1011 slug = slugify_name(answers.display_name)
1012 system_packages = _unique_preserving_order(
1013 [*DEFAULT_SYSTEM_PACKAGES, *answers.system_packages]
1014 )
1015 skill_sources = merge_mandatory_skill_sources(answers.skill_sources)
1016 skills = [
1017 build_catalog_skill(source, mandatory=source in MANDATORY_SKILL_SOURCES)
1018 for source in skill_sources
1019 ]
1020 tools_md = _render_tools_markdown(
1021 skill_sources,
1022 answers.websites,
1023 answers.databases,
1024 answers.access_notes,
1025 )
1026 memory_seed = [
1027 f"Primary role: {answers.role}",
1028 *[f"Website access: {website}" for website in answers.websites],
1029 *[f"Database access: {database}" for database in answers.databases],
1030 *answers.access_notes,
1031 ]
1032 return Manifest(
1033 schema_version=1,
1034 project=ProjectConfig(
1035 name=slug,
1036 version="0.1.0",
1037 description=answers.role,
1038 runtime="openclaw",
1039 ),
1040 runtime=RuntimeConfig(
1041 base_image="python:3.12-slim",
1042 python_version="3.12",
1043 system_packages=system_packages,
1044 python_packages=answers.python_packages,
1045 node_packages=answers.node_packages,
1046 env={"OPENCLAWENV_PROJECT": slug, "PYTHONUNBUFFERED": "1"},
1047 user="root",
1048 workdir="/workspace",
1049 secret_refs=[],
1050 ),
1051 agent=AgentConfig(
1052 agents_md=(
1053 "# Agent Contract\n\n"
1054 f"- Primary role: {answers.role}\n"
1055 "- Review SOUL.md, USER.md and memory.md before acting.\n"
1056 "- Never expose secrets or credentials in output.\n"
1057 "- Prefer reproducible, auditable commands.\n"
1058 ),
1059 soul_md=f"# Soul\n\n{answers.role}\n",
1060 user_md=(
1061 "# User\n\n"
1062 f"Bot `{answers.display_name}` supports the workspace and follows its role.\n"
1063 ),
1064 identity_md=(
1065 "# Identity\n\n"
1066 f"You are {answers.display_name}.\n"
1067 f"Your primary role is: {answers.role}\n"
1068 ),
1069 tools_md=tools_md,
1070 memory_seed=[item for item in memory_seed if item],
1071 agents_md_ref=AGENT_DOC_FILENAMES["agents_md"],
1072 soul_md_ref=AGENT_DOC_FILENAMES["soul_md"],
1073 user_md_ref=AGENT_DOC_FILENAMES["user_md"],
1074 identity_md_ref=AGENT_DOC_FILENAMES["identity_md"],
1075 tools_md_ref=AGENT_DOC_FILENAMES["tools_md"],
1076 memory_seed_ref=AGENT_DOC_FILENAMES["memory_seed"],
1077 ),
1078 skills=skills,
1079 openclaw=OpenClawConfig(
1080 agent_id=slug,
1081 agent_name=answers.display_name,
1082 workspace="/opt/openclaw/workspace",
1083 state_dir="/opt/openclaw",
1084 tools_allow=["shell_command"],
1085 tools_deny=[],
1086 sandbox=SandboxConfig(mode="off"),
1087 ),
1088 access=AccessConfig(
1089 websites=answers.websites,
1090 databases=answers.databases,
1091 notes=answers.access_notes,
1092 ),
1093 )
1096def interactive_menu(root: str | Path, language: str | None = None) -> int:
1097 """Run the interactive menu."""
1098 base = Path(root).resolve()
1099 lang = _select_language() if language is None else _require_language(language)
1100 while True:
1101 print(f"\n{_message(lang, 'menu_title')}")
1102 print(_message(lang, "menu_list"))
1103 print(_message(lang, "menu_add"))
1104 print(_message(lang, "menu_edit"))
1105 print(_message(lang, "menu_delete"))
1106 print(_message(lang, "menu_running"))
1107 print(_message(lang, "menu_exit"))
1108 choice = input(_message(lang, "menu_prompt")).strip()
1110 if choice == "1":
1111 _interactive_browse_bots(base, lang)
1112 continue
1113 if choice == "2":
1114 _interactive_add_bot(base, lang)
1115 continue
1116 if choice == "3":
1117 _interactive_edit_bot(base, lang)
1118 continue
1119 if choice == "4":
1120 _interactive_delete_bot(base, lang)
1121 continue
1122 if choice == "5":
1123 _interactive_browse_running_bots(base, lang)
1124 continue
1125 if choice == "6":
1126 print(_message(lang, "menu_exit_message"))
1127 return 0
1128 print(_message(lang, "menu_unknown"))
1131def _interactive_browse_bots(root: Path, lang: str) -> None:
1132 """Show the managed bot list and dispatch to bot-specific actions or shared stack generation."""
1133 bots = discover_bots(root)
1134 if not bots:
1135 print(_message(lang, "no_bots"))
1136 return
1137 _show_bots(root, lang)
1138 selection = input(_message(lang, "browse_prompt")).strip()
1139 if not selection:
1140 return
1141 if selection.lower() == "a":
1142 try:
1143 stack = generate_all_bots_stack(root)
1144 except OpenEnvError as exc:
1145 print(_message(lang, "generate_all_failed", error=exc))
1146 return
1147 print(_message(lang, "generated_all_compose", path=stack.stack_path))
1148 print(_message(lang, "generated_all_prepared", count=len(stack.bot_artifacts)))
1149 return
1150 bot = _bot_from_selection(bots, selection, lang)
1151 if bot is None:
1152 return
1153 _interactive_bot_actions(root, bot, lang)
1156def _interactive_browse_running_bots(root: Path, lang: str) -> None:
1157 """Show running managed bots and dispatch to runtime-specific actions."""
1158 try:
1159 running_bots = discover_running_bots(root)
1160 except OpenEnvError as exc:
1161 print(_message(lang, "running_failed", error=exc))
1162 return
1163 if not running_bots:
1164 print(_message(lang, "running_no_bots"))
1165 return
1166 _show_running_bots(running_bots, lang)
1167 selection = input(_message(lang, "running_prompt")).strip()
1168 if not selection:
1169 return
1170 running_bot = _running_bot_from_selection(running_bots, selection, lang)
1171 if running_bot is None:
1172 return
1173 _interactive_running_bot_actions(root, running_bot, lang)
1176def _show_bots(root: Path, lang: str) -> None:
1177 """Print a localized summary of all managed bots."""
1178 bots = discover_bots(root)
1179 if not bots:
1180 print(_message(lang, "no_bots"))
1181 return
1182 print(f"\n{_message(lang, 'bots_header')}")
1183 for index, bot in enumerate(bots, start=1):
1184 print(f"{index}. {bot.display_name} [{bot.slug}]")
1185 print(f" {_message(lang, 'role_label')}: {bot.role}")
1186 print(f" {_message(lang, 'manifest_label')}: {bot.manifest_path}")
1187 print(_message(lang, "bots_generate_stack"))
1190def _show_running_bots(running_bots: Iterable[RunningBotRecord], lang: str) -> None:
1191 """Print a localized summary of running bot containers and their compose artifacts."""
1192 print(f"\n{_message(lang, 'running_header')}")
1193 for index, running_bot in enumerate(running_bots, start=1):
1194 print(f"{index}. {running_bot.display_name} [{running_bot.slug}]")
1195 print(f" {_message(lang, 'role_label')}: {running_bot.bot.role}")
1196 print(f" {_message(lang, 'manifest_label')}: {running_bot.bot.manifest_path}")
1197 print(f" {_message(lang, 'compose_label')}: {running_bot.compose_path}")
1198 print(f" {_message(lang, 'container_label')}: {running_bot.container_name}")
1201def _interactive_bot_actions(root: Path, bot: BotRecord, lang: str) -> None:
1202 """Handle artifact generation and document improvement actions for one managed bot."""
1203 print(f"\n{_message(lang, 'bot_actions_title', display_name=bot.display_name)}")
1204 print(_message(lang, "bot_actions_generate"))
1205 print(_message(lang, "bot_actions_improve_docs"))
1206 print(_message(lang, "bot_actions_back"))
1207 choice = input(_message(lang, "bot_actions_prompt")).strip()
1208 if choice == "1":
1209 try:
1210 artifacts = generate_bot_artifacts(root, bot.slug)
1211 except OpenEnvError as exc:
1212 print(_message(lang, "generate_failed", error=exc))
1213 return
1214 print(_message(lang, "generated_lockfile", path=artifacts.lock_path))
1215 print(_message(lang, "generated_dockerfile", path=artifacts.dockerfile_path))
1216 print(_message(lang, "generated_compose", path=artifacts.compose_path))
1217 print(_message(lang, "generated_env", path=artifacts.env_path))
1218 return
1219 if choice == "2":
1220 instruction = input(_message(lang, "edit_docs_prompt")).strip()
1221 try:
1222 api_key = _resolve_openrouter_api_key(root, lang)
1223 result = improve_bot_markdown_documents(
1224 root,
1225 bot.slug,
1226 instruction=instruction,
1227 api_key=api_key,
1228 )
1229 except OpenEnvError as exc:
1230 print(_message(lang, "edit_docs_failed", error=exc))
1231 return
1232 print(_message(lang, "edit_docs_done", summary=result.summary))
1233 for path in result.updated_paths:
1234 print(_message(lang, "edit_docs_updated_file", path=path))
1235 return
1236 if choice == "3":
1237 return
1238 print(_message(lang, "bot_actions_unknown"))
1241def _interactive_running_bot_actions(root: Path, running_bot: RunningBotRecord, lang: str) -> None:
1242 """Handle runtime actions such as log viewing and skill snapshots for a running bot."""
1243 print(
1244 f"\n{_message(lang, 'running_actions_title', display_name=running_bot.display_name)}"
1245 )
1246 print(_message(lang, "running_actions_logs"))
1247 print(_message(lang, "running_actions_snapshot"))
1248 print(_message(lang, "running_actions_back"))
1249 choice = input(_message(lang, "running_actions_prompt")).strip()
1250 if choice == "1":
1251 try:
1252 logs = preview_running_bot_logs(root, running_bot.slug)
1253 except OpenEnvError as exc:
1254 print(_message(lang, "logs_failed", error=exc))
1255 return
1256 print(_message(lang, "logs_header", display_name=running_bot.display_name))
1257 print(logs.rstrip() if logs.strip() else _message(lang, "logs_empty"))
1258 return
1259 if choice == "2":
1260 try:
1261 result = create_skill_snapshot(root, running_bot.slug)
1262 except OpenEnvError as exc:
1263 print(_message(lang, "snapshot_failed", error=exc))
1264 return
1265 if not result.added_skill_names and not result.hydrated_skill_names:
1266 print(_message(lang, "snapshot_no_changes"))
1267 return
1268 print(_message(lang, "snapshot_manifest", path=result.manifest_path))
1269 if result.lock_path is not None: 1269 ↛ 1271line 1269 didn't jump to line 1271 because the condition on line 1269 was always true
1270 print(_message(lang, "snapshot_lockfile", path=result.lock_path))
1271 for name in result.added_skill_names:
1272 print(_message(lang, "snapshot_added_skill", name=name))
1273 for name in result.hydrated_skill_names:
1274 print(_message(lang, "snapshot_hydrated_skill", name=name))
1275 return
1276 if choice == "3":
1277 return
1278 print(_message(lang, "running_actions_unknown"))
1281def _interactive_add_bot(root: Path, lang: str) -> None:
1282 """Collect localized prompts required to create a new managed bot."""
1283 print(f"\n{_message(lang, 'add_title')}")
1284 answers = BotAnswers(
1285 display_name=_prompt_nonempty(_message(lang, "prompt_name"), lang),
1286 role=_prompt_nonempty(_message(lang, "prompt_role"), lang),
1287 skill_sources=_prompt_csv(_message(lang, "prompt_skills")),
1288 system_packages=_prompt_csv(_message(lang, "prompt_system_packages")),
1289 python_packages=_prompt_csv(_message(lang, "prompt_python_packages")),
1290 node_packages=_prompt_csv(_message(lang, "prompt_node_packages")),
1291 secret_names=_prompt_csv(_message(lang, "prompt_secrets")),
1292 websites=_prompt_csv(_message(lang, "prompt_websites")),
1293 databases=_prompt_csv(_message(lang, "prompt_databases")),
1294 access_notes=_prompt_csv(_message(lang, "prompt_access_notes")),
1295 )
1296 try:
1297 record = create_bot(root, answers)
1298 except OpenEnvError as exc:
1299 print(_message(lang, "create_failed", error=exc))
1300 return
1301 print(_message(lang, "created", display_name=record.display_name, path=record.manifest_path))
1304def _interactive_edit_bot(root: Path, lang: str) -> None:
1305 """Collect localized prompts required to update an existing managed bot."""
1306 bots = discover_bots(root)
1307 if not bots:
1308 print(_message(lang, "edit_no_bots"))
1309 return
1310 bot = _select_bot(root, _message(lang, "edit_select"), lang)
1311 if bot is None:
1312 return
1314 current = _answers_from_record(bot)
1315 print(f"\n{_message(lang, 'edit_title', display_name=bot.display_name)}")
1316 answers = BotAnswers(
1317 display_name=_prompt_with_default(
1318 _message(lang, "prompt_name"),
1319 current.display_name,
1320 ),
1321 role=_prompt_with_default(_message(lang, "prompt_role"), current.role),
1322 skill_sources=_prompt_csv_with_default(
1323 _message(lang, "prompt_skills"),
1324 current.skill_sources,
1325 ),
1326 system_packages=_prompt_csv_with_default(
1327 _message(lang, "prompt_system_packages"),
1328 current.system_packages,
1329 ),
1330 python_packages=_prompt_csv_with_default(
1331 _message(lang, "prompt_python_packages"),
1332 current.python_packages,
1333 ),
1334 node_packages=_prompt_csv_with_default(
1335 _message(lang, "prompt_node_packages"),
1336 current.node_packages,
1337 ),
1338 secret_names=_prompt_csv_with_default(
1339 _message(lang, "prompt_secrets"),
1340 current.secret_names,
1341 ),
1342 websites=_prompt_csv_with_default(
1343 _message(lang, "prompt_websites"),
1344 current.websites,
1345 ),
1346 databases=_prompt_csv_with_default(
1347 _message(lang, "prompt_databases"),
1348 current.databases,
1349 ),
1350 access_notes=_prompt_csv_with_default(
1351 _message(lang, "prompt_access_notes"),
1352 current.access_notes,
1353 ),
1354 )
1355 try:
1356 record = update_bot(root, bot.slug, answers)
1357 except OpenEnvError as exc:
1358 print(_message(lang, "update_failed", error=exc))
1359 return
1360 print(_message(lang, "updated", display_name=record.display_name, path=record.manifest_path))
1363def _interactive_delete_bot(root: Path, lang: str) -> None:
1364 """Confirm and delete a managed bot from the local catalog."""
1365 bots = discover_bots(root)
1366 if not bots:
1367 print(_message(lang, "delete_no_bots"))
1368 return
1369 bot = _select_bot(root, _message(lang, "delete_select"), lang)
1370 if bot is None:
1371 return
1372 confirm = input(
1373 _message(
1374 lang,
1375 "delete_confirm",
1376 display_name=bot.display_name,
1377 slug=bot.slug,
1378 )
1379 ).strip().lower()
1380 if confirm not in YES_WORDS[lang]:
1381 print(_message(lang, "delete_cancelled"))
1382 return
1383 try:
1384 delete_bot(root, bot.slug)
1385 except OpenEnvError as exc:
1386 print(_message(lang, "delete_failed", error=exc))
1387 return
1388 print(_message(lang, "deleted", display_name=bot.display_name))
1391def _prompt_nonempty(prompt: str, lang: str) -> str:
1392 """Prompt until the user provides a non-empty value."""
1393 while True:
1394 value = input(prompt).strip()
1395 if value:
1396 return value
1397 print(_message(lang, "required_field"))
1400def _prompt_with_default(prompt: str, default: str) -> str:
1401 """Prompt once and fall back to a default value when the input is empty."""
1402 value = input(f"{prompt}[{default}] ").strip()
1403 return value or default
1406def _prompt_csv(prompt: str) -> list[str]:
1407 """Parse a comma-separated prompt response into a list of trimmed values."""
1408 raw = input(prompt).strip()
1409 if not raw:
1410 return []
1411 return [item.strip() for item in raw.split(",") if item.strip()]
1414def _prompt_csv_with_default(prompt: str, default: list[str]) -> list[str]:
1415 """Parse a comma-separated prompt response while allowing the caller to keep defaults."""
1416 default_text = ", ".join(default)
1417 raw = input(f"{prompt}[{default_text}] ").strip()
1418 if not raw:
1419 return list(default)
1420 return [item.strip() for item in raw.split(",") if item.strip()]
1423def _select_bot(root: Path, prompt: str, lang: str) -> BotRecord | None:
1424 """Display bots, collect a selection, and resolve it to a managed bot record."""
1425 bots = discover_bots(root)
1426 _show_bots(root, lang)
1427 selection = _prompt_nonempty(prompt, lang)
1428 return _bot_from_selection(bots, selection, lang)
1431def _bot_from_selection(
1432 bots: list[BotRecord],
1433 selection: str,
1434 lang: str,
1435) -> BotRecord | None:
1436 """Convert a one-based menu selection into a managed bot record."""
1437 if not selection.isdigit():
1438 print(_message(lang, "invalid_number"))
1439 return None
1440 index = int(selection) - 1
1441 if index < 0 or index >= len(bots):
1442 print(_message(lang, "out_of_range"))
1443 return None
1444 return bots[index]
1447def _running_bot_from_selection(
1448 bots: list[RunningBotRecord],
1449 selection: str,
1450 lang: str,
1451) -> RunningBotRecord | None:
1452 """Convert a one-based menu selection into a running bot record."""
1453 if not selection.isdigit():
1454 print(_message(lang, "invalid_number"))
1455 return None
1456 index = int(selection) - 1
1457 if index < 0 or index >= len(bots):
1458 print(_message(lang, "out_of_range"))
1459 return None
1460 return bots[index]
1463def _select_language() -> str:
1464 """Prompt for the menu language until a supported alias is chosen."""
1465 while True:
1466 print(f"\n{_message('pl', 'language_title')}")
1467 print(_message("pl", "language_option_pl"))
1468 print(_message("pl", "language_option_en"))
1469 selection = input(_message("pl", "language_prompt")).strip()
1470 language = _normalize_language(selection)
1471 if language is not None:
1472 return language
1473 print(_message("pl", "language_invalid"))
1476def _normalize_language(selection: str) -> str | None:
1477 """Map a free-form language selection to the canonical `pl` or `en` code."""
1478 return LANGUAGE_ALIASES.get(selection.strip().lower())
1481def _require_language(language: str) -> str:
1482 """Validate and normalize a language code used by non-interactive callers."""
1483 normalized = _normalize_language(language)
1484 if normalized is None:
1485 raise OpenEnvError(f"Unsupported menu language: {language}")
1486 return normalized
1489def _message(language: str, key: str, **kwargs: object) -> str:
1490 """Return one localized UI message formatted with the supplied keyword values."""
1491 return MESSAGES[language][key].format(**kwargs)
1494def _answers_from_record(bot: BotRecord) -> BotAnswers:
1495 """Convert a stored bot manifest back into editable interactive answers."""
1496 system_packages = [
1497 package
1498 for package in bot.manifest.runtime.system_packages
1499 if package not in DEFAULT_SYSTEM_PACKAGES
1500 ]
1501 return BotAnswers(
1502 display_name=bot.display_name,
1503 role=bot.role,
1504 skill_sources=[
1505 skill.source or skill.name
1506 for skill in bot.manifest.skills
1507 if not is_mandatory_skill(skill)
1508 ],
1509 system_packages=system_packages,
1510 python_packages=list(bot.manifest.runtime.python_packages),
1511 node_packages=list(bot.manifest.runtime.node_packages),
1512 secret_names=[secret.name for secret in bot.manifest.runtime.secret_refs],
1513 websites=list(bot.manifest.access.websites),
1514 databases=list(bot.manifest.access.databases),
1515 access_notes=list(bot.manifest.access.notes),
1516 )
1519def _write_agent_docs(bot_dir: Path, agent: AgentConfig) -> None:
1520 """Write all agent markdown documents referenced by the manifest into the bot directory."""
1521 _write_agent_doc(bot_dir, agent.agents_md_ref, agent.agents_md)
1522 _write_agent_doc(bot_dir, agent.soul_md_ref, agent.soul_md)
1523 _write_agent_doc(bot_dir, agent.user_md_ref, agent.user_md)
1524 if agent.identity_md is not None:
1525 _write_agent_doc(bot_dir, agent.identity_md_ref, agent.identity_md)
1526 if agent.tools_md is not None:
1527 _write_agent_doc(bot_dir, agent.tools_md_ref, agent.tools_md)
1528 if agent.memory_seed_ref is not None:
1529 _write_agent_doc(bot_dir, agent.memory_seed_ref, _memory_seed_text(agent.memory_seed))
1532def _write_agent_doc(bot_dir: Path, relative_path: str | None, content: str) -> None:
1533 """Write one referenced agent markdown file when the manifest defines a target path."""
1534 if relative_path is None:
1535 return
1536 target_path = bot_dir / relative_path
1537 target_path.parent.mkdir(parents=True, exist_ok=True)
1538 target_path.write_text(content, encoding="utf-8")
1541def _memory_seed_text(lines: list[str]) -> str:
1542 """Render memory seed entries back into the newline-separated markdown file format."""
1543 if not lines:
1544 return ""
1545 return "\n".join(lines).strip() + "\n"
1548def _resolve_openrouter_api_key(root: Path, lang: str) -> str:
1549 """Load the OpenRouter API key from the environment or prompt and persist it."""
1550 api_key = get_project_env_value(root, "OPENROUTER_API_KEY")
1551 if api_key:
1552 return api_key
1553 print(_message(lang, "openrouter_key_missing"))
1554 provided = getpass(_message(lang, "openrouter_key_prompt")).strip()
1555 if not provided:
1556 raise OpenEnvError("OPENROUTER_API_KEY is required for this action.")
1557 env_path = write_project_env_value(root, "OPENROUTER_API_KEY", provided)
1558 print(_message(lang, "openrouter_key_saved", path=env_path))
1559 return provided
1562def _ensure_bot_agent_documents_materialized(bot: BotRecord) -> BotRecord:
1563 """Ensure every agent document exists as a file before document-improvement workflows run."""
1564 bot_dir = bot.manifest_path.parent
1565 manifest = bot.manifest
1566 changed = False
1567 agent = manifest.agent
1568 document_specs = [
1569 ("agents_md", "agents_md_ref", AGENT_DOC_FILENAMES["agents_md"], agent.agents_md),
1570 ("soul_md", "soul_md_ref", AGENT_DOC_FILENAMES["soul_md"], agent.soul_md),
1571 ("user_md", "user_md_ref", AGENT_DOC_FILENAMES["user_md"], agent.user_md),
1572 (
1573 "identity_md",
1574 "identity_md_ref",
1575 AGENT_DOC_FILENAMES["identity_md"],
1576 agent.identity_md,
1577 ),
1578 ("tools_md", "tools_md_ref", AGENT_DOC_FILENAMES["tools_md"], agent.tools_md),
1579 (
1580 "memory_seed",
1581 "memory_seed_ref",
1582 AGENT_DOC_FILENAMES["memory_seed"],
1583 _memory_seed_text(agent.memory_seed),
1584 ),
1585 ]
1586 for _, ref_attr, default_ref, content in document_specs:
1587 if content is None: 1587 ↛ 1588line 1587 didn't jump to line 1588 because the condition on line 1587 was never true
1588 continue
1589 reference = getattr(agent, ref_attr)
1590 if reference is None:
1591 setattr(agent, ref_attr, default_ref)
1592 reference = default_ref
1593 changed = True
1594 target = bot_dir / reference
1595 if not target.exists():
1596 target.parent.mkdir(parents=True, exist_ok=True)
1597 target.write_text(content, encoding="utf-8")
1598 if changed:
1599 bot.manifest_path.write_text(render_manifest(manifest), encoding="utf-8")
1600 return load_bot(bot.manifest_path.parent.parent.parent, bot.slug)
1601 return bot
1604def _bot_document_context(bot: BotRecord) -> dict[str, object]:
1605 """Build the structured context payload sent to OpenRouter for document editing."""
1606 documents = _bot_documents(bot.manifest)
1607 return {
1608 "bot": {
1609 "name": bot.display_name,
1610 "slug": bot.slug,
1611 "project_name": bot.manifest.project.name,
1612 "version": bot.manifest.project.version,
1613 "role": bot.manifest.project.description,
1614 "skills": [
1615 {
1616 "name": skill.name,
1617 "description": skill.description,
1618 "source": skill.source,
1619 }
1620 for skill in bot.manifest.skills
1621 ],
1622 "websites": list(bot.manifest.access.websites),
1623 "databases": list(bot.manifest.access.databases),
1624 "notes": list(bot.manifest.access.notes),
1625 "secret_names": [
1626 secret.name for secret in bot.manifest.runtime.secret_refs
1627 ],
1628 "system_packages": list(bot.manifest.runtime.system_packages),
1629 "python_packages": list(bot.manifest.runtime.python_packages),
1630 "node_packages": list(bot.manifest.runtime.node_packages),
1631 },
1632 "documents": documents,
1633 }
1636def _bot_documents(manifest: Manifest) -> dict[str, str]:
1637 """Return the markdown documents that OpenRouter is allowed to inspect and rewrite."""
1638 documents = {
1639 manifest.agent.agents_md_ref or AGENT_DOC_FILENAMES["agents_md"]: manifest.agent.agents_md,
1640 manifest.agent.soul_md_ref or AGENT_DOC_FILENAMES["soul_md"]: manifest.agent.soul_md,
1641 manifest.agent.user_md_ref or AGENT_DOC_FILENAMES["user_md"]: manifest.agent.user_md,
1642 manifest.agent.memory_seed_ref
1643 or AGENT_DOC_FILENAMES["memory_seed"]: _memory_seed_text(manifest.agent.memory_seed),
1644 }
1645 if manifest.agent.identity_md is not None:
1646 documents[
1647 manifest.agent.identity_md_ref or AGENT_DOC_FILENAMES["identity_md"]
1648 ] = manifest.agent.identity_md
1649 if manifest.agent.tools_md is not None:
1650 documents[manifest.agent.tools_md_ref or AGENT_DOC_FILENAMES["tools_md"]] = (
1651 manifest.agent.tools_md
1652 )
1653 return dict(sorted(documents.items()))
1656def _normalize_markdown_content(content: str) -> str:
1657 """Normalize saved markdown so files end with exactly one trailing newline."""
1658 return content.rstrip() + "\n"
1661def _unique_paths(paths: list[Path]) -> list[Path]:
1662 """Deduplicate updated document paths while preserving their first-seen order."""
1663 seen: set[Path] = set()
1664 result: list[Path] = []
1665 for path in paths:
1666 resolved = path.resolve()
1667 if resolved not in seen:
1668 seen.add(resolved)
1669 result.append(path)
1670 return result
1673def _render_tools_markdown(
1674 skill_sources: list[str],
1675 websites: list[str],
1676 databases: list[str],
1677 access_notes: list[str],
1678) -> str:
1679 """Render the default `TOOLS.md` document derived from bot answers."""
1680 lines = ["# Tools", ""]
1681 if skill_sources: 1681 ↛ 1685line 1681 didn't jump to line 1685 because the condition on line 1681 was always true
1682 lines.append("## Skill Sources")
1683 lines.extend(f"- {source}" for source in skill_sources)
1684 lines.append("")
1685 if websites:
1686 lines.append("## Websites")
1687 lines.extend(f"- {website}" for website in websites)
1688 lines.append("")
1689 if databases:
1690 lines.append("## Databases")
1691 lines.extend(f"- {database}" for database in databases)
1692 lines.append("")
1693 if access_notes:
1694 lines.append("## Access Notes")
1695 lines.extend(f"- {note}" for note in access_notes)
1696 lines.append("")
1697 lines.append("Use the allowed tools with least privilege and document important actions.")
1698 return "\n".join(lines).strip() + "\n"
1701def _unique_preserving_order(items: list[str]) -> list[str]:
1702 """Deduplicate text items while preserving the original user-provided order."""
1703 seen: set[str] = set()
1704 result: list[str] = []
1705 for item in items:
1706 if item not in seen:
1707 seen.add(item)
1708 result.append(item)
1709 return result
1712def _compose_path_for_bot(bot: BotRecord) -> Path:
1713 """Return the expected compose file path for a managed bot."""
1714 return bot.manifest_path.parent / default_compose_filename(bot.display_name)
1717def _container_name_for_bot(bot: BotRecord) -> str:
1718 """Return the expected gateway container name for a managed bot."""
1719 return gateway_container_name(bot.display_name)
1722def _load_running_bot(root: str | Path, slug: str) -> RunningBotRecord:
1723 """Load a managed bot and verify that its compose bundle is currently running."""
1724 bot = load_bot(root, slug)
1725 compose_path = _compose_path_for_bot(bot)
1726 if not compose_path.exists():
1727 raise OpenEnvError(f"Compose file not found for bot `{bot.slug}`: {compose_path}")
1728 container_name = _container_name_for_bot(bot)
1729 running_containers = list_running_container_names()
1730 if container_name not in running_containers:
1731 raise OpenEnvError(f"Bot `{bot.slug}` is not currently running.")
1732 return RunningBotRecord(
1733 bot=bot,
1734 compose_path=compose_path,
1735 container_name=container_name,
1736 )
1739def _apply_skill_snapshot(
1740 manifest: Manifest,
1741 captured_skills: Iterable[CapturedSkill],
1742) -> tuple[list[str], list[str]]:
1743 """Merge captured runtime skills into the manifest and report what changed."""
1744 existing_by_name = {skill.name: skill for skill in manifest.skills}
1745 added_skill_names: list[str] = []
1746 hydrated_skill_names: list[str] = []
1747 for captured in sorted(captured_skills, key=lambda item: item.name):
1748 existing = existing_by_name.get(captured.name)
1749 if existing is None:
1750 manifest.skills.append(
1751 SkillConfig(
1752 name=captured.name,
1753 description=captured.description,
1754 content=captured.content,
1755 source=captured.source,
1756 assets=dict(captured.assets),
1757 )
1758 )
1759 added_skill_names.append(captured.name)
1760 continue
1761 if _hydrate_skill_from_snapshot(existing, captured): 1761 ↛ 1747line 1761 didn't jump to line 1747 because the condition on line 1761 was always true
1762 hydrated_skill_names.append(captured.name)
1763 return added_skill_names, hydrated_skill_names
1766def _hydrate_skill_from_snapshot(skill: SkillConfig, captured: CapturedSkill) -> bool:
1767 """Fill missing skill fields from a runtime snapshot without overwriting authored content."""
1768 changed = False
1769 if skill.content is None and captured.content.strip(): 1769 ↛ 1772line 1769 didn't jump to line 1772 because the condition on line 1769 was always true
1770 skill.content = captured.content
1771 changed = True
1772 if not skill.assets and captured.assets: 1772 ↛ 1775line 1772 didn't jump to line 1775 because the condition on line 1772 was always true
1773 skill.assets = dict(captured.assets)
1774 changed = True
1775 if skill.source is None and captured.source is not None: 1775 ↛ 1778line 1775 didn't jump to line 1778 because the condition on line 1775 was always true
1776 skill.source = captured.source
1777 changed = True
1778 if ( 1778 ↛ 1787line 1778 didn't jump to line 1787 because the condition on line 1778 was always true
1779 changed
1780 and captured.description
1781 and (
1782 skill.description.startswith("Always-installed skill referenced")
1783 or skill.description.startswith("Skill referenced from catalog source")
1784 )
1785 ):
1786 skill.description = captured.description
1787 return changed