阶段四 · 编码代理 | 第 12 讲(全 14 讲)
第 12 讲Skills、提示模板、主题与 Pi Packages 生态
第 11 讲的扩展是「写代码接入运行时」;本讲的四件套是「不写代码(或只写声明)教 Pi 新工作流」:技能(Skills)按需注入说明书,提示模板(Prompt Templates)复用文本,主题(Themes)换终端配色,Pi Packages 把三者连同扩展一起用 npm / git 分发。
学习目标
SKILL.md 的规范形态:frontmatter 七个字段、目录结构(scripts/ / references/ / assets/)与 Agent Skills 规范的兼容关系。read 工具自取;会用 /skill:name 强制加载。/ 命令,会用 JSON 主题文件定制终端配色。pi install npm:/git: 的安装解析、package.json 的 pi.* 声明、package-manager.ts / pi-manifest.ts 的资源发现,以及宿主依赖必须放 peerDependencies 的原因。packages/coding-agent/docs/skills.md与src/core/skills.ts:技能规范、校验、按需注入(精读)packages/coding-agent/docs/prompt-templates.md与src/core/prompt-templates.ts:模板展开与参数替换packages/coding-agent/docs/themes.md与src/modes/interactive/theme/:主题文件格式与 system 主题packages/coding-agent/docs/packages.md、src/core/package-manager.ts、src/core/pi-manifest.ts:包安装与资源解析- 仓库根
.pi/skills/github-work.md、.pi/skills/release.md:项目自用技能的真实写法(选读)
§12.1轻量定制四件套:定位与选型
Pi 的定制能力是一条「成本递增」的谱线:改一个 JSON 就能换配色,写一个 Markdown 文件就能复用提示,放一个目录就能教模型一套工作流,写 TypeScript 才需要动用第 11 讲的扩展系统。本讲的四件套正好落在这条谱线的低代码端。
先给一句话类比:提示模板是一张「便签」——把常用输入展开成文本起点;技能是一本「随用随取的说明书」——平时只露封面(名字和描述),翻开时才读全文;主题是一层「皮肤」——只影响终端呈现,不改变任何行为;Pi 包是一个「礼盒」——把前几样连同扩展打进一个 npm / git 可分发的单元。
| 需求 | 用哪件 | 是否可执行 | 进入上下文的时机 |
|---|---|---|---|
| 同一段提示词反复手敲(如代码审查、周报生成) | Prompt template(~/.pi/agent/prompts/*.md 或 .pi/prompts/) |
否(纯文本展开) | 输入 /name 的瞬间整体展开进用户消息 |
| 一套完整工作流:多步指令 + 参考资料 + 辅助脚本 | Skill(SKILL.md + scripts/ / references/ / assets/) |
脚本由模型按指令调用工具执行 | 启动只注入三行元数据;任务匹配时模型用 read 自取全文 |
| 终端 / HTML 导出配色 | Theme(~/.pi/agent/themes/*.json 或 .pi/themes/) |
否(纯呈现) | 不进模型上下文 |
| 新增工具、拦截事件、改 UI | Extension(第 11 讲,TypeScript) | 是(真实代码注册进运行时) | 按扩展机制注入(工具 schema、事件处理器等) |
| 把以上任意几样分享给团队 | Pi package(pi install npm: / git: / 本地路径) |
取决于内含资源 | 由内含资源决定 |
判断口诀:只是文本起点,用模板;需要模型「按图索骥」地执行一套流程,用技能;要改程序行为,才写扩展;要分发,就打包。官方文档对技能的定位原话是:"Use a skill when a workflow needs more context than a prompt template but does not need a new executable integration point."——比模板需要更多上下文,但还不需要新的可执行接入点。
§12.2SKILL.md:技能的规范形态
§12.2.1目录结构与最小示例
一个技能就是一个包含 SKILL.md 的目录。官方文档给出的规范结构:
pdf-tools/
├── SKILL.md
├── scripts/
│ └── extract.sh
├── references/
│ └── formats.md
└── assets/
└── template.json
SKILL.md 本身是一段 frontmatter 加直接指令:
---
name: pdf-tools
description: Extract text and tables from PDF files. Use when reading, converting, or inspecting PDFs.
---
# PDF tools
Read `references/formats.md` before converting a document. Run scripts relative to this skill directory.
§12.2.2frontmatter 全字段表
Pi 实现了 Agent Skills 规范(agentskills.io)。规范定义的字段如下,Pi 全部接受;多数无效字段只产生警告而不会阻止启动:
| 字段 | 类型 | 用途 | Pi 的处理 |
|---|---|---|---|
name | string | 命令与显示名(/skill:name 即由此而来) | 缺失时回退到父目录名;按规范校验格式(见下) |
description | string | 给模型看的路由描述 | 必填;缺失或空白的技能不加载 |
license | string | 许可证名或随包许可证文件 | 透传元数据 |
compatibility | string | 环境要求(如操作系统、依赖工具) | 透传元数据,Pi 不强制拦截 |
metadata | key-value | 任意附加元数据 | 透传 |
allowed-tools | string[] | 实验性的预批准工具列表 | 实验性字段,当前仅作声明 |
disable-model-invocation | boolean | 对模型隐藏该技能 | true 时从系统提示的技能清单中剔除,仅 /skill:name 显式可用 |
名称与描述的硬性约束直接写在源码的校验函数里——名字最长 64 字符、描述最长 1024 字符:
/** Max name length per spec */
const MAX_NAME_LENGTH = 64;
/** Max description length per spec */
const MAX_DESCRIPTION_LENGTH = 1024;
function validateName(name: string): string[] {
const errors: string[] = [];
if (name.length > MAX_NAME_LENGTH)
errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`);
if (!/^[a-z0-9-]+$/.test(name))
errors.push(`name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)`);
if (name.startsWith("-") || name.endsWith("-"))
errors.push(`name must not start or end with a hyphen`);
if (name.includes("--"))
errors.push(`name must not contain consecutive hyphens`);
return errors;
}
§12.2.3Agent Skills 规范兼容与宽容策略
因为实现了开放规范,Pi 的技能可以与 Claude 等其他实现互换。Pi 在兼容之外有几点自己的宽容与坚持:
- 名字与目录名不一致:Pi 既不要求也不警告(frontmatter 的
name优先于父目录名)。但规范的其他实现可能强制二者一致,所以「目录名 = frontmatter name」仍是可移植的写法。 - 格式非法只警告:超长、非法字符等产出 warning,不阻断启动;但 malformed 的 SKILL.md 与 声明了却没有 description 的技能 不会被加载。
- 名字冲突保留先发现者:同名技能,先被发现者胜出,后者产生 collision 警告。
- 独立 Markdown 技能:Pi 也接受单独的
.md文件(仓库根.pi/skills/里就是这么用的),但「含 SKILL.md 的目录」是规范的便携形态,应优先使用。
§12.2.4描述写法决定路由质量
技能没有工具调用、没有 if-else,模型「选不选用它」完全取决于对 description 的语义匹配。官方文档明确给出反例:"Helps with PDFs" 这种描述没有提供任何路由信息;正例的写法是 "Extract text and tables from PDF files. Use when reading, converting, or inspecting PDFs."——一句话里同时包含做什么(what)与何时适用(when)。
写作模板可以总结为:description: <能力动词短语>. Use when <触发场景关键词>.。再对照仓库自己的技能,Pi 作者写的 github-work 正是这种「what + when」双段式:
---
name: github-work
description: Work on GitHub issues and pull requests of this repo. Use when asked to
investigate, analyze, fix, or review an issue or PR, and when asked to wrap up
such work (changelog, comment, commit, push, close).
---
# Working on GitHub issues and PRs
The repo's prompt templates in `.pi/prompts/` (two directories above this skill)
define the workflow. Read the matching template in full and follow it.
§12.3按需加载:上下文经济学
技能机制的核心洞察是:系统提示里的每个字节都是「租金」——所有技能的全文常驻上下文,等于让每个会话为所有工作流付费。Pi 的解法是把说明书做成「广告 + 自取」两段式。
源码层面,注入广告的函数是 skills.ts 的 formatSkillsForPrompt()。注意第一行的过滤:disable-model-invocation 的技能从这一步就被剔除:
export function formatSkillsForPrompt(
skills: Skill[], fileReadTool: "read" | "bash" | "indirect" = "read"
): 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.",
fileReadTool === "read"
? "Use the read tool to load a skill's file when the task matches its description."
: /* bash / indirect 变体,此处省略 */ "",
"Resolve relative paths against the skill's directory (parent of SKILL.md). Use absolute paths in tool calls.",
"",
"<available_skills>",
];
for (const skill of visibleSkills) {
lines.push(" <skill>");
lines.push(` <name>${escapeXml(skill.name)}</name>`);
lines.push(` <description>${escapeXml(skill.description)}</description>`);
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
lines.push(" </skill>");
}
lines.push("</available_skills>");
return lines.join("\n");
}
§12.3.1/skill:name 强制调用与 disable-model-invocation
模型路由可能失手(任务相关但描述没写清触发词)。这时用户可以输入 /skill:pdf-tools extract report.pdf 强行加载:前缀匹配技能名,剩余参数作为附加请求拼接。实现位于 agent-session.ts 的 _expandSkillCommand():
private _expandSkillCommand(text: string): string {
if (!text.startsWith("/skill:")) return text;
const spaceIndex = text.indexOf(" ");
const skillName = spaceIndex === -1 ? text.slice(7) : text.slice(7, spaceIndex);
const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
const skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName);
if (!skill) return text; // 未知技能,原样传递
const content = readFileSync(skill.filePath, "utf-8");
const body = stripFrontmatter(content).trim();
const skillBlock =
`<skill name="${skill.name}" location="${skill.filePath}">\n` +
`References are relative to ${skill.baseDir}.\n\n${body}\n</skill>`;
return args ? `${skillBlock}\n\n${args}` : skillBlock;
}
skill_expansion 错误(此处省略 try/catch)。两个配套细节:
enableSkillCommands设置控制/skill:name是否出现在交互式命令补全里;手动输入的命令始终有效,设置只影响「发现性」。disable-model-invocation: true适合「只想让人调用」的技能:比如消耗较高、有副作用、或面向特定运维流程的指令集。它仍然能被/skill:name加载——被隐藏的只是模型自动路由这一条路。- 编辑技能后无需重启会话,
/reload即可重新扫描。
这套「启动只登记元数据、用时才真正加载」的模式,几乎就是 ServiceLoader 读 META-INF/services/ 描述文件 + 第一次调用才实例化实现的组合:系统提示里的 <available_skills> 相当于服务描述清单,read 工具读取 SKILL.md 相当于首次 ServiceLoader.load(...).iterator().next() 触发类加载。
区别在于 Java 的路由是类型精确匹配,而这里的「路由器」是一个语言模型——所以 description 承担了 Java 里「接口全限定名」的角色:写得含糊,等于把 SPI 接口起名叫 MyService2,没人找得到它。
§12.4发现位置与作用域:从哪扫、扫到哪
Pi 从四类默认位置发现技能(另有 Settings 的 skills 显式路径与包内 skills/,见 §12.7):
| 位置 | 作用域 | 信任门槛 | 说明 |
|---|---|---|---|
~/.pi/agent/skills/ | 用户全局 | 无需项目信任 | agent 目录下的技能根,递归发现含 SKILL.md 的目录 |
<cwd>/.pi/skills/ | 项目 | 项目信任(project trust)后加载 | 随仓库分发的团队技能;因为技能可指示模型跑脚本,未授权项目不加载 |
~/.agents/skills/ | 用户全局 | 始终视为受信任的用户资源 | Agent Skills 规范位置,与其他实现共享技能库 |
<cwd>/.agents/skills/(沿祖先上溯) | 项目 | 项目信任后加载 | 从工作目录逐级向上收集 .agents/skills,存在仓库根(.git)时停在仓库根;否则走到文件系统根 |
包内 skills/ 目录或 pi.skills 清单 | 随包(个人或项目 settings) | 项目声明需信任 | 由 PackageManager 解析(§12.7) |
向上遍历的实现在 package-manager.ts,逻辑非常克制——先找 .git 定出仓库根,再逐级压栈:
function findGitRepoRoot(startDir: string): string | null {
let dir = resolve(startDir);
while (true) {
if (existsSync(join(dir, ".git"))) return dir;
const parent = dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function collectAncestorAgentsSkillDirs(startDir: string): string[] {
const skillDirs: string[] = [];
const resolvedStartDir = resolve(startDir);
const gitRepoRoot = findGitRepoRoot(resolvedStartDir);
let dir = resolvedStartDir;
while (true) {
skillDirs.push(join(dir, ".agents", "skills"));
if (gitRepoRoot && dir === gitRepoRoot) break;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return skillDirs;
}
hasTrustRequiringProjectResources() 会把 cwd 或祖先里的 .agents/skills 判为需要项目信任的资源。目录内部的发现规则(loadSkillsFromDirInternal 的文档注释):
- 某目录含
SKILL.md→ 它就是一个技能根,不再继续下钻(子目录属于技能私有资源,不是新技能); - 否则,根目录下的直接
.md子文件可各自作为独立技能(仓库.pi/skills/*.md就是这种形态); - 子目录递归寻找
SKILL.md;跳过node_modules与点开头的隐藏目录;尊重.gitignore/.ignore/.fdignore规则; - 同名冲突:先到先得,后到者记 collision 警告;符号链接会解析真实路径去重。
§12.5Prompt Templates:可复用的 / 命令
提示模板把 Markdown 文件变成编辑器里的 / 命令。它解决的是最朴素的重复:每次评审代码都要手敲一大段审查要求?把它存成模板,以后只敲 /review。官方示例:
---
description: Review staged git changes
argument-hint: "[focus]"
---
Review the staged changes. Focus on ${1:-correctness, security, and error handling}.
/review。description 出现在命令补全(缺省取正文第一个非空行);argument-hint 用 <尖括号> 表示必填参数、[方括号] 表示可选。改完模板同样 /reload 生效。加载位置与技能对称:用户级 ~/.pi/agent/prompts/、项目级 <cwd>/.pi/prompts/(信任后)、Settings 显式路径、以及 Pi 包。约定目录只扫描直接的 .md 子文件(不递归);包清单与 settings 可以用显式路径和 glob 深入嵌套目录。
§12.5.1参数替换:bash 风格的位置参数
| 语法 | 展开结果 |
|---|---|
$1, $2, … | 第 N 个位置参数 |
$@ 或 $ARGUMENTS | 全部参数以空格连接 |
${1:-default} | 第 1 个参数,缺省(或为空)时用 default |
${@:-default} | 全部参数,为空时用 default |
${@:N} | 从第 N 个开始的全部参数 |
${@:N:L} | 从第 N 个开始取 L 个参数 |
参数解析遵循 shell 式引号规则(parseCommandArgs):引号只在参数开头才开启引用,所以 doesn't 里的撇号保持原样;未闭合的引号按普通文本处理;引号内用反斜杠转义同类引号("the \"fast\" path" → the "fast" path),其余反斜杠原样保留。替换发生在模板字符串上,参数值里的 $1 不会被递归再替换。
入口函数 expandPromptTemplate() 相当简洁——前缀匹配命令名,找到就解析参数并替换:
export function expandPromptTemplate(text: string, templates: PromptTemplate[]): string {
if (!text.startsWith("/")) return text;
const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
if (!match) return text;
const templateName = match[1];
const argsString = match[2] ?? "";
const template = templates.find((t) => t.name === templateName);
if (template) {
const args = parseCommandArgs(argsString);
return substituteArgs(template.content, args);
}
return text;
}
_preparePrompt):先跑扩展的 input 事件钩子 → 再展开 /skill:name → 再展开模板。同名时扩展命令优先于模板命令。§12.5.2模板与技能的边界
- 模板 = 文本起点:展开即结束,没有第二阶段;适合「把这段话作为提示词发出去」。
- 技能 = 完整工作流:有路由(description 匹配)、有随附资源(references / scripts / assets)、正文指挥模型按步骤取用;适合多步骤、需要参考资料的过程。
- 成本模型不同:模板展开的内容必然全程占用上下文;技能正文只在被加载的那次对话占上下文,平时只有三行广告。
- 组合用法:技能正文指去读模板(如仓库的
github-work技能路由到.pi/prompts/is.md等模板),让「触发」与「内容」解耦。
${1:-correctness, security…} 等价于 commons-text StringSubstitutor 的变量 + 默认值语法,只是变量名换成了 bash 风格的位置参数;${@:N:L} 则是 bash 的切片语法照搬。展开逻辑是纯正则替换加一层 shell 式分词器(parseCommandArgs),没有模板引擎的可编程性——这正是刻意的:模板只产生文本,不会有副作用。
§12.6Themes:终端配色系统
主题控制交互模式与 HTML 导出中 Pi 使用的颜色。内置 system、dark、light 三个,默认 system。
§12.6.1system 主题:跟随终端的调色板
system 不是一套固定颜色,而是从终端取色再重建:启动时查询终端的默认前景/背景色和 16 个 ANSI 颜色(通常几毫秒内返回,Pi 最多等 100ms);每个 UI 角色的色相取自某个 ANSI 色(错误取红、链接取蓝),再按背景亮度调整明度,保证正文对背景和面板至少 4.5:1 的 WCAG 对比度。终端在明暗之间切换时,Pi 会重新查询并重建主题。三种响应级别:
| 终端报告 | 结果 |
|---|---|
| 背景色 + ANSI 色 | 全部取自终端调色板,并按实际背景放置 |
| 仅背景色 | Pi 自己的色相,按实际背景放置 |
| 什么都不报告 | 退回 ANSI 索引 + 终端默认色,由终端自己渲染;次级文字变淡、面板无底色 |
§12.6.2选择与自动切换
/settings→ Theme 选择,写入theme设置,如{ "theme": "dark" };缺省即system。- 自动模式写作
"light/dark"(浅色在前、深色在后):Pi 依据终端报告的背景/前景判断明暗,其次用终端明暗通知、COLORFGBG环境变量,最后默认 dark。主题名不能含/,因为该符号保留给这种「浅/深」对写法;system是保留名,自定义同名主题会被忽略。 - 单次运行临时换主题:
pi --use-theme light或pi --use-theme light/dark,不改动保存的设置。
§12.6.3自定义主题文件
自定义主题是一个 JSON 文件,放在 ~/.pi/agent/themes/my-theme.json(agent 目录默认 ~/.pi/agent),文件名即主题名;项目主题放 .pi/themes/(信任后加载)。顶层字段:
| 属性 | 必填 | 职责 |
|---|---|---|
$schema | 否 | 指向 theme.schema.json,启用编辑器校验与补全 |
name | 是 | 标识主题;唯一、不含 /、不能叫 system |
appearance | 否 | "dark" / "light":主题面向的背景;缺省时由颜色推断 |
vars | 否 | 可复用颜色变量,可引用其他变量(Pi 解析引用链;缺失或循环引用则主题无效) |
colors | 是 | 为终端 UI 角色(而非单个组件)分配颜色,如 accent / error / userMessageBg / toolDiff* / syntax* / md* |
export | 否 | 覆盖 HTML 导出的页面与面板背景;缺省时由 userMessageBg 推导 |
颜色值支持六种写法:RGB 十六进制("#0af" / "#00aaff")、OKLCH、OKHSL、256 色 ANSI 索引(39)、变量引用("primary")、终端默认色("")。内置主题用 OKHSL:饱和度是相对该色相/明度下 sRGB 色域上限的比例,等饱和度看起来等鲜艳。truecolor 可用时直接渲染,256 色终端做近似,HTML 导出把 OKHSL 转成十六进制(CSS 不支持 OKHSL)。主题对象是严格 schema:未记录的顶层字段与颜色 token 会被拒绝;自定义色一律放 vars。看一眼内置 dark.json 的真实节选:
{
"$schema": "https://pi.dev/schemas/theme.schema.json",
"name": "dark",
"appearance": "dark",
"vars": {
"text": "okhsl(234 3% 89%)",
"muted": "okhsl(229 6% 67%)",
"violet": "okhsl(295 50% 67%)",
"green": "okhsl(159 59% 67%)",
"red": "okhsl(20 72% 67%)",
"blueBg": "okhsl(233 41% 24%)"
},
"colors": {
"accent": "violet",
"border": "okhsl(231 57% 65%)",
"success": "green",
"error": "red",
"warning": "yellow",
"muted": "muted",
"text": "text",
"selectedBg": "blueBg",
"userMessageBg": "blueBg",
"userMessageText": "text",
"toolPendingBg": "okhsl(229 5% 24%)",
"toolSuccessBg": "okhsl(158 46% 25%)",
"toolErrorBg": "okhsl(19 54% 25%)",
"mdLink": "blue",
"mdCode": "violet"
}
}
/reload。重名主题按资源冲突(collision)上报。§12.7Pi Packages:npm / git 分发定制资源
Pi 包就是「普通目录或 npm 包」:它能暴露约定的资源目录、在 package.json 的 pi 键下声明显式路径,并携带自己的运行时依赖。安装与管理的命令面:
pi install npm:@example/pi-tools@1.0.0
pi install git:github.com/example/pi-tools@v1
pi install ./local-package
pi list # 查看已配置的包
pi remove <source> # 移除一个
pi update --extensions # 对账式更新包安装
pi -e npm:@example/pi-tools # 单次试用,不写入设置
~/.pi/agent/settings.json;加 --local / -l 写入 .pi/settings.json(仅项目信任后读取)。-e 临时包缓存在 ~/.pi/agent/tmp/extensions/,每次启动与 /reload 时检查:npm 有更新匹配版本则重装(精确锁定 @1.2.3 不检查);git 包拉取并重置到远端 ref(已是目标 commit 则不拉取);检查失败或离线时用缓存副本。| 来源 | 示例 | 行为 |
|---|---|---|
| npm | npm:@example/pi-tools@1.0.0 | 安装到 Pi 的 npm 目录;带版本号的规格会被锁定 |
| git | git:github.com/example/pi-tools@v1 | 克隆并对账到选定 ref;tag 与 commit 也视为锁定,更新不会移动已配置的 ref |
| URL | https://github.com/example/pi-tools | 按 git 来源处理 |
| 本地 | ./pi-tools | 从解析后的路径加载,不复制;相对路径以包含它的 settings 文件为基准解析;文件路径视为单个扩展,目录走正常包发现 |
身份与容错也值得一提:npm 包按包名、git 包按去掉 ref 的仓库 URL、本地包按解析后的绝对路径识别,防止同一包以等价声明加载两次。启动时若某包安装失败(如 npm 无法访问 registry),Pi 警告并跳过其资源继续运行,下次启动或 /reload 再试;pi install 与 pi update --extensions 仍会报错并以非零码退出。
§12.7.1包内资源类型与发现规则
collectPackageResources() 里一次完成;注意 pi 对象只有包含资源字段时才算「显式清单」,只放 changelogPath / 画廊图等元数据时仍走目录自动发现。清单读取函数小而清晰,值得整段读——readPiManifest() 只认 package.json 里 pi 键的四个资源数组与 changelogPath:
export interface PiManifest {
extensions?: string[];
skills?: string[];
prompts?: string[];
themes?: string[];
changelogPath?: string;
}
export function readPiManifest(packageJsonPath: string): PiManifest | null {
try {
const pkg: unknown = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8")));
if (!isObject(pkg) || !isObject(pkg.pi)) return null;
const manifest: PiManifest = {};
for (const field of RESOURCE_FIELDS) {
const entries = pkg.pi[field];
if (Array.isArray(entries) && entries.every((e) => typeof e === "string"))
manifest[field] = entries;
}
if (typeof pkg.pi.changelogPath === "string")
manifest.changelogPath = pkg.pi.changelogPath;
return manifest;
} catch { return null; }
}
RESOURCE_FIELDS = ["extensions", "skills", "prompts", "themes"]。消费侧的裁决逻辑在 collectPackageResources():清单有资源字段 → 完全按清单;否则扫描四个约定目录:
if (manifest && hasManifestResourceFields(manifest)) {
for (const resourceType of RESOURCE_TYPES) {
const entries = manifest[resourceType];
this.addManifestEntries(entries, packageRoot, resourceType,
this.getTargetMap(accumulator, resourceType), metadata);
}
return true;
}
// 无显式资源清单:回退到约定目录自动发现
let hasAnyDir = false;
for (const resourceType of RESOURCE_TYPES) {
const dir = join(packageRoot, resourceType);
if (existsSync(dir)) {
const files = collectResourceFiles(dir, resourceType);
for (const f of files)
this.addResource(this.getTargetMap(accumulator, resourceType), f, metadata, true);
hasAnyDir = true;
}
}
return hasAnyDir;
{ "pi": { "extensions": ["./src/extension.ts"], "skills": ["./resources/skills"], "prompts": ["./resources/prompts/*.md"], "themes": ["./resources/themes/*.json"] } }。路径相对包根,数组支持 glob 与排除;点前缀或符号链接的资源根要直接列出,因为 glob 遍历发现不了它们。§12.7.2安装机制:npm 前缀与 git 对账
installNpm() 用 npm install --prefix 把包装进 Pi 自己的 npm 目录;installGit() 则克隆、检出 ref、并在存在 package.json 时安装其依赖:
private async installGit(source: GitSource, scope: SourceScope): Promise<void> {
const targetDir = this.getGitInstallPath(source, scope);
if (existsSync(targetDir)) { /* 已存在:fetch 并对齐 ref 后返回(省略) */ return; }
// ...建目录、清理更新标记(省略)
await this.runCommand("git", ["clone", source.repo, targetDir]);
if (source.ref) {
await this.runCommand("git", ["checkout", source.ref], { cwd: targetDir });
}
const packageJsonPath = join(targetDir, "package.json");
if (existsSync(packageJsonPath)) {
await this.runNpmCommand(this.getGitDependencyInstallArgs(), { cwd: targetDir });
}
}
getPackageManagerName() 识别,安装参数因管理器而异)。离线模式由 PI_OFFLINE=1/true/yes 开启,跳过一切网络访问。§12.7.3运行时依赖声明规则:为什么宿主包不能进 dependencies
这是本讲最容易踩坑、也最能体现「宿主边界」思维的一条规则。Pi 在运行时向扩展与技能供给以下包:
@earendil-works/pi-ai@earendil-works/pi-agent-core@earendil-works/pi-coding-agent@earendil-works/pi-tuitypebox
规则:
- 宿主提供的包应声明在
peerDependencies且版本范围为"*",不要打包进产物。Pi 会对托管 npm 包和用 npm/pnpm/Bun 安装的 git 包抑制自动 peer 安装。 - 绝不把宿主提供的包列进
dependencies:物理副本会在编译后的 ESM 中绕过 Pi 的扩展模块映射,制造重复的类、注册表与初始化——Pi 检测到这种清单配置会报扩展警告。 - 扩展真正 import 的其他运行时包放
dependencies,安装 npm / git 源时会一并安装。 - 依赖的其他 Pi 包必须包含在发布的 tarball 中,并通过其
node_modules资源路径引用。 - 每个已安装的包以独立模块根加载:不要指望两个包共享同一依赖实例,也不要指望一个包能解析另一个包未声明的依赖。本地包不安装不修改,依赖树由作者自己负责。
Servlet 容器提供 servlet-api,WAR 里绝不该再打一份——否则 ClassLoader 加载出两个 HttpServlet.class,instanceof 与静态注册表全部错乱。Pi 的规则同构:宿主(pi 进程)已经持有 pi-coding-agent 等模块并做了「扩展 import → 宿主实例」的映射;包里再带一份物理副本,就等于 WAR 里塞 servlet-api。而「每个包独立模块根」类似 OSGi 的每 Bundle 独立 ClassLoader:隔离优先,共享需显式声明。
§12.7.4settings 里的资源选择与作用域叠加
settings 中包声明可以用对象形式收窄加载哪些资源:
{
"packages": [
{
"source": "npm:@example/pi-tools",
"extensions": ["extensions/*.ts", "!extensions/legacy.ts"],
"skills": [],
"prompts": ["prompts/review.md"]
}
]
}
[] = 该类一个不装;!pattern 排除 glob 匹配;+path 精确包含;-path 精确排除。过滤器只能收窄,不能暴露包本身未声明的资源。同一包可同时出现在个人与项目 settings:项目条目通常替换个人条目;autoload: false 的项目条目则作为个人条目之上的「过滤增量」。用 pi config 可交互启停资源,Tab 切换作用域。包更新后还能展示变更日志:加载至少一个扩展的包,Pi 在下次全新启动时读取包 changelog(默认 CHANGELOG.md,可用 pi.changelogPath 指定或置空禁用),只显示比上次记录版本新的条目,格式与 Pi 自己的 ## [1.2.0] 版本标题一致。只提供 skills / prompts / themes 的包不显示 changelog。pi-package 关键字让 npm 包可被 Pi 包画廊(pi.dev/packages)发现,可选 pi.image / pi.video 提供预览图。
§12.8生态观:站在规范与集合之上
本讲机制的价值最终由生态兑现。两个官方推荐的技能集合:
- Anthropic skills 集合(github.com/anthropics/skills):Claude 生态的技能库。因为 Pi 实现的是同一份 Agent Skills 规范(目录 + SKILL.md + 同样的 frontmatter 字段),Anthropic 的技能可以基本直接放进
~/.pi/agent/skills/或.agents/skills/使用——这正是「实现开放规范」的回报。 - Pi skills 集合(github.com/badlogic/pi-skills):Pi 作者维护的技能集合,适合作为 Pi 特有工具集(bash / read / grep 等)下的写法范本。
对团队的实践建议:把「团队 Java 代码规范、数据库变更审查流程、发布 checklist」这类写得出说明书、但不需要新工具的知识做成技能;要跨项目强制生效,就升级为 Pi 包(技能 + 提示模板 + 主题 + 可选扩展)发到内部 npm registry,成员一条 pi install npm:@your-org/pi-standards 即可。第 14 讲的安全清单同样适用于此:安装前审查第三方包源码与 SKILL.md、scripts——技能会指示模型执行程序,这是真实的执行面。
§12.9动手实践:数据库变更审查技能与本地包
~/.pi/agent/skills/db-review/SKILL.md 写 frontmatter(name: db-review;description 写清「审查 SQL/DDL 变更 + 何时触发」双段式),正文分步:先读 references/standards.md(团队规范:索引、迁移、回滚要求),再用 scripts/extract-ddl.sh 从 diff 中提取 DDL,最后按规范输出审查结论。注意正文中引用随附文件一律用相对技能目录的路径。/skill:db-review 迁移文件路径 强制调用对比。给 description 换成含糊版重试一次,体会路由质量差异。disable-model-invocation: true,/reload 后确认模型不再自动选中,而 /skill:db-review 仍可用。~/.pi/agent/prompts/ddl.md(带 argument-hint: "<migration-file>",正文用 ${1:-当前 staged 变更});复制内置 dark.json 改出 my-theme.json,在 /settings 里选中验证。pi-db-standards/:package.json(含 keywords: ["pi-package"])+ 约定目录 skills/db-review/、prompts/ddl.md、themes/my-theme.json,再加一个最小扩展 extensions/check.ts。宿主包声明进 peerDependencies: { "@earendil-works/pi-coding-agent": "*" }。执行 pi install ./pi-db-standards 与 pi list 验证。dependencies 安装一次,观察 Pi 的扩展警告;改回 peerDependencies 后 pi update --extensions 对账,确认警告消失。§12.10常见误区
误区一:把 SKILL.md 全文塞进系统提示才算「加载了技能」。恰恰相反:启动时注入的只有 name / description / location 三元组广告,全文由模型在任务匹配时用 read 工具自取。技能的数量成本极低(每技能三行),但每次被加载的正文是实打实的上下文支出——正文应写「读什么、做什么」,不要把 references 全文复制进 SKILL.md。
误区二:description 写成 "Helps with databases" 这种一句话标签。description 是唯一的路由信号,必须同时写清「做什么」与「何时适用」。含糊描述 = 模型永远想不起这个技能,机制形同虚设。照官方反例/正例的差距校准自己的写法。
误区三:为图省事把 @earendil-works/pi-* 放进 dependencies。物理副本会绕过扩展模块映射,在编译 ESM 中产生重复的类、注册表和初始化工作。宿主提供的五个包必须以 peerDependencies: "*" 声明且不打包;Pi 检测到违规清单会报扩展警告。
误区四:以为项目 .pi/skills/ 与 .agents/skills/ 无条件生效。项目技能只在 project trust 授予后加载(.agents/skills 同样计入需要信任的项目资源);用户全局的 ~/.agents/skills/ 则始终视为受信任用户资源。技能可指示模型运行脚本,这个门槛是有意为之的安全边界。
§12.11自测问答
技能的 description 应该同时写清什么?为什么?点击展开答案
同时写清技能做什么(what)与何时适用(when)。因为 Pi 不做任何基于代码的路由——系统提示只注入 name/description/location 三元组,模型是否加载技能完全取决于对 description 的语义匹配。反例 "Helps with PDFs" 缺少触发信息;正例 "Extract text and tables from PDF files. Use when reading, converting, or inspecting PDFs." 一句话完成两个任务。上限 1024 字符。
disable-model-invocation 何时使用?它会不会让技能彻底失效?点击展开答案
用于「只应被人调用」的技能:成本高、有副作用、或属于特定运维流程,不应让模型自行决定加载。它不会让技能失效——该技能只是从 formatSkillsForPrompt() 生成的 <available_skills> 广告中被过滤,模型无法自动路由,但 /skill:name 显式命令始终可用。配套的 enableSkillCommands 设置只控制命令是否出现在交互式补全里,不影响手输命令。
打包时为什么不能把宿主提供的包(如 @earendil-works/pi-coding-agent)列进 dependencies?正确做法是什么?点击展开答案
宿主(pi 进程)在运行时已向扩展供给这些模块并建立了「扩展 import → 宿主实例」的映射。dependencies 里的物理副本会在编译后的 ESM 中绕过该映射,加载出第二份类与注册表(重复初始化、类型不相等、注册表分裂)。正确做法:宿主提供的五个包(pi-ai、pi-agent-core、pi-coding-agent、pi-tui、typebox)声明在 peerDependencies 且范围 "*",不打包进产物;扩展真正需要的其他运行时依赖才放 dependencies。
项目 .agents/skills/ 的向上发现规则是什么?在哪停住?点击展开答案
从当前工作目录开始,逐级向上收集每一层的 .agents/skills 目录;如果祖先链上存在 .git(即位于一个 Git 仓库内),遍历停在仓库根,否则一直走到文件系统根。这样 monorepo 子目录能看到仓库级技能,又不会带进仓库之外的目录。这些项目位置的技能需 project trust 授予后才加载。
prompt template 与 skill 的适用边界怎么划?各自何时进入上下文?点击展开答案
模板是「文本起点」:输入 /name 即整体展开进用户消息,一次性、无第二阶段,适合复用固定提示;技能是「完整工作流」:带路由描述与 references/scripts/assets 随附资源,正文指挥模型按步骤取用。进入上下文的时机不同——模板展开内容必然全程占用上下文;技能平时只占三行广告位,正文只在模型(或用户经 /skill:name)加载后占用。二者可组合:技能负责触发路由,正文指去读模板(如仓库 github-work 技能)。
Pi 包在没有 pi 清单时如何发现资源?什么情况下 pi 对象才算「显式清单」?点击展开答案
无清单时按约定目录自动发现:extensions/(智能发现 .ts/.js 与 index 入口)、skills/(递归找 SKILL.md)、prompts/、themes/。只有当 package.json 的 pi 对象包含至少一个资源字段(extensions/skills/prompts/themes)时才算显式清单并完全按清单加载;只含 changelogPath、画廊图等元数据的 pi 对象仍走目录自动发现。settings 里的对象过滤器只能收窄包声明的资源,不能新增。