Skip to content

7.2 Skill 系统

本页分析版本earendil-works/pi@c13ffe12026-07-30

本章解决什么问题3.7 给过一句口诀——「Extension 是代码,Skill 是知识」。这句话在源码层面究竟体现为什么?一个 SKILL.md 文件从磁盘走到模型眼前,中间经过哪几个函数?为什么 Skill 明明是「能力」却不是一个工具(Tool)?disable-model-invocation 这个开关又切断了哪条路?本章把这条链路从磁盘扫描一路走到系统提示词(System Prompt),再走到 /skill:name 那条旁路。

前置知识3.7 Compaction、Extension 与 Skill(代码 vs 知识、渐进式披露)、6.4 工具系统(read 工具是什么)、6.8 配置系统~/.pi/agent.pi、项目信任)、4.4 从哪里开始读源码(主链路轮廓)。

学习目标:读完本章后你能

  • 用「Pi 进程读不读这个文件的正文」这一条标准,把 Skill 和 Extension 的区别讲到源码级;
  • 逐字段说清 SKILL.md 的 frontmatter,并说出唯一会导致 skill 被拒绝加载的条件;
  • 复述发现规则的三条:SKILL.md 短路、根层 .md、递归子目录,并指出两种发现模式("pi" / "agents")的差异;
  • 画出「磁盘 → loadSkills → 系统提示词 XML → 模型 read」这条注入链,并说出 read 工具没开时会发生什么;
  • 解释 disable-model-invocation 为什么只影响系统提示词、不影响 /skill:name
  • 在没有任何 API Key 的前提下,自己造一个最小 skill、用 --skill 加载、在启动清单里看到它。

建立直觉:Skill 是货架上的说明书,不是货架上的机器

3.7 已经把两者的分工说清楚了。这里把那句口诀往源码方向再推一步,给出一条可以直接用来判断的硬标准

Pi 这个进程,会不会执行或解释这个文件的正文? Extension 是 .ts 文件,Pi 把它 import 进自己的进程、注册它的钩子——Pi 执行它。 Skill 是 .md 文件,Pi 从头到尾只读它 frontmatter 里的两个字段(namedescription),正文一个字都不看——读正文的是模型,用的是 read 工具

这不是比喻,是源码事实。coding-agent 侧表示一个 skill 的数据结构里,压根没有存放正文的字段:

earendil-works/pi@c13ffe1第 67–81 行在 GitHub 查看 ↗
frontmatter 只显式声明三个字段,其余任意字段落入索引签名被忽略;Skill 结构里有 filePath 和 baseDir,唯独没有 content。
ts
// packages/coding-agent/src/core/skills.ts:67-81(行末中文为本书添加的批注,源码中没有)
export interface SkillFrontmatter {
	name?: string;
	description?: string;
	"disable-model-invocation"?: boolean;
	[key: string]: unknown;
}

export interface Skill {
	name: string;
	description: string;
	filePath: string;   // ← SKILL.md 的绝对路径
	baseDir: string;    // ← skill 目录,即 filePath 的父目录
	sourceInfo: SourceInfo;
	disableModelInvocation: boolean;
}

Skill 里没有 content,只有 filePath。这一个缺失的字段就是整章的钥匙:Pi 交给模型的不是内容,是一张写着「有这么一本书、讲的是这个、放在这个书架上」的目录卡片。要不要把书取下来,由模型自己决定。这就是 3.7 里提到的**渐进式披露(Progressive Disclosure)**在 Pi 里的具体形态。

📘 概念渐进式披露(Progressive Disclosure)
一种控制上下文成本的策略:常驻上下文里只放「索引」,正文按需加载。代价是模型可能判断失误、该读的时候没读——官方文档也承认这一点(packages/coding-agent/docs/skills.md:68 括号里写着「模型并不总会这么做,可用提示词或 /skill:name 强制」;同文件 71 行把这套机制直接命名为 progressive disclosure)。/skill:name 存在的理由,就是给这个概率性行为一个确定性兜底。
⚠️ 常见误解以为 Skill 会注册成一个工具(Tool)
Skill 不进工具列表,模型也无法「调用」一个 skill。系统提示词里出现的是一段 <available_skills> XML 文本;模型「使用」skill 的动作,物理上就是调用 read 工具去读一个 .md 文件。因此 skill 段落是否注入,直接取决于 read 工具在不在——下文的 hasRead 门控就是干这件事的。

最小示例:三行 frontmatter 就是一个完整的 Skill

一个能被加载的 skill,最少只需要一个文件。建一个目录 hello-skill/,里面放 SKILL.md

markdown
---
name: hello-skill
description: Explains how this book verifies skill loading. Use when the user asks about the pi book skill lab.
---

# Hello Skill

1. Say hello.
2. Tell the user which file you read this from.

把它交给 Pi 之后,进入系统提示词的只有下面这段——正文(# Hello Skill 以下)完全没有出现。这是本书用仓库自带的 tsx 直接调用 loadSkills() + formatSkillsForPrompt() 得到的真实输出(跑法见本章实践任务步骤 5):

text
The following skills provide specialized instructions for specific tasks.
Use the read tool to load a skill's file when the task matches its description.
When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.

<available_skills>
  <skill>
    <name>hello-skill</name>
    <description>Explains how this book verifies skill loading. Use when the user asks about the pi book skill lab.</description>
    <location>/tmp/pi-skill-lab/hello-skill/SKILL.md</location>
  </skill>
</available_skills>

三条信息:叫什么、什么时候用、在哪里。第三条 <location> 是绝对路径——它就是给模型调用 read 工具时用的实参。

frontmatter 逐字段

字段是否必需源码行为
description缺失或全空白 → 不加载skills.ts:305-307)。这是唯一的硬性拒绝条件
name缺失时回退为父目录名skills.ts:296)。校验失败只产生 warning,skill 照常加载
disable-model-invocation等于 true 时从系统提示词中剔除(skills.ts:316 解析、skills.ts:336 过滤),但 /skill:name 仍可用
其他任意字段落入索引签名 [key: string]: unknown,coding-agent 的加载路径不消费

校验规则(全部只产生 warning,不阻止加载):name 必须是小写字母、数字或连字符,≤64 字符,不以连字符开头或结尾,无连续连字符(skills.ts:92-112);description ≤1024 字符(skills.ts:117-127)。两个上限常量在 skills.ts:11skills.ts:14,注释写着 "per spec"——指的是 Agent Skills 标准。

🌱 初学者提示为什么 description 是唯一的硬性要求
从源码结构看,理由很直接:description 是模型判断「这个 skill 和当前任务有没有关系」的唯一依据。没有它,这个 skill 在系统提示词里就是一条永远不会被选中的死条目,加载了也没有意义。名字写错顶多难看,描述没了则完全失效——所以前者告警、后者拒绝。这两段逻辑写在同一个函数里,看一眼先后顺序就清楚。
ts
// packages/coding-agent/src/core/skills.ts:295-307
		// Use name from frontmatter, or fall back to parent directory name
		const name = frontmatter.name || parentDirName;

		// Validate name
		const nameErrors = validateName(name);
		for (const error of nameErrors) {
			diagnostics.push({ type: "warning", message: error, path: filePath });
		}

		// Still load the skill even with warnings (unless description is completely missing)
		if (!frontmatter.description || frontmatter.description.trim() === "") {
			return { skill: null, diagnostics };
		}
packages/coding-agent/src/core/skills.ts · frontmatter.name || parentDirName
earendil-works/pi@c13ffe1第 295–307 行在 GitHub 查看 ↗
loadSkillFromFile 的核心十行:name 回退到父目录名;所有校验失败都只是 warning;只有 description 缺失才返回 skill: null。

回到 Pi 源码:从磁盘到系统提示词

第一步:skill 目录从哪里来

在正常的 CLI(命令行界面,Command-Line Interface)流程下,skills.ts 自己不负责决定扫哪些目录。目录清单由 PackageManager.resolve() 汇总,一次性把用户级、项目级、.agents/ 三套来源合并成一个路径列表:

earendil-works/pi@c13ffe1第 2342–2358 行在 GitHub 查看 ↗
自动发现目录的定义处:用户级 agentDir 下四类资源目录、项目级 .pi 下四类,以及 ~/.agents/skills;项目侧的 .agents/skills 祖先目录集合只在项目受信任时收集。

归纳成表(CONFIG_DIR_NAME 默认 .piagentDir 默认 ~/.pi/agent,见 6.8):

来源路径需要项目信任?发现模式
用户级(pi 约定)~/.pi/agent/skills/"pi"
用户级(跨 harness 约定)~/.agents/skills/"agents"
项目级(pi 约定)<cwd>/.pi/skills/"pi"
项目级(跨 harness 约定)<cwd> 及祖先目录的 .agents/skills/"agents"
settings.jsonskills 数组任意项目侧需信任按落点判定
命令行 --skill <path>任意显式路径

两种发现模式的差别只有一行代码:只有 "pi" 模式允许「根目录下的直接 .md 文件」各自算一个 skill.agents/ 那套只认 SKILL.md 目录。

ts
// packages/coding-agent/src/core/package-manager.ts:405-409
			const relPath = toPosixPath(relative(root, fullPath));
			if (mode === "pi" && dir === root && isFile && entry.name.endsWith(".md") && !ig.ignores(relPath)) {
				entries.push(fullPath);
				continue;
			}
earendil-works/pi@c13ffe1第 405–409 行在 GitHub 查看 ↗
SkillDiscoveryMode 的唯一分歧点:pi 模式下根层 .md 直接算一个 skill;agents 模式下这个条件不成立,根层 .md 被跳过。

dir === root 这个条件值得注意:只有根层.md 算数,递归进子目录之后就不再收集散装 .md 了。

第二步:扫描与解析

拿到目录列表后才轮到 skills.ts。它的发现规则写在函数注释里,且注释与实现一致:

earendil-works/pi@c13ffe1第 160–171 行在 GitHub 查看 ↗
三条发现规则的权威出处:目录含 SKILL.md 则该目录就是一个 skill 根且不再递归;否则加载根层直接 .md 子文件;并递归子目录去找 SKILL.md。

「不再递归」在实现里表现为一个 return——第一轮循环只找 SKILL.md,一旦命中就带着结果直接返回,连同级的其他条目都不再看(skills.ts:194-221)。这条短路规则决定了:把一个 skill 目录嵌套在另一个 skill 目录里,外层赢、内层根本不会被发现。对应测试是 packages/coding-agent/test/skills.test.ts:108"should prefer a directory's root SKILL.md over nested SKILL.md files"

第二轮循环负责递归,同时做两件卫生工作:跳过 . 开头的目录和 node_modulesskills.ts:223-231),并按 .gitignore / .ignore / .fdignore 过滤(skills.ts:16 定义文件名,skills.ts:47-65 加载规则)。这解释了一个容易困惑的现象:放在被 gitignore 命中的目录里的 skill 不会被加载,而且没有任何报错

汇总阶段用 Map<name, Skill> 去重,先到者胜,同名冲突记一条 type: "collision" 诊断(skills.ts:410-426);符号链接指向同一物理文件时用 canonicalizePath 静默跳过(skills.ts:403-408)。冲突的优先级顺序(project > user > package)有专门的回归测试:packages/coding-agent/test/suite/regressions/2781-skill-collision-precedence.test.ts

第三步:--skill--no-skills 的关系

命令行这两个开关的说明写得很克制(真实采集research/cli-captures/pi-help.txt:43-44):

text
  --skill <path>                 Load a skill file or directory (can be used multiple times)
  --no-skills, -ns               Disable skills discovery and loading

但「disable ... loading」并不等于「一个都不加载」。源码里 --no-skills 只砍掉自动发现的那部分,显式 --skill 路径照样合并进去:

ts
// packages/coding-agent/src/core/resource-loader.ts:467-472
		const skillPaths = this.noSkills
			? this.mergePaths(cliEnabledSkills, this.additionalSkillPaths)
			: this.mergePaths([...cliEnabledSkills, ...enabledSkills], this.additionalSkillPaths);

		this.lastSkillPaths = skillPaths;
		this.updateSkillsFromPaths(skillPaths, metadataByPath);
earendil-works/pi@c13ffe1第 467–472 行在 GitHub 查看 ↗
noSkills 为真时只丢掉 enabledSkills(自动发现的结果),additionalSkillPaths(来自 --skill)在两个分支里都保留。

再往下一层,updateSkillsFromPaths 调用 loadSkills 时把 includeDefaults 固定写死为 falseresource-loader.ts:676-681)——默认目录已经由 PackageManager 汇总过了,不能再扫一遍。据此推断(尚未在源码中直接证实):loadSkills 里那个 includeDefaults: true 分支(skills.ts:430-433)是留给把 Pi 当库用的 SDK 调用者的,正常 CLI 流程恒为 false;依据是仓库内对该参数只有这一个传入点。

第四步:注入系统提示词

skill 数据结构最终在重建系统提示词时被消费:

earendil-works/pi@c13ffe1第 1021–1054 行在 GitHub 查看 ↗
AgentSession 每次工具集变化时重建系统提示词:从 ResourceLoader 取 SYSTEM.md、APPEND_SYSTEM.md、skills、AGENTS.md,组装成 BuildSystemPromptOptions 交给 buildSystemPrompt。

注意 _rebuildSystemPrompt 的入参是工具名列表——这不是巧合。skills 段落有一道门:

ts
// packages/coding-agent/src/core/system-prompt.ts:154-157
	// Append skills section (only if read tool is available)
	if (hasRead && skills.length > 0) {
		prompt += formatSkillsForPrompt(skills);
	}
packages/coding-agent/src/core/system-prompt.ts · formatSkillsForPrompt(skills)
earendil-works/pi@c13ffe1第 154–157 行在 GitHub 查看 ↗
skills 段落只在 read 工具位于选中工具集里时才注入;hasRead 在 system-prompt.ts:101 由 tools.includes("read") 得出。自定义提示词路径同理,见 system-prompt.ts:63-67。

这道门是「Skill 不是 tool」这个设计的直接后果:既然模型只能靠 read 工具取正文,那么在 pi --exclude-tools read--no-tools 之后,把 skill 清单塞进上下文就纯属浪费 token。一种看法是,这属于难得的「连提示词也要做死代码消除」的工程细节;代价是它让系统提示词的内容依赖于工具集,调试时容易忽略。

最后一环是格式化:

earendil-works/pi@c13ffe1第 335–361 行在 GitHub 查看 ↗
把 skill 列表渲染成 Agent Skills 标准的 XML 目录:先过滤掉 disableModelInvocation 的项,再逐条输出 name/description/location 三元组。
ts
// packages/coding-agent/src/core/skills.ts:335-348
export function formatSkillsForPrompt(skills: Skill[]): string {
	const visibleSkills = skills.filter((s) => !s.disableModelInvocation);

	if (visibleSkills.length === 0) {
		return "";
	}

	const lines = [
		"\n\nThe following skills provide specialized instructions for specific tasks.",
		"Use the read tool to load a skill's file when the task matches its description.",
		// …(省略:第三句,说明 skill 正文里的相对路径应按 skill 目录解析)
		"",
		"<available_skills>",
	];
	// …(省略 350-356:为每个 skill push 出 <skill><name/><description/><location/></skill>)

第 336 行那个 filter 就是 disable-model-invocation 的全部作用范围——它只影响系统提示词,不影响 skill 是否被加载。加了这个开关的 skill 依然在 session.skills 里、依然出现在 /skill: 自动补全里,只是模型「不知道」它存在。对应测试是 packages/coding-agent/test/skills.test.ts:308-330"should exclude skills with disableModelInvocation from prompt",它断言 <name>hidden-skill</name> 不出现,且 <skill> 只出现一次。

第五步:/skill:name 那条旁路

模型自主加载是概率性的。Pi 另给了一条确定性通道:用户直接输入 /skill:name,Pi 在消息发出去之前就把正文塞进用户消息。

earendil-works/pi@c13ffe1第 1301–1325 行在 GitHub 查看 ↗
以 /skill: 开头的输入被就地展开:按名字查 skill,readFileSync 读全文、stripFrontmatter 去掉 frontmatter,包成 skill block;找不到名字就原样透传。
ts
// packages/coding-agent/src/core/agent-session.ts:1311-1315
		try {
			const content = readFileSync(skill.filePath, "utf-8");
			const body = stripFrontmatter(content).trim();
			const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${skill.baseDir}.\n\n${body}\n</skill>`;
			return args ? `${skillBlock}\n\n${args}` : skillBlock;

这五行里有三个值得记住的细节:

  1. 读盘的是 Pi 自己,不是模型——这是全章唯一一处 Pi 进程真正读 skill 正文的地方,而且是用户显式要求的结果;
  2. baseDir 被写进提示语("References are relative to ..."),这样 skill 正文里的相对路径(例如 scripts/process.sh)对模型才有意义;
  3. 命令后面跟的参数用 \n\n 直接接在 </skill> 之后,没有任何前缀——这一点下文与官方文档对照时还会再提。

交互模式把每个 skill 注册成一个自动补全命令 skill:${skill.name}interactive-mode.ts:641),受 settings.jsonenableSkillCommands 开关控制(默认 truesettings-manager.ts:1049)。

图解

图加载中…

图 7.2-1 Skill 的发现与注入链路
从上到下读。上半部分是「哪些目录会被扫」,注意 --skill 直接汇入 updateSkillsFromPaths,绕过了 PackageManager——这就是 --no-skills 砍不掉它的原因。下半部分是「元数据怎么变成提示词文本」,菱形节点是本章最容易被忽略的一道门:read 工具不在,整段 skills 就不会出现。每个节点都标了真实源码位置,可以用 grep -rn 逐个复核。

这张图里没有任何一条线通向「工具注册表」。这正是 Skill 与 7.3 自定义工具 的分水岭:后者真的会往工具列表里加一项,前者只往提示词里加一段文本。

下面这张图对比两条「让模型真正拿到正文」的路径:

图加载中…

图 7.2-2 两条加载路径:模型自主 read 与 /skill 强制展开
关注两处差异。第一,谁去读文件:路径 A 是模型发起 toolCall、Pi 代为执行 read 工具;路径 B 是 Pi 在 _expandSkillCommand(agent-session.ts:1301)里直接 readFileSync。第二,正文落在哪种消息里:路径 A 落在工具结果里,路径 B 落在用户消息里。disable-model-invocation 切断的只有路径 A——因为它删掉的正是路径 A 起点所依赖的那份目录。

把两张图串起来,本章的完整调用链是:

  1. 起点 createAgentSessionServicespackages/coding-agent/src/core/agent-session-services.ts:134
  2. resourceLoader.reload()(调用点 agent-session-services.ts:152,定义 resource-loader.ts:387
  3. packageManager.resolve()resource-loader.ts:403,目录汇总 package-manager.ts:2342-2358
  4. updateSkillsFromPathsresource-loader.ts:472,定义 resource-loader.ts:671
  5. loadSkillsresource-loader.ts:676,定义 skills.ts:387
  6. loadSkillsFromDirInternalskills.ts:173)→ loadSkillFromFileskills.ts:277)→ parseFrontmatterpackages/coding-agent/src/utils/frontmatter.ts:28
  7. AgentSession.setActiveToolsByNameagent-session.ts:926)→ _rebuildSystemPrompt(调用点 agent-session.ts:939,定义 agent-session.ts:1021
  8. buildSystemPromptagent-session.ts:1054,定义 packages/coding-agent/src/core/system-prompt.ts:28
  9. 终点 formatSkillsForPromptsystem-prompt.ts:156,定义 skills.ts:335

旁路那条:AgentSession.promptagent-session.ts:1114)→ _expandSkillCommand(调用点 agent-session.ts:1154,定义 agent-session.ts:1301)→ stripFrontmatterutils/frontmatter.ts:39)。TUI(终端界面,Terminal User Interface)侧再用 parseSkillBlockagent-session.ts:127)把这个 block 解析回来做折叠显示(interactive-mode.ts:3318-3336)。

真实启动清单里的那三个名字

本书采集的 TUI 启动画面里有这么一行(真实采集research/cli-captures/pi-tui-main.txt:9-10,采集时的工作目录是 Pi 仓库根):

text
[Skills]
  add-llm-provider, find-skills, ssh-skill

这三个名字来自两个不同的地方,值得逐一落实(源码与文件位置经本次核对):

  • add-llm-provider 来自 Pi 仓库自己的 .pi/skills/add-llm-provider.md,是仓库里 git 追踪的文件。注意它是一个根层散装 .md 文件,不是 SKILL.md 目录——正好命中前面那条 mode === "pi" && dir === root。它的 description 是一份给 packages/ai 添加新 LLM(大语言模型,Large Language Model)Provider(模型服务提供方)的清单,覆盖核心类型、Provider 实现、懒注册、模型生成、测试矩阵、coding-agent 接线与文档。
  • find-skillsssh-skill 不在 Pi 仓库里。它们来自采集机器上的 ~/.agents/skills/——也就是那条跨 harness 的用户级约定目录。换一台机器跑,这两项很可能不出现。

第一条是 4.1 讲过的「Pi 用自己开发自己」的又一个实例,而且是最省事的那种:给自己加 Provider 这件事有固定套路,作者没有把它写成扩展、也没有塞进系统提示词,而是写成一份仓库内的 Markdown,让 Agent 在需要时自己去读。从源码结构看,这与 system-prompt.ts:131-138 那段「Pi documentation」是同一个思路的两个层次:后者告诉模型「Pi 的文档在这些绝对路径下」,前者告诉模型「这个仓库里有一份专门讲加 Provider 的操作手册」。

按 ctrl+o 展开后,清单会按来源分组并显示绝对路径(真实采集,本书实测,复现步骤见实践任务步骤 3):

text
[Skills]
  user
    ~/.agents/skills/find-skills/SKILL.md
    ~/.agents/skills/ssh-skill/SKILL.md
  path
    /private/tmp/pi-skill-lab/hello-skill/SKILL.md

user / path 这两个分组名直接来自 createSkillSourceInfoskills.ts:136-158)对 source 字符串的映射,而 source 由 getSourceskills.ts:447-453)判定:落在用户目录下算 "user"、项目目录下算 "project"、其余(例如 --skill 给的任意路径)算 "path"。同一套信息在 /skill: 自动补全里被压缩成一个字母前缀 [u] / [p] / [t]interactive-mode.ts:523-546)。

官方文档对照

官方文档 packages/coding-agent/docs/skills.md 是本章内容的权威说明,大部分与源码一致:

  • 官方文档说明,Pi 实现了 Agent Skills 标准但「宽松」:多数违规只告警不拒绝,并明确允许 skill 名字与父目录不一致(理由是共享 skill 目录会被多个 harness 使用)。这与 skills.tsvalidateName 一致——它确实没有这条检查。
  • 官方文档说明的目录清单(docs/skills.md:24-41)与 package-manager.ts:2342-2358 一致,包括「.agents/skills 里根层 .md 被忽略」这一条。
  • 官方文档说明「缺 description 不加载;同名冲突告警并保留先发现者」(docs/skills.md:186-188),与 skills.ts:305-307skills.ts:410-426 一致。

有一处与源码不一致,读者按文档理解会走偏:

docs/skills.md:82 写:命令后的参数会以 User: <args> 的形式追加到 skill 内容后面。 源码(agent-session.ts:1315)是 `${skillBlock}\n\n${args}`——发给模型的文本里没有 User: 前缀。「User」只体现在 TUI 层:那段尾巴被单独渲染成一条用户消息组件(interactive-mode.ts:3328-3336)。教学与调试都应以源码为准。

另有一处尚未确认docs/skills.md 的 frontmatter 表还列了 licensecompatibilitymetadataallowed-tools 四个字段(其中 allowed-tools 标注为实验性)。在本章的调研范围内,没有看到 coding-agent 的 skill 加载路径消费它们——SkillFrontmatter 只显式声明三个字段,其余落入索引签名。它们可能由扩展或其他模块处理,尚未确认。

🌱 初学者提示两个包里有两份 skills.ts
packages/agent(包名 @earendil-works/pi-agent-core)里也有一个 harness/skills.ts,与 coding-agent 的那份是平行实现而非复用(coding-agent 并没有 import 它)。差异之一很有意思:harness 版的 validateName 多一条检查——名字与父目录不一致时告警(packages/agent/src/harness/skills.ts:283),也就是说它走的是标准的严格版本,而 coding-agent 刻意不检查。差异之二是 harness 版的 Skill 结构带 content 正文packages/agent/src/harness/types.ts:64-75),因为它面向的是「应用把资源注入进来」而不是「自己去磁盘上发现」。这一对差异本身就是 [6.3](/pi-modules/agent-core) 讲的「库层 vs 产品层」分工的注脚。

实践任务

🛠 实践任务写一个最小 SKILL.md,用 --skill 加载并在启动清单里看到它

目标:亲手走完「造文件 → 被发现 → 进清单 → 进系统提示词」全过程,并顺带验证三条规则:① --no-skills 砍不掉显式 --skill;② 缺 description 的 skill 不加载且会报诊断;③ disable-model-invocation 的 skill 会加载但不进提示词。全程不需要任何 API Key

前置:本地有 Pi 源码仓库(本书锁定的 commit 即可)。下文用 $PI_REPO 表示仓库根目录绝对路径,先执行 export PI_REPO=/你的路径/pi

步骤 1:造两个 skill,一个正常、一个故意缺 description

text
mkdir -p /tmp/pi-skill-lab/hello-skill /tmp/pi-skill-lab/broken-skill
cat > /tmp/pi-skill-lab/hello-skill/SKILL.md <<'EOF'
---
name: hello-skill
description: Explains how this book verifies skill loading. Use when the user asks about the pi book skill lab.
---

# Hello Skill

1. Say hello.
2. Tell the user which file you read this from.
EOF
printf -- '---\nname: broken-skill\n---\n\n# Broken\n' > /tmp/pi-skill-lab/broken-skill/SKILL.md

步骤 2:启动 Pi,只加载正常那个。用仓库自带的 pi-test.sh 直接跑源码(4.1 用过);--no-env 清掉环境里的 API Key,另两个开关的含义按帮助原文(真实采集research/cli-captures/pi-help.txt:55-56)是 --offline「禁用启动期网络操作」、--no-approve, -na「本次运行忽略项目本地文件」:

cd /tmp/pi-skill-lab
$PI_REPO/pi-test.sh --no-env --offline --no-approve --skill ./hello-skill

预期现象:启动画面里出现 [Skills] 一节,其中至少hello-skill。本书实测(macOS + tmux,采集机器的 ~/.agents/skills 里另有两个 skill)输出为:

[Skills]
  find-skills, hello-skill, ssh-skill

如果你的 ~/.agents/skills~/.pi/agent/skills 是空的,你只会看到 hello-skill——这同样算成功。

步骤 3:按 ctrl+o 展开,清单会按来源分组并给出绝对路径。本书实测输出:

[Skills]
  user
    ~/.agents/skills/find-skills/SKILL.md
    ~/.agents/skills/ssh-skill/SKILL.md
  path
    /private/tmp/pi-skill-lab/hello-skill/SKILL.md

确认你的 hello-skill 落在 path 分组下——它是通过 --skill 进来的,不属于任何默认目录。再输入 /skill:,自动补全列表会带上来源字母前缀(本书实测输出,右侧描述被终端宽度截断):

→ skill:find-skills  [u] Helps users discover and install agent skills when they ask questions lik
  skill:ssh-skill    [u] Use this skill for SSH and remote server operations, including login, com
  skill:hello-skill  [t] Explains how this book verifies skill loading. Use when the user asks abo

确认 hello-skill 那一行是 [t],与展开清单里的 path 分组对得上——这个字母由 getAutocompleteSourceTaginteractive-mode.ts:523-546)从 sourceInfo.scope 映射而来,加方括号在 prefixAutocompleteDescriptioninteractive-mode.ts:548-554)。看完按两次 ctrl+c 退出。

步骤 4:验证 --no-skills 与缺 description。这次把两个 skill 都显式传进去,同时打开 --no-skills

cd /tmp/pi-skill-lab
$PI_REPO/pi-test.sh --no-env --offline --no-approve --no-skills --skill ./hello-skill --skill ./broken-skill

本书实测输出(自动发现的那两个消失了;broken-skill 被拒绝并给出诊断):

text
[Skills]
  hello-skill

[Skill conflicts]
  /private/tmp/pi-skill-lab/broken-skill/SKILL.md
    description is required

步骤 5(看看进入提示词的到底是什么):写一个只读探针,直接调用本章讲的两个函数。存成 /tmp/pi-skill-lab/probe.ts,把第一行的路径换成你的 $PI_REPO 实际值:

import { formatSkillsForPrompt, loadSkills } from "/你的路径/pi/packages/coding-agent/src/core/skills.ts";
const { skills, diagnostics } = loadSkills({
	cwd: "/tmp/pi-skill-lab",
	agentDir: "/tmp/pi-skill-lab/agent",
	skillPaths: process.argv.slice(2),
	includeDefaults: false,
});
console.log("skills      =", skills.map((s) => s.name));
console.log("diagnostics =", diagnostics.map((d) => `${d.type}: ${d.message}`));
console.log("--- system prompt fragment ---");
console.log(formatSkillsForPrompt(skills));

用仓库自带的 tsx 跑(不需要 build,也不会改动仓库里任何文件):

cd $PI_REPO
./node_modules/.bin/tsx --tsconfig ./tsconfig.json /tmp/pi-skill-lab/probe.ts /tmp/pi-skill-lab/hello-skill /tmp/pi-skill-lab/broken-skill

本书实测输出(正文一个字都没进提示词,只有三元组):

text
skills      = [ 'hello-skill' ]
diagnostics = [ 'warning: description is required' ]
--- system prompt fragment ---


The following skills provide specialized instructions for specific tasks.
Use the read tool to load a skill's file when the task matches its description.
When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.

<available_skills>
  <skill>
    <name>hello-skill</name>
    <description>Explains how this book verifies skill loading. Use when the user asks about the pi book skill lab.</description>
    <location>/tmp/pi-skill-lab/hello-skill/SKILL.md</location>
  </skill>
</available_skills>

步骤 6(验证 disable-model-invocation):再造一个带开关的 skill,然后重跑探针:

text
mkdir -p /tmp/pi-skill-lab/secret-skill
cat > /tmp/pi-skill-lab/secret-skill/SKILL.md <<'EOF'
---
name: secret-skill
description: Only reachable via /skill:secret-skill. Never advertised in the system prompt.
disable-model-invocation: true
---

# Secret Skill
EOF
cd $PI_REPO
./node_modules/.bin/tsx --tsconfig ./tsconfig.json /tmp/pi-skill-lab/probe.ts /tmp/pi-skill-lab/hello-skill /tmp/pi-skill-lab/secret-skill

本书实测输出的第一行是 skills = [ 'hello-skill', 'secret-skill' ]——两个都加载了;但下方的 XML 里只有 hello-skill

如何判断成功:四个断言全部对上即通过。① 步骤 2 的清单里有 hello-skill;② 步骤 4 里自动发现的 skill 消失但 hello-skill 还在,且 broken-skilldescription is required;③ 步骤 5 的 XML 里没有 Say hello 这类正文;④ 步骤 6 里 secret-skill 出现在 skills 数组但不出现在 XML 里。

常见错误

  • 把命令写成 ... | tee out.txt:管道让标准输出不是 TTY(终端),resolveAppModemain.ts:109-120,见 4.4)会自动切到 print 模式并立刻退出,你根本看不到 TUI 清单。这是本书实测踩过的坑。
  • 文件名写成小写 skill.md:发现逻辑用的是精确比较 entry.name !== "SKILL.md"skills.ts:195)。放在 ~/.pi/agent/skills/my-skill/skill.md 这种位置时它不会被发现——第一轮找不到 SKILL.md,第二轮递归进子目录后 dir === root 不再成立,散装 .md 规则也不适用。统一用全大写最省事。
  • 把 lab 目录放在被 .gitignore 命中的路径下:skills.ts:47-65 会按 ignore 规则过滤,skill 静默消失、没有任何报错。
  • 关于 --no-approve:本书实测在空的 lab 目录下加不加它,[Skills] 一节完全一样——它的作用是让本次运行忽略项目本地文件。如果你把 lab 目录建在一个已有 .pi/AGENTS.md 的项目里,去掉它会连带把该项目的资源和信任流程一起牵进来,观察结果就不干净了。

对应源码位置:发现 packages/coding-agent/src/core/skills.ts:160-275;解析与拒绝 skills.ts:277-325--skill--no-skills 合并 packages/coding-agent/src/core/resource-loader.ts:467-472;注入门控 packages/coding-agent/src/core/system-prompt.ts:154-157;XML 格式化 skills.ts:335-361;来源分组 skills.ts:136-158skills.ts:447-453。相关测试:packages/coding-agent/test/skills.test.ts(尤其第 308 行的 "should exclude skills with disableModelInvocation from prompt")与 packages/coding-agent/test/resource-loader.test.ts"should discover skills from agentDir")。

本章小结

  • Skill 的本质是元数据加一个文件路径。coding-agent 的 Skill 结构里没有 content 字段(skills.ts:74-81);Pi 唯一一次读 skill 正文,是用户敲 /skill:name 时(agent-session.ts:1312)。这就是「Extension 是代码、Skill 是知识」在源码里的样子。
  • 格式极简、校验极宽description 是唯一硬性要求,缺了就不加载;name 可省(回退父目录名),写错只告警;未知字段被忽略。
  • 发现规则三条:目录含 SKILL.md 则短路、不再递归;"pi" 模式下根层散装 .md 各算一个 skill;递归子目录找 SKILL.md.gitignore 生效,node_modules. 开头目录被跳过。同名冲突先到者胜。
  • 注入的是文本,不是工具formatSkillsForPrompt 输出 <available_skills> XML 三元组,且只在 read 工具存在时注入(system-prompt.ts:155)。disable-model-invocation 唯一的作用就是把自己从这段 XML 里摘掉。
  • --no-skills 关的是自动发现,不是显式 --skillresource-loader.ts:467-469)。
  • Pi 仓库自带的 .pi/skills/add-llm-provider.md 是「拿 skill 记录自己怎么被扩展」的实例;启动清单里另外两个名字来自机器上的 ~/.agents/skills/,并不属于仓库。
  • 关键术语:技能(Skill)渐进式披露(Progressive Disclosure)frontmatter发现模式(SkillDiscoveryMode)名字冲突(collision)skill block
  • 关键源码索引:
    • 数据结构与校验:packages/coding-agent/src/core/skills.ts:67-127
    • 发现与解析:skills.ts:160-325
    • XML 格式化:skills.ts:335-361
    • 汇总去重与冲突:skills.ts:387-487
    • 目录来源与发现模式:packages/coding-agent/src/core/package-manager.ts:347-419:2342-2472
    • CLI 开关合并:packages/coding-agent/src/core/resource-loader.ts:467-472:671-692
    • 系统提示词门控:packages/coding-agent/src/core/system-prompt.ts:154-157(自定义提示词路径 :63-67
    • 会话侧组装与展开:packages/coding-agent/src/core/agent-session.ts:1021-1054:1301-1325
    • TUI 命令注册:packages/coding-agent/src/modes/interactive/interactive-mode.ts:636-648
    • 测试:packages/coding-agent/test/skills.test.tstest/resource-loader.test.tstest/sdk-skills.test.tstest/suite/regressions/2781-skill-collision-precedence.test.ts
    • 官方文档:packages/coding-agent/docs/skills.md
  • 自测问题:
    1. 一个 SKILL.md 的 frontmatter 只写了 name 没写 description,它会被加载吗?只写了 description 没写 name 呢?分别说出对应源码行。
    2. pi --tools bash,edit --skill ./my-skill 启动,my-skill 会出现在启动清单里吗?会出现在系统提示词里吗?为什么?
    3. 一个 skill 加了 disable-model-invocation: true,用户仍然想用它,有几种办法?它们分别走图 7.2-2 里的哪条路径?
    4. 为什么 --no-skills --skill ./x 这个看似矛盾的组合是有意义的?给出源码依据。
  • 下一章:7.3 自定义工具与斜杠命令——本章反复强调「Skill 不是 tool」,下一章讲真正往工具列表里加东西是怎么做的。
  • 尚未展开的内容:Skill 的 npm / git 包分发(PackageSourcepackage.jsonpi.skills 清单,位于 package-manager.ts 其余 2000 余行)留待深入;prompt-templates.ts 的模板展开机制(纯客户端字符串替换,与 skill 的「模型自主加载」正好形成对照)见 6.7 系统提示词与 Prompt Templates;扩展在 before_agent_start 事件里整体覆盖系统提示词的能力(agent-session.ts:1246-1253)见 7.1 Extension 系统

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)