This commit is contained in:
X-iZhang
2026-02-08 20:28:57 +00:00
parent e2fbbf1b02
commit b923c73283
3 changed files with 236 additions and 13 deletions
+8 -7
View File
@@ -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
+169 -2
View File
@@ -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
+59 -4
View File
@@ -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)