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

1"""Interactive bot catalog management.""" 

2 

3from __future__ import annotations 

4 

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 

12 

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 

74 

75 

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} 

369 

370 

371@dataclass(slots=True) 

372class BotAnswers: 

373 """Normalized answers collected from the interactive bot creation or edit flow.""" 

374 

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) 

385 

386 

387@dataclass(slots=True) 

388class BotRecord: 

389 """Managed bot discovered from the local `bots/` catalog.""" 

390 

391 slug: str 

392 manifest_path: Path 

393 manifest: Manifest 

394 

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 

399 

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 

404 

405 

406@dataclass(slots=True) 

407class GeneratedArtifacts: 

408 """Paths and metadata produced when rendering one bot build bundle.""" 

409 

410 bot: BotRecord 

411 lock_path: Path 

412 dockerfile_path: Path 

413 compose_path: Path 

414 env_path: Path 

415 image_tag: str 

416 

417 

418@dataclass(slots=True) 

419class AllBotsStackArtifacts: 

420 """Result of generating the shared compose stack for every managed bot.""" 

421 

422 stack_path: Path 

423 bot_artifacts: list[GeneratedArtifacts] 

424 

425 

426@dataclass(slots=True) 

427class DocumentImprovementResult: 

428 """Summary of markdown documents updated by the OpenRouter improvement flow.""" 

429 

430 bot: BotRecord 

431 summary: str 

432 updated_paths: list[Path] 

433 

434 

435@dataclass(slots=True) 

436class RunningBotRecord: 

437 """Managed bot enriched with runtime details about its running Docker container.""" 

438 

439 bot: BotRecord 

440 compose_path: Path 

441 container_name: str 

442 

443 @property 

444 def display_name(self) -> str: 

445 """Return the display name of the running bot.""" 

446 return self.bot.display_name 

447 

448 @property 

449 def slug(self) -> str: 

450 """Return the managed slug of the running bot.""" 

451 return self.bot.slug 

452 

453 

454@dataclass(slots=True) 

455class SkillSnapshotResult: 

456 """Result of reconciling installed runtime skills back into the bot manifest.""" 

457 

458 bot: BotRecord 

459 manifest_path: Path 

460 lock_path: Path | None 

461 added_skill_names: list[str] 

462 hydrated_skill_names: list[str] 

463 

464 

465def bots_root(root: str | Path) -> Path: 

466 """Return the canonical directory that stores all managed bot folders.""" 

467 return Path(root).resolve() / "bots" 

468 

469 

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() 

473 

474 

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 

481 

482 

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 

490 

491 

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 

514 

515 

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) 

533 

534 

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.") 

541 

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.") 

546 

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 

555 

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) 

569 

570 

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) 

577 

578 

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 ) 

591 

592 

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 

612 

613 

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) 

618 

619 

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 ) 

632 

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 ) 

641 

642 rendered_manifest = render_manifest(manifest) 

643 running_bot.bot.manifest_path.write_text(rendered_manifest, encoding="utf-8") 

644 

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 

659 

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 ) 

667 

668 

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) 

674 

675 lock_path = _preferred_lockfile_path(bot.manifest_path.parent) 

676 write_lockfile(lock_path, lockfile) 

677 raw_lock_text = dump_lockfile(lockfile) 

678 

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 ) 

689 

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)) 

695 

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 ) 

712 

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 ) 

721 

722 

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 ) 

752 

753 

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) 

765 

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() 

771 

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) 

780 

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 ) 

805 

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) 

829 

830 

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 

843 

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 

861 

862 

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() 

878 

879 

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") 

921 

922 

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) 

937 

938 

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 ) 

956 

957 

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 

976 

977 

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] = [] 

988 

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) 

994 

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 ) 

1007 

1008 

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 ) 

1094 

1095 

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() 

1109 

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")) 

1129 

1130 

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) 

1154 

1155 

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) 

1174 

1175 

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")) 

1188 

1189 

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}") 

1199 

1200 

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")) 

1239 

1240 

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")) 

1279 

1280 

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)) 

1302 

1303 

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 

1313 

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)) 

1361 

1362 

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)) 

1389 

1390 

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")) 

1398 

1399 

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 

1404 

1405 

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()] 

1412 

1413 

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()] 

1421 

1422 

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) 

1429 

1430 

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] 

1445 

1446 

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] 

1461 

1462 

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")) 

1474 

1475 

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()) 

1479 

1480 

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 

1487 

1488 

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) 

1492 

1493 

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 ) 

1517 

1518 

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)) 

1530 

1531 

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") 

1539 

1540 

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" 

1546 

1547 

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 

1560 

1561 

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 

1602 

1603 

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 } 

1634 

1635 

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())) 

1654 

1655 

1656def _normalize_markdown_content(content: str) -> str: 

1657 """Normalize saved markdown so files end with exactly one trailing newline.""" 

1658 return content.rstrip() + "\n" 

1659 

1660 

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 

1671 

1672 

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" 

1699 

1700 

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 

1710 

1711 

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) 

1715 

1716 

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) 

1720 

1721 

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 ) 

1737 

1738 

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 

1764 

1765 

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