Pi Packages
把扩展、技能、提示模板和主题打包分发
上一篇 扩展开发 解决的是「怎么写一个扩展」。这一篇解决「怎么把它连同技能、提示模板、主题一起发出去,让别人一条命令装上」。
先把最容易搞错的一件事放在最前面,因为它是这一整篇唯一会让人当场装失败的:
pi install pi-tool-react是错的。 Pi 不猜。裸名会被当成本地路径去resolve,然后因为路径不存在而抛错。从 npm 装,源必须写成npm:pi-tool-react。
这一条不是风格问题,是 parseSource() 的字面行为。下面的 11.2 会把它拆开讲。
11.1 什么是 Pi 包
一个普通包,加一个 pi 字段
结论先行:**Pi 包就是一个普通的 npm 包,或者一个普通目录。**没有专门的打包格式,没有 manifest DSL,没有编译步骤,也没有 Pi 自己的注册中心。
唯一的约定是:如果 package.json 里有一个 pi 对象,Pi 就按它声明的路径去加载资源。
{
"name": "my-pi-package",
"pi": {
"extensions": ["./src/extension.ts"],
"skills": ["./resources/skills"],
"prompts": ["./resources/prompts/*.md"],
"themes": ["./resources/themes/*.json"]
}
}整个 pi 字段只有四个键,源码里是一个常量数组,没有任何隐藏的第五个:
// packages/coding-agent/src/core/pi-manifest.ts:4-11
export interface PiManifest {
extensions?: string[];
skills?: string[];
prompts?: string[];
themes?: string[];
}
const RESOURCE_FIELDS = ["extensions", "skills", "prompts", "themes"] as const;这四个键正好对应 定制阶梯 里最上面的四层。包的作用就是把阶梯的若干层打包成一个可分发的单元。
没有 pi 字段时怎么找
大部分包其实不需要写清单。约定目录就够了:
my-pi-package/
├── package.json
├── extensions/ ← 扩展入口
├── skills/ ← 每个子目录一个 SKILL.md
├── prompts/ ← 提示模板 .md
└── themes/ ← 主题 .json判定顺序在 collectPackageResources()(package-manager.ts:2220):
- 调用方带了 filter(也就是用户写了带过滤的
packages条目)→ 走过滤路径; - 没有 filter,读
<包根>/package.json的pi字段,有值就按声明走; - 都没有 → 逐个探测四个约定目录,存在就收集,全部默认启用。
每种资源认什么文件,是一张写死的正则表(package-manager.ts:218-223):
注意 extensions 那行和其他三个不一样。注释写明了原因:
// package-manager.ts:655-665
/**
* Extensions use smart discovery (index.ts in subdirs), others use recursive collection.
*/
function collectResourceFiles(dir: string, resourceType: ResourceType): string[] {
if (resourceType === "skills") return collectSkillEntries(dir, "pi");
if (resourceType === "extensions") return collectAutoExtensionEntries(dir);
return collectFiles(dir, FILE_PATTERNS[resourceType]);
}只有扩展走「一个目录一个入口」的智能发现,其余三类都是无脑递归。原因是扩展会带依赖、会有同名冲突,加载顺序决定谁赢,递归扫描出的顺序不可预测;提示模板和主题是纯数据,顺序无所谓。上一篇讲扩展目录发现时讲的 package.json 的 pi.extensions → index.ts → index.js 这套优先级,在包内部的 extensions/ 目录里同样生效。
三种扫描都遵守同两条硬规则:跳过点开头的条目(if (entry.name.startsWith(".")) continue),跳过 node_modules。以及尊重 .gitignore / .ignore / .fdignore 三个忽略文件。
包里的代码就是宿主进程里的代码
这条必须早说,因为它决定了审包和审依赖链的必要性。
一个 Pi 包里的扩展,和 Pi 本体跑在同一个进程、同一套 OS 权限下。官方文档的原话是「Packages can execute extension code and can include skills that instruct the model to run programs」。翻译成可操作的一句:装一个 Pi 包,等于装了它声明的每一个依赖,以及每一个技能里指示模型去执行的东西。
这也是为什么安装源语法值得抠到字符级别——你以为装的是一个「插件」,实际你在往自己的执行环境里加代码。这条线的完整展开在 扩展开发的安全一节。
11.2 安装源
parseSource() 的三级判定
裸名为什么会被当成本地路径,看这个函数就够了:
// package-manager.ts:1486-1511
private parseSource(source: string): ParsedSource {
if (source.startsWith("npm:")) {
const spec = source.slice("npm:".length).trim();
const { name, version } = this.parseNpmSpec(spec);
return { type: "npm", spec, name, version, range: getNpmVersionRange(version), pinned: isExactNpmVersion(version) };
}
if (isLocalPath(source)) {
return { type: "local", path: source }; // ← 裸名在这里被吃掉
}
const gitParsed = parseGitUrl(source);
if (gitParsed) return gitParsed;
return { type: "local", path: source }; // 兜底也是 local
}三步,顺序不能颠倒:
npm:开头 → 无条件按 npm 解析。因为这一步排在最前面,后面两步根本看不到它,所以npm:是硬前缀,没有「看起来像 npm 就按 npm 算」的余地。- 不是本地路径 → 交给
parseGitUrl()试。 - **兜底是
local,不是报错。**所以一个拼错的源不会「类型不识别」,而是变成一个不存在的本地路径,在安装时报Path does not exist: ...。
第 3 条是「裸名为什么静默失败」的原因——它根本没走到「这看起来像不像包名」的判断上。
哪些前缀不算本地路径
判定本地与非本地的是一个共享的小函数(src/utils/paths.ts:50-65):
export function isLocalPath(value: string): boolean {
const trimmed = value.trim();
// Known non-local prefixes. file: URLs are local paths and are intentionally resolved by resolvePath().
if (
trimmed.startsWith("npm:") ||
trimmed.startsWith("git:") ||
trimmed.startsWith("github:") ||
trimmed.startsWith("http:") ||
trimmed.startsWith("https:") ||
trimmed.startsWith("ssh:") ||
trimmed.startsWith("builtin:")
) {
return false;
}
return true;
}七个前缀。但要区分两件事:被识别为「非本地」不等于「能装」。
github: 这一行值得展开。git.ts 里的判定是:
// src/utils/git.ts:172-179
export function parseGitUrl(source: string): GitSource | null {
const trimmed = source.trim();
const hasGitPrefix = trimmed.startsWith("git:");
const url = hasGitPrefix ? trimmed.slice(4).trim() : trimmed;
if (!hasGitPrefix && !/^(https?|ssh|git):\/\//i.test(url)) {
return null; // ← "github:user/repo" 走这里返回 null
}
...github: 不是 git:,也不是带 :// 的协议 URL,所以 parseGitUrl 返回 null,parseSource 落到兜底的 { type: "local", path: "github:user/repo" },然后报路径不存在。
结论:github: 出现在前缀黑名单里是为了不把它当文件系统路径去解析(main.ts:557 就是这个用法的受益者),它不是包的 git 简写。 要写 GitHub 仓库,用完整形式:git:github.com/user/repo。
git: 的两种模式
一旦有了 git: 前缀,parseGitUrl 会放宽限制接受全部历史简写;没有前缀时只接受显式协议 URL。源码注释把这句规则写成了两句:
With
git:prefix, accept all historical shorthand forms. Without git: prefix, only accept explicit protocol URLs.
于是有这张表:
最后一行「https:// 不用 git: 前缀也能用」是这份表里最反直觉的一条。它之所以成立,是因为 isLocalPath 显式排除了 http: 和 https:,字符串没有变成裸名,才轮得到 parseGitUrl 去试。
pi install 的帮助文本把能用的五种列全了(src/package-manager-cli.ts:330-337):
pi install npm:@foo/bar
pi install git:github.com/user/repo
pi install git:git@github.com:user/repo
pi install https://github.com/user/repo
pi install ssh://git@github.com/user/repo
pi install ./local/path四种源的行为差异
三行的细节都有讲究。
npm 装的落在自己管理的目录下,不是全局 node_modules。getManagedNpmInstallPath() 会先算 ~/.pi/agent/npm/node_modules/<name>,只有在受管路径下找不到时才回退到「全局包管理器根」(getLegacyGlobalNpmInstallPath(),pnpm 和 npm 各有各的查法)。落盘前 Pi 会先把安装根变成一个合法 npm 工程:写一个 {"name":"pi-extensions","private":true} 的 package.json,再写一个内容为 *\n!.gitignore\n 的 .gitignore,还会给目录打上云同步忽略标记(ensureNpmProject() / ensureGitIgnore() / markPathIgnoredByCloudSync())。目的是让这些安装物不会污染你的仓库,也不会被 iCloud 之类同步工具拖走。
git 源带 ref 就不动。 装了 git:github.com/example/repo@v1 之后,pi update 做的动作是 git fetch origin v1 + checkout,而不是 git pull 到最新 main。文档的原话是「Git tags and commits are also pinned; package updates reconcile the checkout but do not move a configured ref」。想跟上游走就别写 ref。
本地源不复制。 installParsedSource() 只处理 npm 和 git 两种类型,local 直接 return。所以本地包是你工作区的一部分,改一个文件,下一次 pi 启动就生效,不需要重装。这条对开发自己的包很好用:写扩展时的迭代闭环仍然是「改文件 → /reload」,或者干脆用 -e 指过去。
本地相对路径从哪解析
normalizePackageSourceForSettings() 写进 settings 时会把绝对路径换成相对 settings 文件的相对路径:
// package-manager.ts:1475-1484
private normalizePackageSourceForSettings(source: string, scope: SourceScope): string {
const parsed = this.parseSource(source);
if (parsed.type !== "local") return source;
const baseDir = this.getBaseDirForScope(scope);
const resolved = this.resolvePath(parsed.path);
const rel = relative(baseDir, resolved);
return rel || ".";
}对应文档那句「Relative local paths resolve from the settings file that contains them」。所以 ~/.pi/agent/settings.json 里的 ./tools/foo 是相对 ~/.pi/agent/ 的,.pi/settings.json 里的是相对 .pi/ 的。同一行相对路径写在两个文件里指的是两个不同的地方,这是项目包最常见的困惑来源。
身份:同一个包不会被装两遍
Pi 给每种源算一个匹配键(getSourceMatchKeyForInput / ForSettings):
npm 键丢版本、git 键丢 ref,是刻意的:写了 npm:foo@1.0.0 和 npm:foo@^1.2.0 两条,是同一个包的两次声明,应该合并而不是并存。匹配键相同,第二次声明走的是替换而不是追加。
反过来说,你在终端打错前缀时,Pi 会尽力提示而不是直接失败。findSuggestedConfiguredSource() 拿你的输入去比对已配置源的 name / spec / host/path / host/path@ref,命中就给一句 No matching package found for X. Did you mean <你写过的那个源>?。
一次性试用
不想改配置、不想进 settings,用 -e / --extension:
pi -e npm:@example/pi-tools它走的是 temporary 作用域,装在 <agentDir>/tmp/extensions/ 下的临时目录。这个作用域在源码里是三种之一(user / project / temporary),也是唯一一个不需要 project trust 的作用域——因为它根本不读项目配置。
11.3 清单与资源过滤
这一节有两个独立的「过滤」,混在一起是本文第二个容易写错的地方:
- 包自己的清单:
package.json的pi字段,作者写的,决定这个包有什么; - 用户的过滤器:settings 里
packages数组的对象形式,使用者写的,决定从这个包里装哪些。
第二个不是写在 extensions 字段里的。这一点必须说死:
extensions/skills/prompts/themes这四个过滤字段,是packages数组里对象的成员,不是顶层extensions键。
顶层 extensions 是另一回事,它是「直接加载这个路径/这个目录」的资源路径列表,和包过滤没关系。完整 settings 结构在 配置。
字符串形式与对象形式
PackageSourceSchema(settings-schema.ts:171-189)是一个二选一的联合:
const PackageSourceSchema = Type.Union(
[
Type.String({ description: "Load all resources from the package." }),
Type.Object({
source: Type.String(),
autoload: Type.Optional(
Type.Boolean({ description: "When false, start empty and only apply explicit resource patterns." }),
),
extensions: Type.Optional(Type.Array(Type.String())),
skills: Type.Optional(Type.Array(Type.String())),
prompts: Type.Optional(Type.Array(Type.String())),
themes: Type.Optional(Type.Array(Type.String())),
}),
],
...
);字符串形式 = 这个包的全部资源都装。 对象形式才有过滤能力。
{
"packages": [
{
"source": "npm:@example/pi-tools",
"extensions": ["extensions/*.ts", "!extensions/legacy.ts"],
"skills": [],
"prompts": ["prompts/review.md"]
}
]
}这一行的意思翻译过来是:扩展只装 extensions/ 下的 .ts,但排除 legacy.ts;技能一个都不装;提示模板只装 review.md 这一个;主题那行省略了,所以主题全装。
五种写法的语义
对每一种资源类型,四种写法加省略,一共五种状态:
四条规则的实际执行顺序在 applyPatterns()(package-manager.ts:751-797)里写死了:
// Step 1: Apply includes (or all if no includes)
// Step 2: Apply excludes
// Step 3: Force-include (add back from allPaths, overriding exclusions)
// Step 4: Force-exclude (remove even if included or force-included)所以四种写法的优先级是单向压倒的:+ 能救回被 ! 排掉的,- 能砍掉 + 刚加回来的。你不需要考虑书写顺序。
有两个细节值得记住,它们都不是「glob 匹配」能解释的:
+path 和 -path 不走 glob。 它们走 matchesAnyExactPattern(),是精确比较(相对路径 / 绝对路径 / basename 三选一命中)。+ext/*.ts 不会生效——想批量加回来请用不带 + 的普通 pattern。这条设计是刻意的,源码注释写了原因:
Glob entries discover visible paths; exact entries can target dot paths or symlinked trees.
+ / - 对技能是按目录算的。 matchesAnyExactPattern() 里有一段特判:如果目标是 SKILL.md,它还会拿这个文件的父目录去比。所以 "-skills/legacy" 去掉的是整个 legacy 技能,不是 legacy/SKILL.md 这个文件。
过滤器不能凭空变出东西
文档里那句「Filters narrow the package manifest. They do not expose resources that the package itself did not declare」不是修辞,是代码结构决定的。
collectManifestFiles() 的候选集 allFiles 只有两种来源:包自己的 pi 字段声明的条目,或者约定目录扫出来的文件。过滤器作用在这个集合上,永远作用不到集合外面。 一个包没声明 prompts,你写 "prompts": ["*.md"] 得到的 allFiles 是空数组,applyPatterns 返回空集。
想改这个边界只能改包本身,或者直接用顶层 prompts 路径绕过去。
autoload: false:把项目条目变成增量
这是 packages.md 里最容易被跳过、也最有价值的一条。
同一个包可以同时出现在个人配置和项目配置里。默认情况下,项目条目替换个人条目——项目说装什么就是什么。
加了 autoload: false 之后语义完全变了:项目条目不再是替换,而是一层覆盖在个人条目之上的增量。
源码里是两条完全不同的路径(collectPackageResources() 的三分支):
applyPackageDeltaFilter() 的实现决定了「增量」是什么意思:它只为 pattern 命中的路径产生条目,没命中的路径根本不产生任何输出。于是个人条目里那些资源原样保留,只有被 pattern 点到的路径的 enabled 状态被改写。patterns 为空时它直接 return,什么都不做。
落到一个具体场景:
// ~/.pi/agent/settings.json —— 团队统一的完整套装
{ "packages": ["npm:@acme/pi-suite"] }
// .pi/settings.json —— 我只想关掉其中一个技能
{
"packages": [
{ "source": "npm:@acme/pi-suite", "autoload": false, "skills": ["-skills/internal-deploy"] }
]
}第二条不会把套装的技能清空,只会关掉 internal-deploy 那一个。去掉 autoload: false,同样的配置会变成「技能全没了,只剩 -skills/internal-deploy 这个不存在的路径」。
同一个包被声明两次的加载顺序
包内资源和直接写路径的资源混在一起时,谁赢由一个六级的排序函数决定(resourcePrecedenceRank(),package-manager.ts:198-204,rank 从 0 到 5):
「先到先得」的资源收集要靠这个排序才成立。注意 rank 5: 内置扩展永远排在最后,也就是说你写的任何东西都在内置的 codemode / tool-search / mcp 之前被考虑。内置扩展被顶掉的机制在 扩展开发 10.8。
临时开关:pi config
不想手改 JSON,pi config 是一个交互式 TUI。它从个人配置启动,按 Tab 在全局和项目之间切换,也可以 pi config -l 直接从项目覆盖启动。
它的用途就是「把已发现资源逐个勾上或勾掉」,等价于在对象形式里生成过滤 pattern——但省掉你手写的那一步。
11.4 管理与更新
四个包命令
PackageCommand 是个四项联合(src/package-manager-cli.ts:46):
export type PackageCommand = "install" | "remove" | "update" | "list";pi install 只有 -l
用法串是写死的常量(getPackageCommandUsage(),package-manager-cli.ts:287-298):
case "install":
return `${APP_NAME} install <source> [-l] [--approve|--no-approve]`;install 自己专属的开关只有 -l / --local 一个,作用是写进 .pi/settings.json 而不是 ~/.pi/agent/settings.json。
后面那两个是所有命令共用的 project trust 开关,不是 install 特有的。加 -l 会写项目 settings,因此必须过 trust 判定——源码里是 assertProjectTrustedForScope("project"),不通过直接抛。
值得单列一张表,因为这几个是旧文档里出现过、且根本不存在的:
--force 出现在 pi update 上时还有一条额外约束:受管安装(managed-install.json 标记)下会被拒绝,并报错让你重跑安装器。理由是受管安装的版本由安装器决定。
pi update 的五种目标
pi update 是个多目标命令,不给目标是更新 Pi 本体:
**没有 --packages。**包侧的开关叫 --extensions,因为 Pi 早期的资源模型里这些「包」就叫扩展,--extensions 这个名字是历史遗留。写下 --packages 会走到未知选项路径。
更新 Pi 本体有三个前提,缺一不可(02 安装与运行模式 展开过):有对应命令 ∧ 装在全局包管理器根下 ∧ 路径可写。本地装的 Pi 自更新不了。
包这一侧没有这三个前提,因为它就是通过 npm/pnpm/bun 装的。更新时 Pi 读已装包的 package.json 里的 version,和源上的目标版本比,gt(target, installed) 才算有更新。网络请求超时 10 秒(NETWORK_TIMEOUT_MS),npm 版本查询和 git 更新的并发上限都是 4(UPDATE_CHECK_CONCURRENCY / GIT_UPDATE_CONCURRENCY)。设了 PI_OFFLINE=1(或 true / yes)就直接跳过在线检查。
一个容易误读的机制
包安装时会关闭包管理器的 peer 依赖解析:
源码注释说明了原因:
Extension packages run inside pi and resolve pi APIs through loader aliases/virtual modules. Disable peer dependency resolution for managed installs so package managers do not install or solve host-provided
@earendil-works/pi-*peers. Stale auto-installed pi peers can otherwise block updates.
这条机制的存在本身就说明了 11.5 要讲的规则有多硬——Pi 不得不在每个包管理器上都做一次专门配置,才能保证你写在 peerDependencies 里的宿主包不会被自动装第二份。第二个理由更实际:自动装出来的旧版 pi 包会挡住更新。
顺便澄清一个常见混淆:Pi 自己锁传递依赖用的机制是 packages/coding-agent/install-lock/(一个 package.json 加一个 package-lock.json,根包名 pi-coding-agent-install),不是 npm-shrinkwrap.json。但那是安装器给 Pi 本体用的,和你写的包没有关系。
11.5 发布
最小包
不写清单,把文件按约定目录放好就行:
my-pi-package/
├── package.json
├── extensions/
│ └── index.ts
├── skills/
│ └── code-review/
│ └── SKILL.md
├── prompts/
│ └── summarize.md
└── themes/
└── midnight.jsonpackage.json 甚至可以是完全普通的 npm 清单。Pi 只读 pi 字段,读不到就退回约定目录。
要过滤或者资源不在约定位置时
{
"name": "my-pi-package",
"version": "0.1.0",
"keywords": ["pi-package"],
"pi": {
"extensions": ["./src/extension.ts", "./src/extra.ts"],
"skills": ["./resources/skills"],
"prompts": ["./resources/prompts/*.md"],
"themes": ["./resources/themes/*.json"]
}
}路径相对包根。数组接受 glob 和排除(前缀语法与 11.3 完全一致,因为 addManifestEntries() 和 applyPatterns() 走的是同一套)。
这里有一个容易忽略的事实:包作者自己也能用 ! / + / - 在清单里禁用自己声明的资源。 addManifestEntries() 会对清单条目先跑一遍 applyPatterns,所以「全部发布,但其中一个默认不加载」这种需求写在包里就行,不需要使用者去 settings 里覆盖。
文档还提醒了一条 glob 的固有限制:点开头的目录和符号链接的树,glob 遍历可能扫不到,需要在清单里直接列出它们。这也是 11.3 末尾 +path 走精确匹配的原因。
依赖:五个宿主包必须放 peerDependencies
这是整篇文章里唯一一条「写错了不会有任何报错,但运行时会出现两份」的规则,所以单独讲。
Pi 提供给扩展和技能用的包有五个:
@earendil-works/pi-ai@earendil-works/pi-agent-core@earendil-works/pi-coding-agent@earendil-works/pi-tuitypebox
这五个必须声明在 peerDependencies,版本范围写 "*",绝不能放进 dependencies。
{
"name": "my-pi-package",
"peerDependencies": {
"@earendil-works/pi-ai": "*",
"@earendil-works/pi-agent-core": "*",
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"typebox": "*"
},
"dependencies": {
"zod": "3.23.8"
}
}为什么这么严。Pi 在加载扩展时会检查每个包根的 package.json,看 dependencies 里有没有这五个里的任何一个,命中就发一条扩展警告:
Host-provided extension packages must be declared in peerDependencies with a
"*"range, not dependencies: … Installed copies can bypass the extension loader and create duplicate runtime modules.
「绕过扩展加载器」的机制是模块映射:Pi 加载扩展时会把 @earendil-works/pi-* 这些标识符重定向到宿主自己那份实现,这样扩展拿到的 Agent 类、ExtensionAPI 类型、TypeBox 的 Type 都是宿主的那一份。一旦你在 dependencies 里放了一份实体拷贝,编译过的 ESM 会绕过这个映射从 node_modules 里解析出第二份。
后果不是「多占点磁盘」:
- 两份
ExtensionAPI类型互不兼容(结构上相同但引用不同),跨边界的类型检查全部失效; - 两份 TypeBox 的
Type.Object()产出的 schema 对象来自不同模块,运行时校验可能失效; - 模块被初始化两遍,任何模块级状态(注册表、缓存、单例)都是两份;
- 版本一旦不一致,行为差异会出现在最不可能被排查的地方。
11.4 讲的那个 --legacy-peer-deps / --omit=peer 配置,正是为了让包管理器不去自动安装这五个 peer——但它只挡住了自动安装,挡不住你手写在 dependencies 里的那一条。
反过来,扩展真正 import 的第三方运行时依赖,应该正常放在 dependencies 里。 Pi 在安装 npm 或 git 源时会一并装上。
还有两条边界:
- 其它 Pi 包(比如你依赖
pi-mcp)不能走 peer 路线——它们不由宿主提供。文档要求它们必须包含在发布的 tarball 里,并通过它们的node_modules资源路径引用。 - 本地包不会被安装也不会被修改,它的依赖树完全是包作者自己的责任。
最后一条运行时约束:已安装的包用彼此独立的模块根加载。不要依赖两个包共享同一个依赖实例,也不要指望一个包能解析到另一个包没有声明的依赖。 这条是给包作者的硬性纪律——「在我机器上能跑」在这里不成立,因为隔离是刻意的。
上架
keywords 里加 pi-package,这个包就具备在 Pi 包画廊 里被发现的资格。pi.image 和 pi.video 两个可选字段用来给画廊加预览图。
值得单独提一句的是:这两个字段 Pi 自己的清单读取器不认。 readPiManifest() 只遍历 RESOURCE_FIELDS 那四个键,pi.image / pi.video 会被静默忽略。它们是给画廊用的元数据,不影响本地加载行为——所以你在 package.json 里看到它们不会困惑。
一份发布前自查表
11.6 事实来源
本文所有断言都可以在仓库里复算:
完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。
上一篇 10 扩展开发 讲的是包里那份代码怎么写;概念页见 07 定制阶梯。想看这些资源被加载之后怎么调度,回到 06 工具与信任边界。