Agent CLI深水区 - 命令技能插件系统
Agent CLI 深水区 09:命令、技能与插件系统
三者区别
Agent CLI 里经常有 command、skill、plugin 三个词,容易混。先看一张对照表,「谁执行」这列里,「程序」指 CLI 代码直接跑完、全程不经过模型;「模型 + 工具」指要靠一次 LLM 推理来决定怎么做。
| 名称 | 本质 | 谁执行 |
|---|---|---|
| Command | 确定性 CLI 命令 | 程序 |
| Skill | 可复用任务说明书 | 模型 + 工具 |
| Plugin | 扩展工具/命令/资源 | 程序加载 |
拿几个具体输入对号入座:
| 输入 | 类型 |
|---|---|
/help | Command |
/mcp list | Command |
| “review 这次改动”触发代码审查流程 | Skill |
| 新增一个 Jira 工具包 | Plugin |
补两个背景知识。斜杠开头的输入叫 slash command,跟游戏聊天框里的 /指令 是一个用法,输入它就是在操作程序本身。/mcp 里的 MCP 指 Model Context Protocol,一个让 Agent 接入外部工具服务的标准协议,可以理解成给所有工具服务统一定了一套接口规范,后面会反复出现。
Command 系统
Command 不应该交给模型。原因很直接:用户敲 /help 想要的是一份准确、稳定的命令列表,程序查一下注册表就能给出;交给模型生成,每次输出都可能不一样,还白花 token(token 是模型计费和上下文长度的基本单位,大致可以理解成一个词块)。
代码块收起展开
// Command = 一个 slash command 的定义。
// Command 是程序确定性执行的控制面,不应该让模型自由发挥。
type Command = {
name: string; // 命令名,例如 /help、/cost、/mcp。
description: string; // 给 /help 展示的人类说明。
usage: string; // 用法示例,例如 "/mcp list"。
run(args: string[], ctx: CommandContext): Promise<void>; // 命令真正执行的函数。
};
// CommandContext = 命令执行时能看到的运行时信息。
// 不要把整个 AppRuntime 暴露给命令,避免命令乱改内部状态。
type CommandContext = {
cwd: string; // 当前工作目录。
sessionId: string; // 当前会话 ID,用于查询成本、历史等。
config: AppConfig; // 已加载配置。
};注册:
代码块收起展开
// commands = 命令注册表。
// key 是命令名,value 是命令处理器。
const commands = new Map<string, Command>();
// registerCommand = 注册一个 slash command。
// 真实系统里这里还要检查重名,尤其不能让插件覆盖内置命令。
export function registerCommand(command: Command) {
commands.set(command.name, command);
}
// runCommand = 执行用户输入的 slash command。
export async function runCommand(line: string, ctx: CommandContext) {
// 简化解析:按空白切分。真实系统要支持 flags、引号、子命令。
const [name, ...args] = line.trim().split(/\s+/);
// 根据命令名查注册表。
const command = commands.get(name);
if (!command) {
// 命令不存在时直接给确定性错误,不要交给模型编答案。
console.log(`Unknown command: ${name}`);
return;
}
// 执行命令。这里不经过模型。
await command.run(args, ctx);
}Command 例子:/cost
代码块收起展开
// 注册一个 /cost 命令。
// 它只是读取当前 session 的 token/cost 统计,不需要模型参与。
registerCommand({
name: "/cost",
description: "Show token usage and estimated cost.",
usage: "/cost",
async run(_args, ctx) {
// 根据 sessionId 找到这次会话的成本统计。
const report = await loadCostReport(ctx.sessionId);
// 格式化后直接打印到终端。
console.log(formatCostReport(report));
}
});/cost 是确定性查询,不需要模型。
Skill 系统
Skill 是一份可复用任务规程,告诉 Agent 遇到某类任务时怎么做。它和 Tool 的分工不同:Tool 给模型提供一个个可执行的动作(读文件、跑命令),Skill 给模型提供完成一类任务的方法和步骤。
类比一下,Tool 像 Service 层暴露的方法,Skill 像一份操作手册,写清什么场景下按什么顺序调这些方法。
代码块收起展开
下面两个类型体现了 Skill 的两阶段设计:discovery(发现)阶段只扫描出有哪些 skill,用轻量的 manifest(清单,只含名字、描述这类元数据);等某个 skill 真被选中了,才加载完整内容。
provenance 字段记录 skill 从哪来,代码里一共列了六种:builtin 是 CLI 自带的;
user 来自用户个人目录(比如 `~/.claude/skills`);
project 来自当前项目目录;
plugin 由插件带进来;
managed 指托管配置,也就是公司或团队管理员统一下发、个人改不了的那种,类似公司统一推的 IDE 配置;mcp 指 skill 由某个 MCP server 提供,MCP 协议除了暴露工具,还能暴露 prompt 和资源,skill 就能顺着这条路从外部服务进来。
后面讲安全时会用到这个字段。
代码块收起展开
// SkillManifest = discovery 阶段使用的轻量元数据。
type SkillManifest = {
name: string;
description: string;
sourcePath: string;
provenance: "builtin" | "user" | "project" | "plugin" | "managed" | "mcp";
enabled: boolean;
};
// LoadedSkill = 选中后才读取的完整内容。
type LoadedSkill = SkillManifest & {
instructions: string;
references: string[];
scripts: string[];
};示例 skill 文件:
代码块收起展开
---
name: code-review
description: Review a code diff for concrete bugs, regressions, and missing tests. Use when the user asks for code review; do not use for general explanation or implementation.
---
When reviewing code:
1. Inspect git diff first.
2. Focus on bugs, regressions, missing tests.
3. Report findings with file and line references.
4. Do not summarize before findings.文件开头两个 --- 之间的部分叫 frontmatter,是 Markdown 头部的一小段 YAML 元数据,discovery 阶段只读它;正文才是给模型看的操作步骤。
Skill 加载
Codex(OpenAI 的 Agent CLI)、Claude Code(Anthropic 的 Agent CLI)的官方公开机制,和 learn-claude-code(一个拆解这类 CLI 怎么实现的教学开源项目)都指向同一个原则:先暴露元数据,选中后再加载正文,需要时再读 references 或运行 scripts。
为什么非要分层?算笔账:假如装了 50 个 skill,每个正文 2000 token,全塞给模型就是 10 万 token,上下文窗口(模型一次请求能装下的内容上限)基本被吃光。
分层之后,模型平时只看 50 行「名字 + 一句描述」的候选列表,选中哪个才加载哪个的正文。
思路和列表页只查摘要、点进详情页才查大字段是一样的。
Scan → Manifest List → Explicit/Semantic Match → Load SKILL.md → Read Extra Files
Codex 的公开目录约定包括用户级 $HOME/.agents/skills 与项目级 .agents/skills;Claude Code 公开文档使用个人 ~/.claude/skills、项目 .claude/skills 与插件 Skills。
具体目录和覆盖规则属于产品契约(产品公开承诺、用户可以依赖的行为),所以 Skill Loader 只能扫这些约定目录,不能把项目里任意 Markdown 都当成技能,否则随便一个 README 都可能被当成指令混进模型。
代码块收起展开
// discoverSkills = 扫描约定目录,只返回 discovery 元数据。
export async function discoverSkills(roots: string[]): Promise<SkillManifest[]> {
const files = (await Promise.all(
roots.map(root => glob("*/SKILL.md", { cwd: root, absolute: true }))
)).flat();
return Promise.all(files.map(async file => {
const text = await readFile(file, "utf8");
const { frontmatter } = parseMarkdownWithFrontmatter(text);
return {
name: frontmatter.name,
description: frontmatter.description,
sourcePath: file,
provenance: inferProvenance(file),
enabled: true
};
}));
}
// loadSkill = 只有 Skill 真正被选中后才加载完整正文和资源清单。
export async function loadSkill(manifest: SkillManifest): Promise<LoadedSkill> {
const text = await readFile(manifest.sourcePath, "utf8");
const { body } = parseMarkdownWithFrontmatter(text);
const baseDir = dirname(manifest.sourcePath);
return {
...manifest,
instructions: body,
references: await glob("references/**/*", { cwd: baseDir }),
scripts: await glob("scripts/**/*", { cwd: baseDir })
};
}注意一个细节:discoverSkills 虽然把整个文件读进了内存,但只解析并返回 frontmatter,正文没有进入模型的上下文。
真正消耗上下文的只有两样:注入给模型的候选列表,和选中后加载的完整正文。SkillManifest 和 LoadedSkill 拆成两个类型,就是在代码层面防止你顺手把正文提前塞进去。
Skill 匹配
公开产品契约可以确认两条触发路径:
- 显式调用:用户直接点名 Skill。Codex 支持
$skill-name//skills,Claude Code 支持/skill-name。 - 隐式调用:Runtime 先把候选
name + description提供给模型,任务与 description 匹配时由模型选择。
产品内部是否还有额外排序器、阈值或分类器,官方没有统一公开承诺,因此不能把“关键词 includes”(直接判断用户输入里包不包含某个词)或“embedding topK”(把文本转成向量、按相似度取最接近的前 K 个候选)写成 Claude Code/Codex 的固定内部算法。
自己的最小实现可以选择规则或模型匹配,但必须区分“教学实现”和“产品事实”。
description 应同时写清:
- 解决什么任务。
- 哪些用户表达意味着应该触发。
- 哪些相邻任务不应该触发。
- Skill 需要哪些输入或前置条件。
反例:description: Helps with code。它几乎会与所有代码任务冲突。
更好的写法:Review an existing code diff for bugs and regressions. Use for review requests; do not use when the user asks to implement the change.
Skill 注入上下文
learn-claude-code 的最小实现采用两层注入:系统提示(system prompt,每轮请求固定放在最前面、告诉模型该怎么工作的那段文本)里只列 Skill 名称和描述,模型调用 load_skill 后通过 tool_result(工具执行结果返回给模型的那条消息)拿到正文。
真实产品可以使用自己的上下文类型,但必须保留相同的不变量:未选中的正文不进入活跃上下文。
代码块收起展开
const discovery = manifests.map(s => `${s.name}: ${s.description}`).join("\n");
const system = `Available skills:\n${discovery}`;
handlers.load_skill = async ({ name }) => {
const manifest = manifests.find(s => s.name === name);
if (!manifest) return { error: "unknown_skill", name };
return await loadSkill(manifest);
};references、assets 和 scripts 继续按需加载。正文被选中不代表所有附件都要一次性读完。
注意:
- Skill 是规则输入,别把它当工具用。
- Skill 不应该直接执行危险动作。
- Skill 过多也会撑爆上下文。
- Skill 必须记录来源,远程或插件来源不能继承本地信任。
- Skill 冲突和未触发都要能在 trace(运行时留下的执行轨迹日志)中解释:为什么选了它、为什么没选它,事后要能查。
触发稳定性实验
这是个动手作业,目的是验证 description 写得好不好、隐式触发稳不稳。为 review-code 与 review-java-api 写边界有重叠的 description,至少测试五类 case:明确 Java API 审查、普通前端审查、显式点名、模糊请求、实现请求。
每次记录候选列表、最终选择、是否加载正文、任务是否完成。只测“能不能手动调用”证明不了隐式触发质量,因为手动调用根本不走匹配逻辑。
Plugin 系统
Plugin 是程序扩展机制,可以提供:
- 新工具。
- 新命令。
- 新 MCP server 配置。
- 新 skill。
代码块收起展开
// Plugin = 一个插件包暴露出来的对象。
// 插件是程序加载的代码,能注册工具、命令、skill,因此风险比 skill 高。
type Plugin = {
name: string; // 插件名,例如 github-tools。
version: string; // 插件版本,用于兼容和排错。
register(api: PluginApi): Promise<void>; // 插件入口:向 CLI 注册能力。
};
// PluginApi = CLI 暴露给插件的最小注册接口。
// 这里只允许“注册能力”,不直接给插件内部全局对象。
type PluginApi = {
registerTool(tool: Tool<any, any>): void; // 注册新工具,但执行时仍要过权限系统。
registerCommand(command: Command): void; // 注册新 slash command。
registerSkill(skill: Skill): void; // 注册新 skill。
};加载:
代码块收起展开
// loadPlugin = 加载一个插件入口文件。
// 注意:import 插件代码本身就有风险,所以真实系统要先检查 manifest 和权限。
export async function loadPlugin(path: string, api: PluginApi) {
// 动态加载插件模块。
const mod = await import(path);
// 约定插件 default export 是 Plugin 对象。
const plugin: Plugin = mod.default;
// 校验插件结构、版本、名称、能力声明。
validatePlugin(plugin);
// 让插件注册工具/命令/skill。
await plugin.register(api);
}插件安全
插件是代码,风险很高。
必须考虑:
| 风险 | 处理 |
|---|---|
| 插件注册危险工具 | 工具仍走权限系统 |
| 插件读取本地隐私 | 插件来源要可信 |
| 插件覆盖内置命令 | 禁止重名或要求显式 override |
| 插件执行初始化副作用 | 加载前提示用户 |
如果只是给个人学习用,可以先不做插件执行,只做“工具目录扫描”。
Command / Skill / Tool 的关系
顺着上图读:用户输入先判断是不是斜杠开头。是,就走 Command 分支,程序直接执行确定动作;不是,就进入模型回合,Skill 作为上下文影响模型怎么思考,模型再决定调用哪个 Tool 去实际干活。一句话:Command 是确定动作,Skill 影响模型行为,Tool 是模型可调用的能力。
设计原则
五条铁律,左边是原则,右边是一句话原因:
| 原则 | 说明 |
|---|---|
| Command 不调模型 | 能确定就代码执行 |
| Skill 不执行动作 | 只提供流程和规则 |
| Tool 执行动作 | 但必须过权限 |
| Plugin 扩展能力 | 但不能绕过权限 |
| 命名要稳定 | 模型依赖工具名 |
常见坑
这些坑基本都来自把三者边界搞混:
| 坑 | 后果 |
|---|---|
/help 交给模型 | 输出不稳定 |
| skill 太长 | 挤占上下文 |
| skill 互相冲突 | 模型行为混乱 |
| plugin 能覆盖内置工具 | 安全风险 |
| command 和 tool 混在一起 | 权限边界不清 |
读源码时看这些点
- Slash command 在哪里路由?
- Command 是否绕过模型?
- Skill 从哪里加载?
- Skill 如何触发?
- Skill 是否注入 system prompt?
- Plugin 是否能注册工具?
- Plugin 工具是否仍走权限?
- 是否有命名冲突处理?
命令、技能、插件这三套机制决定 Agent CLI 能不能从“一个工具”长成“一个平台”:没有它们,所有能力都得作者自己写死在代码里;有了它们,第三方就能往里加工具、命令和工作流。
三层边界
如果只记一个版本:
- Command = 程序自己的确定性操作
- Skill = 给模型看的任务流程说明
- Plugin = 给程序加载的扩展包
它们的边界非常重要。下表从三个问题切分:这一层由谁做决定、会不会真的改动外部世界(副作用)、要不要走权限检查。
| 层 | 是否让模型决定 | 是否执行副作用 | 是否需要权限 |
|---|---|---|---|
| Command | 否 | 可能 | 视操作而定 |
| Skill | 是,影响模型推理 | 不应直接执行 | 不直接需要,但会影响工具调用 |
| Tool | 是,模型可调用 | 是 | 必须 |
| Plugin | 否,程序加载 | 可能注册副作用能力 | 必须受能力限制 |
最危险的混乱是:让 Skill 直接像插件一样执行代码,或者让 Plugin 注册的工具绕过权限系统。
Command Router 应该在模型之前
Slash command 属于 CLI 的控制面:用户敲它是在操作程序本身,压根不该进入和模型的对话。Command Router 的位置就像 Servlet 过滤器链最前面的那个 Filter:请求先经过它,命中命令就直接处理并返回,没命中才放行给后面的模型。
为什么命令要在模型之前?
/help不需要模型,模型输出还可能不稳定。/cost、/status、/mcp list是本地事实查询。/clear、/compact是会话控制,不能让模型猜。/permissions是安全控制面,必须 deterministic(确定性:同样输入永远同样结果,不能掺模型的随机性)。
命令解析最好支持子命令:
代码块收起展开
// ParsedCommand = 更完整的命令解析结果。
// 适合 /mcp add github --transport stdio 这种带子命令和 flag 的命令。
type ParsedCommand = {
name: string; // 主命令,例如 /mcp。
subcommands: string[]; // 子命令链,例如 ["add", "github"]。
flags: Record<string, string | boolean>; // --transport stdio 解析成 { transport: "stdio" }。
args: string[]; // 普通位置参数。
};例如:
/mcp list/mcp add github --transport stdio/permissions allow shell --scope session/cost --since last-compact
Command 的错误体验
命令错误不要进入模型,也不要只输出 unknown。
代码块收起展开
// formatCommandError = 命令错误提示。
// 目标:用户输错时给可恢复建议,而不是让模型猜。
function formatCommandError(input: string, registry: CommandRegistry) {
// 根据编辑距离找最接近的命令,例如 /hlep -> /help。
const nearest = findNearestCommand(input, registry.names());
return [
`Unknown command: ${input}`,
nearest ? `Did you mean: ${nearest}?` : "",
`Run /help to list commands.`
]
// 过滤掉空字符串。
.filter(Boolean)
// 每条提示单独一行。
.join("\n");
}对 CLI 来说,命令输错属于产品体验问题,由程序按编辑距离(衡量两个字符串差几步增删改的指标,靠它能从 /hlep 猜到 /help)给出确定的纠错建议就够了,轮不到模型来推理。
Skill 的本质:可复用上下文策略
Skill 说白了就是一段在特定任务下临时注入的操作规程。别把它当成插件那样的代码扩展,也别写成一篇堆背景知识的 prompt 小作文。
一个好的 skill 至少包含下面这些字段。重点看 description,它要同时说清“什么时候用我”和“什么时候别用我”,隐式触发全靠它:
| 字段 | 作用 |
|---|---|
| name | 稳定标识 |
| description | 说明何时使用、何时不用,供 discovery 与隐式选择 |
| instructions | 具体流程 |
| constraints | 禁止事项 |
| verification | 完成前检查 |
| references/scripts | 按需读取的资料与自动化脚本 |
示例:
代码块收起展开
---
name: code-review
description: Review an existing code diff for bugs, regressions, and missing tests. Use for review requests; do not use when the user asks to implement the change.
---
When reviewing:
1. Inspect git diff first.
2. Prioritize correctness bugs over style.
3. Report findings first with file and line.
4. If no findings, say so and mention residual risk.好的 skill 写的是行为约束:先做什么、禁止什么、完成前检查什么。往里堆背景资料只会浪费上下文,还会稀释真正的规则。
Skill 触发不能只靠 includes
如果自己实现教学版匹配器,最简单的做法可能是:
代码块收起展开
input.includes(trigger)问题:
- 误触发:用户提到 “不要 review”。
- 漏触发:用户说 “帮我看下这次改动有没有问题”。
- 冲突:多个 skill 同时命中。
- 注入:项目某个文件的内容里恰好(或被人恶意构造)出现触发词,不应该因此激活 skill。这种“数据被当成指令”的问题叫 prompt injection,跟 SQL 注入是同一类毛病。
再强调一次,includes 只是教学示范,Codex/Claude Code 内部用什么算法并没有公开承诺。自建 Runtime 若要增加可解释的匹配层,可以这样设计:
代码块收起展开
// SkillMatch = 一次 skill 匹配的评分结果。
// 不只返回是否命中,还记录为什么命中,方便 trace 和调试。
type SkillMatch = {
skill: SkillManifest; // 被评估的 skill manifest。
score: number; // 匹配分数,越高越应该注入。
reasons: string[]; // 命中原因,例如 explicit trigger、semantic match。
};
// matchSkill = 比 includes 更稳的 skill 选择函数。
function matchSkill(skill: Skill, turn: TurnInput): SkillMatch {
let score = 0;
const reasons: string[] = [];
// description 的明确使用场景命中,给较高分。
if (matchesDeclaredUseCase(skill.description, turn.userText)) {
score += 10;
reasons.push("declared use case");
}
// 可选的自建语义匹配器;这是实现选择,不是产品事实。
if (matchesDescriptionEmbedding(skill, turn.userText)) {
score += 4;
reasons.push("semantic match");
}
// 用户显式写 $skillName,说明一定要用这个 skill。
if (turn.userText.includes(`$${skill.name}`)) {
score += 100;
reasons.push("explicit skill mention");
}
// 如果用户说“不要 review”,触发词附近有否定词,要降分。
if (matchesExclusionBoundary(skill.description, turn.userText)) {
score -= 20;
reasons.push("exclusion boundary");
}
return { skill, score, reasons };
}显式点名 skill 的优先级最高。
模糊语义匹配可以辅助,但不要让它随便激活一堆长 skill。
Skill 冲突处理
多个 skill 同时命中时,不能无脑全部塞进 system prompt。
冲突例子:
brainstorming要先问问题。test-driven-development要先写测试。- 用户明确说“直接改,不要问”。
需要优先级:
代码块收起展开
// SkillPolicy = skill 的选择策略。
// 用来处理多个 skill 同时命中时的优先级和互斥关系。
type SkillPolicy = {
priority: number; // 优先级,安全/流程类通常更高。
exclusiveGroup?: string; // 互斥组,同组只选一个,例如多种 planning 流程。
maxConcurrent?: number; // 最多允许同时注入几个同类 skill。
};处理原则:
- 用户显式指令优先。
- 安全类规则优先。
- 过程类 skill 先于领域类 skill。过程类指规定怎么干活的流程规则,比如 brainstorming、test-driven-development;领域类指针对某个具体技术领域的审查或知识,比如 review-java-api。先定流程再套领域细节,顺序反了流程就乱了。
- 同一 exclusiveGroup 只选最高分。
- 注入前记录选择原因。
注入决策最好像下面这样打印出来,谁被选中、谁被跳过、原因是什么:
代码块收起展开
Selected skills:
- code-review: explicit user requested "review"
- verification-before-completion: completion requires verification
Skipped:
- brainstorming: user asked for review, not design work这让行为可解释,也方便调试。
Skill 注入要有预算
Skill 会吃上下文。大型 skill 可能几千 token。
可以设计:
代码块收起展开
// SkillInjection = 最终注入模型的 skill 形态。
// 因为上下文有限,有时不能注入全文,只能注入摘要或引用。
type SkillInjection = {
skillName: string; // skill 名。
mode: "full" | "summary" | "reference"; // 注入全文、摘要,还是只放引用。
tokenCost: number; // 估算 token 成本。
};注入策略的思路是按触发置信度分级,越拿不准就注入得越少:
| 情况 | 注入方式 |
|---|---|
| 用户显式点名 | full |
| 高置信触发 | full 或 summary |
| 低置信相关 | reference |
| 多个 skill | 优先 full 一个,其余 summary |
| 上下文紧张 | 只注入关键 checklist |
不要把所有 skill 长期放进系统提示。那会让模型行为变慢、变贵、变混乱。
Plugin Manifest
Plugin 必须有清单文件,不能随便 import 任意代码。
代码块收起展开
{
"name": "github-tools", // 插件稳定 ID,命令/工具注册时用它做命名空间。
"version": "1.2.0", // 插件版本,用于兼容性检查和升级回滚。
"entry": "./dist/index.js", // 入口文件,PluginManager 会 import 这个模块。
"capabilities": {
"tools": ["github.searchIssues", "github.createIssue"], // 暴露给模型的可调用工具。
"commands": ["/github"], // 暴露给用户的斜杠命令。
"skills": ["github-workflow"] // 可被 skill matcher 注入的工作流能力。
},
"permissions": {
"network": ["api.github.com"], // 网络白名单,避免插件随意访问外部地址。
"filesystem": "none", // 文件系统权限;none 表示不允许读写本地文件。
"secrets": ["GITHUB_TOKEN"] // 允许读取的密钥名,运行时仍应脱敏审计。
},
"engines": {
"agentCli": ">=0.8.0" // 最低 CLI 版本,避免旧 runtime 加载不兼容插件。
}
}Manifest 的作用:
- 告诉用户插件会扩展什么。
- 告诉系统插件需要什么权限。
- 告诉加载器入口在哪里。
- 告诉版本兼容范围。
没有 manifest 的插件生态很快会变成“运行未知代码”。
Plugin API 要小
给插件的 API 越大,越难保证安全和兼容。
代码块收起展开
// PluginApi = 暴露给插件的最小 API。
// 设计原则:只给插件注册能力的入口,不把内部 runtime 全交出去。
type PluginApi = {
registerTool(tool: ToolDefinition): void; // 注册工具,执行仍走统一 Tool Executor。
registerCommand(command: CommandDefinition): void; // 注册 slash command。
registerSkill(skill: SkillDefinition): void; // 注册 skill。
getConfig<T>(key: string): T | undefined; // 读取插件自己的配置。
log(event: PluginLogEvent): void; // 写插件日志,便于排错。
};不要一开始就暴露:
- 任意文件系统读写。
- 任意网络请求。
- 内部 session 对象。
- 模型原始 API key。
- 权限系统的绕过开关。
插件想执行动作,应该注册工具;工具执行时仍然通过 Tool Executor 和 Permission Gate。
插件加载生命周期
生命周期事件:
代码块收起展开
// PluginLifecycle = 插件生命周期钩子。
// 钩子越多,能力越强,风险也越高。
type PluginLifecycle = {
onLoad?(api: PluginApi): Promise<void>; // 插件加载时执行,最危险。
onSessionStart?(session: SessionInfo): Promise<void>; // 新会话开始时执行。
onSessionEnd?(session: SessionInfo): Promise<void>; // 会话结束时执行。
onUnload?(): Promise<void>; // 插件卸载时清理资源。
};onLoad 最危险。
因为它在用户还没让模型调用工具之前就能运行代码。
因此加载插件本身也应该是一个需要信任的动作。
Capability Model
插件权限不要只有“启用/禁用”。
代码块收起展开
// Capability = 插件能力声明。
// 不是简单“插件启用/禁用”,而是细到网络、文件、secret、工具名。
type Capability =
| { kind: "tool"; names: string[] } // 能注册/调用哪些工具。
| { kind: "command"; names: string[] } // 能注册哪些命令。
| { kind: "network"; hosts: string[] } // 能访问哪些域名。
| { kind: "filesystem"; scope: "none" | "workspace" | "custom"; paths?: string[] } // 文件访问范围。
| { kind: "secret"; names: string[] }; // 能读取哪些密钥。权限检查:
代码块收起展开
// assertCapability = 插件执行敏感动作前的能力检查。
// 插件声明和用户授予的能力必须覆盖这次请求,否则拒绝。
function assertCapability(plugin: PluginIdentity, requested: CapabilityRequest) {
// 读取这个插件已经被授予的能力。
const granted = loadGrantedCapabilities(plugin.name);
// 判断已授予能力是否覆盖本次请求。
if (!capabilityCovers(granted, requested)) {
throw new AgentError("PLUGIN_PERMISSION_DENIED", "Plugin lacks capability.", {
plugin: plugin.name,
requested
});
}
}这样一个 GitHub 插件可以访问 api.github.com,但不能顺手扫用户磁盘。
Plugin 与 MCP 的区别
这两个很容易混。先给一句话版本:Plugin 像引入一个 Spring Boot Starter,代码直接跑在 CLI 自己的进程里;MCP Server 像调用一个外部微服务,走标准协议、有进程边界。表里的 sandbox 指沙箱,即把代码关进一个受限的执行环境,能碰什么资源都被框死。
| 维度 | Plugin | MCP Server |
|---|---|---|
| 运行位置 | Agent CLI 进程内或本地加载 | 独立进程/远端服务 |
| 协议 | CLI 自定义 API | 标准 MCP 协议 |
| 权限 | CLI 自己管 | CLI + MCP 配置共同管 |
| 语言 | 通常跟 CLI 运行时相关 | 任意语言 |
| 隔离 | 较弱,除非 sandbox | 较强,进程边界 |
| 适合 | 扩展 CLI UI/命令/技能 | 暴露工具和资源生态 |
实用建议:
- 能做成 MCP 的外部工具,优先做 MCP。
- 需要深度扩展 CLI 体验,再做 plugin。
- Skill 不需要代码执行时,不要做 plugin。
命名稳定性是协议
模型会依赖工具名、命令名、skill 名。
坏命名:
runThingdoStufftool1
好命名:
read_fileedit_filesearch_textgithub_search_issues
命名原则:
- 动词 + 对象。
- 不用缩写。
- 不随版本频繁改。
- deprecate(标记废弃,类似 Java 的 @Deprecated)时保留兼容层,先警告几个版本再移除。
工具名改一次,历史会话、skill、文档、模型行为都会受影响。
版本兼容
插件和 skill 都需要版本意识。
代码块收起展开
// Compatibility = 插件/skill/schema 的兼容性版本。
// 用来防止 CLI 升级后旧插件悄悄失效。
type Compatibility = {
cliVersion: string; // CLI 主程序版本。
pluginApiVersion: string; // 插件 API 版本。
toolSchemaVersion: string; // 工具 schema 版本。
skillSchemaVersion: string;// skill frontmatter/body 规范版本。
};破坏性变化要这样处理:
- 新增新名字或新字段。
- 老字段标记 deprecated。
- 运行时输出警告。
- 几个版本后再移除。
不要悄悄改 schema。模型看不懂“悄悄”。
安全风险清单
这张表按“风险是什么、什么场景会出事、靠什么防”三列来读:
| 风险 | 场景 | 防线 |
|---|---|---|
| Plugin 初始化窃取文件 | onLoad 直接读磁盘 | manifest + sandbox + trusted source |
| Plugin 注册危险工具 | delete_workspace | 工具权限仍走统一 gate |
| Skill prompt injection | skill 被项目文件伪造 | 只从可信目录加载 |
| Skill 冲突 | 多个流程互相打架 | priority / exclusive group |
| Command 重名覆盖 | 插件覆盖 /help | 禁止覆盖内置命令 |
| Tool schema 欺骗 | 描述说只读,实际写 | runtime permission 不信描述 |
| Secret 泄漏 | 插件拿到 API key | secret scope + redaction |
| Supply chain | npm 包被投毒 | lockfile / signature / hash |
补几个表里的词。Supply chain(供应链)风险指你依赖的第三方包本身被动了手脚,npm 包投毒就是典型;对应防线里,lockfile 锁死依赖版本,signature 校验发布者签名,hash 校验包内容没被偷换,跟 Maven 锁版本加校验 checksum 是一回事。
redaction 指日志输出前把密钥等敏感值打码。
一句话总结:描述不可信,运行时边界才可信。工具说自己“只读”不算数,权限系统按实际行为拦截才算数。
可观测性
平台化之后,必须知道每个能力来自哪里。
代码块收起展开
// CapabilityProvenance = 能力来源记录。
// 当工具失败或行为异常时,要能看出它来自内置、插件、MCP 还是用户配置。
type CapabilityProvenance = {
name: string; // 能力名,例如 github.createIssue。
kind: "command" | "tool" | "skill"; // 能力类型。
source: "builtin" | "plugin" | "mcp" | "user"; // 来源。
packageName?: string; // 如果来自插件/MCP,记录包名。
version?: string; // 来源版本。
path?: string; // 本地来源路径。
};当工具失败时,错误要带 provenance:
代码块收起展开
Tool github.createIssue failed
source: plugin github-tools@1.2.0
permission: network api.github.com allowed
error: 401 Bad credentials这样用户知道是模型错、工具错、插件错,还是配置错。
插件供应链与隔离策略
插件系统一旦允许第三方扩展,就升级成了供应链问题,要回答四件事:谁发布、谁安装、运行时能碰什么、出事后能不能定位和禁用。
插件治理至少要分三层,对应插件生命周期的三个时间点,每层管一个问题:
| 层级 | 解决什么 | 最小机制 |
|---|---|---|
| 安装前 | 插件是否可信 | 来源、签名、hash、权限声明 |
| 加载时 | 插件能注册什么能力 | manifest 校验、capability allowlist、版本兼容 |
| 运行时 | 插件能力是否越界 | sandbox、统一 ToolExecutor、audit、禁用开关 |
表里 capability allowlist 是能力白名单(只有明确列出来的能力才放行),audit 是审计日志(每次敏感操作都留记录)。不要让插件在 onLoad() 里直接拿到无限权限,推荐把插件能力声明写成可审计对象:
代码块收起展开
// PluginSecurityProfile = 插件安全画像。
// 安装、加载、运行三阶段都围绕这个对象做判断。
type PluginSecurityProfile = {
pluginId: string; // 插件稳定 ID,不随展示名变化。
version: string; // 插件版本,用于回滚和兼容判断。
source: {
registry: string; // 来源仓库或 marketplace。
packageHash: string; // 安装包 hash,防止同版本内容漂移。
signature?: string; // 可选签名,成熟平台应要求签名。
};
declaredCapabilities: Array<
| "read_workspace" // 读取工作区文件。
| "write_workspace" // 修改工作区文件。
| "network" // 访问网络。
| "spawn_process" // 启动子进程。
| "register_tool" // 注册工具。
| "register_skill" // 注册 skill。
>;
runtimeLimits: {
maxStartupMs: number; // 加载超时,防止插件卡住 CLI。
maxMemoryMb: number; // 内存上限。
allowedHosts: string[]; // network capability 的 host 白名单。
};
};加载生命周期应该是“先验证,再注册”,每一步只做当前阶段允许的事,别边执行边相信:
| 步骤 | 允许做什么 | 禁止做什么 |
|---|---|---|
| read manifest | 读取静态元数据 | 执行插件代码 |
| verify package | 校验 hash/signature/version | 自动拉取任意依赖 |
| resolve capabilities | 计算权限集合 | 让插件自己解释权限 |
| create sandbox | 创建受限上下文 | 暴露宿主全局对象 |
| register contributions | 注册 command/skill/tool 描述 | 直接执行副作用 |
| activate on demand | 用户触发后激活 | 启动时批量跑插件逻辑 |
插件故障要能快速隔离。表里的“自动熔断”和微服务里的熔断器(Hystrix/Sentinel 那套)一个意思:连续失败就先停用,别让它继续拖累主流程:
| 故障 | 平台动作 | 用户可见信息 |
|---|---|---|
| 加载超时 | 禁用本次加载,记录 pluginId/version | 哪个插件启动慢 |
| manifest 不兼容 | 跳过插件,提示所需 CLI 版本 | 需要升级 CLI 还是插件 |
| 工具执行失败 | 走 ToolExecutor 错误归一化 | 能力来源和错误码 |
| 权限越界 | 拒绝并标记安全事件 | 插件请求了什么越界能力 |
| 连续失败 | 自动熔断插件 | 禁用原因和恢复方式 |
最小隔离策略可以先不做完整容器,但至少要做到:
- 插件不能绕过 CommandRouter / ToolExecutor / SkillLoader 的统一入口。
- 插件只能注册能力描述,拿不到宿主对象,更不能直接调用宿主内部方法。
- 每个能力都带 provenance:builtin、plugin、mcp、user config。
- 插件失败不应该拖垮主 CLI;最多禁用该插件或该能力。
- 插件升级要保留旧版本 hash 和失败记录,方便回滚。
衡量插件系统成熟度,“能装插件”只是起点,真正的标准是插件出了事也能停、能查、能退、能限制影响半径。
最小平台实现顺序
不要一上来做完整插件市场。建议顺序:
- 内置 Command Router。
- 内置 Tool Registry + 权限系统。
- 本地 Skill Loader,只读 Markdown。
- MCP Server 配置加载。
- Plugin Manifest 解析,但先只允许注册 skill。
- Plugin 注册 command/tool。
- Capability permission。
- 插件签名、版本、市场、隔离。
每一步都能独立产生价值。
Java 后端视角类比
把前面的概念全部映射到你熟悉的 Spring 生态,哪个概念卡住了就回来查这张表:
| Agent CLI 概念 | Java 后端类比 |
|---|---|
| Command Router | Controller 中的管理接口 |
| Skill Loader | 规则配置 / 策略配置 |
| Tool Registry | Service Bean Registry |
| Plugin Manager | Spring Boot Starter / SPI |
| Capability | RBAC 权限点 |
| Manifest | pom.xml + 配置元数据 |
| MCP Server | 外部微服务 |
| Tool schema | OpenAPI / DTO |
| Provenance | Bean 来源 / Actuator 信息 |
表里的 SPI 指 Java 的 Service Provider Interface,就是 JDBC 驱动那种“接口在核心包、实现由第三方 jar 提供、运行时自动发现”的扩展机制。
如果你理解 Spring 的 Bean、Starter、AutoConfiguration,就能理解 Agent CLI 的插件化:
核心问题永远是“谁能注册能力、能力从哪里来、运行时是否受统一治理”。
读源码时的高阶问题
看到 command/skill/plugin 模块时,继续问:
- Slash command 是否在模型前处理?
- 命令解析是否支持 flags/subcommands?
- 内置命令能否被覆盖?
- Skill 是否只从可信目录加载?
- Skill 触发是否可解释?
- 多 skill 冲突如何处理?
- Skill 注入是否有 token 预算?
- Plugin 是否有 manifest?
- Plugin 是否能绕过 Tool Executor?
- Plugin 权限是启用/禁用,还是 capability 级别?
- MCP、Plugin、Tool 的边界是否清晰?
- 失败日志里是否能看出能力来源?
命令、技能、插件这三层如果设计清楚,Agent CLI 才能从一个“聊天壳子”演化成可扩展工程平台。