Ctrl K
ComunidadGuías…

scout/cli.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Scout Agent CLI 入口.

Usage:
    scout                          # 启动终端对话
    scout --web                    # 启动 Web 界面 (默认端口 8848)
    scout --web --port 9000        # 指定端口
    scout --model qwen-plus        # 指定模型
    scout --provider dashscope     # 指定 provider
    scout --max-turns 50           # 最大迭代次数
"""

from __future__ import annotations

import argparse
import asyncio
import os
import sys
from pathlib import Path

from rich.console import Console


def _build_console() -> Console:
    """构造一个不会因输出流编码而崩溃的 Console。

    Windows 上把 stdout 重定向到文件/管道时,流编码会落到 cp936(GBK)。
    rich 的 legacy Windows 渲染路径直接往该流写文本,遇到 emoji 就抛
    UnicodeEncodeError,把整个命令掀掉——「scout --web > log.txt」曾因此
    在打印启动横幅时直接崩进程。

    这里只把错误策略改成 replace:宁可把 emoji 降级成「?」,也不让 CLI 崩。
    不改 encoding,避免真实控制台出现二次乱码。
    """
    for stream in (sys.stdout, sys.stderr):
        reconfigure = getattr(stream, "reconfigure", None)
        if reconfigure is None:
            continue
        try:
            reconfigure(errors="replace")
        except (ValueError, OSError):  # 已脱离 tty、或流不支持重配时静默跳过
            pass
    return Console()


console = _build_console()

DEFAULT_SYSTEM_PROMPT = """\
You are Scout, a helpful AI assistant with persistent memory and skills.

## Tools (核心工具集)
- file: 统一文件操作 (read/write/edit/list)
- shell: 执行 shell/bash 命令
- execute_code: 执行 Python 代码
- web_search / web_fetch: 搜索互联网、抓取网页
- memory_save / memory_search / memory_list: 长期记忆读写检索
- knowledge: 知识库读写
- send_file: 发送文件给用户
技能 (skills) 可扩展能力,复杂任务先查可用技能再执行。

## 记忆规则
- 用户说"记住/以后/总是" → memory_save
- 用户问历史/不确定 → 先 memory_search
- 主动保存用户偏好、决策、结论

## 流程
理解意图 → 选工具/技能 → 执行观察 → 汇总回复。

Always respond in the same language as the user's input.
Be concise, helpful, and proactive.
"""


def load_env() -> None:
    """加载 .env 文件 — 从多个候选路径查找(cwd 与项目根)."""
    candidates = [
        os.path.join(os.getcwd(), ".env"),
        os.path.join(os.path.dirname(__file__), "..", ".env"),
    ]
    for env_path in candidates:
        env_path = os.path.normpath(env_path)
        if os.path.exists(env_path):
            with open(env_path, encoding="utf-8") as f:
                for line in f:
                    line = line.strip()
                    if not line or line.startswith("#"):
                        continue
                    if "=" in line:
                        key, _, value = line.partition("=")
                        key = key.strip()
                        value = value.strip()
                        if key and key not in os.environ:
                            os.environ[key] = value
            return


def init_logging() -> None:
    """初始化日志系统(按天轮转 + 自动清理)."""
    from scout.core.log_config import setup_logging
    setup_logging(
        log_file="scout.log",
        retention_days=30,  # 保留 30 天,自动清理旧日志
        console=False,  # CLI 模式不重复输出到控制台(Rich 已处理)
    )


def init_skills() -> None:
    """初始化技能管理器并迁移旧版技能."""
    try:
        from scout.automation.skill_manager import get_skill_manager
        
        manager = get_skill_manager()
        
        # 尝试从旧的项目内目录迁移技能
        old_custom_path = Path(__file__).resolve().parent.parent / "skills" / "custom"
        manager.migrate_old_skills(old_custom_path)
        
        # 发现所有技能
        skills = manager.discover_all()
        console.print(f"[dim]已加载 {len(skills)} 个技能 (内置 + 用户自定义)[/]")
        
    except Exception as e:
        console.print(f"[yellow]警告: 技能管理器初始化失败: {e}[/]")


def init_plugins() -> None:
    """初始化插件系统."""
    try:
        from scout.plugins.manager import get_plugin_manager
        
        manager = get_plugin_manager()
        manager.auto_discover()
        
        plugins = manager.list_plugins()
        enabled = [p for p in plugins if p.get("enabled")]
        
        console.print(f"[dim]已加载 {len(plugins)} 个插件,启用 {len(enabled)} 个[/]")
        
    except Exception as e:
        console.print(f"[yellow]警告: 插件系统初始化失败: {e}[/]")


def ensure_manifest(app_version: str | None = None) -> None:
    """确保数据目录 manifest 存在并记录版本,检测数据格式兼容性."""
    try:
        from scout import __version__
        from scout.config.manifest import check_data_format, ensure_manifest as _ensure

        _ensure(app_version or __version__)
        compatible, msg = check_data_format()
        if not compatible:
            console.print(f"[bold red]升级检查失败: {msg}[/]")
        elif "迁移" in msg or "高于" in msg:
            console.print(f"[yellow]{msg}[/]")
    except Exception as e:  # noqa: BLE001
        console.print(f"[yellow]警告: manifest 检查失败: {e}[/]")


# ── CLI 精简工具集 ──────────────────────────────
# 参照 CowAgent / Claude Code:CLI 只暴露核心读写执行工具,减少上下文占用。
# file 已合并 read/write/edit/list(UnifiedFileTool),shell 即 bash 执行。
CLI_MINIMAL_TOOLS = {
    "file",          # 读/写/编辑/列目录(read/write/edit 的合并)
    "shell",         # bash 命令执行
    "execute_code",  # Python 代码执行
    "web_search",    # 联网搜索
    "web_fetch",     # 获取网页
    "send_file",     # 发送文件给用户
    "memory_save", "memory_search", "memory_list", "knowledge",  # 记忆
}

async def run_console(args: argparse.Namespace) -> None:
    """启动终端交互."""
    api_key = args.api_key or os.environ.get("SCOUT_LLM_API_KEY", "")
    model = args.model or os.environ.get("SCOUT_LLM_MODEL", "gpt-4o-mini")
    base_url = args.base_url or os.environ.get("SCOUT_LLM_BASE_URL")
    provider = args.provider or os.environ.get("SCOUT_LLM_PROVIDER", "openai")

    if not api_key:
        console.print("[red]错误: 未设置 API Key[/]")
        console.print("[dim]请设置环境变量或创建 .env 文件:[/]")
        console.print("[dim]  SCOUT_LLM_API_KEY=\"your-api-key\"[/]")
        console.print("[dim]  SCOUT_LLM_MODEL=\"qwen-plus\"[/]")
        console.print("[dim]  SCOUT_LLM_PROVIDER=\"dashscope\"[/]")
        console.print("[dim]  SCOUT_LLM_BASE_URL=\"https://dashscope.aliyuncs.com/compatible-mode/v1\"[/]")
        sys.exit(1)

    from scout.llm.providers.registry import create_provider
    from scout.tools.registry import ToolRegistry
    from scout.engine.agent import Agent
    from scout.adapters.console import console_loop

    ToolRegistry.discover()

    llm = create_provider(provider=provider, api_key=api_key, model=model, base_url=base_url)
    agent = Agent(
        llm=llm,
        system_prompt=DEFAULT_SYSTEM_PROMPT,
        max_turns=args.max_turns,
        allow_tools=CLI_MINIMAL_TOOLS,  # CLI 精简工具集(file/shell/execute_code/联网/记忆)
    )

    await console_loop(agent, DEFAULT_SYSTEM_PROMPT)


async def run_web(args: argparse.Namespace) -> None:
    """启动 Web 服务."""
    from scout.config import ConfigManager

    config_mgr = ConfigManager()
    config = config_mgr.load()

    # 命令行参数优先,其次配置文件
    api_key = args.api_key or config.api_key or os.environ.get("SCOUT_LLM_API_KEY", "")
    model = args.model or config.model or os.environ.get("SCOUT_LLM_MODEL", "gpt-4o-mini")
    base_url = args.base_url or config.base_url or os.environ.get("SCOUT_LLM_BASE_URL")
    provider = args.provider or config.provider or os.environ.get("SCOUT_LLM_PROVIDER", "openai")
    # 安全默认:仅回环地址。需局域网/公网访问时请显式 --host 0.0.0.0(并确保已设置登录凭证)。
    host = args.host or config.web_host or "127.0.0.1"
    port = args.port or config.web_port or 8848

    if not api_key:
        console.print("\n[bold green]🧭 Scout Agent Web 服务启动(无 API Key)[/]")
        console.print(f"[dim]请在浏览器中打开 http://localhost:{port},点击「设置」配置 API Key[/]\n")
    else:
        # 保存到 config.json 以便 Web 端读取
        config.api_key = api_key
        config.model = model
        config.base_url = base_url
        config.provider = provider
        config_mgr.save(config)

    import uvicorn
    from scout.llm.providers.registry import create_provider
    from scout.tools.registry import ToolRegistry
    from scout.engine.agent import Agent
    from scout.web.server import create_web_app

    # ★ 2026-09-30(体验分析中危):启动反馈前置。此前地址信息在工具 discover
    # 全量 import + Agent 全子系统初始化**之后**才打印 —— CLI 冷启动可静默
    # 数秒到十几秒,像卡死。现在先打印地址,再打印初始化进度。
    console.print("\n[bold green]🧭 Scout Agent Web 服务启动[/]")
    console.print(f"[dim]地址: http://localhost:{port}[/]")
    console.print(f"[dim]API:  http://localhost:{port}/v1/chat/completions[/]")
    console.print(f"[dim]WebSocket: ws://localhost:{port}/ws[/]")
    if host not in ("127.0.0.1", "localhost"):
        console.print(f"[bold yellow]⚠ 正在监听 {host}:{port}(非本机回环),请确认已通过设置页初始化登录凭证,否则 API 将返回 401[/]")
    console.print("[dim]正在初始化工具与 Agent 子系统…[/]")

    ToolRegistry.discover()

    if api_key:
        llm = create_provider(provider=provider, api_key=api_key, model=model, base_url=base_url)
        agent = Agent(
            llm=llm,
            max_turns=args.max_turns,
            deep_thinking=config.deep_thinking,
            enable_security=True,
            enable_skills=True,
            enable_workspace=True,
            enable_bus=True,
        )
    else:
        agent = None

    app = create_web_app(agent)

    console.print("[dim]初始化完成,开始监听…[/]\n")

    config = uvicorn.Config(app, host=host, port=port, log_level="info")
    server = uvicorn.Server(config)
    await server.serve()


def key_main(argv: list[str]) -> None:
    """多 Provider API Key 管理: scout key --add <provider> <api_key> [--no-activate] | --activate <provider> | --list."""
    from scout.config.manager import ConfigManager

    mgr = ConfigManager()
    parser = argparse.ArgumentParser(prog="scout key", description="多 Provider API Key 管理(加密存储)")
    parser.add_argument("--add", nargs=2, metavar=("PROVIDER", "API_KEY"), help="保存某 provider 的 API Key(默认同时切换激活)")
    parser.add_argument("--no-activate", action="store_true", help="仅保存,不切换激活")
    parser.add_argument("--activate", type=str, metavar="PROVIDER", help="切换当前激活 provider(key 取自已保存的)")
    parser.add_argument("--list", action="store_true", help="列出已保存 key 的 provider(不泄露明文)")
    args = parser.parse_args(argv)

    if args.add:
        provider, api_key = args.add
        mgr.save_provider_key(provider, api_key, activate=not args.no_activate)
        if args.no_activate:
            print(f"[ok] 已加密保存 {provider} 的 API Key(未切换激活)")
        else:
            print(f"[ok] 已加密保存 {provider} 的 API Key,并切换为当前激活")
        return
    if args.activate:
        ok = mgr.activate_provider(args.activate)
        if ok:
            print(f"[ok] 已切换至 {args.activate}")
        else:
            print(f"[!] {args.activate} 未保存 API Key,请先: scout key --add {args.activate} <key>")
        return
    if args.list:
        keys = mgr.list_provider_keys()
        config = mgr.load()
        if not keys:
            print("(空) 尚未保存任何 provider 的 API Key")
            return
        for provider, has in sorted(keys.items()):
            mark = "▶ 激活" if provider == config.provider else "   "
            print(f"  {mark} {provider}: {'已保存' if has else '未保存'}")
        return
    parser.print_help()


def main() -> None:
    """CLI 入口."""
    load_env()

    # API Key 管理子命令: scout key ...
    if len(sys.argv) > 1 and sys.argv[1] == "key":
        key_main(sys.argv[2:])
        return

    # 环境诊断子命令: scout doctor
    if len(sys.argv) > 1 and sys.argv[1] == "doctor":
        from scout.doctor import run_doctor
        sys.exit(run_doctor())

    # 后台守护管理命令 (cow 风格): scout start/stop/restart/status/logs/update/version
    if len(sys.argv) > 1 and sys.argv[1] in (
        "start", "stop", "restart", "status", "logs", "update", "version",
    ):
        from scout.manager import (
            start, stop, restart, status, logs, update, version,
        )
        from scout.manager import get_project_root
        _cmds = {
            "start": start, "stop": stop, "restart": restart,
            "status": status, "logs": logs, "update": update, "version": version,
        }
        # 切换到项目根目录,确保路径一致
        os.chdir(get_project_root())
        cmd = _cmds[sys.argv[1]]
        cmd.main(args=sys.argv[2:], standalone_mode=False)
        return

    # ── 原有交互 / Web 逻辑 ──
    # 初始化日志系统(轮转 + 格式化)
    init_logging()
    
    # 初始化技能系统
    init_skills()
    
    # 初始化插件系统
    init_plugins()

    # 数据目录版本检查 / manifest(升级能力)
    ensure_manifest()

    parser = argparse.ArgumentParser(
        prog="scout",
        description="Scout Agent 🧭 — Always one step ahead.",
    )
    parser.add_argument("--model", "-m", type=str, default=None, help="LLM 模型名")
    parser.add_argument("--provider", "-p", type=str, default=None, help="LLM provider")
    parser.add_argument("--base-url", type=str, default=None, help="LLM API base URL")
    parser.add_argument("--api-key", type=str, default=None, help="LLM API key")
    parser.add_argument("--max-turns", type=int, default=30, help="最大迭代次数 (默认 30)")
    parser.add_argument("--web", action="store_true", help="启动 Web 界面")
    parser.add_argument("--port", type=int, default=8848, help="Web 服务端口 (默认 8848)")
    parser.add_argument("--host", type=str, default=None, help="Web 服务监听地址 (默认 127.0.0.1)")
    parser.add_argument("--version", "-v", action="version", version=f"Scout Agent {__import__('scout').__version__}")

    args = parser.parse_args()

    try:
        if args.web:
            asyncio.run(run_web(args))
        else:
            asyncio.run(run_console(args))
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":
    main()