Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

CLI/TUI:终端优先的交互层

本章核心源码cli.py(8736 行)、hermes_cli/main.py(5580 行)、hermes_cli/commands.py(1025 行)

定位:本章拆解 Hermes 的终端交互层——从 hermes 命令的入口分发到 HermesCLI 的 prompt_toolkit TUI,理解 CLI-First 赌注如何在 8736 行代码中落地。 前置依赖:第 3 章(请求旅程)、第 4 章(AIAgent 内核)。适用场景:想理解 CLI 的交互机制,或准备添加新的 slash command。

为什么 CLI 需要 8736 行

一般认为 CLI 是"轻量"的入口——解析参数、调用 API、打印结果。但 Hermes 的 CLI 不是一个简单的命令行包装器,它是一个完整的 TUI 应用

  1. 多行编辑:用户可以用 Alt+Enter 输入多行消息,而非被限制在一行
  2. 流式渲染:模型响应逐 token 渲染到终端,支持 Markdown 格式化
  3. 中断与重定向:用户在 agent 运行时输入新消息,agent 被中断并处理新消息
  4. 状态栏:底部显示模型名、token 用量、上下文占用等实时信息
  5. 多模态交互:剪贴板图片粘贴、语音输入/TTS 输出、sudo 密码提示
  6. Slash 命令系统:40+ 个命令,支持自动补全、别名、分类帮助

这些交互需求让 cli.py 膨胀到 8736 行——但这不是随意膨胀,而是 CLI-First 赌注的工程代价。

入口层架构

graph TD
    A["hermes 命令"] --> B["hermes_cli/main.py<br/>argparse 分发"]
    B --> C1["hermes chat<br/>默认"]
    B --> C2["hermes setup"]
    B --> C3["hermes gateway"]
    B --> C4["hermes cron"]
    B --> C5["hermes doctor"]
    B --> C6["hermes model"]
    B --> C7["hermes tools"]
    B --> C8["hermes config"]
    B --> C9["hermes sessions"]
    B --> C10["hermes acp"]
    
    C1 --> D["cli.py:main 8525"]
    D --> E["HermesCLI.__init__"]
    E --> F1["单次查询模式<br/>cli.py:8696"]
    E --> F2["交互模式<br/>cli.py:7064 run"]
    
    F2 --> G["prompt_toolkit<br/>Application"]

两层入口

Hermes 的 CLI 有两层入口。第一层是 hermes_cli/main.py,它使用 argparse 将 hermes 命令分发到不同的子命令:

# hermes_cli/main.py:0-43(文档字符串展示了完整的子命令列表)
"""
Usage:
    hermes                     # Interactive chat (default)
    hermes chat                # Interactive chat
    hermes gateway             # Run gateway in foreground
    hermes gateway start       # Start gateway as service
    hermes setup               # Interactive setup wizard
    hermes cron                # Manage cron jobs
    hermes doctor              # Check configuration and dependencies
    hermes acp                 # Run as an ACP server for editor integration
    hermes sessions browse     # Interactive session picker with search
"""

注意第一行——hermes 不带子命令时直接进入交互聊天。这不是偶然,而是 CLI-First 的设计决策:最常用的操作不需要记忆任何子命令

第二层是 cli.py:main()cli.py:8525),它是交互聊天的真正入口。通过 python-fire 将函数参数暴露为命令行选项:

# cli.py:8525-8546
def main(
    query: str = None,
    q: str = None,
    toolsets: str = None,
    skills: str | list[str] | tuple[str, ...] = None,
    model: str = None,
    provider: str = None,
    api_key: str = None,
    base_url: str = None,
    max_turns: int = None,
    verbose: bool = False,
    quiet: bool = False,
    compact: bool = False,
    list_tools: bool = False,
    gateway: bool = False,
    resume: str = None,
    worktree: bool = False,
    w: bool = False,
    checkpoints: bool = False,
    pass_session_id: bool = False,
):

Profile 系统

在任何模块导入之前,main.py 执行了一个关键的预处理——profile override(hermes_cli/main.py:82-136):

# hermes_cli/main.py:82-83
def _apply_profile_override() -> None:
    """Pre-parse --profile/-p and set HERMES_HOME before module imports."""

Profile 允许用户维护多个独立的 Hermes 配置(不同的 API key、不同的记忆、不同的 skills)。这个预处理必须在所有模块导入之前执行,因为很多模块在 import 时就读取 HERMES_HOME 并缓存为模块级常量。如果 profile 切换发生在导入之后,HERMES_HOME 的变化就无法传播到已经缓存了旧值的模块。

TTY 保护

交互式子命令(hermes toolshermes setuphermes model)需要终端输入。当它们被管道调用时(如 echo "" | hermes tools),curses 和 input() 会空转消耗 100% CPU。_require_tty() 守护(hermes_cli/main.py:52-66)在这些命令执行前检查 stdin 是否是终端,阻止非交互式调用:

# hermes_cli/main.py:52-66
def _require_tty(command_name: str) -> None:
    """Exit with a clear error if stdin is not a terminal."""
    if not sys.stdin.isatty():
        print(
            f"Error: 'hermes {command_name}' requires an interactive terminal.\n"
            f"It cannot be run through a pipe or non-interactive subprocess.",
            file=sys.stderr,
        )
        sys.exit(1)

HermesCLI 类:TUI 的核心

HermesCLIcli.py:1307)是整个 CLI 的核心。它的职责不仅是调用 AIAgent,还包括构建一个完整的 prompt_toolkit 应用。

初始化链

HermesCLI.__init__()cli.py:1315-1456)解析来自三个来源的配置(优先级从高到低):

来源示例覆盖关系
CLI 参数--model claude-opus-4-20250514最高优先
config.yamlmodel.default: gpt-5.3-codex中间
环境变量HERMES_INFERENCE_PROVIDER=openrouter最低(仅部分场景生效)

一个重要的设计决策:LLM_MODELOPENAI_MODEL 环境变量不被检查cli.py:1379-1383)。在多 agent 场景中,这些环境变量可能被其他工具设置,会导致意外的模型切换。config.yaml 是唯一权威来源。

# cli.py:1379-1386
# Model comes from: CLI arg or config.yaml (single source of truth).
# LLM_MODEL/OPENAI_MODEL env vars are NOT checked — config.yaml is
# authoritative.  This avoids conflicts in multi-agent setups where
# env vars would stomp each other.
_model_config = CLI_CONFIG.get("model", {})
_config_model = (_model_config.get("default") or _model_config.get("model") or "") \
    if isinstance(_model_config, dict) else (_model_config or "")
self.model = model or _config_model or _DEFAULT_CONFIG_MODEL

prompt_toolkit TUI 架构

run() 方法(cli.py:7064)构建了 prompt_toolkit 的 Application,这是一个成熟的终端 UI 框架:

# cli.py:7064-7110(关键状态初始化)
def run(self):
    """Run the interactive CLI loop with persistent input at bottom."""
    self._agent_running = False
    self._pending_input = queue.Queue()     # 正常输入(命令 + 新查询)
    self._interrupt_queue = queue.Queue()   # agent 运行时的中断消息
    self._should_exit = False

两个 Queue 的设计是 TUI 交互的核心:

stateDiagram-v2
    [*] --> Idle
    
    Idle --> Processing: 用户输入进入 pending_input
    Processing --> Idle: agent 返回结果
    
    Processing --> Interrupted: 用户新消息进入 interrupt_queue
    Interrupted --> Processing: agent 中断后处理新消息
    
    Processing --> Clarify: agent 调用 clarify 工具
    Clarify --> Processing: 用户选择或输入回答
    
    Processing --> Approval: 危险命令需要审批
    Approval --> Processing: 用户批准或拒绝
    
    Idle --> [*]: /quit

run() 还初始化了大量的 UI 状态变量(cli.py:7123-7159):

  • _clarify_state / _clarify_freetext:clarify 工具的问答状态
  • _sudo_state:sudo 密码提示状态
  • _approval_state:危险命令审批状态
  • _secret_state:密钥输入状态
  • _attached_images:剪贴板图片附件
  • _voice_mode / _voice_recording:语音模式状态

每种状态都有对应的 deadline(超时时间),防止用户忘记响应时无限阻塞 agent。

按键绑定与输入路由

run() 方法注册了关键的按键绑定(cli.py:7183-7313)。Enter 键的路由逻辑是 TUI 中最复杂的部分——它根据当前 UI 状态决定输入去向:

UI 状态Enter 行为目标
Sudo 密码提示提交密码_sudo_state["response_queue"]
Secret 输入提交密钥_secret_state["response_queue"]
危险命令审批确认选择_approval_state["response_queue"]
Clarify 自由文本提交答案_clarify_state["response_queue"]
Clarify 选择模式确认选项_clarify_state["response_queue"]
Agent 运行中 + 文本中断或排队_interrupt_queue_pending_input
Agent 空闲 + 文本提交查询_pending_input
# cli.py:7279-7287
@kb.add('escape', 'enter')
def handle_alt_enter(event):
    """Alt+Enter inserts a newline for multi-line input."""
    event.current_buffer.insert_text('\n')

@kb.add('c-j')
def handle_ctrl_enter(event):
    """Ctrl+Enter (c-j) inserts a newline."""
    event.current_buffer.insert_text('\n')

Alt+Enter 和 Ctrl+Enter 插入换行,让用户在不提交的情况下输入多行消息。这是 CLI 超越传统 readline REPL 的关键能力。

中断与重定向

当用户在 agent 运行时输入新消息,有两种模式(cli.py:1358-1360):

# cli.py:1358-1360
_bim = CLI_CONFIG["display"].get("busy_input_mode", "interrupt")
self.busy_input_mode = "queue" if str(_bim).strip().lower() == "queue" else "interrupt"
  • interrupt 模式(默认):新消息进入 _interrupt_queue,agent 被中断,立即处理新消息
  • queue 模式:新消息进入 _pending_input,等待当前 turn 完成后处理

中断模式是 CLI-First 的核心交互创新——用户不需要等 agent 完成当前任务才能发送新指令。chat() 方法(cli.py:6424)在每个 API 调用和工具执行之间轮询 _interrupt_queue,一旦发现新消息就设置 agent._interrupt_requested = True

Slash 命令系统

命令注册表

所有 slash 命令定义在 hermes_cli/commands.pyCOMMAND_REGISTRYcommands.py:45-144)中:

# hermes_cli/commands.py:26-38
@dataclass(frozen=True)
class CommandDef:
    """Definition of a single slash command."""
    name: str                          # 规范名:"background"
    description: str                   # 人类可读描述
    category: str                      # "Session", "Configuration" 等
    aliases: tuple[str, ...] = ()      # 别名:("bg",)
    args_hint: str = ""                # 参数占位符:"<prompt>"
    subcommands: tuple[str, ...] = ()  # tab 补全的子命令
    cli_only: bool = False             # 仅 CLI 可用
    gateway_only: bool = False         # 仅 Gateway 可用
    gateway_config_gate: str | None = None  # config 门控

注册表是单一事实来源commands.py:0-8 的文档明确声明)。CLI 帮助、Gateway 分发、Telegram BotCommands、Slack 子命令映射、自动补全——所有消费方都从 COMMAND_REGISTRY 派生数据。

命令按功能分类:

分类命令数示例
Session16/new, /retry, /undo, /branch, /compress, /background
Configuration9/model, /prompt, /yolo, /reasoning, /voice
Tools & Skills7/tools, /skills, /cron, /browser, /plugins
Info7/help, /usage, /insights, /platforms, /paste
Exit1/quit(别名 /exit, /q

插件命令注册

第三方插件可以通过 register_plugin_command() 动态注册新命令(commands.py:172-175):

# hermes_cli/commands.py:172-175
def register_plugin_command(cmd: CommandDef) -> None:
    """Append a plugin-defined command to the registry and refresh lookups."""
    COMMAND_REGISTRY.append(cmd)
    rebuild_lookups()

rebuild_lookups()commands.py:178-199)重建所有派生的查找字典——这保证了插件命令在注册后立即出现在帮助、自动补全和 Gateway 分发中。

CLI/Gateway 命令分离

CommandDefcli_onlygateway_only 标志控制命令在不同入口的可见性:

  • /clearcli_only=True——清屏在消息平台上没有意义
  • /approve/denygateway_only=True——CLI 用内建 UI 处理审批
  • /model/new:两端都可用

gateway_config_gate 字段提供了更细粒度的控制(commands.py:97):/verbose 命令默认是 cli_only,但如果 config.yaml 中设置了 display.tool_progress_command: true,Gateway 端也会启用它。

回调适配

CLI 通过 AIAgent 的 11 个回调接口适配终端 IO(详见第 4 章)。核心的适配模式是将 agent 的同步回调桥接到 prompt_toolkit 的异步 UI

当 agent 调用 clarify 工具时,clarify_callback 将问题和选项注入 _clarify_state,prompt_toolkit 的渲染循环检测到状态变化后切换 UI 为选择模式。用户用方向键选择后,答案通过 response_queue(一个 queue.Queue)返回给 agent 线程。这个跨线程通信机制同样用于 sudo 密码输入(_sudo_state)、密钥捕获(_secret_state)和危险命令审批(_approval_state)。

main() 的分支逻辑

main()cli.py:8525)根据参数走不同路径:

路径触发条件行为
Gateway--gateway启动消息平台网关(cli.py:8584
List tools--list-tools打印工具列表并退出
单次查询(安静)-q "..." --quiet执行查询,仅打印结果和 session_id
单次查询(正常)-q "..."显示 banner,执行查询
交互模式-q启动 TUI REPL(cli.py:8732

Worktree 隔离(cli.py:8593-8614)在交互模式和单次查询模式下都可用。当 --worktree-w 被指定时,_setup_worktree() 创建一个独立的 git worktree,让当前 agent 实例在隔离的分支上工作:

# cli.py:8597-8608
use_worktree = worktree or w or CLI_CONFIG.get("worktree", False)
if use_worktree:
    _repo = _git_repo_root()
    if _repo:
        _prune_stale_worktrees(_repo)
    wt_info = _setup_worktree()
    if wt_info:
        _active_worktree = wt_info
        os.environ["TERMINAL_CWD"] = wt_info["path"]
        atexit.register(_cleanup_worktree, wt_info)

Worktree 信息还会注入 agent 的 system prompt(cli.py:8669-8678),让 agent 知道自己在隔离分支上工作,应该 commit 和创建 PR。

设计启示

拆解 CLI/TUI 的 8736 行代码,可以提炼出三个设计原则:

  1. 交互复杂度在入口层消化:中断/重定向、多模态输入、密码提示等交互逻辑全部在 CLI 层处理,编排层(AIAgent)通过回调看到的是简单的同步接口。这让同一个 AIAgent 可以零修改适配 Gateway 等完全不同的交互模型

  2. 单一命令注册表COMMAND_REGISTRY 是 slash 命令的唯一事实来源,所有消费方(CLI 帮助、Gateway、Telegram、Slack、自动补全)都从中派生。新增命令只需添加一个 CommandDef,五个消费方自动更新

  3. 渐进式复杂度hermes(无参数)直接进入聊天,hermes -q 单次查询,hermes --toolsets 定制工具集,hermes -w 隔离工作区。从简单到复杂,用户按需解锁功能


设计赌注回扣:本章是 CLI-First 赌注的核心体现。8736 行的 TUI 代码证明 Hermes 不把终端当作 Web UI 的"降级版"——多行编辑、流式渲染、中断重定向、状态栏、语音输入等功能让终端体验与图形界面对等。同时,回调体系和命令注册表也回扣了 Run Anywhere 赌注:同一套命令系统同时服务 CLI 和 Gateway。


版本演化说明

本章核心分析基于 Hermes Agent v0.8.0(2026 年 4 月)。 HermesCLI 的 prompt_toolkit TUI 和统一命令注册表在 v0.3.0 发布窗口前后就已经存在。Worktree 隔离同样属于 v0.3.0 窗口内较早落地的能力,而 busy_input_mode 则可以明确放到 v0.5.0 发布窗口。