跳到主内容
创建时间:最后更新:作者:白川

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 就按它声明的路径去加载资源。

json
{
  "name": "my-pi-package",
  "pi": {
    "extensions": ["./src/extension.ts"],
    "skills": ["./resources/skills"],
    "prompts": ["./resources/prompts/*.md"],
    "themes": ["./resources/themes/*.json"]
  }
}

整个 pi 字段只有四个键,源码里是一个常量数组,没有任何隐藏的第五个:

typescript
// 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 字段时怎么找

大部分包其实不需要写清单。约定目录就够了:

text
my-pi-package/
├── package.json
├── extensions/      ← 扩展入口
├── skills/          ← 每个子目录一个 SKILL.md
├── prompts/         ← 提示模板 .md
└── themes/          ← 主题 .json

判定顺序在 collectPackageResources()(package-manager.ts:2220):

  1. 调用方带了 filter(也就是用户写了带过滤的 packages 条目)→ 走过滤路径;
  2. 没有 filter,读 <包根>/package.json 的 pi 字段,有值就按声明走;
  3. 都没有 → 逐个探测四个约定目录,存在就收集,全部默认启用。

每种资源认什么文件,是一张写死的正则表(package-manager.ts:218-223):

资源类型认什么发现方式
extensions
.ts / .js
智能发现:根目录有 pi.extensions 或 index.ts 就用它,否则逐个子目录判定
skills
名为 SKILL.md 的文件
递归,命中即取它的父目录作为一个技能
prompts
.md
递归
themes
.json
递归

注意 extensions 那行和其他三个不一样。注释写明了原因:

typescript
// 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() 的三级判定

裸名为什么会被当成本地路径,看这个函数就够了:

typescript
// 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
}

三步,顺序不能颠倒:

  1. npm: 开头 → 无条件按 npm 解析。因为这一步排在最前面,后面两步根本看不到它,所以 npm: 是硬前缀,没有「看起来像 npm 就按 npm 算」的余地。
  2. 不是本地路径 → 交给 parseGitUrl() 试。
  3. **兜底是 local,不是报错。**所以一个拼错的源不会「类型不识别」,而是变成一个不存在的本地路径,在安装时报 Path does not exist: ...。

第 3 条是「裸名为什么静默失败」的原因——它根本没走到「这看起来像不像包名」的判断上。

哪些前缀不算本地路径

判定本地与非本地的是一个共享的小函数(src/utils/paths.ts:50-65):

typescript
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;
}

七个前缀。但要区分两件事:被识别为「非本地」不等于「能装」。

前缀被 isLocalPath 排除parseGitUrl 真的能解析
npm:
是
不走这条路,parseSource 第一步就拦了
git:
是
是(且支持全部历史简写形式)
http: / https:
是
是
ssh:
是
是
github:
是
否
builtin:
是
不适用(不是包源)

github: 这一行值得展开。git.ts 里的判定是:

typescript
// 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.

于是有这张表:

写法例子能用吗
git: + 简写
git:github.com/user/repo@v1
能
git: + scp 风格
git:git@github.com:user/repo
能
显式 https://
https://github.com/user/repo
能
显式 ssh://
ssh://git@github.com/user/repo
能
无前缀简写
github.com/user/repo
不能,落成本地路径
无前缀 scp
git@github.com:user/repo
不能,同上
github: 简写
github:user/repo
不能,见上一节

最后一行「https:// 不用 git: 前缀也能用」是这份表里最反直觉的一条。它之所以成立,是因为 isLocalPath 显式排除了 http: 和 https:,字符串没有变成裸名,才轮得到 parseGitUrl 去试。

pi install 的帮助文本把能用的五种列全了(src/package-manager-cli.ts:330-337):

text
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
npm:@example/pi-tools@1.0.0
<agentDir>/npm/node_modules/<name>
按版本范围重解
git
git:github.com/example/pi-tools@v1
<agentDir>/git/<host>/<path>
有 ref 只 fetch 不移动 ref
本地
./pi-tools
不复制,直接用解析后的路径
不更新,你改文件就生效

三行的细节都有讲究。

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 文件的相对路径:

typescript
// 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
npm:<包名>(忽略版本)
git
git:<host>/<path>(忽略 ref)
本地
local:<解析后的绝对路径>

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:

bash
pi -e npm:@example/pi-tools

它走的是 temporary 作用域,装在 <agentDir>/tmp/extensions/ 下的临时目录。这个作用域在源码里是三种之一(user / project / temporary),也是唯一一个不需要 project trust 的作用域——因为它根本不读项目配置。

11.3 清单与资源过滤

这一节有两个独立的「过滤」,混在一起是本文第二个容易写错的地方:

  1. 包自己的清单:package.json 的 pi 字段,作者写的,决定这个包有什么;
  2. 用户的过滤器:settings 里 packages 数组的对象形式,使用者写的,决定从这个包里装哪些。

第二个不是写在 extensions 字段里的。这一点必须说死:

extensions / skills / prompts / themes 这四个过滤字段,是 packages 数组里对象的成员,不是顶层 extensions 键。

顶层 extensions 是另一回事,它是「直接加载这个路径/这个目录」的资源路径列表,和包过滤没关系。完整 settings 结构在 配置。

字符串形式与对象形式

PackageSourceSchema(settings-schema.ts:171-189)是一个二选一的联合:

typescript
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())),
		}),
	],
	...
);

字符串形式 = 这个包的全部资源都装。 对象形式才有过滤能力。

json
{
  "packages": [
    {
      "source": "npm:@example/pi-tools",
      "extensions": ["extensions/*.ts", "!extensions/legacy.ts"],
      "skills": [],
      "prompts": ["prompts/review.md"]
    }
  ]
}

这一行的意思翻译过来是:扩展只装 extensions/ 下的 .ts,但排除 legacy.ts;技能一个都不装;提示模板只装 review.md 这一个;主题那行省略了,所以主题全装。

五种写法的语义

对每一种资源类型,四种写法加省略,一共五种状态:

写法效果
省略这个键
装这个类型里所有允许的东西
[]
这个类型一个都不装
"pattern"
只装匹配这个 glob 的(无排除项时这是白名单)
"!pattern"
从结果里排除匹配这个 glob 的
"+path"
强制加回这一个精确路径(能覆盖 ! 排除)
"-path"
强制去掉这一个精确路径(能覆盖 + 和普通 include)

四条规则的实际执行顺序在 applyPatterns()(package-manager.ts:751-797)里写死了:

typescript
// 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() 的三分支):

条件走哪条
autoload === false
applyPackageDeltaFilter()
有该类型的 pattern
applyPackageFilter()
都没有
collectDefaultResources()

applyPackageDeltaFilter() 的实现决定了「增量」是什么意思:它只为 pattern 命中的路径产生条目,没命中的路径根本不产生任何输出。于是个人条目里那些资源原样保留,只有被 pattern 点到的路径的 enabled 状态被改写。patterns 为空时它直接 return,什么都不做。

落到一个具体场景:

json
// ~/.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来源
0
项目 settings 条目
1
项目自动发现
2
用户 settings 条目
3
用户自动发现
4
包提供的资源
5
内置扩展(builtin: 前缀)

「先到先得」的资源收集要靠这个排序才成立。注意 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):

typescript
export type PackageCommand = "install" | "remove" | "update" | "list";
命令作用
pi install <source>
装包并写进 settings
pi list
列出已配置的包
pi remove <source>
卸载并删掉 settings 条目(pi uninstall 是别名)
pi update ...
更新 Pi 本体、包、或模型目录

pi install 只有 -l

用法串是写死的常量(getPackageCommandUsage(),package-manager-cli.ts:287-298):

typescript
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"),不通过直接抛。

值得单列一张表,因为这几个是旧文档里出现过、且根本不存在的:

写法实际情况
pi install --project
不存在。要项目作用域用 -l
pi install --force
不存在。--force 是 pi update 的,用于强制重装 Pi 本体
pi install --no-save
不存在。pi install 永远会写 settings
pi install <裸名>
不报错之外什么都不对。裸名 = 本地路径
pi update --packages
不存在。更新包用 pi update --extensions
pi update --purge
不存在

--force 出现在 pi update 上时还有一条额外约束:受管安装(managed-install.json 标记)下会被拒绝,并报错让你重跑安装器。理由是受管安装的版本由安装器决定。

pi update 的五种目标

pi update 是个多目标命令,不给目标是更新 Pi 本体:

目标命令做什么
Pi 本体
pi update
默认目标。--self / update self / update pi 都是别名
全部已装包
pi update --extensions
遍历所有已配置源做版本对账
单个包
pi update <source>
只更新这一个。--extension <source> 是别名
模型目录
pi update --models
刷新 models-store.json(见 模型与供应商)
Pi + 全部包
pi update --all
两件都做

**没有 --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 依赖解析:

包管理器参数
npm
install … --prefix <root> --legacy-peer-deps
pnpm
install … --prefix <root> --config.auto-install-peers=false --config.strict-peer-dependencies=false --config.strict-dep-builds=false
bun
install … --cwd <root> --omit=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 发布

最小包

不写清单,把文件按约定目录放好就行:

text
my-pi-package/
├── package.json
├── extensions/
│   └── index.ts
├── skills/
│   └── code-review/
│       └── SKILL.md
├── prompts/
│   └── summarize.md
└── themes/
    └── midnight.json

package.json 甚至可以是完全普通的 npm 清单。Pi 只读 pi 字段,读不到就退回约定目录。

要过滤或者资源不在约定位置时

json
{
  "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-tui
  • typebox

这五个必须声明在 peerDependencies,版本范围写 "*",绝不能放进 dependencies。

json
{
  "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 里看到它们不会困惑。

一份发布前自查表

检查项怎么算对
name / version
普通 npm 惯例,能被 pi install npm:<name>@<version> 解析
五个宿主包
在 peerDependencies 且为 "*",不在 dependencies
第三方运行时依赖
在 dependencies
扩展入口
.ts 或 .js;目录里靠 index.ts 或 pi.extensions 指路
技能
每个技能一个含 SKILL.md 的目录
路径
相对包根;点开头目录和符号链接树直接列出,别指望 glob
默认加载的东西
尽量少。想让用户选择的东西用 codemode / deferred 暴露(见 MCP)
keywords
想上画廊就加 pi-package
装完看一眼警告
出现 host-provided 那条警告说明 dependencies 写错了

11.6 事实来源

本文所有断言都可以在仓库里复算:

断言来源
Pi 包是普通 npm 包/目录,pi 字段只有四个资源键
packages/coding-agent/docs/packages.md:3-5;src/core/pi-manifest.ts:4-11
readPiManifest 只遍历四个字段,其余静默忽略
packages/coding-agent/src/core/pi-manifest.ts:17-35
无 pi 字段时按四个约定目录发现
package-manager.ts:2256-2268
有 filter / 有 manifest / 只有约定目录的三分支顺序
package-manager.ts:2226-2268
每类资源的文件正则
package-manager.ts:218-223
只有 extensions 走智能发现,其余递归
package-manager.ts:655-665、collectAutoExtensionEntries() :599
跳过点开头条目、跳过 node_modules、读三种 ignore 文件
package-manager.ts:342、:417、:506、:543、:617;:225
技能以 SKILL.md 为单位递归收集
package-manager.ts:377-454
parseSource 三级判定,兜底是 local
package-manager.ts:1486-1511
npm: 是唯一无条件前缀
同上 :1487-1498
本地路径判定排除 7 个前缀
src/utils/paths.ts:50-65
github: 全仓库只在 isLocalPath 出现,parseGitUrl 不认
grep -rn '"github:"' packages/coding-agent/src/ 仅 paths.ts:56;src/utils/git.ts:172-179
无 git: 前缀时只接受显式协议 URL
src/utils/git.ts:172-179 及函数注释
install 用法只有 [-l] 与共用 trust 开关
src/package-manager-cli.ts:287-298、:317-338
install 帮助文本列出五种可用源写法
src/package-manager-cli.ts:330-337
包命令共四个,uninstall 是 remove 别名
src/package-manager-cli.ts:46;docs/cli.md:287
pi update 五种目标,无 --packages
docs/cli.md:269-283;package-manager-cli.ts:358-379
--force 用于强制重装 Pi,受管安装下被拒
docs/cli.md:281;src/config.ts:79-99
项目作用域必须过 trust,否则抛「Project is not trusted」
assertProjectTrustedForScope() package-manager.ts:1781-1785;调用点 :1045、:1074、:2095
npm 受管安装位置与回退
package-manager.ts:2132-2151
安装根会写 pi-extensions package.json 与全量忽略 .gitignore
package-manager.ts:2068-2082
git ref 安装后只 fetch 不移动 ref
package-manager.ts:1897-1928;docs/packages.md:38
本地源不复制
package-manager.ts:1398-1407(只处理 npm/git)、:1055-1060
相对本地路径按 scope 的 baseDir 解析后写成相对路径
package-manager.ts:1475-1484
匹配键:npm 丢版本、git 丢 ref、本地用绝对路径
package-manager.ts:1413-1434
输错源时给"Did you mean"提示
package-manager.ts:1436-1467
-e 走 temporary 作用域、临时目录位置
package-manager.ts:237-242、:2091-2099、:2183-2190
PackageSourceSchema 字符串/对象二选一及六个成员
settings-schema.ts:171-189、:302-306
过滤写在 packages 数组对象里,不是 extensions 字段
settings-schema.ts:176-183;PackageFilter package-manager.ts:206-212
省略=全装、[]=不装、!/+/- 的语义
docs/packages.md:111-117;applyPatterns() package-manager.ts:751-797
四步执行顺序,+ 覆盖 !、- 覆盖一切
package-manager.ts:769-796
+/- 走精确匹配而非 glob,为的是点开头路径与符号链接树
package-manager.ts:299、:700-718
+/- 对 SKILL.md 按父目录匹配
package-manager.ts:700-718
过滤器不能暴露包未声明的资源
collectManifestFiles() package-manager.ts:2342-2362;docs/packages.md:119
autoload: false 走 delta 分支且只输出命中路径
package-manager.ts:2230-2231、:2319-2335
项目条目默认替换个人条目
docs/packages.md:125
资源优先级 rank 0–5,内置排最后
package-manager.ts:198-204、:2652
pi config 是 TUI,Tab 切作用域
docs/packages.md:121;src/package-manager-cli.ts:302-314
三种包管理器的 peer 安装抑制参数
package-manager.ts:1827-1844、:1851-1872
npm 版本查询超时 10 秒、并发 4
package-manager.ts:56-58
PI_OFFLINE 取值 1/true/yes
package-manager.ts:60-64
install-lock/ 是安装器给 Pi 本体锁依赖用的
packages/coding-agent/install-lock/package.json;根 AGENTS.md 依赖一节
五个宿主包清单
docs/packages.md:80-86
宿主包误放 dependencies 的警告原文与原因
src/core/resource-loader.ts:53-64、:87-95
其它 Pi 包须打进 tarball 并走 node_modules 资源路径
docs/packages.md:90
本地包的依赖树由作者负责
docs/packages.md:88
已安装包用独立模块根加载
docs/packages.md:92
pi-package 关键字与 pi.image / pi.video
docs/packages.md:74;pi-manifest.ts:11 不含这两个字段
包的 skill 可能指示模型执行程序
docs/packages.md:21

完整事实表(含每条断言的推导命令)见专栏仓库的 research/pi-agent/FACTS.md。

上一篇 10 扩展开发 讲的是包里那份代码怎么写;概念页见 07 定制阶梯。想看这些资源被加载之后怎么调度,回到 06 工具与信任边界。