@1lon/pi-preset 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/pi-preset-sync/SKILL.md +77 -0
- package/LICENSE +21 -0
- package/README.md +124 -2
- package/dist/cli.js +524 -0
- package/extensions/pi-code-skills-dedup.ts +109 -0
- package/extensions/question-render.ts +93 -0
- package/extensions/user-message-frame.ts +145 -0
- package/package.json +49 -4
- package/presets/public.json +18 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-preset-sync
|
|
3
|
+
description: 把本机 ~/.pi/agent 里的自定义扩展和第三方包清单同步回 my-pi-preset 仓库,并在用户确认后提交推送。当用户说「同步 preset」「把这个扩展同步进仓库」「更新我的 pi 扩展清单并提交」时使用。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# pi-preset-sync
|
|
7
|
+
|
|
8
|
+
在 my-pi-preset 仓库的 clone 根目录中运行。把本机最新的 pi 扩展内容与包清单回写进仓库,
|
|
9
|
+
经用户确认后提交并推送到 GitHub 的 `main`。
|
|
10
|
+
|
|
11
|
+
`npx -y @1lon/pi-preset sync` 只负责让文件和清单一致;**git 操作由本 skill 编排**,
|
|
12
|
+
因为它们需要用户拍板。
|
|
13
|
+
|
|
14
|
+
## 流程
|
|
15
|
+
|
|
16
|
+
### 1. 前置检查
|
|
17
|
+
|
|
18
|
+
- 确认当前目录是本仓库 clone:`package.json` 的 `name` 为 `@1lon/pi-preset`。不是就停下来,
|
|
19
|
+
问用户仓库在哪。
|
|
20
|
+
- 确认当前分支是 `main`:`git rev-parse --abbrev-ref HEAD`。不是 `main` 就停下来报告,
|
|
21
|
+
**不要自动切分支**。
|
|
22
|
+
- 确认 git 身份:`git config user.name` 应为 `kelieCn`,`git config user.email` 应为
|
|
23
|
+
`kelieCn720@gmail.com`。不符就先提示用户(本仓库是 GitHub 个人仓库,公司身份会导致
|
|
24
|
+
CLA 校验失败且贡献不计数)。**只提示,绝不擅自修改全局 git 配置**;用户要求时用
|
|
25
|
+
`git config --local` 在本仓库内修正。
|
|
26
|
+
|
|
27
|
+
### 2. 执行 sync
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx -y @1lon/pi-preset sync
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`npx -y` 里的 `-y` 不能省,否则 npx 会停在「是否安装」的交互上。
|
|
34
|
+
|
|
35
|
+
若用户提到要纳入某个本机新增的扩展文件,加 `--include <文件名>` 后重跑。
|
|
36
|
+
若本机还没装过这个包、或想先确认差异,可先跑 `npx -y @1lon/pi-preset sync --dry-run`。
|
|
37
|
+
|
|
38
|
+
### 3. 展示改动
|
|
39
|
+
|
|
40
|
+
把 cli 输出的「新增 / 修改 / 删除」三段原样呈现给用户,**不要改写、不要省略、不要总结成一句话**。
|
|
41
|
+
需要时补充说明:哪些文件变了、清单里都是哪些包、哪些本机包因为不可公开被排除。
|
|
42
|
+
|
|
43
|
+
若三段皆空(输出「已是最新」),报告一句「已是最新,无需提交」然后结束——**不要提交空 commit**。
|
|
44
|
+
|
|
45
|
+
### 4. 等用户确认
|
|
46
|
+
|
|
47
|
+
停下来等明确答复。用户没说「可以 / 提交 / 推送」之前,不执行任何 git 命令。
|
|
48
|
+
用户要求调整时,改完重跑第 2 步。
|
|
49
|
+
|
|
50
|
+
### 5. 提交并推送
|
|
51
|
+
|
|
52
|
+
用户确认后:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git add <本次 sync 触及的路径> # 见下方约束
|
|
56
|
+
git commit -m "<message>"
|
|
57
|
+
git push origin main
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
commit message 按实际改动写,中文,一句话说明这次同步了什么(例如
|
|
61
|
+
`sync: 更新 question-render 扩展并新增 pi-x 包`),不加 emoji。
|
|
62
|
+
|
|
63
|
+
### 6. 报告
|
|
64
|
+
|
|
65
|
+
给出 commit sha、推送结果(远端 + 分支),以及工作区是否干净。
|
|
66
|
+
|
|
67
|
+
## 硬约束
|
|
68
|
+
|
|
69
|
+
- **`git add` 只能加本次 sync 写过的具体路径**——`extensions/` 下变动的文件、
|
|
70
|
+
`presets/public.json`。**绝不能 `git add -A`、`git add .`、`git commit -a`**:
|
|
71
|
+
工作区里可能还有与本次同步无关的改动,一并提交会把它们带进仓库。
|
|
72
|
+
- **commit message 不得包含 `Co-Authored-By` 之类的 AI 协作者 trailer。**
|
|
73
|
+
- 只在当前 `main` 分支上提交推送,不切分支、不建分支、不 `--force`。
|
|
74
|
+
- 推送失败(远端有新提交)时停下来报告,由用户决定 `pull --rebase` 还是合并;
|
|
75
|
+
不自动处理冲突。
|
|
76
|
+
- 公司内网的包(如 `git:git@gitlab.basemind.com:...`)由 cli 自动排除在公开清单之外。
|
|
77
|
+
如果发现它出现在将被提交的内容里,**立刻停下**,不要推送。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kelieCn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,125 @@
|
|
|
1
|
-
#
|
|
1
|
+
# my-pi-preset
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
我自己使用的 [pi](https://pi.dev) 扩展 preset。第三方包清单 + 手写的自定义扩展,
|
|
4
|
+
换机时一条命令恢复,日常一条命令回写。
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
.
|
|
8
|
+
├── src/
|
|
9
|
+
│ ├── cli/
|
|
10
|
+
│ │ ├── bin.ts # 进程入口 → dist/cli.js
|
|
11
|
+
│ │ ├── index.ts # 注册所有命令 + main()
|
|
12
|
+
│ │ ├── pull.ts # pull 命令
|
|
13
|
+
│ │ └── sync.ts # sync 命令
|
|
14
|
+
│ ├── types.ts # 共用类型
|
|
15
|
+
│ ├── paths.ts # 包根 / agent 目录 / clone 校验
|
|
16
|
+
│ ├── package-source.ts # source 解析、身份归一、公开性白名单
|
|
17
|
+
│ └── fs-helpers.ts # 读写 JSON / 托管文件识别 / git 脏状态
|
|
18
|
+
├── dist/cli.js # 构建产物,bin 指向它
|
|
19
|
+
├── test/unit/ # vitest 单元套件(import src/,不构建)
|
|
20
|
+
├── test/helpers/sandbox.ts # 临时目录沙盒 + 子进程封装
|
|
21
|
+
├── test-e2e/cli.e2e.ts # spawn 真实进程的 e2e
|
|
22
|
+
├── extensions/ # 手写的 pi 扩展
|
|
23
|
+
├── presets/public.json # 公开的第三方包清单
|
|
24
|
+
└── .agents/skills/pi-preset-sync # sync → 确认 → 提交推送
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 换机恢复
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npx -y @1lon/pi-preset pull
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
做两件事,**只增不删、不改**:
|
|
34
|
+
|
|
35
|
+
1. 把 `presets/public.json` 里本机还没有的包追加进 `~/.pi/agent/settings.json` 的 `packages`。
|
|
36
|
+
本机已存在的包原样保留——包括自己调过的资源过滤规则,不会被清单覆盖。
|
|
37
|
+
2. 把 `extensions/` 下的扩展复制到 `~/.pi/agent/extensions/`。本机同名文件若内容不同,
|
|
38
|
+
先备份到 `.backup-<时间戳>/` 再覆盖。
|
|
39
|
+
|
|
40
|
+
本机多出来的包(公司内部包、正在试用的包)一律不碰,因此两台机器之间可能出现漂移,
|
|
41
|
+
这是刻意换来的:恢复命令永远不会破坏你正在进行的工作。
|
|
42
|
+
|
|
43
|
+
跳过不碰的文件:软链、文件头声明 `installed by` / `managed by` 的文件、
|
|
44
|
+
以及旁边带指纹文件(`xxx.js.xxx-hash`)的工具产物。
|
|
45
|
+
|
|
46
|
+
公司内部使用的扩展不在本清单里,换公司机器时自己 `pi install` 一条。
|
|
47
|
+
|
|
48
|
+
## 日常回写
|
|
49
|
+
|
|
50
|
+
改完 `~/.pi/agent/extensions/` 下的扩展、或用 `pi install` / `pi remove` 调整过包之后,
|
|
51
|
+
在本仓库 clone 的根目录触发 `pi-preset-sync` skill:它会跑 `sync`、把「新增/修改/删除」
|
|
52
|
+
列给你确认,确认后执行 `git add`(只加本次同步的文件)/ `commit` / `push`。
|
|
53
|
+
|
|
54
|
+
手动执行也可以(不提交,只写文件):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx -y @1lon/pi-preset sync --dry-run # 只看差异
|
|
58
|
+
npx -y @1lon/pi-preset sync # 写回当前目录的 clone
|
|
59
|
+
npx -y @1lon/pi-preset sync --include new-ext.ts # 顺带把本机新增的扩展纳入仓库
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`sync` **不做任何 git 操作**。清单按白名单过滤:只有 `npm:` 和 `github.com` 的 git 源
|
|
63
|
+
会写进 `presets/public.json`,任何内网地址或本地路径都会被丢弃并提示。
|
|
64
|
+
|
|
65
|
+
`sync` 方向是「家目录覆盖仓库」,所以仓库里那份若有**未提交的改动**,默认跳过并列为
|
|
66
|
+
冲突,不静默覆盖。确实要用家目录版本覆盖时加 `--force`。
|
|
67
|
+
|
|
68
|
+
## 注意
|
|
69
|
+
|
|
70
|
+
- 改扩展就改 `~/.pi/agent/extensions/` 下那份,重启 pi 生效,然后跑 sync。
|
|
71
|
+
- **不要在 clone 里 `pi -e ./extensions/xxx.ts`**,也**不要 `pi install` 本仓库**。
|
|
72
|
+
pi 按解析后的绝对路径去重,同一扩展出现在两个路径下会被加载两次(工具名冲突、
|
|
73
|
+
renderer 重复注册)。本仓库的 `package.json` 不含 `pi` 字段、也没有 `pi-package` 关键词,
|
|
74
|
+
避免被当成资源包。
|
|
75
|
+
- `pull` / `sync` 默认读 `~/.pi/agent`,可用 `--agent-dir` 或 `PI_CODING_AGENT_DIR` 指向别处。
|
|
76
|
+
- 本仓库不要发布到公司私服:`publishConfig.registry` 已固定为公开 npm。
|
|
77
|
+
- `extensions/pi-code-skills-dedup.ts` 在启动时从 `$PI_CODING_AGENT_DIR`(默认 `~/.pi/agent`)
|
|
78
|
+
下的 `npm/node_modules/pi-code/extensions/skills.ts` 解析 pi-code,不写死绝对路径。
|
|
79
|
+
找不到时只打一条 warning 并跳过 skill 去重,不会让 pi 启动失败。
|
|
80
|
+
|
|
81
|
+
## 开发
|
|
82
|
+
|
|
83
|
+
包管理器用 **pnpm**(`packageManager` 字段已锁 `pnpm@9.15.9`)。
|
|
84
|
+
|
|
85
|
+
**Node 版本**:`engines` 声明 `>=22.19.0`,这是依赖决定的硬下限——
|
|
86
|
+
`commander@15` 要求 `>=22.12.0`,`@earendil-works/*` 这两个 peer 要求 `>=22.19.0`。
|
|
87
|
+
Node 20 装不上,`>=20` 是错的。
|
|
88
|
+
|
|
89
|
+
本地开发还要再严一点:`tsdown@0.23` 的 `engines` 是
|
|
90
|
+
`^22.18.0 || ^24.11.0 || >=26.0.0`,**排除了 23.x 和 25.x**。
|
|
91
|
+
所以可用的开发版本是 22.19+、24.11+、26+。这条没写进 `engines`,
|
|
92
|
+
因为它只影响构建,装包的人不需要满足。
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pnpm install # 装依赖
|
|
96
|
+
pnpm dev --help # = tsx src/cli/index.ts,改完不用构建
|
|
97
|
+
pnpm typecheck # tsc --noEmit,覆盖 src / extensions / test / test-e2e
|
|
98
|
+
pnpm build # tsdown → dist/cli.js
|
|
99
|
+
pnpm test # vitest 单元套件,进程内跑,不构建
|
|
100
|
+
pnpm test:watch # 单元套件 watch 模式
|
|
101
|
+
pnpm test:e2e # vitest e2e,spawn tsx 跑源码,不构建
|
|
102
|
+
pnpm test:e2e:dist # 先 build,再 spawn node dist/cli.js 验证产物
|
|
103
|
+
pnpm test:all # 三段全跑
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
测试分两层,和 `deck-cli` 同一个分法:
|
|
107
|
+
|
|
108
|
+
- `test/unit/` — 只调纯函数(`planPull` / `planSync` / `parseSource` …),
|
|
109
|
+
import 的是 `src/`,vitest 自己转译 TypeScript,**改完源码立刻能跑**。
|
|
110
|
+
全部落在临时目录,不碰真实的 `~/.pi/agent`。
|
|
111
|
+
- `test-e2e/` — spawn 真实进程,断言退出码、stdout/stderr 与落盘效果。
|
|
112
|
+
默认跑 `tsx src/cli/index.ts`,`E2E_TARGET=dist` 时改跑 `node dist/cli.js`。
|
|
113
|
+
|
|
114
|
+
commander 的错误路径(未知子命令、未知选项)**只能在 e2e 里测**:
|
|
115
|
+
它在子命令层面直接 `process.exit()`,同进程调用会杀掉测试进程本身。
|
|
116
|
+
|
|
117
|
+
`bin` 始终指向 `dist/cli.js`,`prepublishOnly` 会先 typecheck 再 build,
|
|
118
|
+
所以发布前不需要手动记住构建这一步。
|
|
119
|
+
|
|
120
|
+
依赖的划分:
|
|
121
|
+
|
|
122
|
+
- `dependencies` 只有 `commander`,CLI 运行时真正需要。
|
|
123
|
+
- `@earendil-works/pi-coding-agent` / `pi-tui` 同时写在 `peerDependencies`(`"*"`)
|
|
124
|
+
和 `devDependencies`。peer 声明表达「`extensions/` 下的代码依赖宿主提供这两个模块」,
|
|
125
|
+
dev 声明让本仓库能做类型检查。
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command, CommanderError } from "commander";
|
|
3
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
// src/paths.ts
|
|
9
|
+
/**
|
|
10
|
+
* 从本模块所在位置向上找最近的 package.json,作为包根。
|
|
11
|
+
* 不能写成 `resolve(dirname(SELF), '..')`:源码在 src/cli/ 下(上两层才是包根),
|
|
12
|
+
* 打包后在 dist/ 下(上一层就是包根),写死层数两边必然有一边错。
|
|
13
|
+
*/
|
|
14
|
+
function findPackageRoot(start) {
|
|
15
|
+
let dir = start;
|
|
16
|
+
for (;;) {
|
|
17
|
+
if (existsSync(join(dir, "package.json"))) return dir;
|
|
18
|
+
const parent = dirname(dir);
|
|
19
|
+
if (parent === dir) throw new Error(`从 ${start} 向上找不到 package.json`);
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const PACKAGE_ROOT = findPackageRoot(dirname(fileURLToPath(import.meta.url)));
|
|
24
|
+
const PACKAGE_JSON = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf8"));
|
|
25
|
+
const PACKAGE_NAME = PACKAGE_JSON.name;
|
|
26
|
+
const PACKAGE_VERSION = PACKAGE_JSON.version;
|
|
27
|
+
/** pi 的 agent 目录:可由 PI_CODING_AGENT_DIR 覆盖,默认 ~/.pi/agent。 */
|
|
28
|
+
function resolveAgentDir(explicit) {
|
|
29
|
+
const candidate = explicit ?? process.env.PI_CODING_AGENT_DIR;
|
|
30
|
+
if (candidate) return resolve(candidate.replace(/^~(?=\/|$)/, homedir()));
|
|
31
|
+
return join(homedir(), ".pi", "agent");
|
|
32
|
+
}
|
|
33
|
+
/** 校验某个目录确实是本仓库的 clone,否则 sync 会写到不该写的地方。 */
|
|
34
|
+
function assertRepoRoot(candidate) {
|
|
35
|
+
const root = resolve(candidate);
|
|
36
|
+
const pkgPath = join(root, "package.json");
|
|
37
|
+
if (!existsSync(pkgPath)) throw new Error(`${root} 不是本仓库:缺少 package.json`);
|
|
38
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
39
|
+
if (pkg.name !== PACKAGE_NAME) throw new Error(`${root} 不是本仓库:package.json 的 name 是 ${pkg.name},应为 ${PACKAGE_NAME}`);
|
|
40
|
+
if (!existsSync(join(root, ".git"))) throw new Error(`${root} 不是仓库 clone:缺少 .git`);
|
|
41
|
+
return root;
|
|
42
|
+
}
|
|
43
|
+
// src/fs-helpers.ts
|
|
44
|
+
/** 文件头出现这些字样说明该文件由别的工具托管,不归本项目管理。 */
|
|
45
|
+
const MANAGED_RE = /\b(installed|managed|generated)\s+by\b/i;
|
|
46
|
+
const MANAGED_HEAD_LINES = 30;
|
|
47
|
+
/** 被当作 pi 扩展的文件后缀。 */
|
|
48
|
+
const EXTENSION_RE = /\.(ts|js|mjs|cjs)$/;
|
|
49
|
+
function readJson(path, fallback) {
|
|
50
|
+
if (!existsSync(path)) return fallback;
|
|
51
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
52
|
+
}
|
|
53
|
+
function writeJson(path, value) {
|
|
54
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
55
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
56
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
|
|
57
|
+
renameSync(tmp, path);
|
|
58
|
+
}
|
|
59
|
+
function listDirFiles(dir, filter = () => true) {
|
|
60
|
+
if (!existsSync(dir)) return [];
|
|
61
|
+
return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile() && !entry.name.startsWith(".") && filter(entry.name)).map((entry) => entry.name).sort();
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 这个文件是不是由别的工具托管的。两种信号:
|
|
65
|
+
* 1. 文件头若干行里有 "installed by" / "managed by" / "generated by";
|
|
66
|
+
* 2. 同目录存在以该文件名开头的旁挂文件(如 agentd-probe.js.agentd-hash)。
|
|
67
|
+
* 第二条刻意不写死工具名,这样新工具也能被认出来。
|
|
68
|
+
*/
|
|
69
|
+
function isManagedFile(path) {
|
|
70
|
+
let text;
|
|
71
|
+
try {
|
|
72
|
+
text = readFileSync(path, "utf8");
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const head = text.split("\n", MANAGED_HEAD_LINES).join("\n");
|
|
77
|
+
if (MANAGED_RE.test(head)) return true;
|
|
78
|
+
try {
|
|
79
|
+
const dir = dirname(path);
|
|
80
|
+
const base = basename(path);
|
|
81
|
+
return readdirSync(dir).some((name) => name !== base && name.startsWith(`${base}.`));
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function sameContent(a, b) {
|
|
87
|
+
try {
|
|
88
|
+
return readFileSync(a, "utf8") === readFileSync(b, "utf8");
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 仓库里这个路径是否有未提交的改动。
|
|
95
|
+
* sync 的方向是「家目录覆盖仓库」,如果仓库那份写着自己还没同步出去的修改,
|
|
96
|
+
* 直接覆盖就是静默丢代码——必须先让用户知道。
|
|
97
|
+
*/
|
|
98
|
+
function hasUncommittedChanges(repoRoot, relativePath) {
|
|
99
|
+
try {
|
|
100
|
+
return execFileSync("git", [
|
|
101
|
+
"-C",
|
|
102
|
+
repoRoot,
|
|
103
|
+
"status",
|
|
104
|
+
"--porcelain",
|
|
105
|
+
"--",
|
|
106
|
+
relativePath
|
|
107
|
+
], {
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
stdio: [
|
|
110
|
+
"ignore",
|
|
111
|
+
"pipe",
|
|
112
|
+
"ignore"
|
|
113
|
+
]
|
|
114
|
+
}).trim().length > 0;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** 备份目录用的时间戳,形如 20261026-171305。 */
|
|
120
|
+
function stamp() {
|
|
121
|
+
const now = /* @__PURE__ */ new Date();
|
|
122
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
123
|
+
return [
|
|
124
|
+
now.getFullYear(),
|
|
125
|
+
pad(now.getMonth() + 1),
|
|
126
|
+
pad(now.getDate()),
|
|
127
|
+
"-",
|
|
128
|
+
pad(now.getHours()),
|
|
129
|
+
pad(now.getMinutes()),
|
|
130
|
+
pad(now.getSeconds())
|
|
131
|
+
].join("");
|
|
132
|
+
}
|
|
133
|
+
function indent(items) {
|
|
134
|
+
return items.map((item) => ` ${item}`).join("\n");
|
|
135
|
+
}
|
|
136
|
+
// src/package-source.ts
|
|
137
|
+
/**
|
|
138
|
+
* 解析一条 package source,与 pi 的 package-manager 保持同一套身份判定。
|
|
139
|
+
* https://github.com/… 与 git@github.com:… 会归一成同一个身份。
|
|
140
|
+
*/
|
|
141
|
+
function parseSource(source) {
|
|
142
|
+
const raw = source.trim();
|
|
143
|
+
if (raw.startsWith("npm:")) return {
|
|
144
|
+
type: "npm",
|
|
145
|
+
name: npmPackageName(raw.slice(4).trim())
|
|
146
|
+
};
|
|
147
|
+
if (/^git:/.test(raw) || /^(https?|ssh|git):\/\//i.test(raw) || /^git@/.test(raw)) {
|
|
148
|
+
const git = parseGitLocation(raw);
|
|
149
|
+
if (git) return {
|
|
150
|
+
type: "git",
|
|
151
|
+
host: git.host,
|
|
152
|
+
path: git.path
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
type: "local",
|
|
157
|
+
path: resolve(raw)
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function packageIdentity(source) {
|
|
161
|
+
const parsed = parseSource(source);
|
|
162
|
+
if (parsed.type === "npm") return `npm:${parsed.name}`;
|
|
163
|
+
if (parsed.type === "git") return `git:${parsed.host}/${parsed.path}`;
|
|
164
|
+
return `local:${parsed.path}`;
|
|
165
|
+
}
|
|
166
|
+
function entrySource(entry) {
|
|
167
|
+
return typeof entry === "string" ? entry : entry.source;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* 公开性白名单。只有明确可以公开的形态才写进 public.json。
|
|
171
|
+
* 刻意写成「谁能进」而不是「谁排除」:将来出现新的内网地址时默认被拒绝。
|
|
172
|
+
*/
|
|
173
|
+
function isPublicSource(source) {
|
|
174
|
+
const parsed = parseSource(source);
|
|
175
|
+
if (parsed.type === "npm") return true;
|
|
176
|
+
if (parsed.type === "git") return parsed.host === "github.com" || parsed.host === "www.github.com";
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
function npmPackageName(spec) {
|
|
180
|
+
if (!spec.startsWith("@")) return spec.split("@")[0] ?? spec;
|
|
181
|
+
const slash = spec.indexOf("/");
|
|
182
|
+
if (slash === -1) return spec;
|
|
183
|
+
const at = spec.indexOf("@", slash);
|
|
184
|
+
return at === -1 ? spec : spec.slice(0, at);
|
|
185
|
+
}
|
|
186
|
+
function parseGitLocation(raw) {
|
|
187
|
+
let value = raw.replace(/^git:/, "").trim();
|
|
188
|
+
const hash = value.indexOf("#");
|
|
189
|
+
if (hash !== -1) value = value.slice(0, hash);
|
|
190
|
+
value = value.replace(/\.git$/, "").replace(/\/$/, "");
|
|
191
|
+
let match = value.match(/^git@([^:]+):(.+)$/);
|
|
192
|
+
if (match) return {
|
|
193
|
+
host: match[1],
|
|
194
|
+
path: match[2]
|
|
195
|
+
};
|
|
196
|
+
match = value.match(/^(?:https?|ssh|git):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i);
|
|
197
|
+
if (match) return {
|
|
198
|
+
host: match[1],
|
|
199
|
+
path: match[2]
|
|
200
|
+
};
|
|
201
|
+
match = value.match(/^([^/:]+\.[^/:]+)\/(.+)$/);
|
|
202
|
+
if (match) return {
|
|
203
|
+
host: match[1],
|
|
204
|
+
path: match[2]
|
|
205
|
+
};
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
// src/cli/pull.ts
|
|
209
|
+
function registerPull(program, withCommon) {
|
|
210
|
+
withCommon(program.command("pull").description("仓库 → 本机:补齐清单中缺少的包,复制仓库 extensions/ 下的扩展文件").action(runPull));
|
|
211
|
+
}
|
|
212
|
+
function runPull(options) {
|
|
213
|
+
const agentDir = resolveAgentDir(options.agentDir);
|
|
214
|
+
const dryRun = Boolean(options.dryRun);
|
|
215
|
+
const plan = planPull({
|
|
216
|
+
repoRoot: PACKAGE_ROOT,
|
|
217
|
+
agentDir
|
|
218
|
+
});
|
|
219
|
+
printPullSummary(plan, applyPull(plan, { dryRun }), dryRun);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* 只增不删、不改:清单里某个 source 在本机已存在的,条目原样保留
|
|
223
|
+
* (包括本机自己调过的资源过滤规则),本机没有的才追加。
|
|
224
|
+
*/
|
|
225
|
+
function planPull(options) {
|
|
226
|
+
const manifest = readJson(join(options.repoRoot, "presets", "public.json"), { packages: [] });
|
|
227
|
+
const settingsPath = join(options.agentDir, "settings.json");
|
|
228
|
+
const settings = readJson(settingsPath, {});
|
|
229
|
+
const existing = new Set((settings.packages ?? []).map((entry) => packageIdentity(entrySource(entry))));
|
|
230
|
+
const toAdd = [];
|
|
231
|
+
for (const entry of manifest.packages ?? []) {
|
|
232
|
+
const identity = packageIdentity(entrySource(entry));
|
|
233
|
+
if (existing.has(identity)) continue;
|
|
234
|
+
existing.add(identity);
|
|
235
|
+
toAdd.push(entry);
|
|
236
|
+
}
|
|
237
|
+
const sourceDir = join(options.repoRoot, "extensions");
|
|
238
|
+
const targetDir = join(options.agentDir, "extensions");
|
|
239
|
+
const files = {
|
|
240
|
+
added: [],
|
|
241
|
+
modified: [],
|
|
242
|
+
unchanged: [],
|
|
243
|
+
skipped: []
|
|
244
|
+
};
|
|
245
|
+
for (const name of listDirFiles(sourceDir, (file) => EXTENSION_RE.test(file))) {
|
|
246
|
+
const target = join(targetDir, name);
|
|
247
|
+
let isLink = false;
|
|
248
|
+
let exists = true;
|
|
249
|
+
try {
|
|
250
|
+
isLink = lstatSync(target).isSymbolicLink();
|
|
251
|
+
} catch {
|
|
252
|
+
exists = false;
|
|
253
|
+
}
|
|
254
|
+
if (!exists) {
|
|
255
|
+
files.added.push(name);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (isLink) {
|
|
259
|
+
files.skipped.push({
|
|
260
|
+
name,
|
|
261
|
+
reason: "本机是软链"
|
|
262
|
+
});
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (isManagedFile(target)) {
|
|
266
|
+
files.skipped.push({
|
|
267
|
+
name,
|
|
268
|
+
reason: "文件头声明由其他工具托管"
|
|
269
|
+
});
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (sameContent(join(sourceDir, name), target)) files.unchanged.push(name);
|
|
273
|
+
else files.modified.push(name);
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
settingsPath,
|
|
277
|
+
settings,
|
|
278
|
+
toAdd,
|
|
279
|
+
files,
|
|
280
|
+
sourceDir,
|
|
281
|
+
targetDir
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function applyPull(plan, options) {
|
|
285
|
+
const { settingsPath, settings, toAdd, files, sourceDir, targetDir } = plan;
|
|
286
|
+
if (options.dryRun) return {};
|
|
287
|
+
if (toAdd.length > 0) {
|
|
288
|
+
settings.packages = [...settings.packages ?? [], ...toAdd];
|
|
289
|
+
writeJson(settingsPath, settings);
|
|
290
|
+
}
|
|
291
|
+
const touching = [...files.added, ...files.modified];
|
|
292
|
+
let backupDir;
|
|
293
|
+
if (touching.length > 0) {
|
|
294
|
+
mkdirSync(targetDir, { recursive: true });
|
|
295
|
+
if (files.modified.length > 0) {
|
|
296
|
+
backupDir = join(targetDir, `.backup-${stamp()}`);
|
|
297
|
+
mkdirSync(backupDir, { recursive: true });
|
|
298
|
+
for (const name of files.modified) copyFileSync(join(targetDir, name), join(backupDir, name));
|
|
299
|
+
}
|
|
300
|
+
for (const name of touching) copyFileSync(join(sourceDir, name), join(targetDir, name));
|
|
301
|
+
}
|
|
302
|
+
return { backupDir };
|
|
303
|
+
}
|
|
304
|
+
function printPullSummary(plan, result, dryRun) {
|
|
305
|
+
const lines = [];
|
|
306
|
+
const touched = [...plan.files.added, ...plan.files.modified];
|
|
307
|
+
if (touched.length > 0) {
|
|
308
|
+
lines.push(`扩展文件 ${dryRun ? "将复制" : "已复制"}:`);
|
|
309
|
+
lines.push(indent(touched));
|
|
310
|
+
}
|
|
311
|
+
if (plan.toAdd.length > 0) {
|
|
312
|
+
lines.push(`包清单 ${dryRun ? "将新增" : "已新增"} ${plan.toAdd.length} 项:`);
|
|
313
|
+
lines.push(indent(plan.toAdd.map((entry) => packageIdentity(entrySource(entry)))));
|
|
314
|
+
}
|
|
315
|
+
if (plan.files.unchanged.length > 0) lines.push(`未变化:${plan.files.unchanged.length} 个扩展文件`);
|
|
316
|
+
for (const item of plan.files.skipped) lines.push(`跳过 ${item.name}(${item.reason})`);
|
|
317
|
+
if (result.backupDir) lines.push(`旧文件已备份到 ${result.backupDir}`);
|
|
318
|
+
if (touched.length === 0 && plan.toAdd.length === 0) lines.push("已是最新,无需改动。");
|
|
319
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
320
|
+
}
|
|
321
|
+
// src/cli/sync.ts
|
|
322
|
+
function registerSync(program, withCommon) {
|
|
323
|
+
withCommon(program.command("sync").description("本机 → 仓库:把扩展文件与包清单写回当前 clone(不执行 git 操作)").option("--repo <path>", "clone 路径,默认当前目录").option("--include <file>", "把本机某个新扩展文件纳入仓库(可多次指定)", collect, []).option("-f, --force", "忽略「仓库有未提交改动」的保护,用家目录版本覆盖").action(runSync));
|
|
324
|
+
}
|
|
325
|
+
function collect(value, previous) {
|
|
326
|
+
return [...previous, value];
|
|
327
|
+
}
|
|
328
|
+
function runSync(options) {
|
|
329
|
+
const repoRoot = assertRepoRoot(options.repo ?? process.cwd());
|
|
330
|
+
const agentDir = resolveAgentDir(options.agentDir);
|
|
331
|
+
const dryRun = Boolean(options.dryRun);
|
|
332
|
+
const plan = planSync({
|
|
333
|
+
repoRoot,
|
|
334
|
+
agentDir,
|
|
335
|
+
include: options.include ?? [],
|
|
336
|
+
force: Boolean(options.force)
|
|
337
|
+
});
|
|
338
|
+
printSyncSummary(plan, dryRun);
|
|
339
|
+
applySync(plan, { dryRun });
|
|
340
|
+
}
|
|
341
|
+
function planSync(options) {
|
|
342
|
+
const include = options.include ?? [];
|
|
343
|
+
const homeExtDir = join(options.agentDir, "extensions");
|
|
344
|
+
const repoExtDir = join(options.repoRoot, "extensions");
|
|
345
|
+
const repoFiles = listDirFiles(repoExtDir, (file) => EXTENSION_RE.test(file));
|
|
346
|
+
const homeFiles = listDirFiles(homeExtDir, (file) => EXTENSION_RE.test(file));
|
|
347
|
+
const extensions = {
|
|
348
|
+
added: [],
|
|
349
|
+
modified: [],
|
|
350
|
+
missing: [],
|
|
351
|
+
unmanaged: [],
|
|
352
|
+
conflicts: []
|
|
353
|
+
};
|
|
354
|
+
for (const name of repoFiles) {
|
|
355
|
+
const home = join(homeExtDir, name);
|
|
356
|
+
if (!existsSync(home) || isManagedFile(home)) {
|
|
357
|
+
extensions.missing.push(name);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (sameContent(join(repoExtDir, name), home)) continue;
|
|
361
|
+
if (!options.force && hasUncommittedChanges(options.repoRoot, join("extensions", name))) {
|
|
362
|
+
extensions.conflicts.push({
|
|
363
|
+
name,
|
|
364
|
+
reason: "仓库这份有未提交的改动,家目录也不同步;先用 --force 覆盖或手工合并"
|
|
365
|
+
});
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
extensions.modified.push(name);
|
|
369
|
+
}
|
|
370
|
+
for (const name of homeFiles) {
|
|
371
|
+
if (repoFiles.includes(name)) continue;
|
|
372
|
+
if (include.includes(name)) {
|
|
373
|
+
extensions.added.push(name);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (isManagedFile(join(homeExtDir, name))) continue;
|
|
377
|
+
extensions.unmanaged.push(name);
|
|
378
|
+
}
|
|
379
|
+
const settings = readJson(join(options.agentDir, "settings.json"), {});
|
|
380
|
+
const incoming = [];
|
|
381
|
+
const dropped = [];
|
|
382
|
+
for (const entry of settings.packages ?? []) {
|
|
383
|
+
const source = entrySource(entry);
|
|
384
|
+
if (isPublicSource(source)) incoming.push(entry);
|
|
385
|
+
else dropped.push(source);
|
|
386
|
+
}
|
|
387
|
+
const manifestPath = join(options.repoRoot, "presets", "public.json");
|
|
388
|
+
const previous = readJson(manifestPath, { packages: [] }).packages ?? [];
|
|
389
|
+
const previousByIdentity = new Map(previous.map((entry) => [packageIdentity(entrySource(entry)), entry]));
|
|
390
|
+
const incomingIdentities = /* @__PURE__ */ new Set();
|
|
391
|
+
const packages = {
|
|
392
|
+
added: [],
|
|
393
|
+
modified: [],
|
|
394
|
+
deleted: []
|
|
395
|
+
};
|
|
396
|
+
for (const entry of incoming) {
|
|
397
|
+
const identity = packageIdentity(entrySource(entry));
|
|
398
|
+
incomingIdentities.add(identity);
|
|
399
|
+
const before = previousByIdentity.get(identity);
|
|
400
|
+
if (before === void 0) packages.added.push(identity);
|
|
401
|
+
else if (JSON.stringify(before) !== JSON.stringify(entry)) packages.modified.push(identity);
|
|
402
|
+
}
|
|
403
|
+
for (const entry of previous) {
|
|
404
|
+
const identity = packageIdentity(entrySource(entry));
|
|
405
|
+
if (!incomingIdentities.has(identity)) packages.deleted.push(identity);
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
homeExtDir,
|
|
409
|
+
repoExtDir,
|
|
410
|
+
extensions,
|
|
411
|
+
manifestPath,
|
|
412
|
+
packages,
|
|
413
|
+
nextPackages: incoming,
|
|
414
|
+
dropped
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function applySync(plan, options) {
|
|
418
|
+
if (options.dryRun) return;
|
|
419
|
+
for (const name of [...plan.extensions.added, ...plan.extensions.modified]) copyFileSync(join(plan.homeExtDir, name), join(plan.repoExtDir, name));
|
|
420
|
+
if (plan.packages.added.length + plan.packages.modified.length + plan.packages.deleted.length > 0) writeJson(plan.manifestPath, { packages: plan.nextPackages });
|
|
421
|
+
}
|
|
422
|
+
/** 输出「新增 / 修改 / 删除」三段,供 pi-preset-sync skill 原样转发给用户确认。 */
|
|
423
|
+
function printSyncSummary(plan, dryRun) {
|
|
424
|
+
const { extensions, packages } = plan;
|
|
425
|
+
const added = [...extensions.added, ...packages.added];
|
|
426
|
+
const modified = [...extensions.modified, ...packages.modified];
|
|
427
|
+
const deleted = [...packages.deleted];
|
|
428
|
+
const sections = [];
|
|
429
|
+
sections.push(dryRun ? "变更预览(--dry-run,未落盘)" : "已同步到当前 clone");
|
|
430
|
+
for (const [label, items] of [
|
|
431
|
+
["新增", added],
|
|
432
|
+
["修改", modified],
|
|
433
|
+
["删除", deleted]
|
|
434
|
+
]) {
|
|
435
|
+
if (items.length === 0) continue;
|
|
436
|
+
sections.push(`\n${label} (${items.length})`);
|
|
437
|
+
sections.push(indent(items));
|
|
438
|
+
}
|
|
439
|
+
if (added.length + modified.length + deleted.length === 0) sections.push("\n已是最新。");
|
|
440
|
+
if (extensions.conflicts.length > 0) {
|
|
441
|
+
sections.push(`\n冲突:仓库有未提交改动,已跳过 (${extensions.conflicts.length})`);
|
|
442
|
+
sections.push(indent(extensions.conflicts.map((item) => item.name)));
|
|
443
|
+
for (const item of extensions.conflicts) sections.push(` ${item.reason}`);
|
|
444
|
+
}
|
|
445
|
+
if (extensions.missing.length > 0) {
|
|
446
|
+
sections.push(`\n本机缺失、已保留仓库版本 (${extensions.missing.length})`);
|
|
447
|
+
sections.push(indent(extensions.missing));
|
|
448
|
+
}
|
|
449
|
+
if (extensions.unmanaged.length > 0) {
|
|
450
|
+
sections.push(`\n本机新增但未纳入管理 (${extensions.unmanaged.length})`);
|
|
451
|
+
sections.push(indent(extensions.unmanaged));
|
|
452
|
+
sections.push(" 确认要纳入时重跑:npx -y @1lon/pi-preset sync --include <文件名>");
|
|
453
|
+
}
|
|
454
|
+
if (plan.dropped.length > 0) {
|
|
455
|
+
sections.push(`\n不写入公开清单 (${plan.dropped.length})`);
|
|
456
|
+
sections.push(indent(plan.dropped));
|
|
457
|
+
}
|
|
458
|
+
process.stdout.write(`${sections.join("\n")}\n`);
|
|
459
|
+
}
|
|
460
|
+
// src/cli/index.ts
|
|
461
|
+
/**
|
|
462
|
+
* pi-preset — 把本仓库(清单 + 自定义扩展)与本机 pi 配置双向对齐。
|
|
463
|
+
*
|
|
464
|
+
* pull 仓库 → 本机
|
|
465
|
+
* sync 本机 → 仓库(不做 git 操作)
|
|
466
|
+
*
|
|
467
|
+
* 作为 bin(dist/cli.js 或 tsx src/cli/index.ts)直接执行时会自己跑 main(),
|
|
468
|
+
* 被 import 时则什么都不做。
|
|
469
|
+
*/
|
|
470
|
+
/** pull 与 sync 共有的选项。 */
|
|
471
|
+
function withCommonOptions(command) {
|
|
472
|
+
return command.option("--agent-dir <path>", "pi 配置目录,默认 $PI_CODING_AGENT_DIR 或 ~/.pi/agent").option("-n, --dry-run", "只打印将要做的改动,不落盘");
|
|
473
|
+
}
|
|
474
|
+
function buildProgram() {
|
|
475
|
+
const program = new Command();
|
|
476
|
+
program.name("pi-preset").description("个人 pi 扩展 preset:把仓库里的包清单与自定义扩展对齐到本机,或反向写回").version(PACKAGE_VERSION);
|
|
477
|
+
registerPull(program, withCommonOptions);
|
|
478
|
+
registerSync(program, withCommonOptions);
|
|
479
|
+
return program;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* 跑一次 CLI,返回退出码。
|
|
483
|
+
*
|
|
484
|
+
* 只对根命令 exitOverride:子命令报错时 commander 仍会直接 process.exit(),
|
|
485
|
+
* 退出码正确,只是超过约 128 KB 的输出可能被截断(本项目输出只有几百字节)。
|
|
486
|
+
*/
|
|
487
|
+
function main(argv = process.argv.slice(2)) {
|
|
488
|
+
const program = buildProgram();
|
|
489
|
+
program.exitOverride();
|
|
490
|
+
program.configureOutput({ writeErr: (str) => process.stderr.write(str) });
|
|
491
|
+
if (argv.length === 0) {
|
|
492
|
+
program.outputHelp();
|
|
493
|
+
return 0;
|
|
494
|
+
}
|
|
495
|
+
try {
|
|
496
|
+
program.parse(argv, { from: "user" });
|
|
497
|
+
return 0;
|
|
498
|
+
} catch (error) {
|
|
499
|
+
if (error instanceof CommanderError) return error.exitCode === 0 ? 0 : 1;
|
|
500
|
+
throw error;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* 本文件既是 bin 也是可 import 的模块。判断「是否被直接执行」不能只比
|
|
505
|
+
* resolve(argv[1]):通过 npm/pnpm 的 bin 软链调用时 argv[1] 是软链路径,
|
|
506
|
+
* 而 import.meta.url 是 dist/cli.js 的真实路径,两者不相等会让 CLI 静默什么都不做。
|
|
507
|
+
*/
|
|
508
|
+
function isEntryPoint() {
|
|
509
|
+
const argv1 = process.argv[1];
|
|
510
|
+
if (!argv1) return false;
|
|
511
|
+
const self = fileURLToPath(import.meta.url);
|
|
512
|
+
try {
|
|
513
|
+
return realpathSync(argv1) === realpathSync(self);
|
|
514
|
+
} catch {
|
|
515
|
+
return resolve(argv1) === self;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (isEntryPoint()) try {
|
|
519
|
+
process.exitCode = main();
|
|
520
|
+
} catch (error) {
|
|
521
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
522
|
+
process.exitCode = 1;
|
|
523
|
+
}
|
|
524
|
+
export { applyPull, applySync, assertRepoRoot, buildProgram, entrySource, isPublicSource, main, packageIdentity, parseSource, planPull, planSync, printPullSummary, printSyncSummary, resolveAgentDir, runPull, runSync };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 为 pi-code 的 Skills 桥接增加按名称去重和原始输入显示。
|
|
3
|
+
* 保留 Pi 已加载的版本和 Claude 路径中原有的先到先得顺序。
|
|
4
|
+
* 原始输入单独持久化为不进入模型上下文的条目,不改变动态参数展开。
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { join, resolve } from 'node:path'
|
|
9
|
+
import { loadSkills, UserMessageComponent, type ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
10
|
+
|
|
11
|
+
const ORIGINAL_INPUT_ENTRY = 'skill-original-input'
|
|
12
|
+
|
|
13
|
+
/** pi 的 agent 目录:可由 PI_CODING_AGENT_DIR 覆盖,默认 ~/.pi/agent。 */
|
|
14
|
+
function resolveAgentDir(): string {
|
|
15
|
+
return process.env.PI_CODING_AGENT_DIR
|
|
16
|
+
? resolve(process.env.PI_CODING_AGENT_DIR.replace(/^~(?=\/|$)/, homedir()))
|
|
17
|
+
: join(homedir(), '.pi', 'agent')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 定位 pi-code 的 skills 扩展。
|
|
22
|
+
* 不能写死绝对路径:换机器、换用户名、或 pi-code 换安装位置都会失效。
|
|
23
|
+
* 依次尝试:pi 的 agent 目录下的 npm 安装根。
|
|
24
|
+
*/
|
|
25
|
+
function resolvePiCodeSkills(): string | undefined {
|
|
26
|
+
const agentDir = resolveAgentDir()
|
|
27
|
+
const candidates = [
|
|
28
|
+
join(agentDir, 'npm', 'node_modules', 'pi-code', 'extensions', 'skills.ts'),
|
|
29
|
+
join(agentDir, 'node_modules', 'pi-code', 'extensions', 'skills.ts'),
|
|
30
|
+
]
|
|
31
|
+
return candidates.find(candidate => existsSync(candidate))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default async function piCodeSkillsDedup(pi: ExtensionAPI) {
|
|
35
|
+
const skillsPath = resolvePiCodeSkills()
|
|
36
|
+
if (!skillsPath) {
|
|
37
|
+
// pi-code 没装(或改了目录结构):本扩展无事可做,但也不能让 pi 因此报错。
|
|
38
|
+
// UI 只存在于事件回调的 ctx 上,ExtensionAPI 本身没有 ui,所以推迟到 session_start。
|
|
39
|
+
pi.on('session_start', (_event, ctx) => {
|
|
40
|
+
ctx.ui.notify('pi-code-skills-dedup: 未找到 pi-code 的 skills 扩展,已跳过 skill 去重。', 'warning')
|
|
41
|
+
})
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
const piCodeSkills = (await import(skillsPath)).default as (api: ExtensionAPI) => unknown
|
|
45
|
+
|
|
46
|
+
pi.registerEntryRenderer<{ text: string }>(ORIGINAL_INPUT_ENTRY, (entry) => {
|
|
47
|
+
if (typeof entry.data?.text !== 'string') return undefined
|
|
48
|
+
// 不随 skill 的展开状态折叠;复用 user 组件以兼容现有 UI 样式。
|
|
49
|
+
return new UserMessageComponent(entry.data.text, undefined, pi.getSettings().outputPad)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const wrapped = new Proxy(pi, {
|
|
53
|
+
get(target, property, receiver) {
|
|
54
|
+
if (property !== 'on') return Reflect.get(target, property, receiver)
|
|
55
|
+
return (event: string, handler: (...args: any[]) => any) => {
|
|
56
|
+
if (event === 'input') {
|
|
57
|
+
return target.on('input', async (input, ctx) => {
|
|
58
|
+
const name = input.text.trimStart().match(/^\/skill:([^\s]+)(?:\s|$)/)?.[1]
|
|
59
|
+
const isUserSkill = input.source !== 'extension' && name && target.getCommands().some(
|
|
60
|
+
command => command.source === 'skill' && command.name === `skill:${name}`,
|
|
61
|
+
)
|
|
62
|
+
if (isUserSkill) {
|
|
63
|
+
// 在原处理器展开/排队之前追加,确保 user 原文排在 skill 块前。
|
|
64
|
+
// custom entry 随会话保存,但不会给模型重复注入用户请求。
|
|
65
|
+
target.appendEntry(ORIGINAL_INPUT_ENTRY, { text: input.text })
|
|
66
|
+
}
|
|
67
|
+
return handler(input, ctx)
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
if (event !== 'resources_discover') return target.on(event as any, handler)
|
|
71
|
+
return target.on('resources_discover', async (event, ctx) => {
|
|
72
|
+
const resources = await handler(event, ctx)
|
|
73
|
+
if (!resources?.skillPaths?.length) return resources
|
|
74
|
+
|
|
75
|
+
const existingNames = new Set(
|
|
76
|
+
target.getCommands()
|
|
77
|
+
.filter(command => command.source === 'skill')
|
|
78
|
+
.map(command => command.name.replace(/^skill:/, '')),
|
|
79
|
+
)
|
|
80
|
+
// 使用 Pi 自己的解析器,保持 SKILL.md 命名、忽略规则和软链接语义。
|
|
81
|
+
const candidates = loadSkills({
|
|
82
|
+
cwd: ctx.cwd,
|
|
83
|
+
agentDir: resolveAgentDir(),
|
|
84
|
+
skillPaths: resources.skillPaths,
|
|
85
|
+
includeDefaults: false,
|
|
86
|
+
})
|
|
87
|
+
const skillPaths = candidates.skills
|
|
88
|
+
.filter(skill => !existingNames.has(skill.name))
|
|
89
|
+
.map(skill => skill.filePath)
|
|
90
|
+
|
|
91
|
+
// 只消除重名项;损坏的文件仍交回 Pi,让其正常显示解析诊断。
|
|
92
|
+
const validPaths = new Set(candidates.skills.map(skill => resolve(skill.filePath)))
|
|
93
|
+
for (const diagnostic of candidates.diagnostics) {
|
|
94
|
+
if (diagnostic.type === 'collision' && diagnostic.path) {
|
|
95
|
+
validPaths.add(resolve(diagnostic.path))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const diagnostic of candidates.diagnostics) {
|
|
99
|
+
if (diagnostic.type !== 'collision' && diagnostic.path && !validPaths.has(resolve(diagnostic.path))) {
|
|
100
|
+
skillPaths.push(diagnostic.path)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { ...resources, skillPaths: [...new Set(skillPaths)] }
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
return piCodeSkills(wrapped)
|
|
109
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make a batched `question` call readable in the transcript.
|
|
3
|
+
*
|
|
4
|
+
* pi-code's `question` tool takes one question (`question` + `options`) or a batch
|
|
5
|
+
* (`questions: [...]`), and only the single shape renders. For a batch:
|
|
6
|
+
*
|
|
7
|
+
* - Its `renderCall` reads `args.question`, which does not exist, so the call line is empty — and
|
|
8
|
+
* pi-compact-tools, which summarizes a custom call from its scalar arguments, finds nothing
|
|
9
|
+
* either, leaving the space right of the tool name blank.
|
|
10
|
+
* - `execute` returns `{ ...collected[0], questions }`, but its `renderResult` reads
|
|
11
|
+
* `details.answer`, so one answer out of three is drawn and `details.questions` is never read.
|
|
12
|
+
*
|
|
13
|
+
* Both are fixed by shaping the data rather than the drawing, in two event handlers:
|
|
14
|
+
*
|
|
15
|
+
* - `tool_call` adds a `question` string to the arguments. pi-code prefers `questions` when both
|
|
16
|
+
* are present (`questionList`), so execution is unchanged, and both renderers now find a
|
|
17
|
+
* summary. compact-tools draws it right of the tool name, in its own style.
|
|
18
|
+
* - `tool_result` replaces the result text with one `问:… 答:…` line per question and drops
|
|
19
|
+
* `details`. Without details, pi-code's result renderer prints the text verbatim, which
|
|
20
|
+
* compact-tools embeds as the expanded body of its row.
|
|
21
|
+
*
|
|
22
|
+
* Shaping data instead of wrapping renderers keeps this out of the row-rendering contest:
|
|
23
|
+
* compact-tools' `session_start` reconciliation replaces the renderer wrapper on every existing
|
|
24
|
+
* transcript row with its own (`unwrap` only recognizes wrappers it made), which silently undoes a
|
|
25
|
+
* `registerToolRenderer` wrapper after `/reload`. Values in the data survive that, and are
|
|
26
|
+
* persisted, so a row keeps rendering correctly in later sessions and in exports.
|
|
27
|
+
*
|
|
28
|
+
* The cost of dropping `details`: the structured answers are no longer in the session record, only
|
|
29
|
+
* the formatted text. Calls made before this extension existed keep their old rendering, since their
|
|
30
|
+
* data was already written.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
34
|
+
|
|
35
|
+
/** pi-code truncates a header to this width for its label slot; matched here. */
|
|
36
|
+
const HEADER_MAX = 12;
|
|
37
|
+
/** How much of a question's text stands in for a missing header. */
|
|
38
|
+
const QUESTION_MAX = 16;
|
|
39
|
+
|
|
40
|
+
interface QuestionDetails {
|
|
41
|
+
question: string;
|
|
42
|
+
header?: string;
|
|
43
|
+
options: string[];
|
|
44
|
+
answer: string | null;
|
|
45
|
+
wasCustom?: boolean;
|
|
46
|
+
multiSelect?: boolean;
|
|
47
|
+
timedOut?: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function clip(text: string, max: number): string {
|
|
51
|
+
const trimmed = String(text ?? "").trim();
|
|
52
|
+
return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The short label a question is identified by: its header, or the question itself. */
|
|
56
|
+
function tag(entry: { header?: string; question?: string }): string {
|
|
57
|
+
return entry.header ? clip(entry.header, HEADER_MAX) : clip(entry.question ?? "", QUESTION_MAX);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function batchOf(value: unknown): Array<{ header?: string; question?: string }> | undefined {
|
|
61
|
+
const list = (value as { questions?: unknown } | undefined)?.questions;
|
|
62
|
+
return Array.isArray(list) && list.length > 0 ? (list as Array<{ header?: string; question?: string }>) : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What the answer to one question was, in the words of the state it ended in. */
|
|
66
|
+
function answerText(detail: QuestionDetails): string {
|
|
67
|
+
if (detail.answer === null) return "(已取消)";
|
|
68
|
+
if (detail.timedOut) return detail.answer ? `(超时自动继续,已选 ${detail.answer})` : "(超时自动继续)";
|
|
69
|
+
if (detail.wasCustom) return `${detail.answer}(手写)`;
|
|
70
|
+
if (detail.multiSelect) return detail.answer || "(未选)";
|
|
71
|
+
const index = detail.options.indexOf(detail.answer) + 1;
|
|
72
|
+
return index > 0 ? `${index}. ${detail.answer}` : detail.answer;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default function (pi: ExtensionAPI) {
|
|
76
|
+
pi.on("tool_call", (event) => {
|
|
77
|
+
if (event.toolName !== "question") return;
|
|
78
|
+
const input = event.input as Record<string, unknown>;
|
|
79
|
+
const list = batchOf(input);
|
|
80
|
+
// Leave a single question, and anything that already has a summary, alone.
|
|
81
|
+
if (!list || typeof input.question === "string") return;
|
|
82
|
+
input.question = list.map(tag).join(" / ");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
pi.on("tool_result", (event) => {
|
|
86
|
+
if (event.toolName !== "question") return;
|
|
87
|
+
const list = (event.details as { questions?: QuestionDetails[] } | undefined)?.questions;
|
|
88
|
+
if (!list?.length) return;
|
|
89
|
+
const text = list.map((detail, i) => `问:${i + 1}. ${detail.question} 答:${answerText(detail)}`).join("\n");
|
|
90
|
+
// Returning no `details` clears them, which is what makes pi-code print this text as-is.
|
|
91
|
+
return { content: [{ type: "text" as const, text }] };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame user messages with a labelled top rule and a plain bottom rule.
|
|
3
|
+
*
|
|
4
|
+
* Pi has no public hook for rendering user messages (`registerMessageRenderer` only covers custom
|
|
5
|
+
* messages), so this wraps `UserMessageComponent.prototype.render`. The wrapper does exactly two
|
|
6
|
+
* things per render: call the original, and prepend/append one line. No caching, no scanning of the
|
|
7
|
+
* rendered text, no walking of the Markdown tree, so it cannot become the slow part of a render pass.
|
|
8
|
+
*
|
|
9
|
+
* Left and right borders are deliberately omitted: a leading `│` ends up in the clipboard whenever
|
|
10
|
+
* the message is copied out of the terminal.
|
|
11
|
+
*
|
|
12
|
+
* The filled background behind the text is dropped too, and the message text is pinned to the muted
|
|
13
|
+
* near-white of the reference design. Themes have no say in either: the active theme's
|
|
14
|
+
* `userMessageBg` and `userMessageText` are rewritten on the fly, which is what a theme declaring
|
|
15
|
+
* those tokens as `""` and `"#d4d7d9"` would do.
|
|
16
|
+
*
|
|
17
|
+
* `customMessageBg` is filled in the same way, for the opposite reason. Skill blocks and custom
|
|
18
|
+
* messages are drawn as a `Box` painted with that token, and a theme that leaves it at `""` (as
|
|
19
|
+
* github-dark-pro does) gives them the terminal's own background, so an expanded skill block runs
|
|
20
|
+
* into the surrounding prose. The theme's own panel color separates it again.
|
|
21
|
+
*
|
|
22
|
+
* Everything happens when the extension loads, with no `session_start` handler: `/reload` builds a
|
|
23
|
+
* new runtime without starting a new session, so a patch installed from a session event would leave
|
|
24
|
+
* the previous load's wrapper in place and the new code dead.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import { UserMessageComponent } from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import {
|
|
30
|
+
backgroundAnsi,
|
|
31
|
+
foregroundAnsi,
|
|
32
|
+
parseColor,
|
|
33
|
+
stripTerminalSequences,
|
|
34
|
+
type TerminalColorMode,
|
|
35
|
+
visibleWidth,
|
|
36
|
+
} from "@earendil-works/pi-tui";
|
|
37
|
+
|
|
38
|
+
/** Holds the unwrapped render so a `/reload` re-wraps the original instead of stacking wrappers. */
|
|
39
|
+
const ORIGINAL_RENDER = Symbol.for("user-message-frame.originalRender");
|
|
40
|
+
/** Where pi keeps the active theme so every module loader, jiti included, sees the same instance. */
|
|
41
|
+
const THEME_KEY = Symbol.for("@earendil-works/pi-coding-agent:theme");
|
|
42
|
+
|
|
43
|
+
const LABEL = " User ";
|
|
44
|
+
/**
|
|
45
|
+
* The rules and the label take the theme's `dim`, the same muted gray the reference screenshot uses
|
|
46
|
+
* (`#6e7681` under github-dark-pro). Bright white was too loud next to the message text.
|
|
47
|
+
*/
|
|
48
|
+
const RULE_TOKEN = "dim";
|
|
49
|
+
const RESET = "\x1b[0m";
|
|
50
|
+
/** The escape sequence a theme token set to `""` renders as: the terminal's own background. */
|
|
51
|
+
const DEFAULT_BG = "\x1b[49m";
|
|
52
|
+
/** Message text, measured off the reference screenshot. */
|
|
53
|
+
const TEXT_COLOR = parseColor("#d4d7d9");
|
|
54
|
+
/** Panel behind skill blocks and custom messages: github-dark-pro's own card color. */
|
|
55
|
+
const CUSTOM_BG_COLOR = parseColor("#161b22");
|
|
56
|
+
/** Leading OSC sequences, such as the OSC 133 prompt zones pi marks user messages with. */
|
|
57
|
+
const OSC_PREFIX = /^(?:\x1b\][^\x07]*\x07)+/;
|
|
58
|
+
|
|
59
|
+
/** Themes already adjusted; a theme switch brings a new object, which is adjusted in turn. */
|
|
60
|
+
const adjusted = new WeakSet<object>();
|
|
61
|
+
|
|
62
|
+
function activeTheme(): ThemeLike | undefined {
|
|
63
|
+
return (globalThis as Record<symbol, ThemeLike | undefined>)[THEME_KEY];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The parts of pi's `Theme` this extension touches. */
|
|
67
|
+
interface ThemeLike {
|
|
68
|
+
fgAnsi?: Map<string, string>;
|
|
69
|
+
bgAnsi?: Map<string, string>;
|
|
70
|
+
getFgAnsi?: (token: string) => string;
|
|
71
|
+
getColorMode?: () => TerminalColorMode;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Repoint the message tokens this extension owns. `fgAnsi` and `bgAnsi` hold the precomputed escape
|
|
76
|
+
* sequence per token, so one Map write each covers every later render.
|
|
77
|
+
*/
|
|
78
|
+
function adjustTheme(theme: ThemeLike | undefined): void {
|
|
79
|
+
if (!theme || adjusted.has(theme)) return;
|
|
80
|
+
adjusted.add(theme);
|
|
81
|
+
// Goes through the theme's own color mode, so a 256-color terminal gets the nearest index.
|
|
82
|
+
const mode = theme.getColorMode?.() ?? "truecolor";
|
|
83
|
+
if (theme.bgAnsi instanceof Map) {
|
|
84
|
+
theme.bgAnsi.set("userMessageBg", DEFAULT_BG);
|
|
85
|
+
theme.bgAnsi.set("customMessageBg", backgroundAnsi(CUSTOM_BG_COLOR, mode));
|
|
86
|
+
}
|
|
87
|
+
if (theme.fgAnsi instanceof Map) {
|
|
88
|
+
theme.fgAnsi.set("userMessageText", foregroundAnsi(TEXT_COLOR, mode));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isBlank(line: string): boolean {
|
|
93
|
+
return stripTerminalSequences(line).trim().length === 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function rule(width: number, label: boolean, osc: string, theme: ThemeLike | undefined): string {
|
|
97
|
+
const fg = theme?.getFgAnsi?.(RULE_TOKEN) ?? "";
|
|
98
|
+
const labelled = label && width >= visibleWidth(LABEL) + 4;
|
|
99
|
+
const body = labelled
|
|
100
|
+
? `─${LABEL}${"─".repeat(width - 1 - visibleWidth(LABEL))}`
|
|
101
|
+
: "─".repeat(width);
|
|
102
|
+
return `${osc}${fg}${body}${fg ? RESET : ""}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function patchRender(): void {
|
|
106
|
+
const proto = UserMessageComponent.prototype as unknown as Record<string | symbol, unknown>;
|
|
107
|
+
const original = (proto[ORIGINAL_RENDER] ?? proto.render) as
|
|
108
|
+
| ((width: number) => string[])
|
|
109
|
+
| undefined;
|
|
110
|
+
if (typeof original !== "function") return;
|
|
111
|
+
proto[ORIGINAL_RENDER] = original;
|
|
112
|
+
proto.render = function framedRender(this: unknown, width: number): string[] {
|
|
113
|
+
const theme = activeTheme();
|
|
114
|
+
adjustTheme(theme);
|
|
115
|
+
const lines = original.call(this, width);
|
|
116
|
+
if (!Array.isArray(lines) || lines.length === 0) return lines;
|
|
117
|
+
const w = Math.max(1, Math.floor(width));
|
|
118
|
+
// The original pads with a blank row above and below the text. The rules take those rows, so
|
|
119
|
+
// the frame hugs the message the way a border would; the prompt-zone markers pi puts on the
|
|
120
|
+
// outermost rows move onto the rules with them.
|
|
121
|
+
let body = lines;
|
|
122
|
+
let topOsc = "";
|
|
123
|
+
let bottomOsc = "";
|
|
124
|
+
const first = body[0];
|
|
125
|
+
if (body.length > 1 && first !== undefined && isBlank(first)) {
|
|
126
|
+
topOsc = OSC_PREFIX.exec(first)?.[0] ?? "";
|
|
127
|
+
body = body.slice(1);
|
|
128
|
+
}
|
|
129
|
+
const last = body[body.length - 1];
|
|
130
|
+
if (body.length > 1 && last !== undefined && isBlank(last)) {
|
|
131
|
+
bottomOsc = OSC_PREFIX.exec(last)?.[0] ?? "";
|
|
132
|
+
body = body.slice(0, -1);
|
|
133
|
+
}
|
|
134
|
+
return [rule(w, true, topOsc, theme), ...body, rule(w, false, bottomOsc, theme)];
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export default function (pi: ExtensionAPI) {
|
|
139
|
+
patchRender();
|
|
140
|
+
// A skill block can be the first thing painted in a session, before any user message pulled the
|
|
141
|
+
// token overrides in through the render path below.
|
|
142
|
+
pi.on("session_start", () => {
|
|
143
|
+
adjustTheme(activeTheme());
|
|
144
|
+
});
|
|
145
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1lon/pi-preset",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "My personal pi coding agent preset: package manifest + custom extensions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"packageManager": "pnpm@9.15.9",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pi-preset": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"extensions",
|
|
13
|
+
"presets",
|
|
14
|
+
".agents"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22.19.0"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"registry": "https://registry.npmjs.org",
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"dev": "tsx src/cli/index.ts",
|
|
25
|
+
"build": "tsdown",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"prepublishOnly": "tsc --noEmit && tsdown",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"test:e2e": "vitest run --config vitest.config.e2e.ts",
|
|
31
|
+
"test:e2e:dist": "tsdown && E2E_TARGET=dist vitest run --config vitest.config.e2e.ts",
|
|
32
|
+
"test:all": "vitest run && npm run test:e2e && npm run test:e2e:dist"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"commander": "^15.0.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
39
|
+
"@earendil-works/pi-tui": "*"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@earendil-works/pi-coding-agent": "^1.4.0",
|
|
43
|
+
"@earendil-works/pi-tui": "^1.4.0",
|
|
44
|
+
"@types/node": "^26.6.5",
|
|
45
|
+
"tsdown": "^0.23.2",
|
|
46
|
+
"tsx": "^4.19.0",
|
|
47
|
+
"typescript": "^7.0.2",
|
|
48
|
+
"vitest": "^2.1.9"
|
|
49
|
+
},
|
|
50
|
+
"license": "MIT"
|
|
51
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"packages": [
|
|
3
|
+
"npm:@tintinweb/pi-subagents",
|
|
4
|
+
"npm:pi-input-history",
|
|
5
|
+
"npm:pi-init",
|
|
6
|
+
"npm:pi-powerline-footer",
|
|
7
|
+
"git:github.com/obra/superpowers",
|
|
8
|
+
{
|
|
9
|
+
"source": "npm:pi-code",
|
|
10
|
+
"extensions": [
|
|
11
|
+
"!extensions/mcp/**",
|
|
12
|
+
"!extensions/subagent/**",
|
|
13
|
+
"!extensions/skills.ts"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"npm:pi-compact-tools"
|
|
17
|
+
]
|
|
18
|
+
}
|