Agent CLI深水区 - 命令技能插件系统

Agent CLI 深水区 09:命令、技能与插件系统

三者区别

Agent CLI 里经常有 command、skill、plugin 三个词,容易混。先看一张对照表,「谁执行」这列里,「程序」指 CLI 代码直接跑完、全程不经过模型;「模型 + 工具」指要靠一次 LLM 推理来决定怎么做。

名称本质谁执行
Command确定性 CLI 命令程序
Skill可复用任务说明书模型 + 工具
Plugin扩展工具/命令/资源程序加载

拿几个具体输入对号入座:

输入类型
/helpCommand
/mcp listCommand
“review 这次改动”触发代码审查流程Skill
新增一个 Jira 工具包Plugin

补两个背景知识。斜杠开头的输入叫 slash command,跟游戏聊天框里的 /指令 是一个用法,输入它就是在操作程序本身。/mcp 里的 MCP 指 Model Context Protocol,一个让 Agent 接入外部工具服务的标准协议,可以理解成给所有工具服务统一定了一套接口规范,后面会反复出现。

Command 系统

Command 不应该交给模型。原因很直接:用户敲 /help 想要的是一份准确、稳定的命令列表,程序查一下注册表就能给出;交给模型生成,每次输出都可能不一样,还白花 token(token 是模型计费和上下文长度的基本单位,大致可以理解成一个词块)。

代码块TS · 16 行收起展开
// 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; // 已加载配置。
};

注册:

代码块TS · 27 行收起展开
// 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

代码块TS · 14 行收起展开
// 注册一个 /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 像一份操作手册,写清什么场景下按什么顺序调这些方法。

代码块JAVA · 6 行收起展开
下面两个类型体现了 Skill 的两阶段设计:discovery(发现)阶段只扫描出有哪些 skill,用轻量的 manifest(清单,只含名字、描述这类元数据);等某个 skill 真被选中了,才加载完整内容。
provenance 字段记录 skill 从哪来,代码里一共列了六种:builtin 是 CLI 自带的;
user 来自用户个人目录(比如 `~/.claude/skills`);
project 来自当前项目目录;
plugin 由插件带进来;
managed 指托管配置,也就是公司或团队管理员统一下发、个人改不了的那种,类似公司统一推的 IDE 配置;

mcp 指 skill 由某个 MCP server 提供,MCP 协议除了暴露工具,还能暴露 prompt 和资源,skill 就能顺着这条路从外部服务进来。
后面讲安全时会用到这个字段。

代码块TS · 15 行收起展开
// 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 文件:

代码块MARKDOWN · 10 行收起展开
---
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 都可能被当成指令混进模型。

代码块TS · 33 行收起展开
// 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 匹配

公开产品契约可以确认两条触发路径:

  1. 显式调用:用户直接点名 Skill。Codex 支持 $skill-name//skills,Claude Code 支持 /skill-name
  2. 隐式调用: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(工具执行结果返回给模型的那条消息)拿到正文。
真实产品可以使用自己的上下文类型,但必须保留相同的不变量:未选中的正文不进入活跃上下文。

代码块TS · 8 行收起展开
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-codereview-java-api 写边界有重叠的 description,至少测试五类 case:明确 Java API 审查、普通前端审查、显式点名、模糊请求、实现请求。
每次记录候选列表、最终选择、是否加载正文、任务是否完成。只测“能不能手动调用”证明不了隐式触发质量,因为手动调用根本不走匹配逻辑。

Plugin 系统

Plugin 是程序扩展机制,可以提供:

  • 新工具。
  • 新命令。
  • 新 MCP server 配置。
  • 新 skill。
代码块TS · 15 行收起展开
// 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。
};

加载:

代码块TS · 15 行收起展开
// 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 的关系 User Input Slash? Command Skill Context Tool Action

顺着上图读:用户输入先判断是不是斜杠开头。是,就走 Command 分支,程序直接执行确定动作;不是,就进入模型回合,Skill 作为上下文影响模型怎么思考,模型再决定调用哪个 Tool 去实际干活。一句话:Command 是确定动作,Skill 影响模型行为,Tool 是模型可调用的能力。

设计原则

五条铁律,左边是原则,右边是一句话原因:

原则说明
Command 不调模型能确定就代码执行
Skill 不执行动作只提供流程和规则
Tool 执行动作但必须过权限
Plugin 扩展能力但不能绕过权限
命名要稳定模型依赖工具名

常见坑

这些坑基本都来自把三者边界搞混:

后果
/help 交给模型输出不稳定
skill 太长挤占上下文
skill 互相冲突模型行为混乱
plugin 能覆盖内置工具安全风险
command 和 tool 混在一起权限边界不清

读源码时看这些点

  1. Slash command 在哪里路由?
  2. Command 是否绕过模型?
  3. Skill 从哪里加载?
  4. Skill 如何触发?
  5. Skill 是否注入 system prompt?
  6. Plugin 是否能注册工具?
  7. Plugin 工具是否仍走权限?
  8. 是否有命名冲突处理?

命令、技能、插件这三套机制决定 Agent CLI 能不能从“一个工具”长成“一个平台”:没有它们,所有能力都得作者自己写死在代码里;有了它们,第三方就能往里加工具、命令和工作流。

三层边界

如果只记一个版本:

  • Command = 程序自己的确定性操作
  • Skill = 给模型看的任务流程说明
  • Plugin = 给程序加载的扩展包

它们的边界非常重要。下表从三个问题切分:这一层由谁做决定、会不会真的改动外部世界(副作用)、要不要走权限检查。

是否让模型决定是否执行副作用是否需要权限
Command可能视操作而定
Skill是,影响模型推理不应直接执行不直接需要,但会影响工具调用
Tool是,模型可调用必须
Plugin否,程序加载可能注册副作用能力必须受能力限制

最危险的混乱是:让 Skill 直接像插件一样执行代码,或者让 Plugin 注册的工具绕过权限系统。

Command Router 应该在模型之前

Slash command 属于 CLI 的控制面:用户敲它是在操作程序本身,压根不该进入和模型的对话。Command Router 的位置就像 Servlet 过滤器链最前面的那个 Filter:请求先经过它,命中命令就直接处理并返回,没命中才放行给后面的模型。

Slash Command 控制流 Raw Input Slash? Parse Known? Handler Help/Error Agent Turn

为什么命令要在模型之前?

  • /help 不需要模型,模型输出还可能不稳定。
  • /cost/status/mcp list 是本地事实查询。
  • /clear/compact 是会话控制,不能让模型猜。
  • /permissions 是安全控制面,必须 deterministic(确定性:同样输入永远同样结果,不能掺模型的随机性)。

命令解析最好支持子命令:

代码块TS · 8 行收起展开
// 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。

代码块TS · 16 行收起展开
// 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按需读取的资料与自动化脚本

示例:

代码块MARKDOWN · 10 行收起展开
---
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

如果自己实现教学版匹配器,最简单的做法可能是:

代码块TS · 1 行收起展开
input.includes(trigger)

问题:

  • 误触发:用户提到 “不要 review”。
  • 漏触发:用户说 “帮我看下这次改动有没有问题”。
  • 冲突:多个 skill 同时命中。
  • 注入:项目某个文件的内容里恰好(或被人恶意构造)出现触发词,不应该因此激活 skill。这种“数据被当成指令”的问题叫 prompt injection,跟 SQL 注入是同一类毛病。

再强调一次,includes 只是教学示范,Codex/Claude Code 内部用什么算法并没有公开承诺。自建 Runtime 若要增加可解释的匹配层,可以这样设计:

代码块TS · 39 行收起展开
// 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 要先写测试。
  • 用户明确说“直接改,不要问”。

需要优先级:

代码块TS · 7 行收起展开
// SkillPolicy = skill 的选择策略。
// 用来处理多个 skill 同时命中时的优先级和互斥关系。
type SkillPolicy = {
  priority: number;        // 优先级,安全/流程类通常更高。
  exclusiveGroup?: string; // 互斥组,同组只选一个,例如多种 planning 流程。
  maxConcurrent?: number;  // 最多允许同时注入几个同类 skill。
};

处理原则:

  1. 用户显式指令优先。
  2. 安全类规则优先。
  3. 过程类 skill 先于领域类 skill。过程类指规定怎么干活的流程规则,比如 brainstorming、test-driven-development;领域类指针对某个具体技术领域的审查或知识,比如 review-java-api。先定流程再套领域细节,顺序反了流程就乱了。
  4. 同一 exclusiveGroup 只选最高分。
  5. 注入前记录选择原因。

注入决策最好像下面这样打印出来,谁被选中、谁被跳过、原因是什么:

代码块PLAINTEXT · 6 行收起展开
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。

可以设计:

代码块TS · 7 行收起展开
// 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 任意代码。

代码块JSONC · 18 行收起展开
{
  "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 越大,越难保证安全和兼容。

代码块TS · 9 行收起展开
// 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。

插件加载生命周期

插件加载生命周期 Agent CLI Manager manifest trust import register Registry ready

生命周期事件:

代码块TS · 8 行收起展开
// PluginLifecycle = 插件生命周期钩子。
// 钩子越多,能力越强,风险也越高。
type PluginLifecycle = {
  onLoad?(api: PluginApi): Promise<void>;               // 插件加载时执行,最危险。
  onSessionStart?(session: SessionInfo): Promise<void>; // 新会话开始时执行。
  onSessionEnd?(session: SessionInfo): Promise<void>;   // 会话结束时执行。
  onUnload?(): Promise<void>;                           // 插件卸载时清理资源。
};

onLoad 最危险。
因为它在用户还没让模型调用工具之前就能运行代码。
因此加载插件本身也应该是一个需要信任的动作。

Capability Model

插件权限不要只有“启用/禁用”。

代码块TS · 8 行收起展开
// 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[] }; // 能读取哪些密钥。

权限检查:

代码块TS · 14 行收起展开
// 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 指沙箱,即把代码关进一个受限的执行环境,能碰什么资源都被框死。

维度PluginMCP Server
运行位置Agent CLI 进程内或本地加载独立进程/远端服务
协议CLI 自定义 API标准 MCP 协议
权限CLI 自己管CLI + MCP 配置共同管
语言通常跟 CLI 运行时相关任意语言
隔离较弱,除非 sandbox较强,进程边界
适合扩展 CLI UI/命令/技能暴露工具和资源生态

实用建议:

  • 能做成 MCP 的外部工具,优先做 MCP。
  • 需要深度扩展 CLI 体验,再做 plugin。
  • Skill 不需要代码执行时,不要做 plugin。

命名稳定性是协议

模型会依赖工具名、命令名、skill 名。

坏命名:

  • runThing
  • doStuff
  • tool1

好命名:

  • read_file
  • edit_file
  • search_text
  • github_search_issues

命名原则:

  • 动词 + 对象。
  • 不用缩写。
  • 不随版本频繁改。
  • deprecate(标记废弃,类似 Java 的 @Deprecated)时保留兼容层,先警告几个版本再移除。

工具名改一次,历史会话、skill、文档、模型行为都会受影响。

版本兼容

插件和 skill 都需要版本意识。

代码块TS · 8 行收起展开
// Compatibility = 插件/skill/schema 的兼容性版本。
// 用来防止 CLI 升级后旧插件悄悄失效。
type Compatibility = {
  cliVersion: string;        // CLI 主程序版本。
  pluginApiVersion: string;  // 插件 API 版本。
  toolSchemaVersion: string; // 工具 schema 版本。
  skillSchemaVersion: string;// skill frontmatter/body 规范版本。
};

破坏性变化要这样处理:

  1. 新增新名字或新字段。
  2. 老字段标记 deprecated。
  3. 运行时输出警告。
  4. 几个版本后再移除。

不要悄悄改 schema。模型看不懂“悄悄”。

安全风险清单

这张表按“风险是什么、什么场景会出事、靠什么防”三列来读:

风险场景防线
Plugin 初始化窃取文件onLoad 直接读磁盘manifest + sandbox + trusted source
Plugin 注册危险工具delete_workspace工具权限仍走统一 gate
Skill prompt injectionskill 被项目文件伪造只从可信目录加载
Skill 冲突多个流程互相打架priority / exclusive group
Command 重名覆盖插件覆盖 /help禁止覆盖内置命令
Tool schema 欺骗描述说只读,实际写runtime permission 不信描述
Secret 泄漏插件拿到 API keysecret scope + redaction
Supply chainnpm 包被投毒lockfile / signature / hash

补几个表里的词。Supply chain(供应链)风险指你依赖的第三方包本身被动了手脚,npm 包投毒就是典型;对应防线里,lockfile 锁死依赖版本,signature 校验发布者签名,hash 校验包内容没被偷换,跟 Maven 锁版本加校验 checksum 是一回事。
redaction 指日志输出前把密钥等敏感值打码。

一句话总结:描述不可信,运行时边界才可信。工具说自己“只读”不算数,权限系统按实际行为拦截才算数。

可观测性

平台化之后,必须知道每个能力来自哪里。

代码块TS · 10 行收起展开
// 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:

代码块PLAINTEXT · 4 行收起展开
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() 里直接拿到无限权限,推荐把插件能力声明写成可审计对象:

代码块TS · 24 行收起展开
// 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 错误归一化能力来源和错误码
权限越界拒绝并标记安全事件插件请求了什么越界能力
连续失败自动熔断插件禁用原因和恢复方式

最小隔离策略可以先不做完整容器,但至少要做到:

  1. 插件不能绕过 CommandRouter / ToolExecutor / SkillLoader 的统一入口。
  2. 插件只能注册能力描述,拿不到宿主对象,更不能直接调用宿主内部方法。
  3. 每个能力都带 provenance:builtin、plugin、mcp、user config。
  4. 插件失败不应该拖垮主 CLI;最多禁用该插件或该能力。
  5. 插件升级要保留旧版本 hash 和失败记录,方便回滚。

衡量插件系统成熟度,“能装插件”只是起点,真正的标准是插件出了事也能停、能查、能退、能限制影响半径。

最小平台实现顺序

不要一上来做完整插件市场。建议顺序:

  1. 内置 Command Router。
  2. 内置 Tool Registry + 权限系统。
  3. 本地 Skill Loader,只读 Markdown。
  4. MCP Server 配置加载。
  5. Plugin Manifest 解析,但先只允许注册 skill。
  6. Plugin 注册 command/tool。
  7. Capability permission。
  8. 插件签名、版本、市场、隔离。

每一步都能独立产生价值。

Java 后端视角类比

把前面的概念全部映射到你熟悉的 Spring 生态,哪个概念卡住了就回来查这张表:

Agent CLI 概念Java 后端类比
Command RouterController 中的管理接口
Skill Loader规则配置 / 策略配置
Tool RegistryService Bean Registry
Plugin ManagerSpring Boot Starter / SPI
CapabilityRBAC 权限点
Manifestpom.xml + 配置元数据
MCP Server外部微服务
Tool schemaOpenAPI / DTO
ProvenanceBean 来源 / Actuator 信息

表里的 SPI 指 Java 的 Service Provider Interface,就是 JDBC 驱动那种“接口在核心包、实现由第三方 jar 提供、运行时自动发现”的扩展机制。

如果你理解 Spring 的 Bean、Starter、AutoConfiguration,就能理解 Agent CLI 的插件化:
核心问题永远是“谁能注册能力、能力从哪里来、运行时是否受统一治理”。

读源码时的高阶问题

看到 command/skill/plugin 模块时,继续问:

  1. Slash command 是否在模型前处理?
  2. 命令解析是否支持 flags/subcommands?
  3. 内置命令能否被覆盖?
  4. Skill 是否只从可信目录加载?
  5. Skill 触发是否可解释?
  6. 多 skill 冲突如何处理?
  7. Skill 注入是否有 token 预算?
  8. Plugin 是否有 manifest?
  9. Plugin 是否能绕过 Tool Executor?
  10. Plugin 权限是启用/禁用,还是 capability 级别?
  11. MCP、Plugin、Tool 的边界是否清晰?
  12. 失败日志里是否能看出能力来源?

命令、技能、插件这三层如果设计清楚,Agent CLI 才能从一个“聊天壳子”演化成可扩展工程平台。

延伸阅读