diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index 2bca283..c3d6ea9 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -107,6 +107,7 @@ class AgentProvisionPlan: instance_name: str prompt_file: Path guest_env: dict[str, str] + has_prompt: bool = False env_vars: dict[str, str] = field(default_factory=dict) dirs: tuple[AgentProvisionDir, ...] = () files: tuple[AgentProvisionFile, ...] = () diff --git a/bot_bottle/backend/docker/bottle.py b/bot_bottle/backend/docker/bottle.py index 7294051..b11deb4 100644 --- a/bot_bottle/backend/docker/bottle.py +++ b/bot_bottle/backend/docker/bottle.py @@ -9,6 +9,7 @@ from typing import cast from ...agent_provider import PromptMode, prompt_args from .. import Bottle, ExecResult +from ..terminal import exec_shell_script class DockerBottle(Bottle): @@ -22,12 +23,16 @@ class DockerBottle(Bottle): *, agent_command: str = "claude", agent_prompt_mode: PromptMode = "append_file", + terminal_title: str = "", + terminal_color: str = "", ): self.name = container self._teardown = teardown self.prompt_path = prompt_path_in_container self._agent_prompt_mode = agent_prompt_mode self.agent_command = agent_command + self.terminal_title = terminal_title + self.terminal_color = terminal_color self.agent_provider_template = ( "codex" if agent_command == "codex" else "claude" ) @@ -47,9 +52,11 @@ class DockerBottle(Bottle): return cmd def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: - return subprocess.run( - self.agent_argv(argv, tty=tty), check=False, - ).returncode + agent_argv = self.agent_argv(argv, tty=tty) + script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None + if script is None: + return subprocess.run(agent_argv, check=False).returncode + return subprocess.run(["sh", "-lc", script], check=False).returncode def exec(self, script: str, *, user: str = "node") -> ExecResult: # Pipe via stdin to `sh -s` so the caller never has to worry diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index 11380d4..18d6ad0 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -175,6 +175,8 @@ def launch( None, agent_command=plan.agent_command, agent_prompt_mode=plan.agent_prompt_mode, + terminal_title=plan.spec.label or plan.spec.agent_name, + terminal_color=plan.spec.color, ) bottle.prompt_path = provision(plan, bottle) diff --git a/bot_bottle/backend/smolmachines/bottle.py b/bot_bottle/backend/smolmachines/bottle.py index 81e0ee6..5764dae 100644 --- a/bot_bottle/backend/smolmachines/bottle.py +++ b/bot_bottle/backend/smolmachines/bottle.py @@ -24,6 +24,7 @@ from typing import Mapping, cast from ...agent_provider import PromptMode, prompt_args from .. import Bottle, ExecResult +from ..terminal import exec_shell_script from . import pty_resize as _pty_resize from . import smolvm as _smolvm @@ -68,6 +69,8 @@ class SmolmachinesBottle(Bottle): guest_env: Mapping[str, str] | None = None, agent_command: str = "claude", agent_prompt_mode: PromptMode = "append_file", + terminal_title: str = "", + terminal_color: str = "", ) -> None: self.name = machine_name # In-VM path to the agent's prompt file. None when the @@ -81,6 +84,8 @@ class SmolmachinesBottle(Bottle): self._guest_env = dict(guest_env or {}) self._agent_prompt_mode = agent_prompt_mode self.agent_command = agent_command + self.terminal_title = terminal_title + self.terminal_color = terminal_color self.agent_provider_template = ( "codex" if agent_command == "codex" else "claude" ) @@ -128,9 +133,11 @@ class SmolmachinesBottle(Bottle): UID switches via `runuser -u node --` (not `-l`) so we avoid login-shell wiring. HOME / USER come from `smolvm -e` instead, which sets them on the process env.""" - return subprocess.run( - self.agent_argv(argv, tty=tty), check=False, - ).returncode + agent_argv = self.agent_argv(argv, tty=tty) + script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None + if script is None: + return subprocess.run(agent_argv, check=False).returncode + return subprocess.run(["sh", "-lc", script], check=False).returncode # smolvm/libkrun can SIGKILL an otherwise-normal exec during # early-VM provisioning. Retry once after a short settle so diff --git a/bot_bottle/backend/smolmachines/launch.py b/bot_bottle/backend/smolmachines/launch.py index cdcb843..9520ceb 100644 --- a/bot_bottle/backend/smolmachines/launch.py +++ b/bot_bottle/backend/smolmachines/launch.py @@ -103,6 +103,8 @@ def launch( guest_env=plan.guest_env, agent_command=plan.agent_command, agent_prompt_mode=plan.agent_prompt_mode, + terminal_title=plan.spec.label or plan.spec.agent_name, + terminal_color=plan.spec.color, ) bottle.prompt_path = provision(plan, bottle) diff --git a/bot_bottle/backend/terminal.py b/bot_bottle/backend/terminal.py new file mode 100644 index 0000000..66cb23a --- /dev/null +++ b/bot_bottle/backend/terminal.py @@ -0,0 +1,82 @@ +"""Terminal escape-sequence helpers shared across all bottle backends.""" + +from __future__ import annotations + +import shlex + + +# color name → (normal_idx, normal_hex, bright_idx, bright_hex, dark_bg_hex) +# OSC 4 sets indexed palette entries (affects syntax-highlighted code and any +# TUI content that uses indexed colors). dark_bg_hex is used for OSC 11 +# (default background) — a very dark tint that's visible even when the TUI +# uses true/24-bit colors for its own chrome, which would otherwise bypass +# the palette entirely. +_COLORS: dict[str, tuple[int, str, int, str, str]] = { + "black": (0, "#2d2d2d", 8, "#5c5c5c", "#0a0a0a"), + "red": (1, "#c0392b", 9, "#e74c3c", "#1a0707"), + "green": (2, "#27ae60", 10, "#2ecc71", "#071a09"), + "yellow": (3, "#d4ac0d", 11, "#f1c40f", "#1a1507"), + "blue": (4, "#2471a3", 12, "#3498db", "#07071a"), + "magenta": (5, "#7d3c98", 13, "#9b59b6", "#12071a"), + "cyan": (6, "#148f77", 14, "#1abc9c", "#071a1a"), + "white": (7, "#bdc3c7", 15, "#ecf0f1", "#111111"), + "bright-black": (8, "#5c5c5c", 0, "#2d2d2d", "#111111"), + "bright-red": (9, "#e74c3c", 1, "#c0392b", "#200808"), + "bright-green": (10, "#2ecc71", 2, "#27ae60", "#082008"), + "bright-yellow": (11, "#f1c40f", 3, "#d4ac0d", "#201808"), + "bright-blue": (12, "#3498db", 4, "#2471a3", "#080820"), + "bright-magenta": (13, "#9b59b6", 5, "#7d3c98", "#160820"), + "bright-cyan": (14, "#1abc9c", 6, "#148f77", "#082020"), + "bright-white": (15, "#ecf0f1", 7, "#bdc3c7", "#151515"), +} + +# OSC 104 resets all indexed palette entries; OSC 111 resets default background. +_RESET_PRINTF = "printf '\\033]104\\007\\033]111\\007'" + + +def palette_printf(color: str) -> str: + """Shell `printf` command that emits OSC 4 + OSC 11 to tint the terminal + for *color*: sets the normal/bright palette entries AND the default + background to a dark shade of that color. Returns '' if unknown.""" + entry = _COLORS.get(color) + if not entry: + return "" + n_idx, n_hex, b_idx, b_hex, bg_hex = entry + seq = ( + f"\\033]4;{n_idx};{n_hex}\\007" + f"\\033]4;{b_idx};{b_hex}\\007" + f"\\033]11;{bg_hex}\\007" + ) + return f"printf '{seq}'" + + +def exec_shell_script( + agent_argv: list[str], + terminal_title: str = "", + terminal_color: str = "", +) -> str | None: + """Build a shell script string that optionally sets the terminal + title and/or palette before running *agent_argv*, and resets the + palette + background on exit. Returns None when no decoration is + needed — callers should run *agent_argv* directly in that case.""" + title_cmd = ( + f"printf '\\033]0;%s\\007' {shlex.quote(terminal_title)}" + if terminal_title else "" + ) + pal_cmd = palette_printf(terminal_color) + + if not title_cmd and not pal_cmd: + return None + + parts: list[str] = [] + if title_cmd: + parts.append(title_cmd) + if pal_cmd: + parts.append(pal_cmd) + parts.append(shlex.join(agent_argv)) + parts.append(_RESET_PRINTF) + else: + # No palette change — exec so the agent replaces the shell. + parts.append(f"exec {shlex.join(agent_argv)}") + + return "; ".join(parts) diff --git a/bot_bottle/contrib/claude/agent_provider.py b/bot_bottle/contrib/claude/agent_provider.py index 1085176..113849b 100644 --- a/bot_bottle/contrib/claude/agent_provider.py +++ b/bot_bottle/contrib/claude/agent_provider.py @@ -17,9 +17,11 @@ from typing import TYPE_CHECKING from ...agent_provider import ( AgentProvider, AgentProviderRuntime, + AgentProvisionDir, AgentProvisionFile, AgentProvisionPlan, ) +from ...backend.docker import util as docker_mod from ...egress import EgressRoute from ...log import die, info, warn @@ -38,6 +40,71 @@ def _skills_dir(guest_home: str) -> str: def _prompt_path(guest_home: str) -> str: return f"{guest_home}/.bot-bottle-prompt.txt" + +_STATUS_LINE_COLORS = { + "black": "\033[30m", + "red": "\033[31m", + "green": "\033[32m", + "yellow": "\033[33m", + "blue": "\033[34m", + "magenta": "\033[35m", + "cyan": "\033[36m", + "white": "\033[37m", + "bright-black": "\033[90m", + "bright-red": "\033[91m", + "bright-green": "\033[92m", + "bright-yellow": "\033[93m", + "bright-blue": "\033[94m", + "bright-magenta": "\033[95m", + "bright-cyan": "\033[96m", + "bright-white": "\033[97m", +} + +_CLAUDE_THEME_COLORS = { + "black": "black", + "red": "red", + "green": "green", + "yellow": "yellow", + "blue": "blue", + "magenta": "magenta", + "cyan": "cyan", + "white": "white", + "bright-black": "blackBright", + "bright-red": "redBright", + "bright-green": "greenBright", + "bright-yellow": "yellowBright", + "bright-blue": "blueBright", + "bright-magenta": "magentaBright", + "bright-cyan": "cyanBright", + "bright-white": "whiteBright", +} + + +def _status_line_script(label: str, color: str) -> str: + if not label: + return "#!/bin/sh\nprintf '\\n'\n" + label_q = shlex.quote(label) + if color and color in _STATUS_LINE_COLORS: + return ( + "#!/bin/sh\n" + f"printf '%b%s%b\\n' '{_STATUS_LINE_COLORS[color]}' {label_q} '\\033[0m'\n" + ) + return f"#!/bin/sh\nprintf '%s\\n' {label_q}\n" + + +def _custom_theme_payload(color: str) -> dict[str, object] | None: + theme_color = _CLAUDE_THEME_COLORS.get(color) + if not theme_color: + return None + return { + "name": f"Bot-bottle {color}", + "base": "dark", + "overrides": { + "claude": f"ansi:{theme_color}", + }, + } + + _RUNTIME = AgentProviderRuntime( template="claude", command="claude", @@ -78,6 +145,10 @@ class ClaudeAgentProvider(AgentProvider): "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1", "DISABLE_ERROR_REPORTING": "1", } + dirs = ( + AgentProvisionDir(f"{guest_home}/.claude"), + AgentProvisionDir(f"{guest_home}/.claude/themes"), + ) claude_config = state_dir / "claude.json" claude_projects = {guest_home: {"hasTrustDialogAccepted": True}} claude_projects[trusted_path] = {"hasTrustDialogAccepted": True} @@ -87,15 +158,45 @@ class ClaudeAgentProvider(AgentProvider): "bypassPermissionsModeAccepted": True, "projects": claude_projects, } - if label: - payload["name"] = label - if color: - payload["color"] = color claude_config.write_text(json.dumps(payload, indent=2) + "\n") claude_config.chmod(0o600) - files = ( + files = [ AgentProvisionFile(claude_config, f"{guest_home}/.claude.json"), - ) + ] + + claude_settings = state_dir / "claude-settings.json" + claude_settings_payload: dict[str, object] = {} + if label or color: + statusline_script = state_dir / "claude-statusline.sh" + statusline_script.write_text(_status_line_script(label, color)) + statusline_script.chmod(0o755) + files.append(AgentProvisionFile( + statusline_script, + f"{guest_home}/.claude/statusline.sh", + mode="755", + )) + claude_settings_payload["statusLine"] = { + "type": "command", + "command": "~/.claude/statusline.sh", + } + theme_payload = _custom_theme_payload(color) + if theme_payload is not None: + theme_name = f"bot-bottle-{docker_mod.slugify(label or color)}" + theme_file = state_dir / f"{theme_name}.json" + theme_file.write_text(json.dumps(theme_payload, indent=2) + "\n") + theme_file.chmod(0o644) + files.append(AgentProvisionFile( + theme_file, + f"{guest_home}/.claude/themes/{theme_name}.json", + )) + claude_settings_payload["theme"] = f"custom:{theme_name}" + if claude_settings_payload: + claude_settings.write_text(json.dumps(claude_settings_payload, indent=2) + "\n") + claude_settings.chmod(0o600) + files.append(AgentProvisionFile( + claude_settings, + f"{guest_home}/.claude/settings.json", + )) egress_routes = (EgressRoute( host="api.anthropic.com", auth_scheme="Bearer" if auth_token else "", @@ -106,6 +207,7 @@ class ClaudeAgentProvider(AgentProvider): env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}) + has_prompt = prompt_file.exists() and bool(prompt_file.read_text()) return AgentProvisionPlan( template=_RUNTIME.template, command=_RUNTIME.command, @@ -117,7 +219,9 @@ class ClaudeAgentProvider(AgentProvider): prompt_file=prompt_file, env_vars=env_vars, guest_env=resolved_guest_env, - files=files, + has_prompt=has_prompt, + dirs=dirs, + files=tuple(files), egress_routes=egress_routes, hidden_env_names=hidden_env_names, ) @@ -158,7 +262,7 @@ class ClaudeAgentProvider(AgentProvider): user="root", ) agent = plan.spec.manifest.agents[plan.spec.agent_name] - return prompt_path if agent.prompt else None + return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None: """Apply the claude-side declarative provision steps from diff --git a/bot_bottle/contrib/codex/agent_provider.py b/bot_bottle/contrib/codex/agent_provider.py index 4b2c99b..030aa85 100644 --- a/bot_bottle/contrib/codex/agent_provider.py +++ b/bot_bottle/contrib/codex/agent_provider.py @@ -18,8 +18,8 @@ from ...agent_provider import ( CODEX_HOST_CREDENTIAL_HOSTS, AgentProvider, AgentProviderRuntime, - AgentProvisionCommand, AgentProvisionDir, + AgentProvisionCommand, AgentProvisionFile, AgentProvisionPlan, ) @@ -46,6 +46,7 @@ def _skills_dir(guest_home: str) -> str: def _prompt_path(guest_home: str) -> str: return f"{guest_home}/.bot-bottle-prompt.txt" + _RUNTIME = AgentProviderRuntime( template="codex", command="codex", @@ -77,7 +78,7 @@ class CodexAgentProvider(AgentProvider): label: str = "", color: str = "", ) -> AgentProvisionPlan: - del auth_token, label, color # Claude-only knobs + del auth_token, label, color # Claude-only / title-only knobs resolved_guest_env = dict(guest_env or {}) guest_home = self.guest_home trusted_path = trusted_project_path or guest_home @@ -101,6 +102,11 @@ class CodexAgentProvider(AgentProvider): config_file.write_text( f'[projects."{toml_path}"]\n' 'trust_level = "trusted"\n' + "\n" + "[tui]\n" + 'status_line = ["model-with-reasoning"]\n' + 'terminal_title = ["spinner", "project"]\n' + 'theme = "ansi"\n' ) config_file.chmod(0o600) files.append(AgentProvisionFile(config_file, config_path)) @@ -143,6 +149,7 @@ class CodexAgentProvider(AgentProvider): "guest, but Codex did not accept it" ))) + has_prompt = prompt_file.exists() and bool(prompt_file.read_text()) return AgentProvisionPlan( template=_RUNTIME.template, command=_RUNTIME.command, @@ -154,6 +161,7 @@ class CodexAgentProvider(AgentProvider): prompt_file=prompt_file, env_vars=env_vars, guest_env=resolved_guest_env, + has_prompt=has_prompt, dirs=tuple(dirs), files=tuple(files), pre_copy=tuple(pre_copy), @@ -198,7 +206,7 @@ class CodexAgentProvider(AgentProvider): user="root", ) agent = plan.spec.manifest.agents[plan.spec.agent_name] - return prompt_path if agent.prompt else None + return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None: """Apply the codex-side declarative provision steps from diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index a23094b..884c37f 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -62,6 +62,27 @@ class TestAgentProviderRuntime(unittest.TestCase): config = Path(tmp, "codex-config.toml").read_text() self.assertIn('[projects."/home/node/workspace"]', config) + def test_codex_writes_tui_settings_without_mutating_prompt(self): + with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: + prompt_file = Path(tmp) / "prompt.txt" + prompt_file.write_text("Existing instructions.\n") + plan = build_agent_provision_plan( + template="codex", + dockerfile="", + state_dir=Path(tmp), + instance_name="bot-bottle-test", + prompt_file=prompt_file, + label="review-api", + color="bright-cyan", + ) + prompt = prompt_file.read_text() + config = Path(tmp, "codex-config.toml").read_text() + self.assertTrue(plan.has_prompt) + self.assertEqual("Existing instructions.\n", prompt) + self.assertIn("[tui]", config) + self.assertIn('status_line = ["model-with-reasoning"]', config) + self.assertIn('terminal_title = ["spinner", "project"]', config) + def test_codex_forward_host_credentials_adds_auth_and_verify(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: home = Path(tmp) / "host-codex" @@ -126,6 +147,26 @@ class TestAgentProviderRuntime(unittest.TestCase): self.assertIn("/home/node", config["projects"]) self.assertIn("/home/node/workspace", config["projects"]) + def test_claude_writes_statusline_and_theme_without_mutating_prompt(self): + with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: + prompt_file = Path(tmp) / "prompt.txt" + prompt_file.write_text("Existing instructions.\n") + plan = build_agent_provision_plan( + template="claude", + dockerfile="", + state_dir=Path(tmp), + instance_name="bot-bottle-test", + prompt_file=prompt_file, + label="research-ui", + color="green", + ) + prompt = prompt_file.read_text() + settings = json.loads(Path(tmp, "claude-settings.json").read_text()) + self.assertTrue(plan.has_prompt) + self.assertEqual("Existing instructions.\n", prompt) + self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"]) + self.assertEqual("custom:bot-bottle-research-ui", settings["theme"]) + def test_codex_forward_host_credentials_populates_egress_routes(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: home = Path(tmp) / "host-codex" diff --git a/tests/unit/test_backend_terminal.py b/tests/unit/test_backend_terminal.py new file mode 100644 index 0000000..1f639c7 --- /dev/null +++ b/tests/unit/test_backend_terminal.py @@ -0,0 +1,83 @@ +"""Unit tests for backend/terminal.py palette and shell-script helpers.""" + +from __future__ import annotations + +import unittest + +from bot_bottle.backend.terminal import exec_shell_script, palette_printf + + +class TestPalettePrintf(unittest.TestCase): + def test_known_color_returns_printf(self): + cmd = palette_printf("red") + self.assertTrue(cmd.startswith("printf '")) + self.assertIn("\\033]4;1;", cmd) # normal red + self.assertIn("\\033]4;9;", cmd) # bright red + self.assertIn("\\033]11;", cmd) # default background tint + + def test_bright_variant_sets_both_slots(self): + cmd = palette_printf("bright-blue") + self.assertIn("\\033]4;12;", cmd) # bright-blue + self.assertIn("\\033]4;4;", cmd) # blue + + def test_unknown_color_returns_empty(self): + self.assertEqual("", palette_printf("")) + self.assertEqual("", palette_printf("neon-pink")) + + def test_all_named_colors_produce_output(self): + colors = [ + "black", "red", "green", "yellow", + "blue", "magenta", "cyan", "white", + "bright-black", "bright-red", "bright-green", "bright-yellow", + "bright-blue", "bright-magenta", "bright-cyan", "bright-white", + ] + for color in colors: + with self.subTest(color=color): + self.assertTrue(palette_printf(color)) + + +class TestExecShellScript(unittest.TestCase): + _ARGV = ["smolvm", "machine", "exec", "--name", "x", "--", "claude"] + + def test_no_decoration_returns_none(self): + self.assertIsNone(exec_shell_script(self._ARGV)) + self.assertIsNone(exec_shell_script(self._ARGV, terminal_title="", terminal_color="")) + + def test_title_only_uses_exec(self): + script = exec_shell_script(self._ARGV, terminal_title="my-agent") + assert script is not None + self.assertIn("printf", script) + self.assertIn("my-agent", script) + self.assertIn("exec ", script) + # No palette reset when there's no color + self.assertNotIn("\\033]104", script) + + def test_color_only_sets_palette_and_resets(self): + script = exec_shell_script(self._ARGV, terminal_color="green") + assert script is not None + self.assertIn("\\033]4;", script) # indexed palette + self.assertIn("\\033]11;", script) # background tint + self.assertIn("\\033]104", script) # palette reset + self.assertIn("\\033]111", script) # background reset + # No exec-replace when palette is active (shell must survive for reset) + parts = script.split("; ") + agent_part = next(p for p in parts if "smolvm" in p) + self.assertFalse(agent_part.startswith("exec ")) + + def test_title_and_color_both_appear(self): + script = exec_shell_script(self._ARGV, terminal_title="bot", terminal_color="cyan") + assert script is not None + self.assertIn("bot", script) + self.assertIn("\\033]4;", script) + self.assertIn("\\033]11;", script) + self.assertIn("\\033]104", script) + self.assertIn("\\033]111", script) + + def test_title_with_special_chars_is_quoted(self): + script = exec_shell_script(self._ARGV, terminal_title="my agent's label") + assert script is not None + self.assertNotIn("my agent's label", script) # must be shell-quoted + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_contrib_claude_provider.py b/tests/unit/test_contrib_claude_provider.py index df37fec..820e6b6 100644 --- a/tests/unit/test_contrib_claude_provider.py +++ b/tests/unit/test_contrib_claude_provider.py @@ -8,6 +8,8 @@ either side are expected to diverge the implementations.""" from __future__ import annotations +import json +import tempfile import unittest from pathlib import Path from unittest.mock import MagicMock, patch @@ -127,6 +129,21 @@ class TestClaudeProvisionPrompt(unittest.TestCase): self.assertIsNone(r) bottle.cp_in.assert_called_once() + def test_returns_path_when_provider_prompt_exists(self): + bottle = _make_bottle() + provision = AgentProvisionPlan( + template="claude", command="claude", prompt_mode="append_file", + image="", dockerfile="", guest_home="/home/node", + instance_name="bot-bottle-demo-abc12", + prompt_file=Path("/tmp/prompt.txt"), + guest_env={}, + has_prompt=True, + ) + r = ClaudeAgentProvider().provision_prompt( + _plan(agent_prompt="", agent_provision=provision), bottle, + ) + self.assertEqual("/home/node/.bot-bottle-prompt.txt", r) + def test_chowns_to_node_after_copy(self): bottle = _make_bottle() ClaudeAgentProvider().provision_prompt(_plan(), bottle) @@ -246,6 +263,35 @@ class TestClaudeProvision(unittest.TestCase): _plan(agent_provision=provision), bottle, ) + +class TestClaudeUiProvision(unittest.TestCase): + def test_writes_statusline_and_custom_theme_files(self): + with tempfile.TemporaryDirectory(prefix="bb-claude-ui.") as tmp: + state_dir = Path(tmp) + prompt_file = state_dir / "prompt.txt" + prompt_file.write_text("Existing instructions.\n") + plan = ClaudeAgentProvider().provision_plan( + dockerfile="", + state_dir=state_dir, + instance_name="bot-bottle-demo-abc12", + prompt_file=prompt_file, + label="research-ui", + color="bright-cyan", + ) + settings = json.loads((state_dir / "claude-settings.json").read_text()) + statusline = (state_dir / "claude-statusline.sh").read_text() + theme = json.loads((state_dir / "bot-bottle-research-ui.json").read_text()) + prompt_text = prompt_file.read_text() + self.assertTrue(plan.has_prompt) + self.assertEqual("Existing instructions.\n", prompt_text) + self.assertEqual("command", settings["statusLine"]["type"]) + self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"]) + self.assertEqual("custom:bot-bottle-research-ui", settings["theme"]) + self.assertIn("research-ui", statusline) + self.assertIn("\x1b[96m", statusline) + self.assertEqual("dark", theme["base"]) + self.assertEqual("ansi:cyanBright", theme["overrides"]["claude"]) + def test_runs_verify_commands(self): provision = AgentProvisionPlan( template="claude", command="claude", prompt_mode="append_file", diff --git a/tests/unit/test_contrib_codex_provider.py b/tests/unit/test_contrib_codex_provider.py index e0ab6fc..6137fad 100644 --- a/tests/unit/test_contrib_codex_provider.py +++ b/tests/unit/test_contrib_codex_provider.py @@ -9,6 +9,7 @@ no claude equivalent.""" from __future__ import annotations import unittest +import tempfile from pathlib import Path from unittest.mock import MagicMock, patch @@ -130,6 +131,44 @@ class TestCodexProvisionPrompt(unittest.TestCase): self.assertIsNone(r) bottle.cp_in.assert_called_once() + def test_returns_path_when_provider_prompt_exists(self): + bottle = _make_bottle() + provision = AgentProvisionPlan( + template="codex", command="codex", + prompt_mode="read_prompt_file", + image="", dockerfile="", guest_home="/home/node", + instance_name="bot-bottle-demo-abc12", + prompt_file=Path("/tmp/prompt.txt"), + guest_env={}, + has_prompt=True, + ) + r = CodexAgentProvider().provision_prompt( + _plan(agent_prompt="", agent_provision=provision), bottle, + ) + self.assertEqual("/home/node/.bot-bottle-prompt.txt", r) + + def test_writes_tui_settings_into_codex_config(self): + with tempfile.TemporaryDirectory(prefix="bb-codex-ui.") as tmp: + state_dir = Path(tmp) + prompt_file = state_dir / "prompt.txt" + prompt_file.write_text("Existing instructions.\n") + plan = CodexAgentProvider().provision_plan( + dockerfile="", + state_dir=state_dir, + instance_name="bot-bottle-demo-abc12", + prompt_file=prompt_file, + label="research-ui", + color="bright-cyan", + ) + config = (state_dir / "codex-config.toml").read_text() + prompt_text = prompt_file.read_text() + self.assertTrue(plan.has_prompt) + self.assertEqual("Existing instructions.\n", prompt_text) + self.assertIn("[tui]", config) + self.assertIn('status_line = ["model-with-reasoning"]', config) + self.assertIn('terminal_title = ["spinner", "project"]', config) + self.assertIn('theme = "ansi"', config) + class TestCodexProvisionSkills(unittest.TestCase): def test_noop_when_agent_has_no_skills(self):