扩展 Gemini CLI:Hooks、Skills、MCP 与扩展系统
前置知识
- ›本系列第 1-4 篇文章
- ›了解发布/订阅模式与策略设计模式
- ›熟悉 MCP(模型上下文协议)的基本概念
扩展 Gemini CLI:Hooks、Skills、MCP 与扩展系统
Gemini CLI 从设计之初就考虑了高度可定制性。无论是拦截 Agent 执行轮次的 shell 命令钩子,还是为系统引入全新工具能力的 MCP 服务器,代码库提供了多个可扩展接口。本文将逐一梳理这些接口——由五个组件构成的 Hook 系统、四级优先级的 Skill 机制、支持 OAuth 的 MCP 集成、带 HMAC 完整性校验的扩展包系统、作为策略模式实现的模型路由,以及子 Agent 系统。
Hook 系统架构
packages/core/src/hooks/ 中的 Hook 系统由五个组件协同工作,负责在关键生命周期节点执行用户定义的 shell 命令。
flowchart TD
EVENT[Lifecycle Event] --> EH[HookEventHandler<br/>dispatches events]
EH --> HP[HookPlanner<br/>determines which hooks fire]
HP --> HR[HookRunner<br/>executes as shell commands]
HR --> HA[HookAggregator<br/>combines results]
HA --> RESULT[Aggregated Result]
REG[HookRegistry<br/>stores hook configs] --> HP
HookSystem 类负责将这些组件串联起来:
constructor(config: Config) {
this.hookRegistry = new HookRegistry(config);
this.hookRunner = new HookRunner(config);
this.hookAggregator = new HookAggregator();
this.hookPlanner = new HookPlanner(this.hookRegistry);
this.hookEventHandler = new HookEventHandler(
config, this.hookPlanner, this.hookRunner, this.hookAggregator,
);
}
HookRegistry 从多个来源(用户配置、工作区配置、扩展包)收集并存储 Hook 配置。每个 Hook 指定了监听的事件类型、可选的匹配规则,以及是否串行执行。
HookPlanner 负责判断哪些已注册的 Hook 应当响应当前事件,它会查询 Registry 并对匹配表达式进行求值。
HookRunner 将 Hook 作为 shell 命令执行——Hook 不是 JavaScript 函数,而是外部进程。这种设计实现了与语言无关的扩展能力,同时提供了一道安全边界。Hook 脚本通过环境变量和标准输入接收上下文信息。
HookAggregator 汇总同一事件触发的多个 Hook 的执行结果。结果中可包含系统消息、待注入的额外上下文,以及流程控制决策(停止、拦截、继续)。
HookEventHandler 是整体的调度器,提供了 fireSessionStartEvent、fireBeforeAgentEvent、fireAfterAgentEvent 等具有明确类型的方法。
八个生命周期事件如下:
| 事件 | 触发时机 |
|---|---|
SessionStart |
会话开始或恢复时 |
SessionEnd |
会话结束时 |
BeforeAgent |
针对某个提示词发起第一次模型调用之前 |
AfterAgent |
模型响应完成且无待处理工具时 |
BeforeModel |
每次 API 调用之前 |
AfterModel |
每次 API 响应之后 |
BeforeToolSelection |
工具列表发送给模型之前 |
PreCompress |
对话历史压缩之前 |
正如第 2 篇文章所介绍的,BeforeAgent 和 AfterAgent 可以停止执行、附带原因地拦截流程,或注入上下文。BeforeModel 可以修改请求配置或返回合成响应。BeforeToolSelection 可以控制模型实际看到哪些工具。
提示: Hook 以 shell 命令方式执行,因此可以用任意语言编写——Python 脚本、Go 二进制文件,甚至单行 bash 命令都可以。Hook 通过标准输入接收 JSON 格式的上下文,并将 JSON 结果输出到标准输出。
Skills:发现机制与优先级
Skills 是用户可在会话中激活的专用提示词/工具配置。SkillManager 会从四个位置按优先级由低到高加载 Skill:
flowchart BT
B[1. Built-in skills<br/>Lowest precedence] --> E[2. Extension skills]
E --> U[3. User skills<br/>~/.gemini/skills/]
U --> W[4. Workspace skills<br/>.gemini/skills/<br/>Highest precedence]
第 47 行的 discoverSkills() 方法按以下顺序执行:
- 内置 Skills:来自
skills/builtin/目录(标记有isBuiltin = true) - 扩展 Skills:来自已激活的扩展包(
extension.skills) - 用户 Skills:来自
~/.gemini/skills/和~/.gemini/.agents/skills/ - 工作区 Skills:来自
.gemini/skills/和.gemini/.agents/skills/(仅在目录受信任时加载)
当多个 Skill 同名时,addSkillsWithPrecedence() 会让高优先级来源的版本覆盖低优先级来源的版本。这意味着工作区 Skill 可以覆盖内置 Skill,从而实现项目级定制。
值得注意的安全考量是:工作区 Skills 只有在目录受信任的情况下才会加载,不受信任的项目无法注入可能影响 Agent 行为的 Skill。
MCP 服务器集成
MCP(模型上下文协议)集成让 Gemini CLI 能够通过 stdio 或 HTTP 连接外部工具服务器,涵盖 OAuth 认证、服务器发现和动态工具注册等功能。
MCPOAuthProvider 负责处理需要认证的 MCP 服务器的 OAuth/PKCE 流程,管理授权服务器元数据发现、令牌获取、刷新与存储。
sequenceDiagram
participant Config as Config.initialize()
participant MCM as McpClientManager
participant OAuth as MCPOAuthProvider
participant MCP as MCP Server
participant TR as ToolRegistry
participant PE as PolicyEngine
Config->>MCM: Connect to configured servers
MCM->>OAuth: Authenticate (if needed)
OAuth->>MCP: PKCE auth flow
MCP-->>OAuth: Access token
MCM->>MCP: listTools()
MCP-->>MCM: Tool schemas
MCM->>TR: registerTool(DiscoveredMCPTool)
Note over TR: mcp_serverName_toolName
Note over PE: Policy rules with<br/>mcp_serverName_* wildcards<br/>control access
MCP 工具由 DiscoveredMCPTool 包装,该类继承自 BaseDeclarativeTool,并添加了用于策略匹配的 MCP 元数据(服务器名称、工具注解)。mcp_ 前缀约定(由 MCP_TOOL_PREFIX 定义)确保了与内置工具的命名不会冲突,同时支持 mcp_myserver_* 这样的通配符策略规则。
策略引擎的通配符匹配(详见第 4 篇文章)支持三种 MCP 匹配模式:
mcp_*— 匹配来自任意服务器的所有 MCP 工具mcp_serverName_*— 匹配特定服务器的所有工具mcp_serverName_toolName— 匹配某个特定工具
扩展系统与完整性校验
扩展包将多种扩展能力打包成可安装的模块,一个扩展包可以提供 Hooks、Skills、MCP 服务器配置、斜杠命令、主题以及策略规则。
系统的一项关键安全特性是 HMAC 完整性校验机制。IntegrityKeyManager 管理一个 256 位的密钥,用于对扩展元数据进行签名:
class IntegrityKeyManager {
private readonly fallbackKeyPath: string;
private readonly keychainService: KeychainService;
private cachedSecretKey: string | null = null;
async getSecretKey(): Promise<string> {
if (this.cachedSecretKey) return this.cachedSecretKey;
if (await this.keychainService.isAvailable()) {
try {
this.cachedSecretKey = await this.getSecretKeyFromKeychain();
return this.cachedSecretKey;
} catch (e) {
// Fall back to file-based storage
}
}
this.cachedSecretKey = await this.getSecretKeyFromFile();
return this.cachedSecretKey;
}
}
密钥优先存储在操作系统密钥链(通过 KeychainService)中,若不可用则回退到权限为 0o600 的文件存储。安装扩展时,系统会用该密钥对元数据进行签名;加载时则验证签名,以检测是否遭到篡改。
graph TD
subgraph "Extension Package"
H[Hooks]
S[Skills]
MCP[MCP Servers]
CMD[Commands]
TH[Themes]
POL[Policies]
end
INST[Extension Install] --> SIGN[HMAC Sign with secret key]
SIGN --> STORE[Store signed metadata]
LOAD[Extension Load] --> VERIFY[Verify HMAC signature]
VERIFY --> ACTIVE[Activate extension]
VERIFY --> REJECT[Reject tampered extension]
subgraph "Key Storage"
KC[OS Keychain<br/>preferred]
FILE[File ~/.gemini/<br/>fallback, 0600]
end
提示: 如果在系统迁移后扩展校验莫名失败,请检查密钥链是否已随之迁移。
~/.gemini/中的备用密钥文件可能与原系统签名扩展时所用的密钥不一致。
作为扩展点的模型路由
模型路由——即决定每轮对话由哪个模型处理——采用了组合策略模式(Composite Strategy Pattern)。ModelRouterService 按优先级顺序串联七个策略:
flowchart LR
REQ[Routing Request] --> F[FallbackStrategy]
F --> O[OverrideStrategy]
O --> A[ApprovalModeStrategy]
A --> GC[GemmaClassifierStrategy]
GC --> C[ClassifierStrategy]
C --> N[NumericalClassifierStrategy]
N --> D[DefaultStrategy<br/>terminal]
每个策略接收一个 RoutingContext(对话历史、当前请求、请求的模型),要么返回 RoutingDecision,要么将决策传递给下一个策略:
- FallbackStrategy — 主模型不可用时激活
- OverrideStrategy — 处理显式的模型切换请求(如
/model flash) - ApprovalModeStrategy — 为 PLAN 模式选择合适的模型
- GemmaClassifierStrategy — 使用本地 Gemma 模型进行路由分类
- ClassifierStrategy — 通用的基于 LLM 的分类策略
- NumericalClassifierStrategy — 使用数值评分启发式方法
- DefaultStrategy — 终止策略,始终返回已配置的模型
CompositeStrategy 包装所有策略,并保证终止策略一定能产生结果。这是经典的责任链模式(Chain of Responsibility)——每个策略要么处理请求,要么将其传递下去。
子 Agent 与派生 MessageBus
子 Agent 系统让 Gemini CLI 能够为特定任务(如浏览器自动化)派生子 Agent。子 Agent 需要拥有独立的工具注册表和消息总线,以避免与父 Agent 相互干扰。
正如第 1 篇文章所介绍的,第 46–72 行的 MessageBus.derive() 会创建一个作用域隔离的子总线:
derive(subagentName: string): MessageBus {
const bus = new MessageBus(this.policyEngine, this.debug);
bus.publish = async (message: Message) => {
if (message.type === MessageBusType.TOOL_CONFIRMATION_REQUEST) {
return this.publish({
...message,
subagent: message.subagent
? `${subagentName}/${message.subagent}`
: subagentName,
});
}
return this.publish(message);
};
// Delegate subscriptions to parent
bus.subscribe = this.subscribe.bind(this);
// ...
}
派生总线重写了 publish 方法,为确认请求添加子 Agent 名称前缀。其余操作(subscribe、unsubscribe、on、off)则全部委托给父总线。这样一来,子 Agent 的工具确认请求会在父级 UI 中以清晰的归属信息展示(如 "browser/navigate"),而常规事件处理则照常流转。
子 Agent 系统还与 AgentLoopContext 紧密协作。每个子 Agent 会获得一个派生的上下文,拥有独立的工具注册表和消息总线,同时共享相同的 Config 和沙箱管理器。这正是第 1 篇文章中介绍的 AgentLoopContext 接口设计的价值所在——接受该接口的组件对父 Agent 上下文和子 Agent 上下文的处理方式完全一致。
在本系列的最后一篇文章中,我们将深入探讨构建在上述所有系统之上的两个主要交互层——基于 React/Ink 的终端 UI 与可编程 SDK。