diff --git a/EvoScientist/cli.py b/EvoScientist/cli.py index 8e96bf1..a63840a 100644 --- a/EvoScientist/cli.py +++ b/EvoScientist/cli.py @@ -975,7 +975,7 @@ def _load_agent(workspace_dir: str | None = None): # Typer app # ============================================================================= -app = typer.Typer(no_args_is_help=False, add_completion=False) +app = typer.Typer(no_args_is_help=False, add_completion=False, context_settings={"help_option_names": ["-h", "--help"]}) # Config subcommand group config_app = typer.Typer(help="Configuration management commands", invoke_without_command=True) @@ -1280,16 +1280,17 @@ def mcp_remove( @app.callback(invoke_without_command=True) def _main_callback( ctx: typer.Context, - prompt: Optional[str] = typer.Option(None, "-p", "--prompt", help="Query to execute (single-shot mode)"), - thread_id: Optional[str] = typer.Option(None, "--thread-id", help="Thread ID for conversation persistence"), - no_thinking: bool = typer.Option(False, "--no-thinking", help="Disable thinking display"), - workdir: Optional[str] = typer.Option(None, "--workdir", help="Override workspace directory for this session"), - use_cwd: bool = typer.Option(False, "--use-cwd", help="Use current working directory as workspace"), mode: Optional[str] = typer.Option( None, + "-m", "--mode", - help="Workspace mode: 'daemon' (persistent, default) or 'run' (isolated per-session)" + help="Workspace mode: 'daemon' (persistent, default) or 'run' (isolated per-session)", ), + prompt: Optional[str] = typer.Option(None, "-p", "--prompt", help="Query to execute (single-shot mode)"), + thread_id: Optional[str] = typer.Option(None, "--thread-id", help="Thread ID for conversation persistence"), + workdir: Optional[str] = typer.Option(None, "--workdir", help="Override workspace directory for this session"), + use_cwd: bool = typer.Option(False, "--use-cwd", help="Use current working directory as workspace"), + no_thinking: bool = typer.Option(False, "--no-thinking", help="Disable thinking display"), ): """EvoScientist Agent - AI-powered research & code execution CLI.""" # If a subcommand was invoked, don't run the default behavior diff --git a/EvoScientist/onboard.py b/EvoScientist/onboard.py index 6ccfa24..06809a0 100644 --- a/EvoScientist/onboard.py +++ b/EvoScientist/onboard.py @@ -53,7 +53,7 @@ CONFIRM_STYLE = Style.from_dict({ "text": "", }) -STEPS = ["Provider", "API Key", "Model", "Tavily Key", "Workspace", "Parameters", "Channels"] +STEPS = ["Provider", "API Key", "Model", "Tavily Key", "Workspace", "Parameters", "Skills", "Channels"] # ============================================================================= @@ -617,6 +617,170 @@ def _step_parameters(config: EvoScientistConfig) -> tuple[int, int, bool]: return max_concurrent, max_iterations, show_thinking +_RECOMMENDED_SKILLS = [ + { + "label": "ML Paper Writing", + "source": "Orchestra-Research/AI-Research-SKILLs@20-ml-paper-writing", + }, + { + "label": "Literature Review", + "source": "https://github.com/K-Dense-AI/claude-scientific-writer/tree/main/skills/literature-review", + }, + { + "label": "Scientific Brainstorming", + "source": "https://github.com/K-Dense-AI/claude-scientific-skills/tree/main/scientific-skills/scientific-brainstorming", + }, +] + + +def _check_npx() -> bool: + """Check if npx is available on the system. + + Returns: + True if npx is found and working. + """ + try: + result = subprocess.run( + ["npx", "--version"], + capture_output=True, text=True, timeout=10, + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def _detect_node_install_method() -> tuple[str, str]: + """Detect the best way to install Node.js for this environment. + + Returns: + Tuple of (method_name, install_command). + """ + # Check if inside a conda environment + if os.environ.get("CONDA_PREFIX"): + return "conda", "conda install -y nodejs" + + # macOS with Homebrew + if sys.platform == "darwin": + try: + result = subprocess.run( + ["brew", "--version"], + capture_output=True, text=True, timeout=5, + ) + if result.returncode == 0: + return "brew", "brew install node" + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + return "manual", "https://nodejs.org" + + +def _install_node(method: str, command: str) -> bool: + """Install Node.js using the detected method. + + Returns: + True if installation succeeded. + """ + if method == "manual": + return False + + try: + proc = subprocess.run( + command.split(), + timeout=120, + ) + return proc.returncode == 0 + except FileNotFoundError: + console.print(f" [red]✗ {method} not found[/red]") + 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 _step_skills() -> list[str]: + """Step 7: Optionally install recommended skills. + + Shows checkbox first. If user selects nothing, checks npx as an + easter egg — confirms skill discovery is available, or offers to + install Node.js if missing. + + Returns: + List of skill sources that were selected (empty if skipped). + """ + choices = [ + Choice(title=skill["label"], value=skill["source"]) + for skill in _RECOMMENDED_SKILLS + ] + + selected = questionary.checkbox( + "Install predefined skills:", + choices=choices, + style=WIZARD_STYLE, + ).ask() + + if selected is None: + raise KeyboardInterrupt() + + if not selected: + # Easter egg: verify skill discovery environment + console.print(" [dim]Checking skill discovery environment...[/dim]") + has_npx = _check_npx() + if has_npx: + _print_step_skipped("Skills", "none selected — good choice!") + console.print(" [green]✓ npx found — skill discovery available[/green]") + console.print(" [yellow bold]* Less is more[/yellow bold] [dim](EvoScientist can discover and install skills on its own)[/dim]") + else: + console.print(" [yellow]✗ npx not found — skill discovery requires Node.js[/yellow]") + + method, command = _detect_node_install_method() + + if method != "manual": + console.print() + install_node = questionary.confirm( + f"Install Node.js via {method}? ({command})", + default=True, + style=WIZARD_STYLE, + ).ask() + + if install_node is None: + raise KeyboardInterrupt() + + if install_node: + console.print() + if _install_node(method, command): + console.print() + if _check_npx(): + console.print(" [green]✓ npx now available — skill discovery ready[/green]") + else: + console.print(" [yellow]✗ npx still not found after install[/yellow]") + else: + console.print(f" [dim] Install Node.js: {command}[/dim]") + + _print_step_skipped("Skills", "none selected") + + return [] + + from .tools.skills_manager import install_skill + + installed = [] + for source in selected: + label = next(s["label"] for s in _RECOMMENDED_SKILLS if s["source"] == source) + try: + result = install_skill(source) + if result.get("success"): + _print_step_result("Skill", label) + installed.append(source) + else: + _print_step_result("Skill", f"{label} — {result.get('error', 'failed')}", success=False) + except Exception as e: + _print_step_result("Skill", f"{label} — {e}", success=False) + + return installed + + def validate_imessage() -> tuple[bool, str]: """Validate iMessage environment by checking for the imsg CLI. @@ -924,7 +1088,10 @@ def run_onboard(skip_validation: bool = False) -> bool: config.max_iterations = max_iterations config.show_thinking = show_thinking - # Step 7: Channels + # Step 7: Skills + _step_skills() + + # Step 8: Channels imessage_enabled, imessage_allowed_senders = _step_channels(config) config.imessage_enabled = imessage_enabled config.imessage_allowed_senders = imessage_allowed_senders diff --git a/tests/test_onboard.py b/tests/test_onboard.py index d883213..078cc76 100644 --- a/tests/test_onboard.py +++ b/tests/test_onboard.py @@ -22,10 +22,10 @@ from EvoScientist.config import EvoScientistConfig class TestConstants: - 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_steps_has_eight_items(self): + """Test that STEPS contains exactly 8 steps.""" + assert len(STEPS) == 8 + assert STEPS == ["Provider", "API Key", "Model", "Tavily Key", "Workspace", "Parameters", "Skills", "Channels"] def test_wizard_style_is_style_instance(self): """Test that WIZARD_STYLE is a prompt_toolkit Style.""" @@ -444,6 +444,59 @@ class TestSetupImessage: assert result is False +class TestStepSkills: + def test_returns_empty_when_none_selected(self): + """Test skills step returns empty list when user selects nothing.""" + from EvoScientist.onboard import _step_skills + + with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ + mock.patch("EvoScientist.onboard.console"): + mock_q.checkbox.return_value.ask.return_value = [] + result = _step_skills() + + assert result == [] + + def test_installs_selected_skills(self): + """Test skills step installs selected skills and returns sources.""" + from EvoScientist.onboard import _step_skills, _RECOMMENDED_SKILLS + + source = _RECOMMENDED_SKILLS[0]["source"] + + with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ + mock.patch("EvoScientist.onboard.console"), \ + mock.patch("EvoScientist.tools.skills_manager.install_skill") as mock_install: + mock_q.checkbox.return_value.ask.return_value = [source] + mock_install.return_value = {"success": True, "name": "test"} + result = _step_skills() + + assert result == [source] + mock_install.assert_called_once_with(source) + + def test_handles_install_failure(self): + """Test skills step handles installation errors gracefully.""" + from EvoScientist.onboard import _step_skills, _RECOMMENDED_SKILLS + + source = _RECOMMENDED_SKILLS[0]["source"] + + with mock.patch("EvoScientist.onboard.questionary") as mock_q, \ + mock.patch("EvoScientist.onboard.console"), \ + mock.patch("EvoScientist.tools.skills_manager.install_skill") as mock_install: + mock_q.checkbox.return_value.ask.return_value = [source] + mock_install.side_effect = Exception("network error") + result = _step_skills() + + assert result == [] + + def test_raises_keyboard_interrupt_on_cancel(self): + """Test skills step raises KeyboardInterrupt on cancel.""" + from EvoScientist.onboard import _step_skills + + with mock.patch("EvoScientist.onboard.questionary") as mock_q: + mock_q.checkbox.return_value.ask.return_value = None + with pytest.raises(KeyboardInterrupt): + _step_skills() + + class TestStepChannels: def test_returns_disabled_when_skip(self): """Test channels step returns disabled when user selects skip.""" @@ -591,6 +644,7 @@ class TestRunOnboard: "3", # Max concurrent "3", # Max iterations ] + mock_q.checkbox.return_value.ask.return_value = [] # Skills: skip result = run_onboard(skip_validation=True) @@ -636,6 +690,7 @@ class TestRunOnboard: False, # Save config - NO ] mock_q.text.return_value.ask.side_effect = ["3", "3"] + mock_q.checkbox.return_value.ask.return_value = [] # Skills: skip result = run_onboard(skip_validation=True)