diff --git a/EvoScientist/cli.py b/EvoScientist/cli.py index b78271f..08b25fb 100644 --- a/EvoScientist/cli.py +++ b/EvoScientist/cli.py @@ -29,6 +29,7 @@ from prompt_toolkit import PromptSession # type: ignore[import-untyped] from prompt_toolkit.history import FileHistory # type: ignore[import-untyped] from prompt_toolkit.auto_suggest import AutoSuggestFromHistory # type: ignore[import-untyped] from prompt_toolkit.formatted_text import HTML # type: ignore[import-untyped] +from rich.panel import Panel # type: ignore[import-untyped] from rich.text import Text # type: ignore[import-untyped] from rich.table import Table # type: ignore[import-untyped] @@ -234,8 +235,6 @@ def print_banner( info.append("/install-skill", style="bold") info.append(", ", style="dim") info.append("/uninstall-skill", style="bold") - info.append(", ", style="dim") - info.append("/channel", style="bold") console.print(info) console.print() @@ -413,6 +412,69 @@ def _cmd_channel_stop() -> None: # CLI commands # ============================================================================= +def _print_channel_panel(channels: list[tuple[str, bool, str]]) -> None: + """Print a summary panel for active channels. + + Args: + channels: List of (name, ok, detail) tuples. + """ + lines: list[Text] = [] + all_ok = True + for name, ok, detail in channels: + line = Text() + if ok: + line.append("● ", style="green") + line.append(name, style="bold") + else: + line.append("✗ ", style="yellow") + line.append(name, style="bold yellow") + all_ok = False + if detail: + line.append(f" {detail}", style="dim") + lines.append(line) + + body = Text("\n").join(lines) + border = "green" if all_ok else "yellow" + console.print(Panel(body, title="[bold]Channels[/bold]", border_style=border, expand=False)) + console.print() + + +def _auto_start_channel(agent: Any, thread_id: str, allowed_senders_csv: str) -> None: + """Start iMessage channel automatically from config. + + Args: + agent: Compiled agent graph. + thread_id: Current thread ID. + allowed_senders_csv: Comma-separated allowed senders (empty = all). + """ + try: + from .channels.imessage import IMessageConfig + from .channels.imessage.serve import IMessageServer + + allowed: set[str] | None = None + if allowed_senders_csv.strip(): + allowed = {s.strip() for s in allowed_senders_csv.split(",") if s.strip()} + + config = IMessageConfig(allowed_senders=allowed if allowed else None) + + _ChannelState.agent = agent + _ChannelState.thread_id = thread_id + + server = IMessageServer(config, handler=_create_channel_handler()) + _ChannelState.server = server + _ChannelState.thread = threading.Thread( + target=_run_channel_thread, + args=(server,), + daemon=True, + ) + _ChannelState.thread.start() + + detail = ", ".join(sorted(allowed)) if allowed else "all senders" + _print_channel_panel([("iMessage", True, detail)]) + except Exception as e: + _print_channel_panel([("iMessage", False, str(e))]) + + def cmd_interactive( agent: Any, show_thinking: bool = True, @@ -421,6 +483,8 @@ def cmd_interactive( mode: str | None = None, model: str | None = None, provider: str | None = None, + imessage_enabled: bool = False, + imessage_allowed_senders: str = "", ) -> None: """Interactive conversation mode with streaming output. @@ -432,6 +496,8 @@ def cmd_interactive( mode: Workspace mode ('daemon' or 'run'), displayed in banner model: Model name to display in banner provider: LLM provider name to display in banner + imessage_enabled: Whether to auto-start iMessage channel + imessage_allowed_senders: Comma-separated allowed senders """ import nest_asyncio nest_asyncio.apply() @@ -514,6 +580,10 @@ def cmd_interactive( # Start background queue checker queue_task = asyncio.create_task(_check_channel_queue()) + # Auto-start iMessage channel if enabled in config + if imessage_enabled and not _ChannelState.is_running(): + _auto_start_channel(state["agent"], state["thread_id"], imessage_allowed_senders) + try: _print_separator() while state["running"]: @@ -939,6 +1009,8 @@ def _main_callback( mode=effective_mode, model=config.model, provider=config.provider, + imessage_enabled=config.imessage_enabled, + imessage_allowed_senders=config.imessage_allowed_senders, ) diff --git a/EvoScientist/config.py b/EvoScientist/config.py index 2620043..8737329 100644 --- a/EvoScientist/config.py +++ b/EvoScientist/config.py @@ -80,6 +80,10 @@ class EvoScientistConfig: # UI Settings show_thinking: bool = True + # Channel Settings + imessage_enabled: bool = False + imessage_allowed_senders: str = "" # comma-separated, empty = allow all + # ============================================================================= # Config file operations diff --git a/EvoScientist/onboard.py b/EvoScientist/onboard.py index 8c9a3e5..6ccfa24 100644 --- a/EvoScientist/onboard.py +++ b/EvoScientist/onboard.py @@ -7,6 +7,8 @@ workspace settings, and agent parameters. Uses flow-style arrow-key selection UI from __future__ import annotations import os +import subprocess +import sys import questionary from prompt_toolkit.styles import Style @@ -51,7 +53,7 @@ CONFIRM_STYLE = Style.from_dict({ "text": "", }) -STEPS = ["Provider", "API Key", "Model", "Tavily Key", "Workspace", "Parameters"] +STEPS = ["Provider", "API Key", "Model", "Tavily Key", "Workspace", "Parameters", "Channels"] # ============================================================================= @@ -188,11 +190,15 @@ def validate_google_key(api_key: str) -> tuple[bool, str]: from google import genai client = genai.Client(api_key=api_key) # Make a minimal request to validate the key - list(client.models.list(config={"page_size": 1})) + pager = client.models.list(config={"page_size": 1}) + next(iter(pager)) # fetch first model only + return True, "Valid" + except StopIteration: + # Empty result but request succeeded — key is valid return True, "Valid" except Exception as e: error_str = str(e).lower() - if "401" in error_str or "403" in error_str or "unauthorized" in error_str or "invalid" in error_str or "api key" in error_str: + if "400" in error_str or "401" in error_str or "403" in error_str or "unauthorized" in error_str or "invalid" in error_str or "api key" in error_str: return False, "Invalid API key" return False, f"Error: {e}" @@ -611,6 +617,197 @@ def _step_parameters(config: EvoScientistConfig) -> tuple[int, int, bool]: return max_concurrent, max_iterations, show_thinking +def validate_imessage() -> tuple[bool, str]: + """Validate iMessage environment by checking for the imsg CLI. + + Returns: + Tuple of (is_valid, message). + """ + # macOS only + if sys.platform != "darwin": + return False, "iMessage requires macOS" + + from .channels.imessage.probe import find_cli + + cli_path = find_cli() + if not cli_path: + return False, "not_installed" + + # Check version + try: + result = subprocess.run( + [cli_path, "--version"], + capture_output=True, text=True, timeout=5, + ) + version = result.stdout.strip() if result.returncode == 0 else None + except Exception: + version = None + + # Check RPC support + try: + result = subprocess.run( + [cli_path, "rpc", "--help"], + capture_output=True, text=True, timeout=5, + ) + rpc_ok = result.returncode == 0 + except Exception: + rpc_ok = False + + if not rpc_ok: + return False, f"imsg found at {cli_path} but RPC not supported (update with: brew upgrade imsg)" + + version_str = f" ({version})" if version else "" + return True, f"imsg{version_str} at {cli_path}" + + +def _install_imsg() -> bool: + """Run brew install for imsg CLI. + + Returns: + True if installation succeeded. + """ + try: + proc = subprocess.run( + ["brew", "install", "steipete/tap/imsg"], + timeout=120, + ) + return proc.returncode == 0 + except FileNotFoundError: + console.print(" [red]✗ Homebrew not found[/red]") + console.print(" [dim]Install Homebrew first: https://brew.sh[/dim]") + return False + except subprocess.TimeoutExpired: + console.print(" [red]✗ Installation timed out[/red]") + return False + except Exception as e: + console.print(f" [red]✗ Installation failed: {e}[/red]") + return False + + +def _setup_imessage() -> bool: + """Guide the user through iMessage setup: install, validate, test. + + Returns: + True if iMessage is ready to use. + """ + # Step 1: Validate + console.print(" [dim]Checking iMessage environment...[/dim]") + valid, msg = validate_imessage() + + if valid: + console.print(f" [green]✓ {msg}[/green]") + return True + + if msg == "iMessage requires macOS": + console.print(f" [red]✗ {msg}[/red]") + return False + + if msg == "not_installed": + console.print(" [yellow]✗ imsg CLI not installed[/yellow]") + console.print() + + # Step 2: Offer to install + install = questionary.confirm( + "Install imsg via Homebrew? (brew install steipete/tap/imsg)", + default=True, + style=WIZARD_STYLE, + qmark=" ?", + ).ask() + + if install is None: + raise KeyboardInterrupt() + + if install: + console.print() + if _install_imsg(): + console.print() + # Re-validate after install + valid, msg = validate_imessage() + if valid: + console.print(f" [green]✓ {msg}[/green]") + return True + else: + console.print(f" [red]✗ {msg}[/red]") + return False + else: + return False + else: + console.print(" [dim]Skipped. Install manually: brew install steipete/tap/imsg[/dim]") + return False + else: + # RPC not supported or other issue + console.print(f" [red]✗ {msg}[/red]") + return False + + +def _step_channels(config: EvoScientistConfig) -> tuple[bool, str]: + """Step 7: Select a channel to enable on startup. + + Presents a single-select list with "Skip for now" as default. + Selecting a channel triggers validation and guided installation. + + Args: + config: Current configuration. + + Returns: + Tuple of (imessage_enabled, imessage_allowed_senders_csv). + """ + # Determine default based on current config + default = "imessage" if config.imessage_enabled else "skip" + + choices = [ + Choice(title="Skip for now", value="skip"), + Choice(title="iMessage", value="imessage"), + # Future channels: + # Choice(title="Slack", value="slack"), + # Choice(title="Email", value="email"), + ] + + selected = questionary.select( + "Select channel to enable on startup:", + choices=choices, + default=default, + style=WIZARD_STYLE, + use_indicator=True, + ).ask() + + if selected is None: + raise KeyboardInterrupt() + + if selected == "skip": + return False, "" + + # iMessage selected — run guided setup + ready = _setup_imessage() + + if not ready: + # Setup failed — ask if they want to enable anyway + console.print() + enable_anyway = questionary.confirm( + "Enable iMessage anyway? (will try to connect on startup)", + default=False, + style=WIZARD_STYLE, + qmark=" ?", + ).ask() + if enable_anyway is None: + raise KeyboardInterrupt() + if not enable_anyway: + return False, "" + + # Ask for allowed senders (indented to align with ✓ status lines) + senders = questionary.text( + "Allowed senders (comma-separated, empty = all):", + default=config.imessage_allowed_senders, + style=WIZARD_STYLE, + qmark=" ?", + ).ask() + + if senders is None: + raise KeyboardInterrupt() + + return True, senders.strip() + + # ============================================================================= # Progress Rendering (for tests and potential future use) # ============================================================================= @@ -727,6 +924,11 @@ def run_onboard(skip_validation: bool = False) -> bool: config.max_iterations = max_iterations config.show_thinking = show_thinking + # Step 7: Channels + imessage_enabled, imessage_allowed_senders = _step_channels(config) + config.imessage_enabled = imessage_enabled + config.imessage_allowed_senders = imessage_allowed_senders + # Confirm save console.print() save = questionary.confirm( diff --git a/tests/test_config.py b/tests/test_config.py index 2b729f6..3993acb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -76,6 +76,8 @@ class TestEvoScientistConfig: assert config.max_concurrent == 3 assert config.max_iterations == 3 assert config.show_thinking is True + assert config.imessage_enabled is False + assert config.imessage_allowed_senders == "" def test_custom_values(self): """Test that custom values can be set.""" @@ -241,6 +243,23 @@ class TestGetSetValues: set_config_value("max_concurrent", "5") assert get_config_value("max_concurrent") == 5 + def test_set_imessage_enabled_coercion(self, temp_config_dir, clean_env): + """Test that imessage_enabled is coerced from string to bool.""" + save_config(EvoScientistConfig()) + + set_config_value("imessage_enabled", "true") + assert get_config_value("imessage_enabled") is True + + set_config_value("imessage_enabled", "false") + assert get_config_value("imessage_enabled") is False + + def test_set_imessage_allowed_senders(self, temp_config_dir, clean_env): + """Test that imessage_allowed_senders stores comma-separated string.""" + save_config(EvoScientistConfig()) + + set_config_value("imessage_allowed_senders", "+1234567890,+0987654321") + assert get_config_value("imessage_allowed_senders") == "+1234567890,+0987654321" + def test_list_config(self, temp_config_dir, clean_env): """Test listing all config values.""" config = EvoScientistConfig(provider="openai", model="gpt-4o") diff --git a/tests/test_onboard.py b/tests/test_onboard.py index 15ab9c8..d883213 100644 --- a/tests/test_onboard.py +++ b/tests/test_onboard.py @@ -1,5 +1,6 @@ """Tests for EvoScientist onboarding wizard.""" +import subprocess from unittest import mock import pytest @@ -21,10 +22,10 @@ from EvoScientist.config import EvoScientistConfig class TestConstants: - def test_steps_has_six_items(self): - """Test that STEPS contains exactly 6 steps.""" - assert len(STEPS) == 6 - assert STEPS == ["Provider", "API Key", "Model", "Tavily Key", "Workspace", "Parameters"] + def test_steps_has_seven_items(self): + """Test that STEPS contains exactly 7 steps.""" + assert len(STEPS) == 7 + assert STEPS == ["Provider", "API Key", "Model", "Tavily Key", "Workspace", "Parameters", "Channels"] def test_wizard_style_is_style_instance(self): """Test that WIZARD_STYLE is a prompt_toolkit Style.""" @@ -293,6 +294,238 @@ class TestStepWorkspace: assert result == ("run", "/custom/path") +class TestValidateImessage: + def test_valid_when_cli_found_with_rpc(self): + """Test validate_imessage returns valid when imsg CLI found and RPC works.""" + from EvoScientist.onboard import validate_imessage + + version_result = mock.Mock(returncode=0, stdout="imsg 1.2.3") + rpc_result = mock.Mock(returncode=0) + + with mock.patch("EvoScientist.onboard.sys") as mock_sys, \ + mock.patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil, \ + mock.patch("EvoScientist.onboard.subprocess") as mock_sub: + mock_sys.platform = "darwin" + mock_shutil.which.return_value = "/opt/homebrew/bin/imsg" + mock_sub.run.side_effect = [version_result, rpc_result] + valid, msg = validate_imessage() + + assert valid is True + assert "imsg" in msg + assert "1.2.3" in msg + + def test_invalid_when_cli_not_found(self): + """Test validate_imessage returns not_installed when imsg CLI missing.""" + from EvoScientist.onboard import validate_imessage + + with mock.patch("EvoScientist.onboard.sys") as mock_sys, \ + mock.patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil: + mock_sys.platform = "darwin" + mock_shutil.which.return_value = None + valid, msg = validate_imessage() + + assert valid is False + assert msg == "not_installed" + + def test_invalid_on_non_macos(self): + """Test validate_imessage returns invalid on non-macOS.""" + from EvoScientist.onboard import validate_imessage + + with mock.patch("EvoScientist.onboard.sys") as mock_sys: + mock_sys.platform = "linux" + valid, msg = validate_imessage() + + assert valid is False + assert "macOS" in msg + + def test_invalid_when_rpc_not_supported(self): + """Test validate_imessage returns invalid when RPC check fails.""" + from EvoScientist.onboard import validate_imessage + + version_result = mock.Mock(returncode=0, stdout="imsg 0.1.0") + rpc_result = mock.Mock(returncode=1) + + with mock.patch("EvoScientist.onboard.sys") as mock_sys, \ + mock.patch("EvoScientist.channels.imessage.probe.shutil") as mock_shutil, \ + mock.patch("EvoScientist.onboard.subprocess") as mock_sub: + mock_sys.platform = "darwin" + mock_shutil.which.return_value = "/usr/local/bin/imsg" + mock_sub.run.side_effect = [version_result, rpc_result] + valid, msg = validate_imessage() + + assert valid is False + assert "RPC not supported" in msg + + +class TestInstallImsg: + def test_install_success(self): + """Test _install_imsg returns True on success.""" + from EvoScientist.onboard import _install_imsg + + with mock.patch("EvoScientist.onboard.subprocess") as mock_sub: + mock_sub.run.return_value = mock.Mock(returncode=0) + mock_sub.TimeoutExpired = subprocess.TimeoutExpired + result = _install_imsg() + + assert result is True + + def test_install_brew_not_found(self): + """Test _install_imsg handles missing Homebrew.""" + from EvoScientist.onboard import _install_imsg + + with mock.patch("EvoScientist.onboard.subprocess") as mock_sub, \ + mock.patch("EvoScientist.onboard.console"): + mock_sub.run.side_effect = FileNotFoundError() + mock_sub.TimeoutExpired = subprocess.TimeoutExpired + result = _install_imsg() + + assert result is False + + def test_install_failure(self): + """Test _install_imsg returns False on non-zero exit.""" + from EvoScientist.onboard import _install_imsg + + with mock.patch("EvoScientist.onboard.subprocess") as mock_sub: + mock_sub.run.return_value = mock.Mock(returncode=1) + mock_sub.TimeoutExpired = subprocess.TimeoutExpired + result = _install_imsg() + + assert result is False + + +class TestSetupImessage: + def test_already_installed(self): + """Test _setup_imessage returns True when already installed.""" + from EvoScientist.onboard import _setup_imessage + + with mock.patch("EvoScientist.onboard.validate_imessage", return_value=(True, "imsg at /bin/imsg")), \ + mock.patch("EvoScientist.onboard.console"): + result = _setup_imessage() + + assert result is True + + def test_not_macos(self): + """Test _setup_imessage returns False on non-macOS.""" + from EvoScientist.onboard import _setup_imessage + + with mock.patch("EvoScientist.onboard.validate_imessage", return_value=(False, "iMessage requires macOS")), \ + mock.patch("EvoScientist.onboard.console"): + result = _setup_imessage() + + assert result is False + + def test_install_then_valid(self): + """Test _setup_imessage installs and re-validates successfully.""" + from EvoScientist.onboard import _setup_imessage + + with mock.patch("EvoScientist.onboard.validate_imessage") as mock_val, \ + mock.patch("EvoScientist.onboard._install_imsg", return_value=True), \ + mock.patch("EvoScientist.onboard.questionary") as mock_q, \ + mock.patch("EvoScientist.onboard.console"): + mock_val.side_effect = [ + (False, "not_installed"), # First check + (True, "imsg at /bin/imsg"), # After install + ] + mock_q.confirm.return_value.ask.return_value = True # Yes, install + result = _setup_imessage() + + assert result is True + + def test_user_declines_install(self): + """Test _setup_imessage returns False when user declines install.""" + from EvoScientist.onboard import _setup_imessage + + with mock.patch("EvoScientist.onboard.validate_imessage", return_value=(False, "not_installed")), \ + mock.patch("EvoScientist.onboard.questionary") as mock_q, \ + mock.patch("EvoScientist.onboard.console"): + mock_q.confirm.return_value.ask.return_value = False + result = _setup_imessage() + + assert result is False + + +class TestStepChannels: + def test_returns_disabled_when_skip(self): + """Test channels step returns disabled when user selects skip.""" + from EvoScientist.onboard import _step_channels + + config = EvoScientistConfig() + + with mock.patch("EvoScientist.onboard.questionary") as mock_q: + mock_q.select.return_value.ask.return_value = "skip" + result = _step_channels(config) + + assert result == (False, "") + + def test_returns_enabled_when_setup_passes(self): + """Test channels step returns enabled when setup succeeds.""" + from EvoScientist.onboard import _step_channels + + config = EvoScientistConfig() + + with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ + mock.patch("EvoScientist.onboard._setup_imessage", return_value=True): + mock_q.select.return_value.ask.return_value = "imessage" + mock_q.text.return_value.ask.return_value = "" + result = _step_channels(config) + + assert result == (True, "") + + def test_returns_enabled_with_senders(self): + """Test channels step returns enabled with specific senders.""" + from EvoScientist.onboard import _step_channels + + config = EvoScientistConfig() + + with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ + mock.patch("EvoScientist.onboard._setup_imessage", return_value=True): + mock_q.select.return_value.ask.return_value = "imessage" + mock_q.text.return_value.ask.return_value = "+1234567890,+0987654321" + result = _step_channels(config) + + assert result == (True, "+1234567890,+0987654321") + + def test_setup_fails_user_declines(self): + """Test channels step returns disabled when setup fails and user declines.""" + from EvoScientist.onboard import _step_channels + + config = EvoScientistConfig() + + with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ + mock.patch("EvoScientist.onboard._setup_imessage", return_value=False): + mock_q.select.return_value.ask.return_value = "imessage" + mock_q.confirm.return_value.ask.return_value = False + result = _step_channels(config) + + assert result == (False, "") + + def test_setup_fails_user_enables_anyway(self): + """Test channels step enables when setup fails but user confirms.""" + from EvoScientist.onboard import _step_channels + + config = EvoScientistConfig() + + with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ + mock.patch("EvoScientist.onboard._setup_imessage", return_value=False): + mock_q.select.return_value.ask.return_value = "imessage" + mock_q.confirm.return_value.ask.return_value = True + mock_q.text.return_value.ask.return_value = "" + result = _step_channels(config) + + assert result == (True, "") + + def test_raises_keyboard_interrupt_on_cancel(self): + """Test channels step raises KeyboardInterrupt on cancel.""" + from EvoScientist.onboard import _step_channels + + config = EvoScientistConfig() + + with mock.patch("EvoScientist.onboard.questionary") as mock_q: + mock_q.select.return_value.ask.return_value = None + with pytest.raises(KeyboardInterrupt): + _step_channels(config) + + class TestStepParameters: def test_returns_parameters(self): """Test parameters step returns all values.""" @@ -344,6 +577,7 @@ class TestRunOnboard: "claude-sonnet-4-5", # Model "daemon", # Workspace mode True, # Show thinking + "skip", # Channels: skip ] mock_q.password.return_value.ask.side_effect = [ "", # Provider API key (keep current) @@ -394,6 +628,7 @@ class TestRunOnboard: "claude-sonnet-4-5", "daemon", True, # Show thinking + "skip", # Channels: skip ] mock_q.password.return_value.ask.side_effect = ["", ""] mock_q.confirm.return_value.ask.side_effect = [