Add interactive CLI mode and skill management commands

- Implemented interactive CLI in `interactive.py` for real-time user interaction with slash commands.
- Added MCP server management functionality in `mcp_ui.py`, including listing, adding, editing, and removing servers.
- Created skill management commands in `skills_cmd.py` for listing, installing, and uninstalling user skills.
- Enhanced user experience with rich text output and command completion features.
This commit is contained in:
X-iZhang
2026-02-10 19:52:15 +00:00
parent 27572dbd46
commit e987ec1e61
12 changed files with 1878 additions and 1819 deletions
-1653
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
"""EvoScientist CLI package."""
# Backward-compat re-exports (tests import these from EvoScientist.cli)
from ..stream.state import ( # noqa: F401
SubAgentState,
StreamState,
_parse_todo_items,
_build_todo_stats,
)
from .channel import ChannelMessage, _ChannelState # noqa: F401
from .agent import _deduplicate_run_name # noqa: F401
from ._app import app # noqa: F401
from . import commands # noqa: F401 — registers @app.command decorators
def main():
"""CLI entry point."""
import warnings
warnings.filterwarnings("ignore", message=".*not known to support tools.*")
warnings.filterwarnings("ignore", message=".*type is unknown and inference may fail.*")
from .commands import _configure_logging
_configure_logging()
app()
+47
View File
@@ -0,0 +1,47 @@
"""Typer application objects — no intra-package imports to avoid circular deps."""
import typer # type: ignore[import-untyped]
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)
app.add_typer(config_app, name="config")
# MCP subcommand group
_MCP_HELP = """\
Configure and manage MCP servers.
Examples:
# Add a local MCP server (stdio auto-detected):
EvoSci mcp add local-server python -- /path/to/server.py
# Add an npx-based server:
EvoSci mcp add sequential-thinking npx -- -y @modelcontextprotocol/server-sequential-thinking
# Add an HTTP server (http auto-detected from URL):
EvoSci mcp add docs-langchain https://docs.langchain.com/mcp
# Add a stdio server with env vars (hardcoded):
EvoSci mcp add my-server node --env API_KEY=xxx -- server.js
# Add a server with runtime env ref (resolved from .env at startup):
EvoSci mcp add brave-search npx --env-ref BRAVE_API_KEY -- -y @modelcontextprotocol/server-brave-search
# Expose to a specific sub-agent (e.g. research-agent):
EvoSci mcp add brave-search npx --env-ref BRAVE_API_KEY -e research-agent -- -y @modelcontextprotocol/server-brave-search
# Expose to multiple agents:
EvoSci mcp add local-server python -e main,research-agent,code-agent -- /path/to/server.py
# Explicit transport override:
EvoSci mcp add my-sse https://example.com/sse --transport sse
Sub-agents (-e): planner-agent | research-agent | code-agent | debug-agent | data-analysis-agent | writing-agent
"""
mcp_app = typer.Typer(help=_MCP_HELP, invoke_without_command=True)
app.add_typer(mcp_app, name="mcp")
+58
View File
@@ -0,0 +1,58 @@
"""Agent loading and workspace helpers."""
import os
from datetime import datetime
from pathlib import Path
from ..paths import new_run_dir, RUNS_DIR
def _shorten_path(path: str) -> str:
"""Shorten absolute path to relative path from current directory."""
if not path:
return path
try:
cwd = os.getcwd()
if path.startswith(cwd):
rel = path[len(cwd):].lstrip(os.sep)
return os.path.join(os.path.basename(cwd), rel) if rel else os.path.basename(cwd)
return path
except Exception:
return path
def _deduplicate_run_name(name: str, runs_dir: Path = RUNS_DIR) -> str:
"""Return *name* if available, otherwise *name_1*, *name_2*, etc."""
if not (runs_dir / name).exists():
return name
i = 1
while (runs_dir / f"{name}_{i}").exists():
i += 1
return f"{name}_{i}"
def _create_session_workspace(name: str | None = None) -> str:
"""Create a per-session workspace directory and return its path.
Args:
name: Optional human-friendly run name. Duplicates are resolved
by appending ``_1``, ``_2``, etc. Falls back to a timestamp
if *name* is None.
"""
if name:
session_id = _deduplicate_run_name(name)
else:
session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
workspace_dir = str(new_run_dir(session_id))
os.makedirs(workspace_dir, exist_ok=True)
return workspace_dir
def _load_agent(workspace_dir: str | None = None):
"""Load the CLI agent (with InMemorySaver checkpointer for multi-turn).
Args:
workspace_dir: Optional per-session workspace directory.
"""
from ..EvoScientist import create_cli_agent
return create_cli_agent(workspace_dir=workspace_dir)
+284
View File
@@ -0,0 +1,284 @@
"""Background iMessage channel — state management, thread lifecycle, handlers."""
import asyncio
import logging
import queue
import threading
import uuid
from dataclasses import dataclass
from typing import Any
from rich.panel import Panel
from rich.text import Text
from ..stream.display import console
_channel_logger = logging.getLogger(__name__)
@dataclass
class ChannelMessage:
"""Message from a channel (iMessage, Email, etc.)."""
msg_id: str
content: str
sender: str
channel_type: str # "iMessage", "Email", "Slack"
metadata: Any = None
class _ChannelState:
"""Singleton tracking background iMessage channel and message queue."""
server = None # IMessageServer | None
thread = None # threading.Thread | None
loop = None # asyncio.AbstractEventLoop | None
agent = None # shared agent reference (same as CLI)
thread_id = None # shared thread_id (same conversation as CLI)
# Queue-based communication between channel thread and main CLI thread
message_queue: queue.Queue = queue.Queue()
pending_responses: dict = {} # msg_id -> {"event": Event, "response": str | None}
_response_lock = threading.Lock()
@classmethod
def is_running(cls) -> bool:
return cls.thread is not None and cls.thread.is_alive()
@classmethod
def stop(cls):
if cls.loop and cls.server:
cls.loop.call_soon_threadsafe(
lambda: asyncio.ensure_future(cls.server.stop())
)
if cls.thread:
cls.thread.join(timeout=5)
cls.server = None
cls.thread = None
cls.loop = None
cls.agent = None
cls.thread_id = None
# Clear pending responses
with cls._response_lock:
for slot in cls.pending_responses.values():
slot["event"].set() # Unblock any waiting handlers
cls.pending_responses.clear()
@classmethod
def enqueue(
cls,
content: str,
sender: str,
channel_type: str,
metadata: Any = None,
) -> tuple[str, threading.Event]:
"""Enqueue a message from any channel for main thread processing.
Returns:
Tuple of (msg_id, event) - caller can wait on event for response.
"""
msg_id = str(uuid.uuid4())
event = threading.Event()
with cls._response_lock:
cls.pending_responses[msg_id] = {"event": event, "response": None}
cls.message_queue.put(ChannelMessage(msg_id, content, sender, channel_type, metadata))
return msg_id, event
@classmethod
def set_response(cls, msg_id: str, response: str) -> None:
"""Set response and signal completion."""
with cls._response_lock:
if msg_id in cls.pending_responses:
cls.pending_responses[msg_id]["response"] = response
cls.pending_responses[msg_id]["event"].set()
@classmethod
def get_response(cls, msg_id: str, timeout: float = 300) -> str | None:
"""Wait for and retrieve response.
Args:
msg_id: The message ID to get response for.
timeout: Maximum seconds to wait (default 300 = 5 minutes).
Returns:
The response text, or None if timed out or not found.
"""
with cls._response_lock:
slot = cls.pending_responses.get(msg_id)
if not slot:
return None
if slot["event"].wait(timeout=timeout):
with cls._response_lock:
return cls.pending_responses.pop(msg_id, {}).get("response")
return None
def _run_channel_thread(server):
"""Entry point for background channel thread."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
_ChannelState.loop = loop
try:
loop.run_until_complete(server.run())
except Exception as e:
_channel_logger.error(f"Channel error: {e}")
finally:
loop.close()
def _create_channel_handler():
"""Create iMessage handler that enqueues messages for main thread processing.
The handler enqueues messages to the shared queue and waits for the main
CLI thread to process them with full Rich Live streaming. This ensures
channel messages get the same display quality as direct CLI input.
Returns:
Async handler function: (msg) -> str
"""
async def handler(msg) -> str:
# Enqueue for main thread to process with full Live streaming
msg_id, event = _ChannelState.enqueue(
content=msg.content,
sender=msg.sender,
channel_type="iMessage",
metadata=msg.metadata,
)
# Wait indefinitely for main thread to process and set response
# (no timeout - let the agent work as long as needed)
await asyncio.to_thread(event.wait)
# Get the response
with _ChannelState._response_lock:
response = _ChannelState.pending_responses.pop(msg_id, {}).get("response", "")
return response if response else "(empty response)"
return handler
def _cmd_channel(args: str, agent: Any, thread_id: str) -> None:
"""Start iMessage channel in background thread using the shared agent.
CLI and iMessage share the same agent + thread_id (same conversation).
When an iMessage arrives, the main CLI thread processes it with full
Rich Live streaming — same experience as direct CLI input.
Usage: /channel [--allow SENDER]
"""
from ..channels.imessage import IMessageConfig
from ..channels.imessage.serve import IMessageServer
if _ChannelState.is_running():
console.print("[dim]iMessage channel already running[/dim]")
console.print("[dim]Use[/dim] /channel stop [dim]to disconnect[/dim]\n")
return
parts = args.split() if args else []
allowed = set()
for i, p in enumerate(parts):
if p == "--allow" and i + 1 < len(parts):
allowed.add(parts[i + 1])
config = IMessageConfig(
allowed_senders=allowed if allowed else None,
)
# Store shared agent reference — no separate agent creation
_ChannelState.agent = agent
_ChannelState.thread_id = thread_id
server = IMessageServer(
config,
handler=_create_channel_handler(),
send_thinking=True,
)
_ChannelState.server = server
_ChannelState.thread = threading.Thread(
target=_run_channel_thread,
args=(server,),
daemon=True,
)
_ChannelState.thread.start()
console.print("[green]iMessage channel running in background[/green]")
if allowed:
console.print(f"[dim]Allowed:[/dim] {allowed}")
else:
console.print("[dim]Allowed: all senders[/dim]")
console.print("[dim]Use[/dim] /channel stop [dim]to disconnect[/dim]\n")
def _cmd_channel_stop() -> None:
"""Stop background iMessage channel."""
if not _ChannelState.is_running():
console.print("[dim]No channel running[/dim]\n")
return
_ChannelState.stop()
console.print("[dim]iMessage channel stopped[/dim]\n")
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("\u25cf ", style="green")
line.append(name, style="bold")
else:
line.append("\u2717 ", 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(), send_thinking=True)
_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))])
+457
View File
@@ -0,0 +1,457 @@
"""Typer command registrations — onboard, config, mcp, main callback."""
import logging
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
import typer # type: ignore[import-untyped]
from rich.table import Table
from ..stream.display import console
from ..paths import ensure_dirs, default_workspace_dir
from ._app import app, config_app, mcp_app
from .agent import _shorten_path, _deduplicate_run_name, _create_session_workspace, _load_agent
from .mcp_ui import (
_mcp_list_servers,
_mcp_add_server_from_kwargs,
_mcp_edit_server_fields,
_mcp_remove_server,
_show_mcp_config,
)
from .interactive import cmd_interactive, cmd_run
# =============================================================================
# Onboard command
# =============================================================================
@app.command()
def onboard(
skip_validation: bool = typer.Option(
False,
"--skip-validation",
help="Skip API key validation during setup"
),
):
"""Interactive setup wizard for EvoScientist.
Guides you through configuring API keys, model selection,
workspace settings, and agent parameters.
"""
from ..onboard import run_onboard
run_onboard(skip_validation=skip_validation)
# =============================================================================
# Config commands
# =============================================================================
@config_app.callback(invoke_without_command=True)
def config_callback(ctx: typer.Context):
"""Configuration management commands."""
if ctx.invoked_subcommand is None:
config_list()
@config_app.command("list")
def config_list():
"""List all configuration values."""
from ..config import list_config, get_config_path
config_data = list_config()
table = Table(title="EvoScientist Configuration", show_header=True)
table.add_column("Setting", style="cyan")
table.add_column("Value")
# Mask API keys
def format_value(key: str, value: Any) -> str:
if "api_key" in key and value:
return "***" + str(value)[-4:] if len(str(value)) > 4 else "***"
if value == "":
return "[dim](not set)[/dim]"
return str(value)
for key, value in config_data.items():
table.add_row(key, format_value(key, value))
console.print(table)
console.print(f"\n[dim]Config file: {get_config_path()}[/dim]")
@config_app.command("get")
def config_get(key: str = typer.Argument(..., help="Configuration key to get")):
"""Get a single configuration value."""
from ..config import get_config_value
value = get_config_value(key)
if value is None:
console.print(f"[red]Unknown key: {key}[/red]")
raise typer.Exit(1)
# Mask API keys
if "api_key" in key and value:
display_value = "***" + str(value)[-4:] if len(str(value)) > 4 else "***"
elif value == "":
display_value = "(not set)"
else:
display_value = str(value)
console.print(f"[cyan]{key}[/cyan]: {display_value}")
@config_app.command("set")
def config_set(
key: str = typer.Argument(..., help="Configuration key to set"),
value: str = typer.Argument(..., help="New value"),
):
"""Set a single configuration value."""
from ..config import set_config_value
if set_config_value(key, value):
console.print(f"[green]Set {key}[/green]")
else:
console.print(f"[red]Invalid key: {key}[/red]")
raise typer.Exit(1)
@config_app.command("reset")
def config_reset(
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
):
"""Reset configuration to defaults."""
from ..config import reset_config, get_config_path
config_path = get_config_path()
if not config_path.exists():
console.print("[yellow]No config file to reset.[/yellow]")
return
if not yes:
confirm = typer.confirm("Reset configuration to defaults?")
if not confirm:
console.print("[dim]Cancelled.[/dim]")
return
reset_config()
console.print("[green]Configuration reset to defaults.[/green]")
@config_app.command("path")
def config_path():
"""Show the configuration file path."""
from ..config import get_config_path
path = get_config_path()
exists = path.exists()
status = "[green]exists[/green]" if exists else "[dim]not created yet[/dim]"
console.print(f"{path} ({status})")
# =============================================================================
# MCP commands
# =============================================================================
@mcp_app.callback(invoke_without_command=True)
def mcp_callback(ctx: typer.Context):
"""MCP server management commands."""
if ctx.invoked_subcommand is None:
mcp_list()
@mcp_app.command("list")
def mcp_list():
"""List configured MCP servers."""
_mcp_list_servers()
@mcp_app.command("config")
def mcp_config(
name: Optional[str] = typer.Argument(None, help="Server name (omit to show all)"),
):
"""Show detailed configuration for MCP servers.
\b
Examples:
evosci mcp config # Show all servers in detail
evosci mcp config filesystem # Show one server
"""
status = _show_mcp_config(name or "", show_blank_line=False)
if status == "empty":
console.print("[dim]Add one with:[/dim] EvoSci mcp add <name> <transport> <command-or-url> [args...]")
return
if status == "missing":
raise typer.Exit(1)
@mcp_app.command("add")
def mcp_add(
name: str = typer.Argument(..., help="Server name"),
target: str = typer.Argument(..., help="Command (stdio) or URL (http/sse)"),
args: Optional[list[str]] = typer.Argument(None, help="Extra args for stdio command"),
transport: Optional[str] = typer.Option(None, "--transport", "-T", help="Transport type (default: auto-detect)"),
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Comma-separated tool allowlist"),
expose_to: Optional[str] = typer.Option(None, "--expose-to", "-e", help="Comma-separated target agents"),
header: Optional[list[str]] = typer.Option(None, "--header", "-H", help="HTTP header as Key:Value (repeatable)"),
env: Optional[list[str]] = typer.Option(None, "--env", help="Env var as KEY=VALUE for stdio (repeatable)"),
env_ref: Optional[list[str]] = typer.Option(None, "--env-ref", help="Env var name as ${NAME} runtime ref (repeatable)"),
):
"""Add an MCP server to user config.
\b
Transport is auto-detected: URLs default to http, commands default to stdio.
\b
Examples:
evosci mcp add sequential-thinking npx -- -y @modelcontextprotocol/server-sequential-thinking
evosci mcp add docs-langchain https://docs.langchain.com/mcp
evosci mcp add my-sse https://example.com/sse --transport sse -e research-agent
evosci mcp add brave-search npx --env-ref BRAVE_API_KEY -- -y @modelcontextprotocol/server-brave-search
"""
from ..mcp import build_mcp_add_kwargs
# Merge env and env_ref into a single dict
env_dict: dict[str, str] = {}
for e in (env or []):
if "=" in e:
k, v = e.split("=", 1)
env_dict[k.strip()] = v.strip()
for ref in (env_ref or []):
env_dict[ref] = "${" + ref + "}"
kwargs = build_mcp_add_kwargs(
name=name,
target=target,
extra_args=list(args) if args else None,
transport=transport,
tools=[t.strip() for t in tools.split(",") if t.strip()] if tools else None,
expose_to=[a.strip() for a in expose_to.split(",") if a.strip()] if expose_to else None,
headers={k.strip(): v.strip() for h in (header or []) for k, v in [h.split(":", 1)] if ":" in h} or None,
env=env_dict or None,
)
if not _mcp_add_server_from_kwargs(kwargs, show_reload_hint=False):
raise typer.Exit(1)
@mcp_app.command("edit")
def mcp_edit(
name: str = typer.Argument(..., help="Server name to edit"),
transport: Optional[str] = typer.Option(None, "--transport", help="New transport type"),
command: Optional[str] = typer.Option(None, "--command", help="New command (stdio)"),
url: Optional[str] = typer.Option(None, "--url", help="New URL (http/sse/websocket)"),
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Comma-separated tool allowlist ('none' to clear)"),
expose_to: Optional[str] = typer.Option(None, "--expose-to", "-e", help="Comma-separated target agents ('none' to clear)"),
header: Optional[list[str]] = typer.Option(None, "--header", "-H", help="HTTP header as Key:Value (repeatable)"),
env: Optional[list[str]] = typer.Option(None, "--env", help="Env var as KEY=VALUE for stdio (repeatable)"),
):
"""Edit an existing MCP server in user config.
\b
Examples:
evosci mcp edit filesystem --expose-to main,code-agent
evosci mcp edit filesystem -t read_file,write_file
evosci mcp edit my-api --url http://new-host:9090/mcp
evosci mcp edit my-api --tools none
"""
from ..mcp import build_mcp_edit_fields
fields = build_mcp_edit_fields(
transport=transport,
command=command,
url=url,
tools=tools,
expose_to=expose_to,
headers=header,
env=env,
)
if not _mcp_edit_server_fields(name, fields, show_reload_hint=False):
raise typer.Exit(1)
@mcp_app.command("remove")
def mcp_remove(
name: str = typer.Argument(..., help="Server name to remove"),
):
"""Remove an MCP server from user config."""
if not _mcp_remove_server(name, show_reload_hint=False):
raise typer.Exit(1)
# =============================================================================
# Main callback (default behavior)
# =============================================================================
@app.callback(invoke_without_command=True)
def _main_callback(
ctx: typer.Context,
mode: Optional[str] = typer.Option(
None,
"-m",
"--mode",
help="Workspace mode: 'daemon' (persistent, default) or 'run' (isolated per-session)",
),
name: Optional[str] = typer.Option(
None,
"-n",
"--name",
help="Name for this run (used as directory name instead of timestamp; requires --mode run)",
),
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
if ctx.invoked_subcommand is not None:
return
from dotenv import load_dotenv, find_dotenv # type: ignore[import-untyped]
# find_dotenv() traverses up the directory tree to locate .env
load_dotenv(find_dotenv(), override=True)
# Load and apply configuration
from ..config import get_effective_config, apply_config_to_env
# Build CLI overrides dict
cli_overrides = {}
if mode:
cli_overrides["default_mode"] = mode
if workdir:
cli_overrides["default_workdir"] = workdir
if no_thinking:
cli_overrides["show_thinking"] = False
config = get_effective_config(cli_overrides)
apply_config_to_env(config)
show_thinking = config.show_thinking if not no_thinking else False
# Validate mutually exclusive options
if workdir and use_cwd:
raise typer.BadParameter("Use either --workdir or --use-cwd, not both.")
if mode and (workdir or use_cwd):
raise typer.BadParameter("--mode cannot be combined with --workdir or --use-cwd")
if mode and mode not in ("run", "daemon"):
raise typer.BadParameter("--mode must be 'run' or 'daemon'")
# --name only makes sense in run mode
if name and not (mode == "run" or (not mode and not workdir and not use_cwd and config.default_mode == "run")):
raise typer.BadParameter("--name can only be used with --mode run")
# Sanitize run name: allow alphanumeric, hyphens, underscores
if name:
if not re.fullmatch(r"[A-Za-z0-9_-]+", name):
raise typer.BadParameter("--name may only contain letters, digits, hyphens, and underscores")
ensure_dirs()
# Resolve effective mode from config (CLI mode already applied via overrides)
effective_mode: str | None = None # None means explicit --workdir/--use-cwd was used
# Resolve workspace directory for this session
# Priority: --use-cwd > --workdir > --mode (explicit) > default_workdir > default_mode
if use_cwd:
workspace_dir = os.getcwd()
workspace_fixed = True
elif workdir:
workspace_dir = os.path.abspath(os.path.expanduser(workdir))
os.makedirs(workspace_dir, exist_ok=True)
workspace_fixed = True
elif mode:
# Explicit --mode overrides default_workdir
effective_mode = mode
workspace_root = config.default_workdir or str(default_workspace_dir())
workspace_root = os.path.abspath(os.path.expanduser(workspace_root))
if effective_mode == "run":
runs_dir = Path(workspace_root, "runs")
session_id = _deduplicate_run_name(name, runs_dir) if name else datetime.now().strftime("%Y%m%d_%H%M%S")
workspace_dir = os.path.join(runs_dir, session_id)
os.makedirs(workspace_dir, exist_ok=True)
workspace_fixed = False
else: # daemon
workspace_dir = workspace_root
os.makedirs(workspace_dir, exist_ok=True)
workspace_fixed = True
elif config.default_workdir:
# Use configured default workdir with configured mode
workspace_root = os.path.abspath(os.path.expanduser(config.default_workdir))
effective_mode = config.default_mode
if effective_mode == "run":
runs_dir = Path(workspace_root, "runs")
session_id = _deduplicate_run_name(name, runs_dir) if name else datetime.now().strftime("%Y%m%d_%H%M%S")
workspace_dir = os.path.join(runs_dir, session_id)
os.makedirs(workspace_dir, exist_ok=True)
workspace_fixed = False
else: # daemon
workspace_dir = workspace_root
os.makedirs(workspace_dir, exist_ok=True)
workspace_fixed = True
else:
effective_mode = config.default_mode
if effective_mode == "run":
workspace_dir = _create_session_workspace(name)
workspace_fixed = False
else: # daemon mode (default)
workspace_dir = str(default_workspace_dir())
os.makedirs(workspace_dir, exist_ok=True)
workspace_fixed = True
# Load agent with session workspace
console.print("[dim]Loading agent...[/dim]")
agent = _load_agent(workspace_dir=workspace_dir)
if prompt:
# Single-shot mode: execute query and exit
cmd_run(agent, prompt, thread_id=thread_id, show_thinking=show_thinking, workspace_dir=workspace_dir)
else:
# Interactive mode (default)
cmd_interactive(
agent,
show_thinking=show_thinking,
workspace_dir=workspace_dir,
workspace_fixed=workspace_fixed,
mode=effective_mode,
model=config.model,
provider=config.provider,
imessage_enabled=config.imessage_enabled,
imessage_allowed_senders=config.imessage_allowed_senders,
run_name=name,
)
def _configure_logging():
"""Configure logging with warning symbols for better visibility."""
from rich.logging import RichHandler
class DimWarningHandler(RichHandler):
"""Custom handler that renders warnings in dim style."""
def emit(self, record: logging.LogRecord) -> None:
if record.levelno == logging.WARNING:
# Use Rich console to print dim warning
msg = record.getMessage()
console.print(f"[dim yellow]\u26a0\ufe0f Warning:[/dim yellow] [dim]{msg}[/dim]")
else:
super().emit(record)
# Configure root logger to use our handler for WARNING and above
handler = DimWarningHandler(console=console, show_time=False, show_path=False, show_level=False)
handler.setLevel(logging.WARNING)
# Apply to root logger (catches all loggers including deepagents)
root_logger = logging.getLogger()
# Remove existing handlers to avoid duplicate output
for h in root_logger.handlers[:]:
root_logger.removeHandler(h)
root_logger.addHandler(handler)
root_logger.setLevel(logging.WARNING)
+444
View File
@@ -0,0 +1,444 @@
"""Interactive CLI mode and single-shot execution."""
import asyncio
import os
import queue
import sys
import uuid
from typing import Any
import typer # type: ignore[import-untyped]
from prompt_toolkit import PromptSession # type: ignore[import-untyped]
from prompt_toolkit.completion import Completer, Completion # 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 prompt_toolkit.shortcuts import CompleteStyle # type: ignore[import-untyped]
from prompt_toolkit.styles import Style as PtStyle # type: ignore[import-untyped]
from rich.text import Text
from ..stream.display import console, _run_streaming
from .agent import _shorten_path, _create_session_workspace, _load_agent
from .channel import (
ChannelMessage,
_ChannelState,
_cmd_channel,
_cmd_channel_stop,
_auto_start_channel,
)
from .mcp_ui import _cmd_mcp
from .skills_cmd import _cmd_list_skills, _cmd_install_skill, _cmd_uninstall_skill
# =============================================================================
# Banner
# =============================================================================
EVOSCIENTIST_ASCII_LINES = [
r" \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557",
]
# Blue gradient: deep navy -> royal blue -> sky blue -> cyan
_GRADIENT_COLORS = ["#1a237e", "#1565c0", "#1e88e5", "#42a5f5", "#64b5f6", "#90caf9"]
# Keep the real ASCII art lines (raw strings) rather than the escaped version above
_REAL_ASCII_LINES = [
r" ███████╗ ██╗ ██╗ ██████╗ ███████╗ ██████╗ ██╗ ███████╗ ███╗ ██╗ ████████╗ ██╗ ███████╗ ████████╗",
r" ██╔════╝ ██║ ██║ ██╔═══██╗ ██╔════╝ ██╔════╝ ██║ ██╔════╝ ████╗ ██║ ╚══██╔══╝ ██║ ██╔════╝ ╚══██╔══╝",
r" █████╗ ██║ ██║ ██║ ██║ ███████╗ ██║ ██║ █████╗ ██╔██╗ ██║ ██║ ██║ ███████╗ ██║ ",
r" ██╔══╝ ╚██╗ ██╔╝ ██║ ██║ ╚════██║ ██║ ██║ ██╔══╝ ██║╚██╗██║ ██║ ██║ ╚════██║ ██║ ",
r" ███████╗ ╚████╔╝ ╚██████╔╝ ███████║ ╚██████╗ ██║ ███████╗ ██║ ╚████║ ██║ ██║ ███████║ ██║ ",
r" ╚══════╝ ╚═══╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═╝ ╚══════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ",
]
def print_banner(
thread_id: str,
workspace_dir: str | None = None,
memory_dir: str | None = None,
mode: str | None = None,
model: str | None = None,
provider: str | None = None,
):
"""Print welcome banner with ASCII art logo, thread ID, workspace path, and mode."""
for line, color in zip(_REAL_ASCII_LINES, _GRADIENT_COLORS):
console.print(Text(line, style=f"{color} bold"))
info = Text()
if model or provider or mode:
info.append(" ", style="dim")
parts = []
if model:
parts.append(("Model: ", model))
if provider:
parts.append(("Provider: ", provider))
if mode:
parts.append(("Mode: ", mode))
for i, (label, value) in enumerate(parts):
if i > 0:
info.append(" ", style="dim")
info.append(label, style="dim")
info.append(value, style="magenta")
info.append("\n Type ", style="#ffe082")
info.append("/", style="#ffe082 bold")
info.append(" for commands", style="#ffe082")
console.print(info)
console.print()
# =============================================================================
# Slash-command completer
# =============================================================================
_SLASH_COMMANDS = [
("/thread", "Show thread ID, workspace & memory dir"),
("/new", "Start a new session"),
("/skills", "List installed skills"),
("/install-skill", "Add a skill from path or GitHub"),
("/uninstall-skill", "Remove an installed skill"),
("/mcp", "Manage MCP servers"),
("/channel", "Configure messaging channels"),
("/exit", "Quit EvoScientist"),
]
_COMPLETION_STYLE = PtStyle.from_dict({
"completion-menu": "bg:default noreverse nounderline noitalic",
"completion-menu.completion": "bg:default #888888 noreverse",
"completion-menu.completion.current": "bg:default default bold noreverse",
"completion-menu.meta.completion": "bg:default #888888 noreverse",
"completion-menu.meta.completion.current": "bg:default default bold noreverse",
"scrollbar.background": "bg:default",
"scrollbar.button": "bg:default",
})
class SlashCommandCompleter(Completer):
"""Autocomplete for slash commands — triggers when input starts with '/'."""
def get_completions(self, document, complete_event):
text = document.text_before_cursor
if not text.startswith("/"):
return
for cmd, desc in _SLASH_COMMANDS:
if cmd.startswith(text):
yield Completion(
cmd,
start_position=-len(text),
display=f"{cmd:<40}",
display_meta=desc,
)
# =============================================================================
# Interactive & single-shot modes
# =============================================================================
def cmd_interactive(
agent: Any,
show_thinking: bool = True,
workspace_dir: str | None = None,
workspace_fixed: bool = False,
mode: str | None = None,
model: str | None = None,
provider: str | None = None,
imessage_enabled: bool = False,
imessage_allowed_senders: str = "",
run_name: str | None = None,
) -> None:
"""Interactive conversation mode with streaming output.
Args:
agent: Compiled agent graph
show_thinking: Whether to display thinking panels
workspace_dir: Per-session workspace directory path
workspace_fixed: If True, /new keeps the same workspace directory
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
run_name: Optional run name for /new session deduplication
"""
import nest_asyncio
nest_asyncio.apply()
thread_id = str(uuid.uuid4())
from ..EvoScientist import MEMORY_DIR
memory_dir = MEMORY_DIR
print_banner(thread_id, workspace_dir, memory_dir, mode, model, provider)
history_file = str(os.path.expanduser("~/.EvoScientist_history"))
session = PromptSession(
history=FileHistory(history_file),
auto_suggest=AutoSuggestFromHistory(),
completer=SlashCommandCompleter(),
complete_style=CompleteStyle.COLUMN,
complete_while_typing=True,
style=_COMPLETION_STYLE,
)
def _print_separator():
"""Print a horizontal separator line spanning the terminal width."""
width = console.size.width
console.print(Text("\u2500" * width, style="dim"))
# Mutable state for async loop
state = {
"agent": agent,
"thread_id": thread_id,
"workspace_dir": workspace_dir,
"running": True,
}
def _process_channel_message(msg: ChannelMessage) -> None:
"""Process a message from a channel with full Live streaming."""
# Move past the current prompt line to avoid interference with prompt_toolkit
# Then move back up and clear that line
sys.stdout.write("\n\033[A\033[2K\r")
sys.stdout.flush()
# Display prompt with channel source on second line
console.print(f"[bold blue]>[/bold blue] {msg.content}")
console.print(Text.assemble(
("[", "dim"),
(f"{msg.channel_type}: Received from ", "dim"),
(msg.sender, "cyan"),
("]", "dim"),
))
_print_separator()
console.print()
# Build channel callbacks for intermediate messages (thinking + todo + files)
on_thinking = None
on_todo = None
on_file_write = None
if _ChannelState.is_running() and _ChannelState.server and _ChannelState.loop:
def _send_thinking(thinking_text: str) -> None:
try:
asyncio.run_coroutine_threadsafe(
_ChannelState.server.send_thinking_message(
msg.sender, thinking_text, msg.metadata,
),
_ChannelState.loop,
)
except Exception:
pass # Non-critical — don't break main flow
def _send_todo(todo_items: list) -> None:
try:
lines = [f"\U0001f4cb {len(todo_items)} tasks ongoing"] # 📋
for i, item in enumerate(todo_items, 1):
content = item.get("content", "")
lines.append(f"{i}. {content}")
lines.append("\U0001f680") # 🚀
formatted = "\n".join(lines)
asyncio.run_coroutine_threadsafe(
_ChannelState.server.send_todo_message(
msg.sender, formatted, msg.metadata,
),
_ChannelState.loop,
)
except Exception:
pass # Non-critical — don't break main flow
def _send_file(real_path: str) -> None:
try:
asyncio.run_coroutine_threadsafe(
_ChannelState.server.channel.send_media(
recipient=msg.sender, file_path=real_path,
metadata=msg.metadata,
),
_ChannelState.loop,
)
except Exception:
pass # Non-critical — don't break main flow
on_thinking = _send_thinking
on_todo = _send_todo
on_file_write = _send_file
try:
# Use SAME _run_streaming as CLI input — full Live experience
response_text = _run_streaming(
state["agent"], msg.content, state["thread_id"], show_thinking,
interactive=True, on_thinking=on_thinking, on_todo=on_todo,
on_file_write=on_file_write,
)
# Set response for channel handler to retrieve
_ChannelState.set_response(msg.msg_id, response_text or "")
# Show replied indicator
console.print(Text.assemble(
("[", "dim"),
(f"{msg.channel_type}: Replied to ", "dim"),
(msg.sender, "cyan"),
("]", "dim"),
))
except Exception as e:
console.print(f"[red]Channel processing error: {e}[/red]")
_ChannelState.set_response(msg.msg_id, f"Error: {e}")
_print_separator()
async def _check_channel_queue():
"""Background task to check channel queue periodically."""
while state["running"]:
try:
msg = _ChannelState.message_queue.get_nowait()
_process_channel_message(msg)
except queue.Empty:
pass
await asyncio.sleep(0.1) # Check every 100ms
async def _async_main_loop():
"""Async main loop with prompt_async and channel queue checking."""
# 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"]:
try:
user_input = await session.prompt_async(
HTML('<ansiblue><b>\u276f</b></ansiblue> ')
)
user_input = user_input.strip()
if not user_input:
# Erase the empty prompt line so it looks like nothing happened
sys.stdout.write("\033[A\033[2K\r")
sys.stdout.flush()
continue
_print_separator()
# Special commands
if user_input.lower() in ("/exit", "/quit", "/q"):
console.print("[dim]Goodbye![/dim]")
state["running"] = False
break
if user_input.lower() == "/new":
# New session: new thread; workspace only changes if not fixed
if not workspace_fixed:
state["workspace_dir"] = _create_session_workspace(run_name)
console.print("[dim]Loading new session...[/dim]")
state["agent"] = _load_agent(workspace_dir=state["workspace_dir"])
state["thread_id"] = str(uuid.uuid4())
# Sync shared refs if channel is running
if _ChannelState.is_running():
_ChannelState.agent = state["agent"]
_ChannelState.thread_id = state["thread_id"]
console.print(f"[green]New session:[/green] [yellow]{state['thread_id']}[/yellow]")
if state["workspace_dir"]:
console.print(f"[dim]Workspace:[/dim] [cyan]{_shorten_path(state['workspace_dir'])}[/cyan]\n")
continue
if user_input.lower() == "/thread":
console.print(f"[dim]Thread:[/dim] [yellow]{state['thread_id']}[/yellow]")
if state["workspace_dir"]:
console.print(f"[dim]Workspace:[/dim] [cyan]{_shorten_path(state['workspace_dir'])}[/cyan]")
if memory_dir:
console.print(f"[dim]Memory dir:[/dim] [cyan]{_shorten_path(memory_dir)}[/cyan]")
console.print()
continue
if user_input.lower() == "/skills":
_cmd_list_skills()
continue
if user_input.lower().startswith("/install-skill"):
source = user_input[len("/install-skill"):].strip()
_cmd_install_skill(source)
continue
if user_input.lower().startswith("/uninstall-skill"):
name = user_input[len("/uninstall-skill"):].strip()
_cmd_uninstall_skill(name)
continue
if user_input.lower().startswith("/mcp"):
_cmd_mcp(user_input[4:])
continue
if user_input.lower().startswith("/channel"):
args = user_input[len("/channel"):].strip()
if args.lower() == "stop":
_cmd_channel_stop()
else:
_cmd_channel(args, state["agent"], state["thread_id"])
continue
# Stream agent response
console.print()
_run_streaming(state["agent"], user_input, state["thread_id"], show_thinking, interactive=True)
_print_separator()
except KeyboardInterrupt:
console.print("\n[dim]Goodbye![/dim]")
state["running"] = False
break
except EOFError:
# Handle Ctrl+D
console.print("\n[dim]Goodbye![/dim]")
state["running"] = False
break
except Exception as e:
error_msg = str(e)
if "authentication" in error_msg.lower() or "api_key" in error_msg.lower():
console.print("[red]Error: API key not configured.[/red]")
console.print("[dim]Run [bold]EvoSci onboard[/bold] to set up your API key.[/dim]")
state["running"] = False
break
else:
console.print(f"[red]Error: {e}[/red]")
finally:
queue_task.cancel()
try:
await queue_task
except asyncio.CancelledError:
pass
# Run the async main loop
try:
asyncio.run(_async_main_loop())
except KeyboardInterrupt:
console.print("\n[dim]Goodbye![/dim]")
def cmd_run(agent: Any, prompt: str, thread_id: str | None = None, show_thinking: bool = True, workspace_dir: str | None = None) -> None:
"""Single-shot execution with streaming display.
Args:
agent: Compiled agent graph
prompt: User prompt
thread_id: Optional thread ID (generates new one if None)
show_thinking: Whether to display thinking panels
workspace_dir: Per-session workspace directory path
"""
thread_id = thread_id or str(uuid.uuid4())
width = console.size.width
sep = Text("\u2500" * width, style="dim")
console.print(sep)
console.print(Text(f"> {prompt}"))
console.print(sep)
console.print(f"[dim]Thread: {thread_id}[/dim]")
if workspace_dir:
console.print(f"[dim]Workspace: {_shorten_path(workspace_dir)}[/dim]")
console.print()
try:
_run_streaming(agent, prompt, thread_id, show_thinking, interactive=False)
except Exception as e:
error_msg = str(e)
if "authentication" in error_msg.lower() or "api_key" in error_msg.lower():
console.print("[red]Error: API key not configured.[/red]")
console.print("[dim]Run [bold]EvoSci onboard[/bold] to set up your API key.[/dim]")
raise typer.Exit(1)
else:
console.print(f"[red]Error: {e}[/red]")
raise
+282
View File
@@ -0,0 +1,282 @@
"""MCP server display, operations, and /mcp slash-command dispatcher."""
from typing import Any
from rich.table import Table
from ..stream.display import console
def _mcp_list_servers() -> None:
"""Print a table of configured MCP servers."""
from ..mcp import load_mcp_config
from ..mcp.client import USER_MCP_CONFIG
config = load_mcp_config()
if not config:
console.print("[dim]No MCP servers configured.[/dim]")
console.print("[dim]Add one with:[/dim] /mcp add <name> <transport> <command-or-url> [args...]")
console.print()
return
table = Table(title="MCP Servers", show_header=True)
table.add_column("Server", style="cyan")
table.add_column("Transport", style="green")
table.add_column("Tools", style="yellow")
table.add_column("Expose To", style="magenta")
for name, server in config.items():
transport = server.get("transport", "?")
tools = server.get("tools")
tools_str = ", ".join(tools) if tools else "(all)"
expose_to = server.get("expose_to", ["main"])
if isinstance(expose_to, str):
expose_to = [expose_to]
expose_str = ", ".join(expose_to)
table.add_row(name, transport, tools_str, expose_str)
console.print(table)
console.print(f"\n[dim]Config file: {USER_MCP_CONFIG}[/dim]")
console.print()
def _mcp_add_server_from_kwargs(
kwargs: dict[str, Any],
*,
show_reload_hint: bool = False,
) -> bool:
"""Add an MCP server from prepared kwargs."""
from ..mcp import add_mcp_server
try:
entry = add_mcp_server(**kwargs)
console.print(f"[green]Added MCP server:[/green] [cyan]{kwargs['name']}[/cyan] ({entry['transport']})")
if show_reload_hint:
console.print("[dim]Reload with /new to apply.[/dim]")
return True
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
return False
def _mcp_edit_server_fields(
name: str,
fields: dict[str, Any],
*,
show_reload_hint: bool = False,
) -> bool:
"""Edit an MCP server from prepared field updates."""
from ..mcp import edit_mcp_server
if not fields:
console.print("[red]No fields to edit. Use --transport, --command, --url, --tools, --expose-to, etc.[/red]")
return False
try:
edit_mcp_server(name, **fields)
console.print(f"[green]Updated MCP server:[/green] [cyan]{name}[/cyan]")
for k, v in fields.items():
console.print(f" [dim]{k}:[/dim] {v}")
if show_reload_hint:
console.print("[dim]Reload with /new to apply.[/dim]")
return True
except KeyError as exc:
console.print(f"[red]{exc}[/red]")
return False
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
return False
def _mcp_remove_server(name: str, *, show_reload_hint: bool = False) -> bool:
"""Remove an MCP server by name."""
from ..mcp import remove_mcp_server
clean_name = name.strip()
if not clean_name:
console.print("[red]Usage:[/red] /mcp remove <name>")
return False
if remove_mcp_server(clean_name):
console.print(f"[green]Removed MCP server:[/green] [cyan]{clean_name}[/cyan]")
if show_reload_hint:
console.print("[dim]Reload with /new to apply.[/dim]")
return True
console.print(f"[red]Server not found:[/red] {clean_name}")
return False
def _render_mcp_server_config_table(name: str, server: dict[str, Any]) -> None:
"""Render one MCP server config table."""
table = Table(
title=f"MCP Server: {name}",
show_header=True,
title_style="bold cyan",
)
table.add_column("Setting", style="cyan")
table.add_column("Value")
table.add_row("transport", str(server.get("transport", "(not set)")))
if server.get("command"):
table.add_row("command", str(server["command"]))
if server.get("args"):
table.add_row("args", " ".join(str(a) for a in server["args"]))
if server.get("url"):
table.add_row("url", str(server["url"]))
if server.get("headers"):
for k, v in server["headers"].items():
table.add_row(f"header: {k}", str(v))
if server.get("env"):
for k, v in server["env"].items():
table.add_row(f"env: {k}", str(v))
tools = server.get("tools")
table.add_row("tools", ", ".join(tools) if tools else "[dim](all)[/dim]")
expose_to = server.get("expose_to", ["main"])
if isinstance(expose_to, str):
expose_to = [expose_to]
table.add_row("expose_to", ", ".join(expose_to))
console.print(table)
console.print()
def _show_mcp_config(name: str = "", *, show_blank_line: bool = True) -> str:
"""Show MCP config details.
Returns:
"ok" when rendered, "empty" when no config exists, "missing" when
a specific server name is requested but not found.
"""
from ..mcp import load_mcp_config
from ..mcp.client import USER_MCP_CONFIG
config = load_mcp_config()
if not config:
console.print("[dim]No MCP servers configured.[/dim]")
if show_blank_line:
console.print()
return "empty"
name = name.strip()
if name and name not in config:
console.print(f"[red]Server not found:[/red] {name}")
if show_blank_line:
console.print()
return "missing"
servers = {name: config[name]} if name else config
for srv_name, srv in servers.items():
_render_mcp_server_config_table(srv_name, srv)
console.print(f"[dim]Config file: {USER_MCP_CONFIG}[/dim]")
if show_blank_line:
console.print()
return "ok"
def _cmd_mcp_add(args_str: str) -> None:
"""Handle ``/mcp add ...``."""
import shlex
from ..mcp import parse_mcp_add_args
if not args_str.strip():
console.print("[bold]Usage:[/bold] /mcp add <name> <command-or-url> [args...]")
console.print()
console.print("[dim]Transport is auto-detected: URLs \u2192 http, commands \u2192 stdio[/dim]")
console.print()
console.print("[bold]Examples:[/bold]")
console.print(" /mcp add sequential-thinking npx -y @modelcontextprotocol/server-sequential-thinking")
console.print(" /mcp add docs-langchain https://docs.langchain.com/mcp")
console.print(" /mcp add my-sse http://localhost:9090/sse --transport sse --expose-to research-agent")
console.print()
console.print("[dim]Options:[/dim]")
console.print(" --transport T Transport type (default: auto-detect)")
console.print(" --tools t1,t2 Tool allowlist")
console.print(" --expose-to a1,a2 Target agents (default: main)")
console.print(" --header Key:Value HTTP header (repeatable)")
console.print(" --env KEY=VALUE Env var for stdio (repeatable)")
console.print(" --env-ref KEY Env var as runtime ${KEY} reference (repeatable)")
console.print()
return
try:
tokens = shlex.split(args_str)
kwargs = parse_mcp_add_args(tokens)
_mcp_add_server_from_kwargs(kwargs, show_reload_hint=True)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
console.print()
def _cmd_mcp_edit(args_str: str) -> None:
"""Handle ``/mcp edit <name> --field value ...``."""
import shlex
from ..mcp import parse_mcp_edit_args
if not args_str.strip():
console.print("[bold]Usage:[/bold] /mcp edit <name> --<field> <value> ...")
console.print()
console.print("[dim]Fields:[/dim] --transport, --command, --url, --args, --tools, --expose-to, --header, --env")
console.print("[dim]Use[/dim] --tools none [dim]or[/dim] --expose-to none [dim]to clear a field.[/dim]")
console.print()
console.print("[bold]Examples:[/bold]")
console.print(" /mcp edit filesystem --expose-to main,code-agent")
console.print(" /mcp edit filesystem --tools read_file,write_file")
console.print(" /mcp edit my-api --url http://new-host:8080/mcp")
console.print(" /mcp edit my-api --tools none")
console.print()
return
try:
tokens = shlex.split(args_str)
name, fields = parse_mcp_edit_args(tokens)
_mcp_edit_server_fields(name, fields, show_reload_hint=True)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
console.print()
def _cmd_mcp_remove(name: str) -> None:
"""Handle ``/mcp remove <name>``."""
_mcp_remove_server(name, show_reload_hint=True)
console.print()
def _cmd_mcp_config(name: str) -> None:
"""Handle ``/mcp config [name]``."""
_show_mcp_config(name, show_blank_line=True)
def _cmd_mcp(args: str) -> None:
"""Dispatch ``/mcp`` subcommands."""
args = args.strip()
if not args:
_mcp_list_servers()
return
parts = args.split(maxsplit=1)
subcmd = parts[0].lower()
subargs = parts[1] if len(parts) > 1 else ""
if subcmd == "list":
_mcp_list_servers()
elif subcmd == "add":
_cmd_mcp_add(subargs)
elif subcmd == "edit":
_cmd_mcp_edit(subargs)
elif subcmd == "remove":
_cmd_mcp_remove(subargs)
elif subcmd == "config":
_cmd_mcp_config(subargs)
else:
console.print("[bold]MCP commands:[/bold]")
console.print(" /mcp List configured servers")
console.print(" /mcp list List configured servers")
console.print(" /mcp config Show detailed server config")
console.print(" /mcp add ... Add a server")
console.print(" /mcp edit ... Edit an existing server")
console.print(" /mcp remove ... Remove a server")
console.print()
+73
View File
@@ -0,0 +1,73 @@
"""Slash commands for skill management: /skills, /install-skill, /uninstall-skill."""
from ..stream.display import console
from .agent import _shorten_path
def _cmd_list_skills() -> None:
"""List installed user skills."""
from ..tools.skills_manager import list_skills
from ..paths import USER_SKILLS_DIR
skills = list_skills(include_system=False)
if not skills:
console.print("[dim]No user-installed skills.[/dim]")
console.print("[dim]Install with:[/dim] /install-skill <path-or-url>")
console.print(f"[dim]Skills directory:[/dim] [cyan]{_shorten_path(str(USER_SKILLS_DIR))}[/cyan]")
console.print()
return
console.print(f"[bold]User-Installed Skills[/bold] ({len(skills)}):")
for skill in skills:
console.print(f" [green]{skill.name}[/green] - {skill.description}")
console.print(f"\n[dim]Location:[/dim] [cyan]{_shorten_path(str(USER_SKILLS_DIR))}[/cyan]")
console.print()
def _cmd_install_skill(source: str) -> None:
"""Install a skill from local path or GitHub URL."""
from ..tools.skills_manager import install_skill
if not source:
console.print("[red]Usage:[/red] /install-skill <path-or-url>")
console.print("[dim]Examples:[/dim]")
console.print(" /install-skill ./my-skill")
console.print(" /install-skill https://github.com/user/repo/tree/main/skill-name")
console.print(" /install-skill user/repo@skill-name")
console.print()
return
console.print(f"[dim]Installing skill from:[/dim] {source}")
result = install_skill(source)
if result["success"]:
console.print(f"[green]Installed:[/green] {result['name']}")
console.print(f"[dim]Description:[/dim] {result.get('description', '(none)')}")
console.print(f"[dim]Path:[/dim] [cyan]{_shorten_path(result['path'])}[/cyan]")
console.print()
console.print("[dim]Reload with /new to apply.[/dim]")
else:
console.print(f"[red]Failed:[/red] {result['error']}")
console.print()
def _cmd_uninstall_skill(name: str) -> None:
"""Uninstall a user-installed skill."""
from ..tools.skills_manager import uninstall_skill
if not name:
console.print("[red]Usage:[/red] /uninstall-skill <skill-name>")
console.print("[dim]Use /skills to see installed skills.[/dim]")
console.print()
return
result = uninstall_skill(name)
if result["success"]:
console.print(f"[green]Uninstalled:[/green] {name}")
console.print("[dim]Reload with /new to apply.[/dim]")
else:
console.print(f"[red]Failed:[/red] {result['error']}")
console.print()
+4
View File
@@ -13,6 +13,8 @@ from .client import (
remove_mcp_server,
parse_mcp_add_args,
parse_mcp_edit_args,
build_mcp_add_kwargs,
build_mcp_edit_fields,
VALID_TRANSPORTS,
)
@@ -25,5 +27,7 @@ __all__ = [
"remove_mcp_server",
"parse_mcp_add_args",
"parse_mcp_edit_args",
"build_mcp_add_kwargs",
"build_mcp_edit_fields",
"VALID_TRANSPORTS",
]
+126 -52
View File
@@ -222,6 +222,93 @@ def _infer_transport(target: str) -> str:
return "stdio"
def build_mcp_add_kwargs(
name: str,
target: str,
extra_args: list[str] | None = None,
transport: str | None = None,
tools: list[str] | None = None,
expose_to: list[str] | None = None,
headers: dict[str, str] | None = None,
env: dict[str, str] | None = None,
) -> dict:
"""Build kwargs dict for :func:`add_mcp_server` from structured parameters.
If *transport* is ``None`` it is inferred from *target* (URL → ``http``,
otherwise ``stdio``).
"""
if transport is None:
transport = _infer_transport(target)
kwargs: dict = {"name": name, "transport": transport}
if transport == "stdio":
kwargs["command"] = target
kwargs["args"] = list(extra_args) if extra_args else []
if env:
kwargs["env"] = env
else:
kwargs["url"] = target
if headers:
kwargs["headers"] = headers
if tools:
kwargs["tools"] = tools
if expose_to:
kwargs["expose_to"] = expose_to
return kwargs
def build_mcp_edit_fields(
transport: str | None = None,
command: str | None = None,
url: str | None = None,
tools: str | None = None,
expose_to: str | None = None,
headers: list[str] | None = None,
env: list[str] | None = None,
) -> dict:
"""Build fields dict for :func:`edit_mcp_server` from structured parameters.
*tools* and *expose_to* accept the string ``"none"`` to clear the field,
or a comma-separated list. *headers* and *env* are lists of
``"Key:Value"`` / ``"KEY=VALUE"`` strings respectively.
"""
fields: dict = {}
if transport is not None:
fields["transport"] = transport
if command is not None:
fields["command"] = command
if url is not None:
fields["url"] = url
if tools is not None:
fields["tools"] = (
None
if tools == "none"
else [t.strip() for t in tools.split(",") if t.strip()]
)
if expose_to is not None:
fields["expose_to"] = (
None
if expose_to == "none"
else [a.strip() for a in expose_to.split(",") if a.strip()]
)
if headers:
hdr: dict[str, str] = {}
for h in headers:
if ":" in h:
k, v = h.split(":", 1)
hdr[k.strip()] = v.strip()
if hdr:
fields["headers"] = hdr
if env:
env_dict: dict[str, str] = {}
for e in env:
if "=" in e:
k, v = e.split("=", 1)
env_dict[k.strip()] = v.strip()
if env_dict:
fields["env"] = env_dict
return fields
def parse_mcp_add_args(tokens: list[str]) -> dict:
"""Parse CLI tokens for ``/mcp add`` into kwargs for :func:`add_mcp_server`.
@@ -284,27 +371,16 @@ def parse_mcp_add_args(tokens: list[str]) -> dict:
if not positional:
raise ValueError("A command or URL is required after the server name")
if transport is None:
transport = _infer_transport(positional[0])
kwargs: dict = {"name": name, "transport": transport}
if transport == "stdio":
kwargs["command"] = positional[0]
kwargs["args"] = positional[1:]
if env:
kwargs["env"] = env
else:
kwargs["url"] = positional[0]
if headers:
kwargs["headers"] = headers
if tools:
kwargs["tools"] = tools
if expose_to:
kwargs["expose_to"] = expose_to
return kwargs
return build_mcp_add_kwargs(
name=name,
target=positional[0],
extra_args=positional[1:] or None,
transport=transport,
tools=tools,
expose_to=expose_to,
headers=headers or None,
env=env or None,
)
def parse_mcp_edit_args(tokens: list[str]) -> tuple[str, dict]:
@@ -325,60 +401,58 @@ def parse_mcp_edit_args(tokens: list[str]) -> tuple[str, dict]:
)
name = tokens[0]
fields: dict[str, Any] = {}
headers: dict[str, str] = {}
env: dict[str, str] = {}
# Parse tokens into raw values
transport_val: str | None = None
command_val: str | None = None
url_val: str | None = None
args_val: list[str] | None = None
tools_val: str | None = None
expose_to_val: str | None = None
header_list: list[str] = []
env_list: list[str] = []
i = 1
while i < len(tokens):
tok = tokens[i]
if tok == "--transport" and i + 1 < len(tokens):
fields["transport"] = tokens[i + 1]
transport_val = tokens[i + 1]
i += 2
elif tok == "--command" and i + 1 < len(tokens):
fields["command"] = tokens[i + 1]
command_val = tokens[i + 1]
i += 2
elif tok == "--url" and i + 1 < len(tokens):
fields["url"] = tokens[i + 1]
url_val = tokens[i + 1]
i += 2
elif tok == "--args" and i + 1 < len(tokens):
fields["args"] = tokens[i + 1].split(",")
args_val = tokens[i + 1].split(",")
i += 2
elif tok == "--tools" and i + 1 < len(tokens):
val = tokens[i + 1]
fields["tools"] = (
None
if val == "none"
else [t.strip() for t in val.split(",") if t.strip()]
)
tools_val = tokens[i + 1]
i += 2
elif tok == "--expose-to" and i + 1 < len(tokens):
val = tokens[i + 1]
fields["expose_to"] = (
None
if val == "none"
else [a.strip() for a in val.split(",") if a.strip()]
)
expose_to_val = tokens[i + 1]
i += 2
elif tok == "--header" and i + 1 < len(tokens):
kv = tokens[i + 1]
if ":" in kv:
k, v = kv.split(":", 1)
headers[k.strip()] = v.strip()
header_list.append(tokens[i + 1])
i += 2
elif tok == "--env" and i + 1 < len(tokens):
kv = tokens[i + 1]
if "=" in kv:
k, v = kv.split("=", 1)
env[k.strip()] = v.strip()
env_list.append(tokens[i + 1])
i += 2
else:
i += 1
if headers:
fields["headers"] = headers
if env:
fields["env"] = env
fields = build_mcp_edit_fields(
transport=transport_val,
command=command_val,
url=url_val,
tools=tools_val,
expose_to=expose_to_val,
headers=header_list or None,
env=env_list or None,
)
if args_val is not None:
fields["args"] = args_val
if not fields:
raise ValueError(
+77 -114
View File
@@ -309,6 +309,72 @@ def _step_provider(config: EvoScientistConfig) -> str:
return provider
def _provider_key_info(config: EvoScientistConfig, provider: str):
"""Return (display_name, current_value, validate_fn) for a provider."""
mapping = {
"anthropic": ("Anthropic", config.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", ""), validate_anthropic_key),
"nvidia": ("NVIDIA", config.nvidia_api_key or os.environ.get("NVIDIA_API_KEY", ""), validate_nvidia_key),
"google-genai": ("Google", config.google_api_key or os.environ.get("GOOGLE_API_KEY", ""), validate_google_key),
}
return mapping.get(provider, ("OpenAI", config.openai_api_key or os.environ.get("OPENAI_API_KEY", ""), validate_openai_key))
def _prompt_and_validate_api_key(
prompt_text: str,
current: str,
validate_fn,
skip_validation: bool = False,
placeholder=None,
) -> str | None:
"""Prompt user for an API key, validate, offer save-anyway on failure.
Args:
prompt_text: The question shown to the user.
current: Currently stored key value (may be empty).
validate_fn: Callable(key) -> (bool, str).
skip_validation: If True, skip the validation step entirely.
placeholder: Optional placeholder for the password input.
Returns:
New key string if the user entered one, or None to keep existing.
"""
kwargs: dict = {"style": WIZARD_STYLE, "qmark": QMARK}
if placeholder is not None:
kwargs["placeholder"] = placeholder
new_key = questionary.password(prompt_text, **kwargs).ask()
if new_key is None:
raise KeyboardInterrupt()
new_key = new_key.strip()
key_to_validate = new_key if new_key else current
if not key_to_validate:
return None
if not skip_validation:
console.print(" [dim]Validating...[/dim]", end="")
valid, msg = validate_fn(key_to_validate)
if valid:
console.print(f"\r [green]\u2713 {msg}[/green] ")
return new_key if new_key else None
else:
console.print(f"\r [red]\u2717 {msg}[/red] ")
if not new_key:
return None
save_anyway = questionary.confirm(
"Save anyway?",
default=False,
style=WIZARD_STYLE,
qmark=QMARK,
).ask()
if save_anyway is None:
raise KeyboardInterrupt()
return new_key if save_anyway else None
return new_key if new_key else None
def _step_provider_api_key(
config: EvoScientistConfig,
provider: str,
@@ -324,73 +390,14 @@ def _step_provider_api_key(
Returns:
New API key or None if unchanged.
"""
if provider == "anthropic":
key_name = "Anthropic"
current = config.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", "")
validate_fn = validate_anthropic_key
elif provider == "nvidia":
key_name = "NVIDIA"
current = config.nvidia_api_key or os.environ.get("NVIDIA_API_KEY", "")
validate_fn = validate_nvidia_key
elif provider == "google-genai":
key_name = "Google"
current = config.google_api_key or os.environ.get("GOOGLE_API_KEY", "")
validate_fn = validate_google_key
else:
key_name = "OpenAI"
current = config.openai_api_key or os.environ.get("OPENAI_API_KEY", "")
validate_fn = validate_openai_key
key_name, current, validate_fn = _provider_key_info(config, provider)
# Show current status inline
if current:
display_current = f"***{current[-4:]}"
hint = f"Current: {display_current}"
else:
hint = "Not set"
hint = f"Current: ***{current[-4:]}" if current else "Not set"
prompt_text = f"Enter {key_name} API key ({hint}, Enter to keep):"
# Prompt for new key
new_key = questionary.password(
f"Enter {key_name} API key ({hint}, Enter to keep):",
style=WIZARD_STYLE,
qmark=QMARK,
).ask()
if new_key is None:
raise KeyboardInterrupt()
new_key = new_key.strip()
# Determine the key to validate: new input or existing current key
key_to_validate = new_key if new_key else current
if not key_to_validate:
return None # Nothing to validate
# Validate the key (new or current)
if not skip_validation:
console.print(" [dim]Validating...[/dim]", end="")
valid, msg = validate_fn(key_to_validate)
if valid:
console.print(f"\r [green]✓ {msg}[/green] ")
return new_key if new_key else None # Return new key or None (keep current)
else:
console.print(f"\r [red]✗ {msg}[/red] ")
if not new_key:
return None # Current key invalid, but keep it
# Ask if they want to save anyway
save_anyway = questionary.confirm(
"Save anyway?",
default=False,
style=WIZARD_STYLE,
qmark=QMARK,
).ask()
if save_anyway is None:
raise KeyboardInterrupt()
if save_anyway:
return new_key
return None
else:
return new_key if new_key else None
return _prompt_and_validate_api_key(
prompt_text, current, validate_fn, skip_validation,
)
def _step_model(config: EvoScientistConfig, provider: str) -> str:
@@ -464,57 +471,13 @@ def _step_tavily_key(
"""
current = config.tavily_api_key or os.environ.get("TAVILY_API_KEY", "")
# Show current status inline
if current:
display_current = f"***{current[-4:]}"
hint = f"Current: {display_current}"
else:
hint = "Not set"
hint = f"Current: ***{current[-4:]}" if current else "Not set"
prompt_text = f"Tavily API key for web search ({hint}, Enter to keep):"
# Prompt for new key
new_key = questionary.password(
f"Tavily API key for web search ({hint}, Enter to keep):",
style=WIZARD_STYLE,
qmark=QMARK,
return _prompt_and_validate_api_key(
prompt_text, current, validate_tavily_key, skip_validation,
placeholder=FormattedText([("fg:#858585", "(recommended for web search)")]),
).ask()
if new_key is None:
raise KeyboardInterrupt()
new_key = new_key.strip()
# Determine the key to validate: new input or existing current key
key_to_validate = new_key if new_key else current
if not key_to_validate:
return None # Nothing to validate
# Validate the key (new or current)
if not skip_validation:
console.print(" [dim]Validating...[/dim]", end="")
valid, msg = validate_tavily_key(key_to_validate)
if valid:
console.print(f"\r [green]✓ {msg}[/green] ")
return new_key if new_key else None # Return new key or None (keep current)
else:
console.print(f"\r [red]✗ {msg}[/red] ")
if not new_key:
return None # Current key invalid, but keep it
# Ask if they want to save anyway
save_anyway = questionary.confirm(
"Save anyway?",
default=False,
style=WIZARD_STYLE,
qmark=QMARK,
).ask()
if save_anyway is None:
raise KeyboardInterrupt()
if save_anyway:
return new_key
return None
else:
return new_key if new_key else None
)
def _step_workspace(config: EvoScientistConfig) -> tuple[str, str]: