This commit is contained in:
X-iZhang
2026-02-05 23:58:55 +00:00
parent 9341dc474b
commit 80baf85a2c
5 changed files with 541 additions and 9 deletions
+74 -2
View File
@@ -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,
)
+4
View File
@@ -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
+205 -3
View File
@@ -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(
+19
View File
@@ -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")
+239 -4
View File
@@ -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 = [