"""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 [--no-activate] | --activate | --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} ") 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()