π Pi Agent 源码学习课程 阶段四 · 12/14

阶段四 · 编码代理 | 第 12 讲(全 14 讲)

第 12 讲Skills、提示模板、主题与 Pi Packages 生态

第 11 讲的扩展是「写代码接入运行时」;本讲的四件套是「不写代码(或只写声明)教 Pi 新工作流」:技能(Skills)按需注入说明书,提示模板(Prompt Templates)复用文本,主题(Themes)换终端配色,Pi Packages 把三者连同扩展一起用 npm / git 分发。

预计 3 小时 前置:第 11 讲扩展系统 配套源码:pi-main 本地仓库
技能按需加载示意:系统提示只带一张薄广告卡,正文与脚本在任务匹配后才加载 system prompt name / description / location 薄广告卡:每个技能仅三行元数据 任务匹配 SKILL.md 全文(此时才读取) frontmatter 之后的正文指令 scripts/ 可执行脚本 references/ 参考资料 · assets/ 模板 /skill:name args 用户强制加载,绕过模型路由 disable-model-invocation 从广告中隐藏,仅显式命令可用

学习目标

掌握 SKILL.md 的规范形态:frontmatter 七个字段、目录结构(scripts/ / references/ / assets/)与 Agent Skills 规范的兼容关系。
理解技能的按需加载机制:启动时只把 name + description + path 三元组写进系统提示,正文等模型用 read 工具自取;会用 /skill:name 强制加载。
分清 prompt template 与 skill 的适用边界;会用模板参数替换语法写可复用的 / 命令,会用 JSON 主题文件定制终端配色。
吃透 Pi Packages: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 的目录。官方文档给出的规范结构:

packages/coding-agent/docs/skills.md目录结构示例
pdf-tools/
├── SKILL.md
├── scripts/
│   └── extract.sh
├── references/
│   └── formats.md
└── assets/
    └── template.json
三个子目录各司其职:scripts/ 放可执行脚本(模型按正文指令用 bash / powershell 调用),references/ 放随用随读的参考文档,assets/ 放模板、配置等静态产物。正文里引用这些文件一律用相对技能目录的相对路径。

SKILL.md 本身是一段 frontmatter 加直接指令:

packages/coding-agent/docs/skills.mdSKILL.md 最小形态
---
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.
frontmatter 只有两个字段是硬要求:name 与 description。description 决定模型「何时想起这个技能」——这是整个路由机制的信号源。

§12.2.2frontmatter 全字段表

Pi 实现了 Agent Skills 规范(agentskills.io)。规范定义的字段如下,Pi 全部接受;多数无效字段只产生警告而不会阻止启动:

字段类型用途Pi 的处理
namestring命令与显示名(/skill:name 即由此而来)缺失时回退到父目录名;按规范校验格式(见下)
descriptionstring给模型看的路由描述必填;缺失或空白的技能不加载
licensestring许可证名或随包许可证文件透传元数据
compatibilitystring环境要求(如操作系统、依赖工具)透传元数据,Pi 不强制拦截
metadatakey-value任意附加元数据透传
allowed-toolsstring[]实验性的预批准工具列表实验性字段,当前仅作声明
disable-model-invocationboolean对模型隐藏该技能true 时从系统提示的技能清单中剔除,仅 /skill:name 显式可用

名称与描述的硬性约束直接写在源码的校验函数里——名字最长 64 字符、描述最长 1024 字符:

packages/coding-agent/src/core/skills.ts节选 · name/description 校验
/** 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;
}
规则归纳:小写字母 + 数字 + 连字符;不得以连字符开头/结尾、不得出现连续连字符;上限 64 字符。描述必须非空且 ≤1024 字符。注意错误以警告(diagnostics)形式上报,技能通常仍会加载——真正被拒载的只有两种:SKILL.md 解析失败(malformed)与 description 缺失。

§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」双段式:

.pi/skills/github-work.md仓库真实节选
---
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 的解法是把说明书做成「广告 + 自取」两段式。

技能按需加载流程:启动扫描,只注入三元组广告;任务匹配后由 read 工具加载全文;用户可用 /skill:name 强制;disable-model-invocation 从广告中隐藏 ① 启动扫描 ~/.pi/agent/skills/ cwd/.pi/skills/ ~/.agents/skills/ .agents/skills/ (沿祖先上溯) packages → skills/ ② 注入「广告」进 system prompt <available_skills> <skill> name …</skill> <skill> description …</skill> <skill> location …</skill> </available_skills> ③ 任务到达,模型匹配 用户:"提取这份 PDF 里的表格" 模型比对各技能 description 命中 pdf-tools → 决定读取 (也可能漏选 → 见强制路径) read 工具读取 SKILL.md ④ 按指令取用随附资源 read references/formats.md(参考) bash scripts/extract.sh(执行) assets/template.json(产出模板) 强制路径:/skill:name args 用户显式指定,绕过模型路由 args 作为附加请求拼在正文之后 frontmatter 被剥掉再注入 disable-model-invocation: true 技能在 ② 被过滤,不出现在广告里 → ③ 模型永远无法自动选中它 → 只能走左侧强制路径。enableSkillCommands 设置只影响命令补全是否列出,手输 /skill:name 始终有效。
技能按需加载全流程。系统提示付出的固定成本只是每技能三行元数据;SKILL.md 正文、references、scripts 的成本只在真正用到时发生——这就是「上下文经济学」。

源码层面,注入广告的函数是 skills.ts 的 formatSkillsForPrompt()。注意第一行的过滤:disable-model-invocation 的技能从这一步就被剔除:

packages/coding-agent/src/core/skills.ts节选 · formatSkillsForPrompt
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");
}
注入的内容只有 name / description / location 三元组(略有省略)。引导语明确告诉模型「任务匹配描述时,用 read 工具加载技能文件」并要求以技能目录为基准解析相对路径——skills 与工具系统的协作点就在这句引导语。

§12.3.1/skill:name 强制调用与 disable-model-invocation

模型路由可能失手(任务相关但描述没写清触发词)。这时用户可以输入 /skill:pdf-tools extract report.pdf 强行加载:前缀匹配技能名,剩余参数作为附加请求拼接。实现位于 agent-session.ts 的 _expandSkillCommand():

packages/coding-agent/src/core/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;
}
强制路径做的事:剥掉 frontmatter → 包进 <skill> 标签(附 baseDir 提示)→ 把命令行参数作为追加请求拼在后面。这段展开发生在 input 事件(扩展拦截)之后、进入 agent 循环之前,所以扩展拿到的仍是原始输入。异常分支会经 extension runner 上报 skill_expansion 错误(此处省略 try/catch)。

两个配套细节:

  • enableSkillCommands 设置控制 /skill:name 是否出现在交互式命令补全里;手动输入的命令始终有效,设置只影响「发现性」。
  • disable-model-invocation: true 适合「只想让人调用」的技能:比如消耗较高、有副作用、或面向特定运维流程的指令集。它仍然能被 /skill:name 加载——被隐藏的只是模型自动路由这一条路。
  • 编辑技能后无需重启会话,/reload 即可重新扫描。
Java 视角:技能就是 ServiceLoader + 懒加载的 SPI

这套「启动只登记元数据、用时才真正加载」的模式,几乎就是 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 定出仓库根,再逐级压栈:

packages/coding-agent/src/core/package-manager.ts节选 · .agents/skills 祖先遍历
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;
}
停机条件有两条:到仓库根为止(存在 .git 时),否则一直到文件系统根。这保证了 monorepo 子目录里也能发现仓库级技能,同时不会把仓库之外父目录的技能带进来。trust-manager 中 hasTrustRequiringProjectResources() 会把 cwd 或祖先里的 .agents/skills 判为需要项目信任的资源。

目录内部的发现规则(loadSkillsFromDirInternal 的文档注释):

  • 某目录含 SKILL.md → 它就是一个技能根,不再继续下钻(子目录属于技能私有资源,不是新技能);
  • 否则,根目录下的直接 .md 子文件可各自作为独立技能(仓库 .pi/skills/*.md 就是这种形态);
  • 子目录递归寻找 SKILL.md;跳过 node_modules 与点开头的隐藏目录;尊重 .gitignore / .ignore / .fdignore 规则;
  • 同名冲突:先到先得,后到者记 collision 警告;符号链接会解析真实路径去重。

§12.5Prompt Templates:可复用的 / 命令

提示模板把 Markdown 文件变成编辑器里的 / 命令。它解决的是最朴素的重复:每次评审代码都要手敲一大段审查要求?把它存成模板,以后只敲 /review。官方示例:

packages/coding-agent/docs/prompt-templates.md官方示例 · ~/.pi/agent/prompts/review.md
---
description: Review staged git changes
argument-hint: "[focus]"
---
Review the staged changes. Focus on ${1:-correctness, security, and error handling}.
文件名即命令名:review.md → /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() 相当简洁——前缀匹配命令名,找到就解析参数并替换:

packages/coding-agent/src/core/prompt-templates.ts节选 · 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;
}
调用顺序(agent-session 的 _preparePrompt):先跑扩展的 input 事件钩子 → 再展开 /skill:name → 再展开模板。同名时扩展命令优先于模板命令。

§12.5.2模板与技能的边界

  • 模板 = 文本起点:展开即结束,没有第二阶段;适合「把这段话作为提示词发出去」。
  • 技能 = 完整工作流:有路由(description 匹配)、有随附资源(references / scripts / assets)、正文指挥模型按步骤取用;适合多步骤、需要参考资料的过程。
  • 成本模型不同:模板展开的内容必然全程占用上下文;技能正文只在被加载的那次对话占上下文,平时只有三行广告。
  • 组合用法:技能正文指去读模板(如仓库的 github-work 技能路由到 .pi/prompts/is.md 等模板),让「触发」与「内容」解耦。
Java 视角:模板 ≈ 带位置参数的 MessageFormat / StringSubstitutor

${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 的真实节选:

packages/coding-agent/src/modes/interactive/theme/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"
  }
}
示范了三种写法并用:vars 定义基色 → colors 用变量引用或直接字面量。五个可选色缺省继承:scrollbarTrack←muted、scrollbarThumb←text、searchMatchBg←selectedBg、searchMatchText←text、thinkingMax←thinkingXhigh。活动用户主题支持热重载;其他来源改动后 /reload。重名主题按资源冲突(collision)上报。

§12.7Pi Packages:npm / git 分发定制资源

Pi 包就是「普通目录或 npm 包」:它能暴露约定的资源目录、在 package.json 的 pi 键下声明显式路径,并携带自己的运行时依赖。安装与管理的命令面:

packages/coding-agent/docs/packages.md安装命令
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 则不拉取);检查失败或离线时用缓存副本。
来源示例行为
npmnpm:@example/pi-tools@1.0.0安装到 Pi 的 npm 目录;带版本号的规格会被锁定
gitgit:github.com/example/pi-tools@v1克隆并对账到选定 ref;tag 与 commit 也视为锁定,更新不会移动已配置的 ref
URLhttps://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包内资源类型与发现规则

包内资源类型:package.json 的 pi 清单或约定目录,解析出 extensions、skills、prompts、themes 四类资源,交给各自的加载器 包根(普通目录或 npm 包) package.json → readPiManifest() pi.extensions / skills / prompts / themes(glob、相对包根) 二者取其一:有资源字段的清单优先 约定目录(无 pi 清单时自动发现) extensions/ · skills/ · prompts/ · themes/ extensions skills prompts themes .ts / .js 扩展入口 SKILL.md 目录树 .md 模板 .json 主题 扩展运行时注册 工具 / 事件 / 命令(第 11 讲) loadSkillsFromDir 三行广告进系统提示 / 命令与补全 expandPromptTemplate 展开 主题控制器 严格 schema 校验后应用 运行时依赖:宿主包(pi-ai / pi-agent-core / pi-coding-agent / pi-tui / typebox)→ peerDependencies: "*";其余进 dependencies,安装包时一并装
包内资源类型全景。清单与约定目录的取舍在 collectPackageResources() 里一次完成;注意 pi 对象只有包含资源字段时才算「显式清单」,只放 changelogPath / 画廊图等元数据时仍走目录自动发现。

清单读取函数小而清晰,值得整段读——readPiManifest() 只认 package.json 里 pi 键的四个资源数组与 changelogPath:

packages/coding-agent/src/core/pi-manifest.ts节选
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; }
}
容错策略是「静默降级」:解析失败或 pi 键不存在返回 null,调用方回退到约定目录发现。四个资源字段就是 RESOURCE_FIELDS = ["extensions", "skills", "prompts", "themes"]。

消费侧的裁决逻辑在 collectPackageResources():清单有资源字段 → 完全按清单;否则扫描四个约定目录:

packages/coding-agent/src/core/package-manager.ts节选 · 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;
一个显式清单的例子(docs/packages.md):{ "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 时安装其依赖:

packages/coding-agent/src/core/package-manager.ts节选 · installGit
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 });
  }
}
失败时删除半成品目录并抛错(此处省略 catch 分支)。支持的包管理器:npm / pnpm / Bun(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-tui
  • typebox

规则:

  1. 宿主提供的包应声明在 peerDependencies 且版本范围为 "*",不要打包进产物。Pi 会对托管 npm 包和用 npm/pnpm/Bun 安装的 git 包抑制自动 peer 安装。
  2. 绝不把宿主提供的包列进 dependencies:物理副本会在编译后的 ESM 中绕过 Pi 的扩展模块映射,制造重复的类、注册表与初始化——Pi 检测到这种清单配置会报扩展警告。
  3. 扩展真正 import 的其他运行时包放 dependencies,安装 npm / git 源时会一并安装。
  4. 依赖的其他 Pi 包必须包含在发布的 tarball 中,并通过其 node_modules 资源路径引用。
  5. 每个已安装的包以独立模块根加载:不要指望两个包共享同一依赖实例,也不要指望一个包能解析另一个包未声明的依赖。本地包不安装不修改,依赖树由作者自己负责。
Java 视角:peerDependencies "*" ≈ Maven 的 provided scope

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/coding-agent/docs/packages.mdsettings 片段
{
  "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,最后按规范输出审查结论。注意正文中引用随附文件一律用相对技能目录的路径。
验证发现与广告从能发现该技能的目录启动 Pi,查看启动诊断;向模型描述一个数据库变更任务,观察它是否自行 read 了 SKILL.md;再用 /skill:db-review 迁移文件路径 强制调用对比。给 description 换成含糊版重试一次,体会路由质量差异。
测 disable-model-invocation 与 /reload在 frontmatter 加 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 里的对象过滤器只能收窄包声明的资源,不能新增。